diff --git a/lib/ble/ble_state.dart b/lib/ble/ble_state.dart index 6099c43..5d166de 100644 --- a/lib/ble/ble_state.dart +++ b/lib/ble/ble_state.dart @@ -553,6 +553,89 @@ class ChunkFailureLedger { /// With continuous listening there is no discrete "sync done" signal, so we can't /// fire the DerivationEngine off a SyncReport anymore. Instead, every time records /// are persisted we mark them as dirty; once the inbound record stream goes quiet +/// Whether band records are landing RIGHT NOW. +/// +/// Answers the TestFlight report "don't get to know if syncing is happening or +/// not" without adding a spinner: records arrive in bursts with gaps, so the +/// answer has to hold for a moment after each batch or an indicator would +/// strobe — and it has to expire, or a finished drain would look like a running +/// one forever. +/// +/// Pure, so the window is testable without a band or a clock. +class SyncActivityWindow { + SyncActivityWindow({this.windowMs = 6000}); + + /// How long one batch keeps the answer true. + final int windowMs; + + int _lastMs = 0; + + /// Records just landed. + void mark(int nowMs) => _lastMs = nowMs; + + /// True while the last batch is still within [windowMs]. + bool isActive(int nowMs) => _lastMs > 0 && nowMs - _lastMs < windowMs; + + /// When the current window closes, or null if nothing has arrived. + int? expiresAtMs() => _lastMs > 0 ? _lastMs + windowMs : null; +} + +/// Steps accrued since LOCAL MIDNIGHT, from a counter that counts since the BLE +/// connection began. +/// +/// The Today tile shows `derived day total + live session steps`, and the live +/// half is "since this connection started". This app deliberately holds ONE +/// continuous connection — the whole engine is built around never dropping it — +/// so that counter routinely spans midnight, and at 00:01 the tile showed +/// yesterday's steps plus today's and kept climbing from there. Reported from +/// TestFlight as "steps and calories are not resetting every day, it's +/// accumulating". +/// +/// Rebasing at the day boundary is the fix: steps taken before midnight belong +/// to yesterday, and yesterday's derived total already contains them. +/// +/// Pure so the boundary behaviour is testable without a clock or a band. +class LiveStepDayWindow { + String? _day; + int _base = 0; + + /// [sessionTotal] is the connection-lifetime count; [today] is the local day + /// label. Returns what belongs to [today]. + int stepsToday(int rawSessionTotal, String today) { + // A counter cannot be negative. Letting one through would seat `_base` + // below zero, and the next ordinary reading would then report the + // difference as steps that were never taken. + final sessionTotal = rawSessionTotal > 0 ? rawSessionTotal : 0; + if (_day == null) { + // First observation. The session counter is per-connection and per- + // process, so whatever it holds now was walked during this session — on + // this day. Rebasing here instead would DISCARD a real walk, which is + // what the live-coverage tests caught. + _day = today; + _base = 0; + } else if (_day != today) { + // A day boundary crossed while this window was watching: everything the + // counter holds belongs to the day that just ended. + _day = today; + _base = sessionTotal; + } + // A reconnect zeroes the session counter. Without this the stale, larger + // base would make every subsequent reading negative — clamped to 0 below, + // so a walk after a reconnect on the same day would silently stop counting. + if (sessionTotal < _base) _base = sessionTotal; + final n = sessionTotal - _base; + return n > 0 ? n : 0; + } + + /// The day this window is currently based on — null until first use. + /// + /// Kept current from the SAMPLE path, not just from the widget that displays + /// it: a phone parked on another tab across midnight would otherwise make its + /// first post-midnight read the first observation of any day, and count + /// yesterday's whole session as today's. + String? get day => _day; +} + /// for [quietPeriod] (or [maxWait] elapses since the first un-derived record so a /// never-quiet stream still derives periodically) a derive is scheduled, coalescing /// the burst into a single pass. Pure + deterministic so it's unit-testable without diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 0d4f7f0..2866d21 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -31,7 +31,8 @@ import '../models/app_status.dart'; import '../ble/accessory_setup.dart'; import '../ble/android_background.dart'; import '../ble/ble_engine.dart'; -import '../ble/ble_state.dart' show AlarmConfirmation, AlarmEffect; +import '../ble/ble_state.dart' + show AlarmConfirmation, AlarmEffect, LiveStepDayWindow, SyncActivityWindow; import '../ble/ios_ble_restore.dart'; import '../cloud/companion_client.dart'; import '../compute/derivation_engine.dart'; @@ -1011,6 +1012,8 @@ class AppState extends ChangeNotifier { @override void dispose() { + _syncQuietTimer?.cancel(); + _syncQuietTimer = null; _disposed = true; // EVERY timer this object owns, not just three of them. _spotTimer, // _breathingRecomputeTimer and _workoutTimer used to survive dispose, and @@ -1654,6 +1657,9 @@ class AppState extends ChangeNotifier { /// drain, live-triggered store) after the write is durable, so it can't /// race it — same guarantee dbCounts already relies on above. void _onDataStored() { + // Synchronously, before the async read below: this is the moment records + // became durable, and it is the only path that sees every commit. + _markSyncActivity(); unawaited(() async { dbCounts = await LocalDb.counts(); final recTsHw = await LocalDb.getCursorInt('rec_ts_hw'); @@ -2100,8 +2106,19 @@ class AppState extends ChangeNotifier { /// double-count into `live_coverage` and poison the cadence model. int get _rawSessionSteps => (_liveRaw * ana.StepParams.gain).round(); + /// Rebases the connection-lifetime counter at local midnight — see + /// [LiveStepDayWindow] for why a permanently-connected band made the Today + /// tile carry yesterday's steps into today. + final LiveStepDayWindow _liveStepDay = LiveStepDayWindow(); + int get liveSteps { - final raw = _rawSessionSteps; + final today = todayLabel(); + final dayChanged = _liveStepDay.day != null && _liveStepDay.day != today; + final raw = _liveStepDay.stepsToday(_rawSessionSteps, today); + // The cushion holds a PRE-midnight session total for a few seconds so the + // tile doesn't visibly dip on a reconnect. Carried across the boundary it + // would re-introduce exactly the number we just rebased away. + if (dayChanged) _sessionStepsCushion = 0; if (_sessionStepsCushion <= 0) return raw; if (DateTime.now().millisecondsSinceEpoch - _sessionCushionSetAtMs >= _sessionCushionGraceMs) { @@ -2218,6 +2235,12 @@ class AppState extends ChangeNotifier { // a killed process doesn't lose the whole session — only whatever hasn't // completed a minute yet. See _recoverOrphanedLiveSession. if (committedThisTick) unawaited(_checkpointLiveSession()); + // Keep the day window current from the SAMPLE path. Reading it only when + // the Today tile is built would make the first read after midnight the + // window's first observation of any day — and a first observation counts + // in full, so a phone parked on another tab across midnight would carry + // the whole of yesterday's session into today. + if (committedThisTick) _liveStepDay.stepsToday(_rawSessionSteps, todayLabel()); if (nowMs - _lastLiveUiNotifyMs >= 1000) { _lastLiveUiNotifyMs = nowMs; notifyListeners(); // live readout re-counts the partial minute on read @@ -3568,6 +3591,40 @@ class AppState extends ChangeNotifier { 'reanalyze_progress': reanalyzeProgress, }; + final SyncActivityWindow _syncActivity = SyncActivityWindow(); + + /// Fires once when the activity window closes. `syncingNow` decays on + /// wall-clock time, and nothing else necessarily notifies at that moment — a + /// band that goes quiet after its last batch would leave the indicator lit + /// until some unrelated state change happened along. + Timer? _syncQuietTimer; + + /// Band data is arriving right now. Deliberately narrow: it is not "connected" + /// and not "we would like to sync" — it is only true while records are + /// actually landing, so a quiet indicator means a quiet link rather than a + /// broken one. + /// + /// From TestFlight: "don't get to know if syncing is happening or not". + bool get syncingNow => + _syncActivity.isActive(DateTime.now().millisecondsSinceEpoch); + + /// Records reached durable storage. Called from the durable-write callback — + /// NOT inferred from a sync burst finishing, because `_onDataStored` has + /// already advanced the frontier by then, so the burst's own "did the + /// frontier move" test is false exactly when data has just landed. + void _markSyncActivity() { + final now = DateTime.now().millisecondsSinceEpoch; + _syncActivity.mark(now); + _syncQuietTimer?.cancel(); + _syncQuietTimer = Timer( + Duration(milliseconds: _syncActivity.windowMs), + () { + _syncQuietTimer = null; + notifyListeners(); + }, + ); + } + /// REAL device timestamp of the newest record we hold (the band's own clock), /// NOT when the BLE frame arrived. This is what "last data: …" displays — a /// flash backfill arrives "now" but carries hours-old records. `null` until any diff --git a/lib/ui/design/design.dart b/lib/ui/design/design.dart index b476e5b..de54c38 100644 --- a/lib/ui/design/design.dart +++ b/lib/ui/design/design.dart @@ -30,6 +30,7 @@ export 'recap_card.dart'; export 'ring_week.dart'; export 'rows.dart'; export 'spark.dart'; +export 'sync_dot.dart'; export 'state_chips.dart'; export 'surface.dart'; diff --git a/lib/ui/design/sync_dot.dart b/lib/ui/design/sync_dot.dart new file mode 100644 index 0000000..16c9966 --- /dev/null +++ b/lib/ui/design/sync_dot.dart @@ -0,0 +1,101 @@ +// SyncDot — the quietest possible "data is arriving". +// +// From TestFlight: "don't get to know if syncing is happening or not". The sync +// is deliberately invisible in this app — no progress bars, no spinners, no +// "syncing…" copy — and that is the right default, because the band syncs +// constantly and a user cannot act on any of it. But invisible and broken look +// identical, which is what the report is really about. +// +// So: a 6pt dot beside the title that breathes while records are landing, and +// is absent otherwise. No text, no layout shift (the space is held either way), +// nothing to dismiss. If you are not looking for it you will not notice it; if +// you are wondering whether the thing works, it answers you. + +import 'package:flutter/material.dart'; + +import '../../theme/tokens.dart'; + +class SyncDot extends StatefulWidget { + const SyncDot({super.key, required this.active, this.size = 6}); + + /// The fixed-size box, so a test can measure the reserved space directly + /// rather than through the StatefulWidget element. + static const Key sizeKey = Key('sync-dot-box'); + + /// Records are landing right now (`AppState.syncingNow`). + final bool active; + final double size; + + @override + State createState() => _SyncDotState(); +} + +class _SyncDotState extends State + with SingleTickerProviderStateMixin { + // Created in initState, NOT as a lazy `late final` initialiser. A dot that + // never animates never touches the field, so the first read would be + // `dispose()` — building a Ticker against an already-deactivated element, + // which throws "Looking up a deactivated widget's ancestor is unsafe". The + // quiet path is the common one, so the lazy version was broken for almost + // every user of this widget. + late final AnimationController _pulse; + + @override + void initState() { + super.initState(); + _pulse = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1400), + ); + if (widget.active) _pulse.repeat(reverse: true); + } + + @override + void didUpdateWidget(covariant SyncDot old) { + super.didUpdateWidget(old); + if (widget.active == old.active) return; + // Never leave the controller running while the dot is invisible — a + // repeating animation on an off-screen widget is a permanent 60 Hz wake-up + // on a screen that already fights for the main isolate during a drain. + if (widget.active) { + _pulse.repeat(reverse: true); + } else { + _pulse.stop(); + _pulse.value = 0; + } + } + + @override + void dispose() { + _pulse.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // The box is always occupied, so the title never shifts when the dot + // appears or goes — a jumping wordmark would be far louder than the dot. + return SizedBox( + key: SyncDot.sizeKey, + width: widget.size, + height: widget.size, + child: !widget.active + ? const SizedBox.shrink() + : Semantics( + label: 'Syncing with your band', + liveRegion: true, + child: FadeTransition( + opacity: Tween(begin: 0.25, end: 1).animate( + CurvedAnimation(parent: _pulse, curve: Curves.easeInOut), + ), + child: DecoratedBox( + decoration: BoxDecoration( + color: AppColors.accent, + shape: BoxShape.circle, + ), + ), + ), + ), + ); + } +} diff --git a/lib/ui/today/today_screen.dart b/lib/ui/today/today_screen.dart index 6d44e04..cbd251d 100644 --- a/lib/ui/today/today_screen.dart +++ b/lib/ui/today/today_screen.dart @@ -186,12 +186,13 @@ class _TodayScreenState extends State // the REBUILD TRIGGER is scoped. liveSteps is included (already rate- // limited to ~1/s at the source) so the steps tile doesn't freeze mid-walk // while waiting on an unrelated dbCounts change. - context.select, bool, String, int, bool)>( + context.select, bool, String, int, bool, bool)>( (a) => ( a.dbCounts, a.reanalyzing, a.reanalyzeProgress, a.liveSteps, + a.syncingNow, // The steps tile stops adding the band's live count the moment the // phone owns the day, so a flip has to rebuild it. a.todayStepsFromPhone, @@ -202,14 +203,26 @@ class _TodayScreenState extends State return AppScaffold( // Brand wordmark — a confident title, not a greeting. - titleWidget: Text( - 'Edge', - style: AppText.h1.copyWith( - fontWeight: FontWeight.w800, - letterSpacing: -0.9, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, + titleWidget: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Flexible( + child: Text( + 'Edge', + style: AppText.h1.copyWith( + fontWeight: FontWeight.w800, + letterSpacing: -0.9, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: Sp.x2), + // Breathes only while records are actually landing. See sync_dot.dart + // for why this is the whole of the answer to "is it syncing". + SyncDot(active: context.select((a) => a.syncingNow)), + ], ), actions: [ RoundIconButton( diff --git a/test/live_step_day_window_test.dart b/test/live_step_day_window_test.dart new file mode 100644 index 0000000..20ba5fb --- /dev/null +++ b/test/live_step_day_window_test.dart @@ -0,0 +1,80 @@ +// The Today tile must not carry yesterday's steps into today. +// +// From TestFlight: "steps and calories are not resetting every day - it's +// accumulating". The tile shows `derived day total + live session steps`, and +// the live half counts since the BLE connection began. This app holds ONE +// continuous connection by design, so that counter spans midnight — at 00:01 +// the tile showed yesterday's live total on top of today's derived zero. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ble/ble_state.dart'; + +void main() { + test('a connection that spans midnight starts today at zero', () { + final w = LiveStepDayWindow(); + // A session on one day: everything the counter holds is that day's. + expect(w.stepsToday(4000, '2026-08-08'), 4000); + + // Midnight. Those 4,000 steps are yesterday's, and yesterday's derived + // total already carries them. + expect(w.stepsToday(4000, '2026-08-09'), 0); + + // The counter keeps climbing from where it was; only the new steps count. + expect(w.stepsToday(4120, '2026-08-09'), 120); + }); + + test('a reconnect mid-day does not stop the count', () { + final w = LiveStepDayWindow(); + expect(w.stepsToday(900, '2026-08-08'), 900); + + // Reconnect: the session counter restarts at 0. A stale base would make + // every later reading negative, and clamping that at zero would silently + // stop counting for the rest of the day. + expect(w.stepsToday(0, '2026-08-08'), 0); + expect(w.stepsToday(60, '2026-08-08'), 60); + }); + + test('a reconnect immediately after midnight counts from zero', () { + final w = LiveStepDayWindow(); + w.stepsToday(7000, '2026-08-08'); + expect(w.stepsToday(0, '2026-08-09'), 0); + expect(w.stepsToday(250, '2026-08-09'), 250); + }); + + test('a first-ever observation counts in full — it cannot be yesterday', () { + // The counter is per-connection and per-process, so on a FIRST observation + // whatever it holds was walked in this session, today. Rebasing here would + // discard a real walk (the live-coverage suite catches exactly that). The + // midnight case is covered by having OBSERVED the earlier day, which the + // sample path guarantees independently of whether any screen is built. + final w = LiveStepDayWindow(); + expect(w.stepsToday(3300, '2026-08-09'), 3300); + expect(w.stepsToday(3400, '2026-08-09'), 3400); + }); + + test('a negative counter never becomes a baseline', () { + final w = LiveStepDayWindow(); + w.stepsToday(100, '2026-08-08'); + expect(w.stepsToday(-5, '2026-08-08'), 0); + // The dangerous half: a negative seated in the base would make the next + // ordinary reading report the difference as steps nobody took. + expect(w.stepsToday(0, '2026-08-08'), 0, + reason: 'zero steps is zero steps, whatever came before it'); + }); + + test('a negative reading on a day change is not a baseline either', () { + final w = LiveStepDayWindow(); + w.stepsToday(4000, '2026-08-08'); + expect(w.stepsToday(-5, '2026-08-09'), 0); + expect(w.stepsToday(0, '2026-08-09'), 0); + expect(w.stepsToday(90, '2026-08-09'), 90); + }); + + test('days are tracked, so a skipped day still rebases', () { + final w = LiveStepDayWindow(); + w.stepsToday(1000, '2026-08-08'); + // The phone was off for a day; the band reconnected and kept counting. + expect(w.stepsToday(1500, '2026-08-10'), 0); + expect(w.day, '2026-08-10'); + }); +} diff --git a/test/sync_activity_window_test.dart b/test/sync_activity_window_test.dart new file mode 100644 index 0000000..ba09e5e --- /dev/null +++ b/test/sync_activity_window_test.dart @@ -0,0 +1,39 @@ +// "Don't get to know if syncing is happening or not" (TestFlight). +// +// The window has to hold across the gaps between batches (or an indicator +// strobes) and it has to expire (or a finished drain looks like a running one +// forever). Pure, so both edges are testable without a band. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ble/ble_state.dart'; + +void main() { + test('nothing has arrived, so nothing is syncing', () { + final w = SyncActivityWindow(); + expect(w.isActive(1000), isFalse); + expect(w.expiresAtMs(), isNull); + }); + + test('a batch holds the answer true across the gap to the next one', () { + final w = SyncActivityWindow(windowMs: 6000); + w.mark(10000); + expect(w.isActive(10000), isTrue); + expect(w.isActive(15999), isTrue, reason: 'still inside the window'); + expect(w.isActive(16000), isFalse, reason: 'the window is half-open'); + expect(w.expiresAtMs(), 16000); + }); + + test('each batch re-arms the window', () { + final w = SyncActivityWindow(windowMs: 6000); + w.mark(10000); + w.mark(14000); + expect(w.isActive(18000), isTrue, reason: 'measured from the LAST batch'); + expect(w.isActive(20000), isFalse); + }); + + test('a long-finished drain does not still read as syncing', () { + final w = SyncActivityWindow(windowMs: 6000); + w.mark(1000); + expect(w.isActive(1000 + 60 * 60 * 1000), isFalse); + }); +} diff --git a/test/sync_dot_test.dart b/test/sync_dot_test.dart new file mode 100644 index 0000000..9ba7284 --- /dev/null +++ b/test/sync_dot_test.dart @@ -0,0 +1,61 @@ +// The sync indicator has to be quiet AND honest. +// +// "Don't get to know if syncing is happening or not" (TestFlight). The answer +// is a dot, so the two things worth pinning are that it is absent when nothing +// is arriving, and that it never leaves an animation running while invisible — +// a repeating controller on a hidden widget is a permanent frame-rate wake-up +// on the screen that already competes with a drain for the main isolate. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ui/design/sync_dot.dart'; + +void main() { + // MaterialApp's own route transitions are FadeTransitions too — scope every + // finder to the widget under test or they match the scaffolding. + final dotFade = find.descendant( + of: find.byType(SyncDot), + matching: find.byType(FadeTransition), + ); + + Future pumpDot(WidgetTester t, {required bool active}) => + t.pumpWidget(MaterialApp( + home: Scaffold(body: Center(child: SyncDot(active: active))), + )); + + testWidgets('nothing is drawn when no data is arriving', (t) async { + await pumpDot(t, active: false); + expect(dotFade, findsNothing); + expect(find.bySemanticsLabel('Syncing with your band'), findsNothing); + }); + + testWidgets('it breathes while records land, and says so to a screen reader', + (t) async { + await pumpDot(t, active: true); + expect(dotFade, findsOneWidget); + expect(find.bySemanticsLabel('Syncing with your band'), findsOneWidget); + + final before = t.widget(dotFade); + final o1 = before.opacity.value; + await t.pump(const Duration(milliseconds: 700)); + final o2 = t.widget(dotFade).opacity.value; + expect(o1, isNot(o2), reason: 'a static dot is not a sync indicator'); + }); + + testWidgets('the animation stops when the sync does', (t) async { + await pumpDot(t, active: true); + await pumpDot(t, active: false); + // A still-repeating controller would keep scheduling frames forever, and + // pumpAndSettle times out rather than settling. + await t.pumpAndSettle(); + expect(dotFade, findsNothing); + }); + + testWidgets('it occupies the same space either way', (t) async { + await pumpDot(t, active: false); + final quiet = t.getSize(find.byKey(SyncDot.sizeKey)); + await pumpDot(t, active: true); + expect(t.getSize(find.byKey(SyncDot.sizeKey)), quiet, + reason: 'the title must not shift when a sync starts or ends'); + }); +}