From 108a864ecc71c5b437ef99cb76aabed2f52c7a67 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Thu, 20 Aug 2026 07:40:30 +0530 Subject: [PATCH 1/9] bump insights after a sleep-override/nap re-derive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit overrides rewrote day_result and nothing told the screens, so an edit only showed up after a restart. also dropped reanalyzeDays and the _bumpInsightsRevision alias — no callers. --- lib/state/app_state.dart | 45 ++++++---------------------------------- 1 file changed, 6 insertions(+), 39 deletions(-) diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 06f3ba24..93427ed0 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -1434,7 +1434,7 @@ class AppState extends ChangeNotifier { _log('[derive] session rescore failed: $e'); } await LocalDb.refreshComputeFreshness(); - _bumpInsightsRevision(); + bumpInsights(); notifyListeners(); // screens re-fetch from the derived store // Same signal, for the surfaces that can't listen: home/lock-screen // widget, Watch mirror, Siri intents (WidgetService.refresh). @@ -1852,7 +1852,7 @@ class AppState extends ChangeNotifier { }, ); await LocalDb.refreshComputeFreshness(); - _bumpInsightsRevision(); + bumpInsights(); dbCounts = await LocalDb.counts(); return n; } catch (e) { @@ -1921,6 +1921,9 @@ class AppState extends ChangeNotifier { try { await _derive.run(_profile, force: true); await LocalDb.refreshComputeFreshness(); + // The day_result rows just changed — without this no RevisionReload screen + // re-reads, so an override/nap edit only showed up after a restart. + bumpInsights(); dbCounts = await LocalDb.counts(); } catch (e) { _log('[derive] sleep-override re-derive failed: $e'); @@ -1936,40 +1939,6 @@ class AppState extends ChangeNotifier { /// when they are finalized. Future reanalyzeForNapEdit() => _reanalyzeForOverride(); - Future reanalyzeDays(Set days) async { - if (days.isEmpty || reanalyzing) return 0; - reanalyzing = true; - final ordered = days.toList()..sort(); - reanalyzeProgress = - 'Analyzing ${ordered.length} day${ordered.length == 1 ? '' : 's'}…'; - notifyListeners(); - try { - final n = await _derive.runDays( - _profile, - days, - force: true, - onDayDone: (day, index, total) async { - reanalyzeProgress = 'Analyzing $index/$total'; - if (index == total || index == 1 || index % 3 == 0) { - dbCounts = await LocalDb.counts(); - notifyListeners(); - } - }, - ); - await LocalDb.refreshComputeFreshness(); - _bumpInsightsRevision(); - dbCounts = await LocalDb.counts(); - return n; - } catch (e) { - _log('[derive] reanalyze selected failed: $e'); - return 0; - } finally { - reanalyzing = false; - reanalyzeProgress = ''; - notifyListeners(); - } - } - Future>> dataHistoryDays() => LocalDb.dataHistoryDays(); @@ -2388,8 +2357,6 @@ class AppState extends ChangeNotifier { } } - void _bumpInsightsRevision() => bumpInsights(); - /// Say that the DURABLE data changed, so every screen reading it re-reads. /// /// Public because the writers are not all in here: the log-workout sheet @@ -5180,7 +5147,7 @@ class AppState extends ChangeNotifier { // this the Workout tab, which loads once and caches, showed no trace of // the workout you had just finished in History, "This week", "Tracked" // or the weekly load until the app was restarted. - _bumpInsightsRevision(); + bumpInsights(); } catch (e) { _log('[workout] could not save session $id: $e — keeping it live'); notifyListeners(); From 1d279d365652aa0332cf90afcee666e6d2ac6080 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Thu, 20 Aug 2026 07:41:37 +0530 Subject: [PATCH 2/9] stop inflating the gzip backup twice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit importFromDbFile already sniffs and inflates, and unlike gzip.decoder it checks the trailer — the hand-rolled block would restore a truncated backup short and call it a success. --- lib/state/app_state.dart | 37 +++++-------------------------------- 1 file changed, 5 insertions(+), 32 deletions(-) diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 93427ed0..3afb3c72 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -418,38 +418,11 @@ class AppState extends ChangeNotifier { Future importEdgeBackup(String path) async { importRollupError = null; - // The app's OWN automatic backup is gzipped (`.db.gz`, see - // auto_backup.dart's kBackupExtension) and `importFromDbFile` opens a - // SQLite file, so restoring one on a new phone failed — the most important - // import path there is, and the only one where the user has already lost - // the original. Detected by magic bytes rather than by extension so a - // renamed file still restores. - String src = path; - String? inflated; - try { - final head = await File(path).openRead(0, 2).first; - if (head.length >= 2 && head[0] == 0x1f && head[1] == 0x8b) { - inflated = '$path.inflated.db'; - final sink = File(inflated).openWrite(); - await File(path).openRead().transform(gzip.decoder).pipe(sink); - src = inflated; - } - } catch (_) { - // Unreadable header — hand the original to the importer and let its own - // error be the one the user sees. - src = path; - } - final counts = await () async { - try { - return await LocalDb.importFromDbFile(src); - } finally { - if (inflated != null) { - try { - await File(inflated).delete(); - } catch (_) {} - } - } - }(); + // Gzipped auto-backups (`.db.gz`) are inflated INSIDE importFromDbFile — + // do not add it back here. Its inflate checks the gzip trailer, so a + // truncated backup fails loudly; `gzip.decoder` returns partial output + // without raising and would restore short while reporting success. + final counts = await LocalDb.importFromDbFile(path); // Imported rows include derived day_result/metric_series → refresh rollups. try { await _derive.finalizeImport(_profile); From f503b930ab765effd5283722f0119bb5f6931f79 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Thu, 20 Aug 2026 07:44:15 +0530 Subject: [PATCH 3/9] decode app icons at the size the row draws them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the notification-relay list decodes every app icon at whatever the launcher shipped — up to 512 px — to paint a 32 pt row. cacheWidth only: these are third-party icons and not all of them are square, so pinning both dimensions would squash them. same reasoning as _IconChoice in settings.dart. --- lib/ui2/profile/band_notifications.dart | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/ui2/profile/band_notifications.dart b/lib/ui2/profile/band_notifications.dart index 6b7832a5..0187b81a 100644 --- a/lib/ui2/profile/band_notifications.dart +++ b/lib/ui2/profile/band_notifications.dart @@ -228,6 +228,11 @@ class _AppRow extends StatelessWidget { Widget build(BuildContext c) { final p = P.of(c); final icon = app.icon; + // Decoded at the size it is drawn at, same reasoning as _IconChoice in + // settings.dart: these are launcher masters (Android ships up to 512 px) + // decoded in full to paint a 32 pt row. WIDTH ONLY — a third-party icon + // need not be square, and constraining both dimensions would distort it. + final px = (32 * MediaQuery.devicePixelRatioOf(c)).round(); return Pressable( onTap: () => onChanged?.call(app.package, !app.on), semanticLabel: @@ -239,7 +244,10 @@ class _AppRow extends StatelessWidget { borderRadius: R.rSm, child: icon != null && icon.isNotEmpty ? Image.memory(icon, - width: 32, height: 32, gaplessPlayback: true) + width: 32, + height: 32, + cacheWidth: px, + gaplessPlayback: true) : Container( width: 32, height: 32, From 89194de6efadc93dc335f3e4db4a865fdb7f8fb3 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Thu, 20 Aug 2026 07:44:22 +0530 Subject: [PATCH 4/9] stop re-parsing every record's hex to read one byte MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BurstStats.onHistoricalData took the record hex and ran the whole thing back through hexToBytes just to reach inner[1] — the revision — which the ingest path had already read as recType two hundred lines earlier. one throwaway buffer per record, on every record of every offload: roughly 6 MB of garbage for a full gen4 backfill. pass the revision, drop the Sample the function never looked at. --- lib/ble/ble_engine.dart | 23 +++++++++++------------ test/ble_safe_trim_test.dart | 31 ++++++++++++++++--------------- 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index 53274245..c1d79707 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -2965,7 +2965,7 @@ class BleEngine { // just stores directly if a frame somehow arrives before setup completed. final d = _drain; if (d != null) { - d.onHistoricalRecord(raw, sample); + d.onHistoricalRecord(raw, sample, recType); } else { unawaited(_storeRecord(sample, raw)); } @@ -5015,11 +5015,13 @@ class DrainController { int get currentBurstHistoricalPacketCount => burstStats.historicalPacketCount; String get currentBurstBreakdown => burstStats.breakdownString; - void onHistoricalRecord(RawRecord raw, Sample? sample) { + /// [revision] is the record version byte the ingest path already read off + /// the frame (-1 when the frame was too short to have one). + void onHistoricalRecord(RawRecord raw, Sample? sample, int revision) { records++; recordsThisOffload++; _lastProgressAt = DateTime.now(); - burstStats.onHistoricalData(raw.packetType, raw.counter, sample, raw.hex); + burstStats.onHistoricalData(raw.packetType, raw.counter, revision); if (_buffering) { _raws.add(raw); _samples.add(sample); @@ -5364,19 +5366,16 @@ class BurstStats { return parts.join(', '); } - void onHistoricalData( - int packetType, - int counter, - Sample? sample, - String rawHex, - ) { + /// [revision] is the record version byte (inner[1]), which the caller has + /// already read off the frame. This used to take the record's hex and parse + /// the whole thing back into bytes to reach that one byte — a throwaway + /// buffer per record, on every record of every offload. + void onHistoricalData(int packetType, int counter, int revision) { if (packetType != PacketType.historicalData) return; - final inner = hexToBytes(rawHex); - if (inner.length < 2) { + if (revision < 0) { _unknownCount++; return; } - final revision = inner[1]; if (_ordinaryHistoricalRevisions.contains(revision)) { _dataPacketCountsByRevision[revision] = (_dataPacketCountsByRevision[revision] ?? 0) + 1; diff --git a/test/ble_safe_trim_test.dart b/test/ble_safe_trim_test.dart index 06d77cd4..dd84a05a 100644 --- a/test/ble_safe_trim_test.dart +++ b/test/ble_safe_trim_test.dart @@ -16,8 +16,9 @@ import 'package:openstrap_edge/data/models.dart'; import 'package:openstrap_edge/sync/sync_policy.dart'; /// A well-formed inner-frame hex: [0]=0x2f historical, [1]=0x18 (revision 24), -/// then the u32 record counter. BurstStats re-parses this, so it must be real -/// hex, not a label. +/// then the u32 record counter. Nothing re-parses it any more — BurstStats is +/// handed the revision the ingest path already read — but the commit path +/// stores it, so it stays real hex rather than a label. String _hex(int counter) => '2f18${counter.toRadixString(16).padLeft(8, '0')}'; @@ -61,7 +62,7 @@ void main() { (raws, samples, token, {archives, deviceFamily}) async => throw StateError('OOM in SqlCommand.getSqlArguments'), ); - d.onHistoricalRecord(_raw(1), _sample(1)); + d.onHistoricalRecord(_raw(1), _sample(1), 24); final durable = await d.commit(_tokenA); @@ -76,8 +77,8 @@ void main() { final d = _drainWith( (raws, samples, token, {archives, deviceFamily}) async => throw StateError('rollback'), ); - d.onHistoricalRecord(_raw(1), _sample(1)); - d.onHistoricalRecord(_raw(2), _sample(2)); + d.onHistoricalRecord(_raw(1), _sample(1), 24); + d.onHistoricalRecord(_raw(2), _sample(2), 24); d.onUndecodableRecord(_archive(3)); expect(d.bufferedRecords, 2); @@ -98,8 +99,8 @@ void main() { seenArchives.addAll((archives ?? const []).map((a) => a.hex)); }); // (rebuild the same state on a controller whose commit succeeds) - d2.onHistoricalRecord(_raw(1), _sample(1)); - d2.onHistoricalRecord(_raw(2), _sample(2)); + d2.onHistoricalRecord(_raw(1), _sample(1), 24); + d2.onHistoricalRecord(_raw(2), _sample(2), 24); d2.onUndecodableRecord(_archive(3)); expect(await d2.commit(_tokenA), isTrue); expect(seenRaws, [_hex(1), _hex(2)]); @@ -127,10 +128,10 @@ void main() { log: (_) {}, ); - d.onHistoricalRecord(_raw(1), _sample(1)); + d.onHistoricalRecord(_raw(1), _sample(1), 24); final inFlight = d.commit(_tokenA); // A record arrives while the commit is parked mid-await. - d.onHistoricalRecord(_raw(2), _sample(2)); + d.onHistoricalRecord(_raw(2), _sample(2), 24); gate.complete(); expect(await inFlight, isFalse); @@ -155,12 +156,12 @@ void main() { // Empty token-only commits do not count as trim advance (would feed // auto-continue while the durable frontier stayed frozen). - d.onHistoricalRecord(_raw(0), _sample(0)); + d.onHistoricalRecord(_raw(0), _sample(0), 24); expect(await d.commit(_tokenA), isTrue); expect(d.lastTrimAdvanced, isTrue); fail = true; - d.onHistoricalRecord(_raw(1), _sample(1)); + d.onHistoricalRecord(_raw(1), _sample(1), 24); expect(await d.commit(_tokenB), isFalse); // The cursor did NOT move to tokenB, so nothing may claim it did. expect(d.lastTrimAdvanced, isTrue, reason: 'rolled back to the tokenA state'); @@ -189,7 +190,7 @@ void main() { test('a successful commit clears the buffer and reports durable', () async { final d = _drainWith((raws, samples, token, {archives, deviceFamily}) async {}); - d.onHistoricalRecord(_raw(1), _sample(1)); + d.onHistoricalRecord(_raw(1), _sample(1), 24); expect(await d.commit(_tokenA), isTrue); expect(d.bufferedRecords, 0); @@ -380,7 +381,7 @@ void main() { onArchive: null, log: (_) {}, ); - d.onHistoricalRecord(_raw(1), _sample(1)); + d.onHistoricalRecord(_raw(1), _sample(1), 24); expect(d.bufferedRecords, 0); expect(d.supportsSafeTrim, isFalse); expect(wrote, 1); @@ -390,7 +391,7 @@ void main() { group('P0 — a discarded burst poisons its HISTORY_END token', () { test('discardOpenChunk marks the open burst un-ACKable', () async { final d = _drainWith((raws, samples, token, {archives, deviceFamily}) async {}); - d.onHistoricalRecord(_raw(1), _sample(1)); + d.onHistoricalRecord(_raw(1), _sample(1), 24); expect(d.burstDiscarded, isFalse); d.discardOpenChunk(); @@ -424,7 +425,7 @@ void main() { // flight, landed on a clean guard, and got ACKed verbatim: the band // trimmed exactly the records the watchdog threw away. final d = _drainWith((raws, samples, token, {archives, deviceFamily}) async {}); - d.onHistoricalRecord(_raw(1), _sample(1)); + d.onHistoricalRecord(_raw(1), _sample(1), 24); d.discardOpenChunk(); d.rearm(); From 3d3b9e91ccc450142c4c4684e57d3f8431f7d053 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Thu, 20 Aug 2026 07:44:32 +0530 Subject: [PATCH 5/9] rest countdown rebuilt the whole live shell once a second MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit restLeft was a field behind setState, so each of the ninety ticks between sets rebuilt _LiveStrengthState and with it a fresh LiveShell — header, transport, footer, body — in the file that added LiveTick to stop exactly this. a session with twenty sets does it well over a thousand times. it's a ValueNotifier now, with only the rest ring listening. setState stays at the zero crossing, where the footer and the body branch genuinely change. the yoga hold timer had the same wrapper for nothing — that body already rebuilds at 1 Hz through the shell clock, so the setState just bought a second rebuild of the same subtree. deleted. test pumps a set, ticks a second, and checks the LiveShell instance is the same one while the countdown moved. fails on the old code. --- lib/ui2/activity/live.dart | 54 ++++++++++++++++++++++++++----------- test/ui2_activity_test.dart | 30 +++++++++++++++++++++ 2 files changed, 69 insertions(+), 15 deletions(-) diff --git a/lib/ui2/activity/live.dart b/lib/ui2/activity/live.dart index 62fa399b..cf944026 100644 --- a/lib/ui2/activity/live.dart +++ b/lib/ui2/activity/live.dart @@ -1076,7 +1076,17 @@ class _LiveStrengthState extends State { final logged = []; static const restTarget = 90; - int restLeft = 0; + + /// A listenable rather than a field behind `setState`, for the same reason + /// [LiveShellState.clock] is one: the countdown ticks once a second for a + /// minute and a half between sets, and putting it behind `setState` rebuilt + /// this state, and with it the whole [LiveShell] — header, transport, + /// footer, body — when the only thing that moved was the ring. + /// + /// `setState` still runs at the ZERO CROSSING, where the screen genuinely + /// changes shape: the footer flips back to Log set and the body back to the + /// entry pad. + final restLeft = ValueNotifier(0); Timer? _rest; @override @@ -1089,6 +1099,7 @@ class _LiveStrengthState extends State { @override void dispose() { _rest?.cancel(); + restLeft.dispose(); super.dispose(); } @@ -1164,15 +1175,17 @@ class _LiveStrengthState extends State { rpe: rpe, restSec: prior == null ? null : now.difference(prior).inSeconds, at: now)); - restLeft = restTarget; }); + restLeft.value = restTarget; _persist(); _rest?.cancel(); _rest = Timer.periodic(Motion.tick, (t) { if (!mounted) return; - setState(() => restLeft--); - if (restLeft <= 0) { + restLeft.value--; + if (restLeft.value <= 0) { t.cancel(); + // The one tick the shell has to see — see [restLeft]. + setState(() {}); HapticFeedback.mediumImpact(); // A buzz is not a message. The rest-over moment was reachable only by // feeling the watch, or by watching a number nobody was told to watch. @@ -1184,10 +1197,8 @@ class _LiveStrengthState extends State { void goExercise(int i) { if (i < 0 || i >= plan.length) return; _rest?.cancel(); - setState(() { - index = i; - restLeft = 0; - }); + restLeft.value = 0; + setState(() => index = i); _seedFromHistory(); } @@ -1250,13 +1261,15 @@ class _LiveStrengthState extends State { widget.feed?.call() ?? LiveFeed.none, widget.weightKg, elapsed, widget.private, strength: log), - footer: (ctx) => restLeft > 0 + footer: (ctx) => restLeft.value > 0 ? Row(children: [ Expanded( child: BigButton('+30s', color: C.teal, soft: true, - onTap: () => setState(() => restLeft += 30)), + // No `setState`: nothing on the shell changes shape while + // the countdown stays above zero, and the ring listens. + onTap: () => restLeft.value += 30), ), const SizedBox(width: S.x3), Expanded( @@ -1265,7 +1278,8 @@ class _LiveStrengthState extends State { color: C.teal, onTap: () { _rest?.cancel(); - setState(() => restLeft = 0); + restLeft.value = 0; + setState(() {}); }), ), ]) @@ -1355,7 +1369,13 @@ class _LiveStrengthState extends State { fontFeatures: const [FontFeature.tabularFigures()])), const SizedBox(height: S.x6), - if (restLeft > 0) _rest_(p) else ..._entry(p), + if (restLeft.value > 0) + ValueListenableBuilder( + valueListenable: restLeft, + builder: (_, _, _) => _rest_(p), + ) + else + ..._entry(p), const SizedBox(height: S.x6), if (setsHere.isNotEmpty) ...[ @@ -1483,9 +1503,10 @@ class _LiveStrengthState extends State { child: Stack(alignment: Alignment.center, children: [ CustomPaint( size: const Size(170, 170), - painter: Ring(restLeft / restTarget, p.on(C.teal), p.track)), + painter: + Ring(restLeft.value / restTarget, p.on(C.teal), p.track)), Column(mainAxisSize: MainAxisSize.min, children: [ - Text(clock(restLeft), style: F.n34.copyWith(color: p.ink)), + Text(clock(restLeft.value), style: F.n34.copyWith(color: p.ink)), if (logged.isNotEmpty) Text( logged.last.loadKg == null @@ -1784,9 +1805,12 @@ class _LiveFlowState extends State super.initState(); pose = (LiveDraft.current?.data['pose'] as num?)?.toInt() ?? 0; reached = (LiveDraft.current?.data['pose_max'] as num?)?.toInt() ?? pose; + // No `setState`: this body follows the shell's clock, which already + // rebuilds it once a second, so the wrapper only bought a second rebuild + // of the same subtree at the same rate. _hold = Timer.periodic(Motion.tick, (_) { if (!mounted) return; - setState(() => hold = hold > 0 ? hold - 1 : 30); + hold = hold > 0 ? hold - 1 : 30; }); } diff --git a/test/ui2_activity_test.dart b/test/ui2_activity_test.dart index 8b48edf2..46d637b1 100644 --- a/test/ui2_activity_test.dart +++ b/test/ui2_activity_test.dart @@ -1373,6 +1373,36 @@ void main() { expect(find.text('141'), findsOneWidget); }); + testWidgets('the rest countdown ticks without rebuilding the shell', + (tester) async { + tester.view.physicalSize = const Size(390 * 3, 2400 * 3); + tester.view.devicePixelRatio = 3; + addTearDown(tester.view.reset); + addTearDown(LiveDraft.clear); + + await tester.pumpWidget(_frame( + LiveStrength(activityByName('weight_training')!), + Brightness.light, + 1.0)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Log set')); + await tester.pump(); + expect(find.text('01:30'), findsOneWidget); + + // The countdown used to be a field behind setState, so every one of the + // ninety ticks rebuilt this state — and with it a whole new LiveShell, + // header and transport and all. The widget instance is the tell: only + // _LiveStrengthState.build makes a new one. + final shell = tester.widget(find.byType(LiveShell)); + await tester.pump(const Duration(seconds: 1)); + + expect(find.text('01:29'), findsOneWidget, + reason: 'the ring still counts down'); + expect(identical(tester.widget(find.byType(LiveShell)), shell), + isTrue, + reason: 'the shell must not be rebuilt for a number inside the body'); + }); + testWidgets('a session that failed to save says so and can retry', (tester) async { tester.view.physicalSize = const Size(390 * 3, 2200 * 3); From d752d9027710cdc8ea07b42fdb8859bafe011748 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Thu, 20 Aug 2026 07:52:15 +0530 Subject: [PATCH 6/9] drop dbCounts 13 sites each ran a full-table COUNT(*) over every table just to feed one 'raw=' in the session-start log. counts() stays, two tests use it. --- lib/state/app_state.dart | 20 +++----------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 3afb3c72..15b657ec 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -195,7 +195,6 @@ class AppState extends ChangeNotifier { // "last data: …" indicator must show. Seeded from the DB at init, advanced as // records (drained + live) flow in. int? _lastRecTs; - Map dbCounts = {'raw': 0, 'pending': 0}; final List logLines = []; bool busy = false; @@ -1383,7 +1382,6 @@ class AppState extends ChangeNotifier { heavy: heavy, onDayDone: (day, index, total) async { if (index == total || index == 1 || index % 3 == 0) { - dbCounts = await LocalDb.counts(); notifyListeners(); } }, @@ -1819,14 +1817,12 @@ class AppState extends ChangeNotifier { onDayDone: (day, index, total) async { reanalyzeProgress = 'Analyzing $index/$total'; if (index == total || index == 1 || index % 3 == 0) { - dbCounts = await LocalDb.counts(); notifyListeners(); } }, ); await LocalDb.refreshComputeFreshness(); bumpInsights(); - dbCounts = await LocalDb.counts(); return n; } catch (e) { _log('[derive] reanalyze failed: $e'); @@ -1897,7 +1893,6 @@ class AppState extends ChangeNotifier { // The day_result rows just changed — without this no RevisionReload screen // re-reads, so an override/nap edit only showed up after a restart. bumpInsights(); - dbCounts = await LocalDb.counts(); } catch (e) { _log('[derive] sleep-override re-derive failed: $e'); } finally { @@ -1923,7 +1918,6 @@ class AppState extends ChangeNotifier { Future deleteDays(Set dayIds) async { final deleted = await LocalDb.deleteDays(dayIds); await LocalDb.refreshComputeFreshness(); - dbCounts = await LocalDb.counts(); lastSynced = await LocalDb.latestSample(); notifyListeners(); return deleted; @@ -1931,7 +1925,7 @@ class AppState extends ChangeNotifier { /// Debounced "new data stored" callback from the engine (continuous listening has /// no discrete sync end). The engine already coalesced the burst; we run a single - /// LIGHT derive over the affected day(s) and refresh DB counts for the UI. + /// LIGHT derive over the affected day(s). /// /// This is also THE reliable place to refresh `_lastRecTs` (the "last data" /// freshness banner reads it). `_runSyncBurst`'s own before/after frontier @@ -1940,13 +1934,12 @@ class AppState extends ChangeNotifier { /// checkpoint-based refresh can miss a burst entirely. This callback fires /// on EVERY successful persist path (foreground burst, background/headless /// drain, live-triggered store) after the write is durable, so it can't - /// race it — same guarantee dbCounts already relies on above. + /// race it. 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'); if (recTsHw != null && recTsHw > (_lastRecTs ?? 0)) { _lastRecTs = recTsHw; @@ -2039,7 +2032,6 @@ class AppState extends ChangeNotifier { // policies already trust). _lastRecTs = await LocalDb.getCursorInt('rec_ts_hw') ?? lastSynced?.tsEpoch; - dbCounts = await LocalDb.counts(); await LocalDb.refreshComputeFreshness(); _savedAlarm = (await SharedPreferences.getInstance()).getInt('alarm_epoch'); // Band-gesture mapping: load the saved action + query native capabilities so the @@ -3320,7 +3312,6 @@ class AppState extends ChangeNotifier { '(${report.complete ? "complete" : "stopped early"}).', ); if (report.records > 0) { - dbCounts = await LocalDb.counts(); _deriveScheduler.markStoredData(); } } catch (e) { @@ -3883,7 +3874,7 @@ class AppState extends ChangeNotifier { // The foreground guard stops a wake from fighting this live session for the band. IosBleRestore.foregroundActive = true; IosBleRestore.arm(band.remoteId); - _log('===== SESSION START ===== raw=${dbCounts['raw']}'); + _log('===== SESSION START ====='); await _ensureForegroundLease(); // connect() now subscribes → SET_CLOCK → INIT, so the historical offload is // ALREADY streaming the moment this returns. @@ -3924,14 +3915,12 @@ class AppState extends ChangeNotifier { await _recoverOrphanedLiveSession(); _resetLivePedometer(); // fresh live step count for this connected session await engine.enableLiveStreams(); - dbCounts = await LocalDb.counts(); unawaited( _kickSyncBurst(kickFirst: false).then((report) async { _log( 'Backlog drained: ${report.records} records in ${report.batches} ' 'batches (${report.complete ? "complete" : "stopped early"}).', ); - dbCounts = await LocalDb.counts(); // Re-evaluate the high-frequency wake window now the backlog landed. await _refreshHighFreqWakeWindow(); // The whole backlog landed → heavy foreground finalize (full sleep @@ -4059,7 +4048,6 @@ class AppState extends ChangeNotifier { _log('Reconnected — live on; draining backlog in background.'); unawaited( _kickSyncBurst(kickFirst: false).then((report) async { - dbCounts = await LocalDb.counts(); _log('Reconnect backlog drained: ${report.records} records.'); // Re-evaluate the high-frequency wake window now the backlog // landed. @@ -4122,7 +4110,6 @@ class AppState extends ChangeNotifier { await _syncBurst; } await _kickSyncBurst(kickFirst: true); - dbCounts = await LocalDb.counts(); notifyListeners(); // A just-finished workout window landed from flash → derive it (light). _deriveScheduler.markStoredData(); @@ -4168,7 +4155,6 @@ class AppState extends ChangeNotifier { if (!await engine.requestForegroundSync()) return; final report = await _kickSyncBurst(kickFirst: false); if (report.records > 0) { - dbCounts = await LocalDb.counts(); _deriveScheduler.markStoredData(); notifyListeners(); } From 8ae112ef11d602f4efafd69ba6d4259fdd8e6fbb Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Thu, 20 Aug 2026 07:58:08 +0530 Subject: [PATCH 7/9] drop the spo2 diagnostic logger and the two arrays it kept alive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit spo2 is refused permanently, so the logger was printing compile-time constants — 14 passes over the sleep arrays on the calling isolate, unconditionally, for every derived day. sleepSpo2Red/Ir went with it: deriveDayBundle never read them, they were only serialized and copied across the isolate boundary to be ignored. substrate keeps its raw channels, and the refusal metric is untouched. --- lib/compute/derivation_engine.dart | 83 ------------------------ lib/compute/onehz_pipeline.dart | 29 +++++---- test/daily_energy_consistency_test.dart | 2 - test/derivation_pipeline_test.dart | 10 +-- test/hr_ceiling_zones_test.dart | 2 - test/resting_hr_nocturnal_only_test.dart | 2 - test/strain_resting_hr_source_test.dart | 2 - tool/derive_probe.dart | 2 - 8 files changed, 17 insertions(+), 115 deletions(-) diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index f7b73dd7..54cb270f 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -3172,8 +3172,6 @@ class DerivationEngine { sleepHr: sleepSub.hr, sleepRrTsMs: sleepSub.rrTsMs, sleepRrMs: sleepSub.rrMs, - sleepSpo2Red: sleepSub.spo2Red, - sleepSpo2Ir: sleepSub.spo2Ir, sleepSkinTemp: sleepSub.skinTemp, sleepJson: day.sleepJson, hypnoStages: day.hypnoStages, @@ -3200,7 +3198,6 @@ class DerivationEngine { _perDayTimeout, label: 'day-bundle ${day.date}', ); - _logSpo2Diagnostics(day, input, bundle); // Readiness came back absent for TODAY specifically (not a historical // backfill day, which would just be noise) — log why. This ran inside // Isolate.run so it couldn't call Firebase itself; it just returned the @@ -3722,86 +3719,6 @@ class DerivationEngine { _log('froze headline readiness ${next.value} for ${next.day}'); } - void _logSpo2Diagnostics( - PreparedDerivationDay day, - DayBundleInput input, - Map bundle, - ) { - final red = input.sleepSpo2Red; - final ir = input.sleepSpo2Ir; - final ts = input.sleepTsSec; - if (red.isEmpty || ir.isEmpty || ts.isEmpty) { - _log('[spo2-detect] {"day":"${day.date}","status":"no_sleep_spo2"}'); - return; - } - - int minInt(List xs) => xs.reduce((a, b) => a < b ? a : b); - int maxInt(List xs) => xs.reduce((a, b) => a > b ? a : b); - double meanInt(List xs) => - xs.isEmpty ? 0 : xs.reduce((a, b) => a + b) / xs.length; - - final redNonZero = red.where((v) => v > 0).length; - final irNonZero = ir.where((v) => v > 0).length; - final spo2 = (bundle['spo2'] as Map?)?.cast(); - final ratios = [ - for (var i = 0; i < red.length && i < ir.length; i++) - if (red[i] > 0 && ir[i] > 0) red[i] / ir[i], - ]; - double? meanDouble(List xs) => - xs.isEmpty ? null : xs.reduce((a, b) => a + b) / xs.length; - double? minDouble(List xs) => - xs.isEmpty ? null : xs.reduce((a, b) => a < b ? a : b); - double? maxDouble(List xs) => - xs.isEmpty ? null : xs.reduce((a, b) => a > b ? a : b); - - final payload = { - 'day': day.date, - 'sleep_samples': ts.length, - 'sleep_span_sec': ts.last - ts.first, - 'feature_disabled': spo2?['disabled'] == true, - 'red': { - 'non_zero': redNonZero, - 'zero': red.length - redNonZero, - 'coverage': redNonZero / red.length, - 'unique': red.toSet().length, - 'min': minInt(red), - 'max': maxInt(red), - 'mean': meanInt(red).toStringAsFixed(2), - 'first10': red.take(10).toList(), - }, - 'ir': { - 'non_zero': irNonZero, - 'zero': ir.length - irNonZero, - 'coverage': irNonZero / ir.length, - 'unique': ir.toSet().length, - 'min': minInt(ir), - 'max': maxInt(ir), - 'mean': meanInt(ir).toStringAsFixed(2), - 'first10': ir.take(10).toList(), - }, - 'ratio': { - 'samples': ratios.length, - 'min': minDouble(ratios)?.toStringAsFixed(6), - 'max': maxDouble(ratios)?.toStringAsFixed(6), - 'mean': meanDouble(ratios)?.toStringAsFixed(6), - 'first10': ratios.take(10).map((v) => v.toStringAsFixed(6)).toList(), - }, - 'odi': { - 'disabled': spo2?['disabled'], - 'note': spo2?['note'], - 'value': spo2?['odi_per_hour'], - 'dip_count': spo2?['dip_count'], - 'signal_coverage': spo2?['signal_coverage'], - 'trusted_coverage': spo2?['trusted_coverage'], - 'confidence': spo2?['confidence'], - 'reject_counts': spo2?['reject_counts'], - 'severity_counts': spo2?['severity_counts'], - 'debug': spo2?['debug'], - }, - }; - _log('[spo2-detect] ${jsonEncode(payload)}'); - } - /// Skip reasons that describe a TRANSIENT failure of this particular pass /// rather than a permanently pathological day. These must never finalize: /// finalizing locks the day out of every future pass at this algo version. diff --git a/lib/compute/onehz_pipeline.dart b/lib/compute/onehz_pipeline.dart index 6df7c356..86522c78 100644 --- a/lib/compute/onehz_pipeline.dart +++ b/lib/compute/onehz_pipeline.dart @@ -22,6 +22,7 @@ // the curve series the UI needs + indexed scalars. Survives jsonEncode. import 'dart:math' as math; +import 'dart:typed_data'; import 'package:openstrap_analytics/onehz.dart'; @@ -126,8 +127,6 @@ class DayBundleInput { final List sleepHr; final List sleepRrTsMs; final List sleepRrMs; - final List sleepSpo2Red; - final List sleepSpo2Ir; final List sleepSkinTemp; // ── the SINGLE-SOURCE sleep segmentation (JSON of SleepSegmentation) ────── @@ -192,8 +191,6 @@ class DayBundleInput { required this.sleepHr, required this.sleepRrTsMs, required this.sleepRrMs, - required this.sleepSpo2Red, - required this.sleepSpo2Ir, required this.sleepSkinTemp, required this.sleepJson, required this.hypnoStages, @@ -222,8 +219,6 @@ class DayBundleInput { 'sleep_hr': sleepHr, 'sleep_rr_ts_ms': sleepRrTsMs, 'sleep_rr_ms': sleepRrMs, - 'sleep_spo2_red': sleepSpo2Red, - 'sleep_spo2_ir': sleepSpo2Ir, 'sleep_skin_temp': sleepSkinTemp, 'sleep_json': sleepJson, 'hypno_stages': hypnoStages, @@ -245,9 +240,20 @@ class DayBundleInput { static DayBundleInput fromJson(Map m) { List ints(String k) => ((m[k] as List?) ?? const []).map((e) => (e as num).toInt()).toList(); - List dbls(String k) => ((m[k] as List?) ?? const []) - .map((e) => (e as num).toDouble()) - .toList(); + // The substrate packs these as Float64List and the isolate boundary hands + // them back typed; unboxing them into a plain List was re-boxing + // every element for nothing. Always a COPY, never an alias: on the direct + // (synchronous, in-test) path returning the caller's list would share the + // substrate's arrays across two repos with nobody enforcing read-only. + List dbls(String k) { + final v = (m[k] as List?) ?? const []; + if (v is List) return Float64List.fromList(v); + final out = Float64List(v.length); + for (var i = 0; i < v.length; i++) { + out[i] = (v[i] as num).toDouble(); + } + return out; + } List strs(String k) => ((m[k] as List?) ?? const []).map((e) => e.toString()).toList(); return DayBundleInput( @@ -260,8 +266,6 @@ class DayBundleInput { sleepHr: ints('sleep_hr'), sleepRrTsMs: dbls('sleep_rr_ts_ms'), sleepRrMs: dbls('sleep_rr_ms'), - sleepSpo2Red: ints('sleep_spo2_red'), - sleepSpo2Ir: ints('sleep_spo2_ir'), sleepSkinTemp: ints('sleep_skin_temp'), sleepJson: ((m['sleep_json'] as Map?) ?? const {}) .cast(), @@ -456,7 +460,6 @@ Map deriveDayBundle(Map inputJson) { const kSpo2Refusal = 'refused: the red and IR channels are one signal — ' 'ir − red is a fixed offset within a session, so any ratio built from ' 'them measures baseline drift, not oxygenation'; - final odiTs = [for (final t in d.sleepTsSec) t.toDouble()]; const odi = Metric.absent( tier: Tier.relative, inputs_used: ['spo2_red_raw', 'spo2_ir_raw'], @@ -1044,7 +1047,7 @@ Map deriveDayBundle(Map inputJson) { 'inputs_used': const ['spo2_red_raw', 'spo2_ir_raw'], 'note': kSpo2Refusal, 'debug': { - 'sleep_samples': odiTs.length, + 'sleep_samples': d.sleepTsSec.length, }, }; diff --git a/test/daily_energy_consistency_test.dart b/test/daily_energy_consistency_test.dart index 8f95bc4f..fbb9e0da 100644 --- a/test/daily_energy_consistency_test.dart +++ b/test/daily_energy_consistency_test.dart @@ -354,8 +354,6 @@ void main() { sleepHr: const [], sleepRrTsMs: const [], sleepRrMs: const [], - sleepSpo2Red: const [], - sleepSpo2Ir: const [], sleepSkinTemp: const [], sleepJson: const {}, hypnoStages: const [], diff --git a/test/derivation_pipeline_test.dart b/test/derivation_pipeline_test.dart index 6e6a4700..c224542f 100644 --- a/test/derivation_pipeline_test.dart +++ b/test/derivation_pipeline_test.dart @@ -108,7 +108,7 @@ void main() { final n0 = sub.length; final tiles = (1800 / n0).ceil() + 1; final dayTs = [], dayHr = []; - final sRed = [], sIr = [], sTemp = []; + final sTemp = []; final rrTs = [], rrMs = []; final base = sub.tsSec.first; for (var t = 0; t < tiles; t++) { @@ -116,8 +116,6 @@ void main() { for (var i = 0; i < n0; i++) { dayTs.add(base + shift + i); dayHr.add(sub.hr[i]); - sRed.add(sub.spo2Red[i]); - sIr.add(sub.spo2Ir[i]); sTemp.add(sub.skinTemp[i]); } // Re-anchor each RR beat into this tile's second (preserves order/spacing). @@ -140,8 +138,6 @@ void main() { sleepHr: dayHr, sleepRrTsMs: rrTs, sleepRrMs: rrMs, - sleepSpo2Red: sRed, - sleepSpo2Ir: sIr, sleepSkinTemp: sTemp, sleepJson: day.sleep.toJson(), hypnoStages: hypno, @@ -249,8 +245,6 @@ void main() { sleepHr: hr, sleepRrTsMs: const [], sleepRrMs: const [], - sleepSpo2Red: List.filled(n, 0), - sleepSpo2Ir: List.filled(n, 0), // One temp sample every `tempEvery` seconds; 0 is the absent sentinel. sleepSkinTemp: [ for (var i = 0; i < n; i++) i % tempEvery == 0 ? 3000 : 0, @@ -428,8 +422,6 @@ Map _nightBundle({required int hours}) { sleepHr: hr, sleepRrTsMs: rrTs, sleepRrMs: rrMs, - sleepSpo2Red: List.filled(tsSec.length, 0), - sleepSpo2Ir: List.filled(tsSec.length, 0), sleepSkinTemp: List.filled(tsSec.length, 0), sleepJson: const {}, hypnoStages: const [], diff --git a/test/hr_ceiling_zones_test.dart b/test/hr_ceiling_zones_test.dart index e1e63e37..0b252e8b 100644 --- a/test/hr_ceiling_zones_test.dart +++ b/test/hr_ceiling_zones_test.dart @@ -168,8 +168,6 @@ void main() { sleepHr: const [], sleepRrTsMs: const [], sleepRrMs: const [], - sleepSpo2Red: const [], - sleepSpo2Ir: const [], sleepSkinTemp: const [], sleepJson: const {}, hypnoStages: const [], diff --git a/test/resting_hr_nocturnal_only_test.dart b/test/resting_hr_nocturnal_only_test.dart index c0b2dec6..96f56f65 100644 --- a/test/resting_hr_nocturnal_only_test.dart +++ b/test/resting_hr_nocturnal_only_test.dart @@ -39,8 +39,6 @@ Map _bundle({required bool scoredSleep}) { sleepHr: scoredSleep ? hr : const [], sleepRrTsMs: const [], sleepRrMs: const [], - sleepSpo2Red: scoredSleep ? zeros : const [], - sleepSpo2Ir: scoredSleep ? zeros : const [], sleepSkinTemp: scoredSleep ? zeros : const [], sleepJson: scoredSleep ? {'tst_sec': wornSec, 'efficiency_pct': 90.0} diff --git a/test/strain_resting_hr_source_test.dart b/test/strain_resting_hr_source_test.dart index e8bd3128..8e2b7498 100644 --- a/test/strain_resting_hr_source_test.dart +++ b/test/strain_resting_hr_source_test.dart @@ -150,8 +150,6 @@ void main() { sleepHr: hr, sleepRrTsMs: rrTsMs, sleepRrMs: rrMs, - sleepSpo2Red: List.filled(1800, 0), - sleepSpo2Ir: List.filled(1800, 0), sleepSkinTemp: List.filled(1800, 0), sleepJson: { 'tst_sec': 1800, diff --git a/tool/derive_probe.dart b/tool/derive_probe.dart index 8c71b92b..b3ddf294 100644 --- a/tool/derive_probe.dart +++ b/tool/derive_probe.dart @@ -54,8 +54,6 @@ void main(List args) { sleepHr: sleepSub.hr, sleepRrTsMs: sleepSub.rrTsMs, sleepRrMs: sleepSub.rrMs, - sleepSpo2Red: sleepSub.spo2Red, - sleepSpo2Ir: sleepSub.spo2Ir, sleepSkinTemp: sleepSub.skinTemp, sleepJson: day.sleep.toJson(), hypnoStages: hypno, From cd2bda76878544641072cff8cf15e6768d48292d Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:08:20 +0530 Subject: [PATCH 8/9] deleting days is a durable write, so say so MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit notifyListeners alone left a RevisionReload screen showing days that are gone until some unrelated bump — and this is the one write where the stale copy is data the user asked to destroy. --- lib/state/app_state.dart | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 15b657ec..3d1cb0ec 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -1919,6 +1919,12 @@ class AppState extends ChangeNotifier { final deleted = await LocalDb.deleteDays(dayIds); await LocalDb.refreshComputeFreshness(); lastSynced = await LocalDb.latestSample(); + // Deleting days is a durable write like any other, so the screens holding a + // cached read have to be told. `notifyListeners()` alone leaves a + // RevisionReload screen showing days that are gone until some unrelated + // bump or a restart — and this is the one write where the stale copy is of + // data the user explicitly asked to destroy. + if (deleted > 0) bumpInsights(); notifyListeners(); return deleted; } From f95d9e9bb8bd5c3a12a84bb5e5dd6e1d874ca5b0 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:08:20 +0530 Subject: [PATCH 9/9] the hold pacer stops when the session does paused stops advancing elapsedSec, so the shell stops repainting and a hold still counting behind a frozen screen sat wrong then jumped on resume. a paused session isn't holding a pose. --- lib/ui2/activity/live.dart | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/ui2/activity/live.dart b/lib/ui2/activity/live.dart index cf944026..f84ae00c 100644 --- a/lib/ui2/activity/live.dart +++ b/lib/ui2/activity/live.dart @@ -1808,8 +1808,14 @@ class _LiveFlowState extends State // No `setState`: this body follows the shell's clock, which already // rebuilds it once a second, so the wrapper only bought a second rebuild // of the same subtree at the same rate. + // + // WHICH IS WHY THE PAUSE CHECK IS HERE. A paused session stops advancing + // `elapsedSec`, so the shell stops repainting — and a hold that kept + // counting behind a frozen screen would sit there wrong and then jump on + // resume. It is a pacer for a pose being held; a paused session is not + // holding anything. _hold = Timer.periodic(Motion.tick, (_) { - if (!mounted) return; + if (!mounted || LiveDraft.current?.pausedAt != null) return; hold = hold > 0 ? hold - 1 : 30; }); }