main.go
// Command health runs the health-dashboard showcase (package healthui) on the
// desktop and web with the synthetic live provider. iOS and Android instead use
// the gomobile bind in ./mobile, which injects a HealthKit / Health Connect
// provider into the same widget tree.
//
// go run ./examples/health
package main
import (
"log"
"github.com/doug/gophics/app"
healthui "github.com/doug/gophics/examples/health/ui"
)
func main() {
if err := app.Run(healthui.Root(), healthui.Config()); err != nil {
log.Fatal(err)
}
}
mobile/healthmobile.go
// Package healthmobile is the gomobile-bind surface for the health app.
//
// It holds only what is health's own: building the tree, and the calls a
// native host uses to feed real HealthKit / Health Connect samples into the
// shared Go UI (package healthui). One widget tree, real device data. See
// the iOS/Android host wiring for a native health provider.
//
// Everything generic — the frame loop, input, lifecycle, accessibility — is on
// shell/mobile.Bridge, which the CLI binds alongside this package, so a host
// calls those methods on the Bridge that Start returns.
//
// Build the frameworks (needs the Android NDK / Xcode):
//
// go install golang.org/x/mobile/cmd/gomobile@latest && gomobile init
// gomobile bind -target=ios -o examples/health/ios/Healthmobile.xcframework ./examples/health/mobile
// gomobile bind -target=android -androidapi 26 -o examples/health/android/app/libs/healthmobile.aar ./examples/health/mobile
package healthmobile
import (
"github.com/doug/gophics/app"
healthui "github.com/doug/gophics/examples/health/ui"
"github.com/doug/gophics/shell/mobile"
)
// dev is the provider the host pushes samples into.
var dev *healthui.DeviceProvider
// Start builds the app with a device-backed provider and must be called once
// from the host before any other call. storeName labels the source in the UI
// ("Apple Health" on iOS, "Health Connect" on Android).
//
// On failure it returns a nil bridge and the error to show — two results
// because the second is an error, which is the one shape gomobile allows.
func Start(storeName string) (*mobile.Bridge, error) {
dev = healthui.NewDeviceProvider(storeName)
h, err := app.NewHandler(healthui.App{Provider: dev}, healthui.Config())
if err != nil {
return nil, err
}
return mobile.NewBridge(h), nil
}
// SetAuthorized records the result of the platform permission prompt.
func SetAuthorized(ok bool) { dev.SetAuthorized(ok) }
// PushSample feeds one reading from the native health store into metric m. t is
// a metric-relative x coordinate (seconds for the live heart rate, days for
// weight/sleep, hours for steps — see healthui.Sample); capN bounds retained
// history (0 = keep all). Safe to call from any thread.
//
// To backfill a whole series, call this in a loop oldest→newest: the provider
// is fresh each Start, so appended samples build the series. (There is
// deliberately no batch PushSeries — gomobile can't bind a []float64 parameter,
// only []byte, so such a method never appears in the generated iOS/Android
// binding.)
func PushSample(m int, t, v float64, capN int) {
dev.Push(healthui.Metric(m), t, v, capN)
}
ui/app.go
// Package healthui is the live health-dashboard showcase: a scrollable set of
// metric cards — a real-time heart rate, today's steps, weight, and sleep — each
// with a custom-painted chart, tappable through to a detail screen. It is built
// entirely from gophics widgets + one Canvas per chart, and streams live via a
// per-frame Ticker.
//
// Data comes through the Provider interface (provider.go). Desktop and web run
// the synthetic live provider; on iOS/Android the mobile bind injects a
// deviceProvider fed by HealthKit / Health Connect (Phase 2) — one Go widget
// tree, real device data. App is the root widget.
package healthui
import (
"fmt"
"os"
"strconv"
"strings"
"github.com/doug/gophics/geom"
"github.com/doug/gophics/layout"
"github.com/doug/gophics/paint"
"github.com/doug/gophics/theme"
"github.com/doug/gophics/widget"
)
// BG is the window background used at Start, before a widget context exists (the
// mobile bind passes it as Config.Background). Inside the tree every color comes
// from theme.Of(ctx), so the whole app also follows the platform light/dark
// scheme for free. This matches the light identity's background.
var BG = theme.Light().Bg
// spec is one metric's presentation: label, unit, chart-accent slot, chart.
type spec struct {
m Metric
label string
unit string
caption string
accentIdx int // slot in theme.Theme.Chart — resolved per active theme
cardWindow int // last N samples shown on the dashboard card (0 = all)
fmtVal func(float64) string
draw func(c paint.Canvas, size geom.Size, xs []Sample, accent paint.Color)
}
var specs = []spec{
{HeartRate, "Heart Rate", "bpm", "live", 0, 0, fmt0, drawLineArea},
{Steps, "Steps", "", "today", 1, 0, fmtInt, drawLineArea},
{Weight, "Weight", "kg", "30 days", 2, 30, fmt1, drawLineArea},
{Sleep, "Sleep", "h", "7 nights", 3, 7, fmt1, drawBars},
}
// specFor returns a metric's spec (specs is indexed by Metric).
func specFor(m Metric) spec { return specs[m] }
// lastN returns the last n samples (n <= 0 → all).
func lastN(xs []Sample, n int) []Sample {
if n <= 0 || n >= len(xs) {
return xs
}
return xs[len(xs)-n:]
}
// --- app root: owns the provider + live ticker, gates on onboarding ---
// App is the app's root widget. Provider is the data source; when nil (the
// desktop/web default) it falls back to the synthetic live provider. The mobile
// bind packages inject a deviceProvider fed by HealthKit / App Connect.
type App struct{ Provider Provider }
func (h App) CreateState() widget.State {
p := h.Provider
if p == nil {
p = newSynthProvider()
}
// HEALTH_VIEW skips the onboarding gate — used for screenshots and gallery
// thumbnails. "dashboard" opens the dashboard; a metric name ("heart",
// "weight", …) opens straight to that detail page.
return &healthState{p: p, connected: os.Getenv("HEALTH_VIEW") != ""}
}
// metricByView maps a HEALTH_VIEW name to a metric, for deep-linking screenshots.
func metricByView(v string) (Metric, bool) {
switch v {
case "heart":
return HeartRate, true
case "steps":
return Steps, true
case "weight":
return Weight, true
case "sleep":
return Sleep, true
}
return 0, false
}
type healthState struct {
widget.StateBase[App]
p Provider
connected bool
}
func (s *healthState) Init(ctx widget.Ctx) { ctx.AddTicker(s) }
// Tick advances a live synthetic source (if the provider is an Advancer) and
// repaints. A device provider isn't an Advancer — the platform pushes samples
// via callbacks — so this just repaints so pushed updates show.
func (s *healthState) Tick(dt float64) bool {
s.SetState(func() {
if a, ok := s.p.(Advancer); ok {
a.Advance(dt)
}
})
return true
}
func (s *healthState) Build(ctx widget.Ctx) widget.Widget {
// Resolve the theme from the platform color scheme and provide it to the
// tree, so every page below reads colors with theme.Of(ctx) and the whole
// app follows light/dark automatically.
th := theme.Auto(ctx)
var content widget.Widget
if !s.connected {
content = s.onboarding(th)
} else {
// The dashboard is the Navigator's Home so it (and pushed detail pages)
// can reach the Nav handle. The provider lives here at the root and keeps
// streaming regardless of which page is on top.
home := widget.Widget(dashboard{p: s.p})
if m, ok := metricByView(os.Getenv("HEALTH_VIEW")); ok {
home = detailPage{p: s.p, m: m} // deep-link for screenshots
}
content = widget.Navigator{Home: home}
}
return widget.Provide[theme.Theme]{
Value: th,
Child: widget.Fill{Color: th.Bg, Child: phoneFrame(content)},
}
}
// maxContentW caps the app's content width so a phone-shaped UI doesn't stretch
// awkwardly across a wide desktop/web window.
const maxContentW = 440
// phoneFrame centres child and caps its width at maxContentW on wide windows,
// while letting it fill narrower ones (a real phone). The window background
// (Config.Background = BG) shows on either side.
func phoneFrame(child widget.Widget) widget.Widget {
return widget.LayoutBuilder{Build: func(cs layout.Constraints) widget.Widget {
w := cs.Max.W
if w > maxContentW {
w = maxContentW
}
return widget.Flex{
Axis: layout.Horizontal,
MainAlign: layout.MainCenter,
CrossAlign: layout.CrossStretch, // full height
Children: []widget.Widget{widget.Sized{W: w, Child: child}},
}
}}
}
func (s *healthState) onboarding(th theme.Theme) widget.Widget {
connect := widget.Interactive{
Gestures: widget.Gestures{OnTap: func() { s.SetState(func() { s.connected = true }) }},
Child: widget.Decorated{Color: th.Primary, Radius: 14, Child: widget.Padding{
Insets: geom.InsetsSymmetric(28, 14),
Child: widget.Text{Value: "Connect " + s.p.Name(), Size: 16, Color: th.OnPrimary},
}},
}
return widget.Align{X: 0.5, Y: 0.5, Child: widget.Padding{
All: 32,
Child: widget.Flex{CrossAlign: layout.CrossCenter, Children: []widget.Widget{
widget.Text{Value: "♥", Size: 72, Color: th.Primary},
widget.Padding{Insets: geom.Insets{Top: 12}, Child: widget.Text{Value: "Health", Size: 34, Color: th.Text}},
widget.Padding{Insets: geom.Insets{Top: 6, Bottom: 26}, Child: widget.Text{
Value: "Connect your data to see it live.", Size: 15, Color: th.Muted}},
connect,
}},
}}
}
// --- dashboard page (Navigator Home) ---
type dashboard struct{ p Provider }
func (d dashboard) Build(ctx widget.Ctx) widget.Widget {
th := theme.Of(ctx)
nav := ctx.MustOf[widget.Nav]()
children := []widget.Widget{header(th, d.p)}
for _, sp := range specs {
m := sp.m
children = append(children, card(th, d.p, sp, func() { nav.Push(detailPage{p: d.p, m: m}) }))
}
return widget.Fill{Color: th.Bg, Child: widget.Scroll{
Child: widget.Padding{
Insets: geom.InsetsSymmetric(18, 22),
Child: widget.Flex{CrossAlign: layout.CrossStretch, Children: children},
},
}}
}
func header(th theme.Theme, p Provider) widget.Widget {
return widget.Padding{
Insets: geom.Insets{Bottom: 18},
Child: widget.Flex{CrossAlign: layout.CrossStart, Children: []widget.Widget{
widget.Text{Value: "Health", Size: 32, Color: th.Text},
widget.Text{Value: p.Name(), Size: 14, Color: th.Muted},
}},
}
}
// card renders one dashboard metric card, tappable through to its detail page.
func card(th theme.Theme, p Provider, sp spec, onTap func()) widget.Widget {
val, _ := p.Latest(sp.m)
series := lastN(p.Series(sp.m), sp.cardWindow)
accent := th.ChartAt(sp.accentIdx)
title := widget.Row(
widget.Text{Value: sp.label, Size: 14, Color: accent},
widget.Spacer(),
widget.Text{Value: sp.caption, Size: 12, Color: th.Muted},
)
value := widget.Row(
widget.Text{Value: sp.fmtVal(val.V), Size: 34, Color: th.Text},
widget.Padding{Insets: geom.Insets{Left: 5, Top: 12}, Child: widget.Text{Value: sp.unit, Size: 14, Color: th.Muted}},
)
chart := widget.Expand(widget.Canvas{Clip: true, Draw: func(c paint.Canvas, size geom.Size) {
sp.draw(c, size, series, accent)
}})
body := widget.Decorated{Color: th.Surface, Radius: th.Radius + 8, BorderColor: th.Border, BorderWidth: 1, Child: widget.Padding{
All: 16,
Child: widget.Flex{CrossAlign: layout.CrossStretch, Children: []widget.Widget{title, value, chart}},
}}
return widget.Padding{
Insets: geom.Insets{Bottom: 14},
Child: widget.Sized{H: 170, Child: widget.Interactive{
Gestures: widget.Gestures{OnTap: onTap},
Child: body,
}},
}
}
// --- charts (custom paint) ---
// drawLineArea plots xs as a filled area under a 2px line, with a dot at the
// latest point. Values map to y; index maps to x, so the live heart-rate window
// scrolls left as old samples drop and new ones append.
func drawLineArea(c paint.Canvas, size geom.Size, xs []Sample, accent paint.Color) {
if len(xs) < 2 {
return
}
lo, hi := xs[0].V, xs[0].V
for _, s := range xs {
lo, hi = min(lo, s.V), max(hi, s.V)
}
if hi-lo < 1e-6 {
hi = lo + 1
}
const pad = 8
px := func(i int) float32 { return pad + float32(i)/float32(len(xs)-1)*(size.W-2*pad) }
py := func(v float64) float32 { return size.H - pad - float32((v-lo)/(hi-lo))*(size.H-2*pad) }
area := paint.NewPath()
area.MoveTo(geom.Pt{X: px(0), Y: size.H})
for i, s := range xs {
area.LineTo(geom.Pt{X: px(i), Y: py(s.V)})
}
area.LineTo(geom.Pt{X: px(len(xs) - 1), Y: size.H})
area.Close()
c.FillPath(area, accent.WithAlpha(0.22))
line := paint.NewPath()
line.MoveTo(geom.Pt{X: px(0), Y: py(xs[0].V)})
for i, s := range xs {
line.LineTo(geom.Pt{X: px(i), Y: py(s.V)})
}
c.StrokePath(line, 2, accent)
last := len(xs) - 1
c.FillRRect(geom.RectXYWH(px(last)-3.5, py(xs[last].V)-3.5, 7, 7), 3.5, accent)
}
// drawBars plots xs as rounded bars scaled to the max value — used for sleep.
func drawBars(c paint.Canvas, size geom.Size, xs []Sample, accent paint.Color) {
if len(xs) == 0 {
return
}
hi := xs[0].V
for _, s := range xs {
hi = max(hi, s.V)
}
if hi < 1e-6 {
hi = 1
}
const pad, gap = 8, 7
n := len(xs)
bw := (size.W - 2*pad - gap*float32(n-1)) / float32(n)
for i, s := range xs {
bh := float32(s.V/hi) * (size.H - 2*pad)
x := pad + float32(i)*(bw+gap)
c.FillRRect(geom.RectXYWH(x, size.H-pad-bh, bw, bh), 4, accent.WithAlpha(0.85))
}
}
// --- value formatting ---
func fmt0(v float64) string { return fmt.Sprintf("%.0f", v) }
func fmt1(v float64) string { return fmt.Sprintf("%.1f", v) }
func fmtInt(v float64) string {
n := int(v)
neg := n < 0
if neg {
n = -n
}
s := strconv.Itoa(n)
var out strings.Builder
for i := range s {
if i > 0 && (len(s)-i)%3 == 0 {
out.WriteString(",")
}
out.WriteString(string(s[i]))
}
if neg {
return "-" + out.String()
}
return out.String()
}
ui/config.go
package healthui
import (
"golang.org/x/image/font/gofont/goregular"
"github.com/doug/gophics/app"
"github.com/doug/gophics/geom"
"github.com/doug/gophics/widget"
)
// Root is the dashboard with the synthetic provider — what the desktop and web
// builds run.
//
// The mobile bind package builds its own App with a device-backed provider
// instead (HealthKit / Health Connect), which is the one thing about this app
// that genuinely differs by platform. It shares Config with this one, so only
// the data source varies and not the fonts or the background.
func Root() widget.Widget { return App{} }
// Config is the app's window and font configuration, shared by the desktop
// entry point and the mobile bind surface so the two cannot drift.
func Config() app.Config {
return app.Config{
Title: "Health",
Size: geom.Size{W: 390, H: 760}, // phone-portrait, signalling the mobile target
Background: BG,
Font: goregular.TTF,
}
}
ui/detail.go
package healthui
import (
"github.com/doug/gophics/geom"
"github.com/doug/gophics/layout"
"github.com/doug/gophics/paint"
"github.com/doug/gophics/theme"
"github.com/doug/gophics/widget"
)
// detailPage is a per-metric drill-down pushed onto the Navigator from the
// dashboard. It reads the shared provider and repaints itself each frame so
// live updates (advanced by the root ticker) show here too.
type detailPage struct {
p Provider
m Metric
}
func (detailPage) CreateState() widget.State { return &detailState{} }
type detailState struct {
widget.StateBase[detailPage]
rangeIdx int
}
func (s *detailState) Init(ctx widget.Ctx) { ctx.AddTicker(s) }
// Tick repaints only — the root ticker owns advancing the provider, so the
// detail never double-advances it.
func (s *detailState) Tick(dt float64) bool { s.SetState(nil); return true }
type rangeOpt struct {
label string
n int // last n samples; 0 = all
}
// rangesFor returns the range tabs for a metric, or nil for metrics with a
// single natural window (live heart rate, today's steps).
func rangesFor(m Metric) []rangeOpt {
switch m {
case Weight, Sleep:
return []rangeOpt{{"Week", 7}, {"Month", 30}, {"All", 0}}
}
return nil
}
func (s *detailState) Build(ctx widget.Ctx) widget.Widget {
cfg := s.W()
p, m := cfg.p, cfg.m
sp := specFor(m)
th := theme.Of(ctx)
accent := th.ChartAt(sp.accentIdx)
nav := ctx.MustOf[widget.Nav]()
ranges := rangesFor(m)
series := p.Series(m)
if len(ranges) > 0 {
if s.rangeIdx < 0 || s.rangeIdx >= len(ranges) {
s.rangeIdx = 0
}
series = lastN(series, ranges[s.rangeIdx].n)
}
val, _ := p.Latest(m)
lo, avg, hi := stats(series)
back := widget.Interactive{
Gestures: widget.Gestures{OnTap: nav.Pop},
Child: widget.Text{Value: "‹ Back", Size: 15, Color: accent},
}
head := widget.Flex{CrossAlign: layout.CrossStart, Children: []widget.Widget{
widget.Padding{Insets: geom.Insets{Bottom: 16}, Child: back},
widget.Text{Value: sp.label, Size: 15, Color: accent},
widget.Row(
widget.Text{Value: sp.fmtVal(val.V), Size: 40, Color: th.Text},
widget.Padding{Insets: geom.Insets{Left: 6, Top: 16}, Child: widget.Text{Value: sp.unit, Size: 15, Color: th.Muted}},
),
}}
kids := []widget.Widget{head}
if len(ranges) > 0 {
chips := make([]widget.Widget, len(ranges))
for i, r := range ranges {
idx := i
chips[i] = chip(th, r.label, i == s.rangeIdx, accent, func() {
s.SetState(func() { s.rangeIdx = idx })
})
}
kids = append(kids, widget.Padding{Insets: geom.Insets{Top: 14}, Child: widget.Row(chips...)})
}
kids = append(kids, widget.Padding{
Insets: geom.Insets{Top: 14, Bottom: 18},
Child: widget.Sized{H: 230, Child: widget.Decorated{Color: th.Surface, Radius: th.Radius + 8, BorderColor: th.Border, BorderWidth: 1, Child: widget.Padding{
All: 14,
Child: widget.Canvas{Clip: true, Draw: func(c paint.Canvas, size geom.Size) {
sp.draw(c, size, series, accent)
}},
}}},
})
kids = append(kids, widget.Row(
statBlock(th, "Min", sp.fmtVal(lo)),
statBlock(th, "Avg", sp.fmtVal(avg)),
statBlock(th, "Max", sp.fmtVal(hi)),
))
return widget.Fill{Color: th.Bg, Child: widget.Scroll{Child: widget.Padding{
Insets: geom.InsetsSymmetric(18, 22),
Child: widget.Flex{CrossAlign: layout.CrossStretch, Children: kids},
}}}
}
// chip is a pill-shaped range tab.
func chip(th theme.Theme, label string, selected bool, accent paint.Color, onTap func()) widget.Widget {
fg, bgc := th.Muted, th.SurfaceHover
if selected {
fg, bgc = th.OnPrimary, accent
}
return widget.Interactive{
Gestures: widget.Gestures{OnTap: onTap},
Child: widget.Padding{Insets: geom.Insets{Right: 8}, Child: widget.Decorated{
Color: bgc, Radius: 10,
Child: widget.Padding{Insets: geom.InsetsSymmetric(14, 7), Child: widget.Text{Value: label, Size: 13, Color: fg}},
}},
}
}
// statBlock is one equal-width Min/Avg/Max cell.
func statBlock(th theme.Theme, label, value string) widget.Widget {
return widget.Expand(widget.Flex{CrossAlign: layout.CrossStart, Children: []widget.Widget{
widget.Text{Value: value, Size: 22, Color: th.Text},
widget.Text{Value: label, Size: 12, Color: th.Muted},
}})
}
// stats returns the min, mean, and max of the samples' values.
func stats(xs []Sample) (lo, avg, hi float64) {
if len(xs) == 0 {
return 0, 0, 0
}
lo, hi = xs[0].V, xs[0].V
sum := 0.0
for _, s := range xs {
lo, hi = min(lo, s.V), max(hi, s.V)
sum += s.V
}
return lo, sum / float64(len(xs)), hi
}
ui/provider.go
package healthui
import (
"math"
"math/rand"
"sync"
)
// Metric identifies a health series.
type Metric int
const (
HeartRate Metric = iota // beats per minute, streamed live
Steps // cumulative step count for today
Weight // body mass in kg, one point per day
Sleep // hours slept, one point per night
)
// Sample is one reading. T is a metric-relative logical coordinate (seconds for
// the live heart rate, days for weight/sleep, hours for steps) rather than wall
// time, so the UI renders identically live, headless, and in tests.
type Sample struct {
T float64
V float64
}
// Provider is the source of health data. The synthetic provider below backs the
// desktop and web builds; on iOS/Android the SAME interface is implemented over
// HealthKit and Health Connect (Phase 2), so the widget tree never changes —
// one Go UI, real device data on the phone.
type Provider interface {
// Name identifies the source ("Apple Health", "Health Connect", or the
// synthetic "Sample data") for display.
Name() string
// Series returns the retained history for a metric, oldest first.
Series(m Metric) []Sample
// Latest returns the most recent value for a metric.
Latest(m Metric) (Sample, bool)
// Authorized reports whether the user granted read access.
Authorized() bool
}
// Advancer is an optional capability: a live source the app advances once per
// frame so synthetic data streams in real time. Real device providers push
// samples via platform callbacks instead and won't implement this.
type Advancer interface{ Advance(dt float64) }
// hrWindow is how many seconds of heart-rate history the live chart shows.
const hrWindow = 60.0
// synthProvider generates realistic-looking data and streams a live heart rate.
// It is deterministic from a fixed seed so thumbnails and tests are stable.
type synthProvider struct {
clock float64 // seconds since start, advanced by Advance
hr []Sample
steps []Sample
weight []Sample
sleep []Sample
hrAccum, stepAccum float64
stepsToday float64
rng *rand.Rand
baseHR float64
}
func newSynthProvider() *synthProvider {
p := &synthProvider{rng: rand.New(rand.NewSource(42)), baseHR: 68}
// Weight: 91 daily points (3 months) drifting ~78 → 75 with day-to-day noise.
for d := 90; d >= 0; d-- {
w := 75.0 + float64(d)*0.035 + p.rng.NormFloat64()*0.18
p.weight = append(p.weight, Sample{T: -float64(d), V: w})
}
// Sleep: last 30 nights, 6.4–8.2 hours.
for n := 29; n >= 0; n-- {
p.sleep = append(p.sleep, Sample{T: -float64(n), V: 6.4 + p.rng.Float64()*1.8})
}
// Steps: cumulative over the ~14 waking hours so far today.
cum := 0.0
for h := 0; h <= 14; h++ {
cum += 250 + p.rng.Float64()*950
p.steps = append(p.steps, Sample{T: float64(h), V: cum})
}
p.stepsToday = cum
// Heart rate: prefill the live window ending at t=0.
for t := -hrWindow; t <= 0; t++ {
p.hr = append(p.hr, Sample{T: t, V: p.hrAt(t)})
}
return p
}
func (p *synthProvider) Name() string { return "Sample data · live" }
// hrAt models a resting heart rate: a slow drift plus a faster ripple plus
// beat-to-beat variability.
func (p *synthProvider) hrAt(t float64) float64 {
return p.baseHR + 7*math.Sin(t*0.08) + 2.5*math.Sin(t*0.6) + p.rng.NormFloat64()*1.1
}
// Advance streams new data forward by dt seconds (called once per frame).
func (p *synthProvider) Advance(dt float64) {
p.clock += dt
// Emit ~1 heart-rate sample per second, dropping ones older than the window.
for p.hrAccum += dt; p.hrAccum >= 1; p.hrAccum -= 1 {
t := p.clock
p.hr = append(p.hr, Sample{T: t, V: p.hrAt(t)})
cut := t - hrWindow
i := 0
for i < len(p.hr) && p.hr[i].T < cut {
i++
}
p.hr = p.hr[i:]
}
// Steps climb a couple per second while active.
for p.stepAccum += dt; p.stepAccum >= 1; p.stepAccum -= 1 {
p.stepsToday += 1 + p.rng.Float64()*3
}
}
func (p *synthProvider) Series(m Metric) []Sample {
switch m {
case HeartRate:
return p.hr
case Steps:
return p.steps
case Weight:
return p.weight
case Sleep:
return p.sleep
}
return nil
}
func (p *synthProvider) Latest(m Metric) (Sample, bool) {
if m == Steps {
return Sample{V: p.stepsToday}, true
}
s := p.Series(m)
if len(s) == 0 {
return Sample{}, false
}
return s[len(s)-1], true
}
func (p *synthProvider) Authorized() bool { return true }
// Compile-time proof the synthetic provider satisfies both interfaces.
var (
_ Provider = (*synthProvider)(nil)
_ Advancer = (*synthProvider)(nil)
)
// DeviceProvider is the Provider used on iOS/Android: the native host reads the
// platform health store (HealthKit / Health Connect) and pushes samples in via
// Push. It is NOT an Advancer — the platform drives updates — so the UI ticker
// only repaints. All access is mutex-guarded because Push is called from the
// host's callback threads while the UI reads on the frame thread.
type DeviceProvider struct {
mu sync.RWMutex
name string
authed bool
series [4][]Sample // indexed by Metric
stepsSum float64 // Steps reports a running total, like the synthetic one
}
// NewDeviceProvider builds an empty device provider labelled with the platform
// store's name (e.g. "Apple Health", "Health Connect").
func NewDeviceProvider(name string) *DeviceProvider {
return &DeviceProvider{name: name}
}
func (d *DeviceProvider) Name() string {
d.mu.RLock()
defer d.mu.RUnlock()
return d.name
}
func (d *DeviceProvider) Authorized() bool {
d.mu.RLock()
defer d.mu.RUnlock()
return d.authed
}
// SetAuthorized records the result of the platform permission prompt.
func (d *DeviceProvider) SetAuthorized(ok bool) {
d.mu.Lock()
d.authed = ok
d.mu.Unlock()
}
// Push appends a sample for a metric from the native health store. cap bounds
// the retained history (0 = unbounded); the newest samples are kept.
func (d *DeviceProvider) Push(m Metric, t, v float64, capN int) {
if m < 0 || int(m) >= len(d.series) {
return
}
d.mu.Lock()
defer d.mu.Unlock()
s := append(d.series[m], Sample{T: t, V: v})
if capN > 0 && len(s) > capN {
s = s[len(s)-capN:]
}
d.series[m] = s
if m == Steps {
d.stepsSum = v // HealthKit/Health Connect report cumulative steps directly
}
}
// ReplaceSeries swaps a metric's whole history at once — used when the host
// backfills a range query (e.g. 30 days of weight) rather than streaming.
func (d *DeviceProvider) ReplaceSeries(m Metric, xs []Sample) {
if m < 0 || int(m) >= len(d.series) {
return
}
d.mu.Lock()
d.series[m] = append(d.series[m][:0], xs...)
if m == Steps && len(xs) > 0 {
d.stepsSum = xs[len(xs)-1].V
}
d.mu.Unlock()
}
func (d *DeviceProvider) Series(m Metric) []Sample {
if m < 0 || int(m) >= len(d.series) {
return nil
}
d.mu.RLock()
defer d.mu.RUnlock()
return append([]Sample(nil), d.series[m]...) // copy: caller reads without the lock
}
func (d *DeviceProvider) Latest(m Metric) (Sample, bool) {
d.mu.RLock()
defer d.mu.RUnlock()
if m == Steps {
return Sample{V: d.stepsSum}, len(d.series[Steps]) > 0
}
if m < 0 || int(m) >= len(d.series) || len(d.series[m]) == 0 {
return Sample{}, false
}
s := d.series[m]
return s[len(s)-1], true
}
var _ Provider = (*DeviceProvider)(nil)