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)