From 8631555beef32fc39db526ab5f1d34799e55dacd Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 8 Aug 2026 23:42:31 +0530 Subject: [PATCH 1/2] steps start again at midnight, and a dot for whether anything is happening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the today tile shows the derived day total plus the live count, and the live count is since the ble connection began. the whole engine is built around never dropping that connection, so it spans midnight — at 00:01 the tile carried the whole of yesterday on top of today and kept climbing. the day boundary is watched from the sample path rather than from the widget: a phone parked on another tab across midnight would otherwise make its first read of the day the first read of any day, and a first read counts in full. sync stays invisible — no spinners, no copy — but invisible and broken look the same, so a 6pt dot next to the wordmark breathes while records are actually landing and is absent otherwise. --- lib/ble/ble_state.dart | 52 ++++++++++++++++ lib/state/app_state.dart | 45 +++++++++++++- lib/ui/design/design.dart | 1 + lib/ui/design/sync_dot.dart | 96 +++++++++++++++++++++++++++++ lib/ui/today/today_screen.dart | 31 +++++++--- test/live_step_day_window_test.dart | 68 ++++++++++++++++++++ test/sync_dot_test.dart | 61 ++++++++++++++++++ 7 files changed, 343 insertions(+), 11 deletions(-) create mode 100644 lib/ui/design/sync_dot.dart create mode 100644 test/live_step_day_window_test.dart create mode 100644 test/sync_dot_test.dart diff --git a/lib/ble/ble_state.dart b/lib/ble/ble_state.dart index 6099c43..16030a2 100644 --- a/lib/ble/ble_state.dart +++ b/lib/ble/ble_state.dart @@ -553,6 +553,58 @@ 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 +/// 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 sessionTotal, String today) { + 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..aa85368 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; import '../ble/ios_ble_restore.dart'; import '../cloud/companion_client.dart'; import '../compute/derivation_engine.dart'; @@ -2100,8 +2101,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 +2230,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 @@ -2738,6 +2756,7 @@ class AppState extends ChangeNotifier { // UI reflects real progress as it happens, mid-burst. if (frontierAfter != null && frontierAfter > (_lastRecTs ?? 0)) { _lastRecTs = frontierAfter; + _lastIngestMs = DateTime.now().millisecondsSinceEpoch; notifyListeners(); } final strapNewest = engine.strapHistoryNewestTs; @@ -3568,6 +3587,28 @@ class AppState extends ChangeNotifier { 'reanalyze_progress': reanalyzeProgress, }; + /// Wall-clock time a batch of band records last landed. NOT the record's own + /// timestamp (`lastRecordAt`) — this is "when did data last arrive", which is + /// what tells a user something is happening right now. + int _lastIngestMs = 0; + + /// How long a batch keeps the sync indicator lit. Records arrive in bursts + /// with gaps between them, so a window shorter than this would make the + /// indicator strobe; much longer and it would claim to be syncing after a + /// drain has finished. + static const int _syncActiveWindowMs = 6000; + + /// 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 => + _lastIngestMs > 0 && + DateTime.now().millisecondsSinceEpoch - _lastIngestMs < + _syncActiveWindowMs; + /// 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..4034324 --- /dev/null +++ b/lib/ui/design/sync_dot.dart @@ -0,0 +1,96 @@ +// 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}); + + /// 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( + 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..9061282 --- /dev/null +++ b/test/live_step_day_window_test.dart @@ -0,0 +1,68 @@ +// 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('never returns a negative count', () { + final w = LiveStepDayWindow(); + w.stepsToday(100, '2026-08-08'); + expect(w.stepsToday(-5, '2026-08-08'), 0); + }); + + 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_dot_test.dart b/test/sync_dot_test.dart new file mode 100644 index 0000000..ace3af4 --- /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.byType(SyncDot)); + await pumpDot(t, active: true); + expect(t.getSize(find.byType(SyncDot)), quiet, + reason: 'the title must not shift when a sync starts or ends'); + }); +} From 2d1049717570173f6fe6fb382516547f30f4088f Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 8 Aug 2026 23:52:33 +0530 Subject: [PATCH 2/2] the dot was reading a signal that could not fire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _onDataStored advances the frontier itself, so by the time the sync burst checked whether the frontier had moved it never had — the indicator was hung off a condition that is false exactly when records land. it marks activity at the durable write instead, which is the one path that sees every commit. the window also decayed on wall-clock time with nothing notifying at the boundary, so a band that went quiet left the dot lit until some unrelated change came along. a one-shot timer closes it, cancelled on dispose. a negative counter reading could seat itself as the day's baseline, and the next ordinary reading would report the difference as steps nobody took. --- lib/ble/ble_state.dart | 33 +++++++++++++++++++++- lib/state/app_state.dart | 44 ++++++++++++++++++++--------- lib/ui/design/sync_dot.dart | 5 ++++ test/live_step_day_window_test.dart | 14 ++++++++- test/sync_activity_window_test.dart | 39 +++++++++++++++++++++++++ test/sync_dot_test.dart | 4 +-- 6 files changed, 121 insertions(+), 18 deletions(-) create mode 100644 test/sync_activity_window_test.dart diff --git a/lib/ble/ble_state.dart b/lib/ble/ble_state.dart index 16030a2..5d166de 100644 --- a/lib/ble/ble_state.dart +++ b/lib/ble/ble_state.dart @@ -553,6 +553,33 @@ 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. /// @@ -574,7 +601,11 @@ class LiveStepDayWindow { /// [sessionTotal] is the connection-lifetime count; [today] is the local day /// label. Returns what belongs to [today]. - int stepsToday(int sessionTotal, String 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 diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index aa85368..2866d21 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -32,7 +32,7 @@ import '../ble/accessory_setup.dart'; import '../ble/android_background.dart'; import '../ble/ble_engine.dart'; import '../ble/ble_state.dart' - show AlarmConfirmation, AlarmEffect, LiveStepDayWindow; + show AlarmConfirmation, AlarmEffect, LiveStepDayWindow, SyncActivityWindow; import '../ble/ios_ble_restore.dart'; import '../cloud/companion_client.dart'; import '../compute/derivation_engine.dart'; @@ -1012,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 @@ -1655,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'); @@ -2756,7 +2761,6 @@ class AppState extends ChangeNotifier { // UI reflects real progress as it happens, mid-burst. if (frontierAfter != null && frontierAfter > (_lastRecTs ?? 0)) { _lastRecTs = frontierAfter; - _lastIngestMs = DateTime.now().millisecondsSinceEpoch; notifyListeners(); } final strapNewest = engine.strapHistoryNewestTs; @@ -3587,16 +3591,13 @@ class AppState extends ChangeNotifier { 'reanalyze_progress': reanalyzeProgress, }; - /// Wall-clock time a batch of band records last landed. NOT the record's own - /// timestamp (`lastRecordAt`) — this is "when did data last arrive", which is - /// what tells a user something is happening right now. - int _lastIngestMs = 0; + final SyncActivityWindow _syncActivity = SyncActivityWindow(); - /// How long a batch keeps the sync indicator lit. Records arrive in bursts - /// with gaps between them, so a window shorter than this would make the - /// indicator strobe; much longer and it would claim to be syncing after a - /// drain has finished. - static const int _syncActiveWindowMs = 6000; + /// 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 @@ -3605,9 +3606,24 @@ class AppState extends ChangeNotifier { /// /// From TestFlight: "don't get to know if syncing is happening or not". bool get syncingNow => - _lastIngestMs > 0 && - DateTime.now().millisecondsSinceEpoch - _lastIngestMs < - _syncActiveWindowMs; + _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 diff --git a/lib/ui/design/sync_dot.dart b/lib/ui/design/sync_dot.dart index 4034324..16c9966 100644 --- a/lib/ui/design/sync_dot.dart +++ b/lib/ui/design/sync_dot.dart @@ -18,6 +18,10 @@ 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; @@ -72,6 +76,7 @@ class _SyncDotState extends State // 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 diff --git a/test/live_step_day_window_test.dart b/test/live_step_day_window_test.dart index 9061282..20ba5fb 100644 --- a/test/live_step_day_window_test.dart +++ b/test/live_step_day_window_test.dart @@ -52,10 +52,22 @@ void main() { expect(w.stepsToday(3400, '2026-08-09'), 3400); }); - test('never returns a negative count', () { + 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', () { 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 index ace3af4..9ba7284 100644 --- a/test/sync_dot_test.dart +++ b/test/sync_dot_test.dart @@ -53,9 +53,9 @@ void main() { testWidgets('it occupies the same space either way', (t) async { await pumpDot(t, active: false); - final quiet = t.getSize(find.byType(SyncDot)); + final quiet = t.getSize(find.byKey(SyncDot.sizeKey)); await pumpDot(t, active: true); - expect(t.getSize(find.byType(SyncDot)), quiet, + expect(t.getSize(find.byKey(SyncDot.sizeKey)), quiet, reason: 'the title must not shift when a sync starts or ends'); }); }