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/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index 573b130a..bf5ed297 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 @@ -3740,86 +3737,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/lib/state/app_state.dart b/lib/state/app_state.dart index 06f3ba24..3d1cb0ec 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; @@ -418,38 +417,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); @@ -1410,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(); } }, @@ -1434,7 +1405,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). @@ -1846,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(); - _bumpInsightsRevision(); - dbCounts = await LocalDb.counts(); + bumpInsights(); return n; } catch (e) { _log('[derive] reanalyze failed: $e'); @@ -1921,7 +1890,9 @@ class AppState extends ChangeNotifier { try { await _derive.run(_profile, force: true); await LocalDb.refreshComputeFreshness(); - dbCounts = await LocalDb.counts(); + // 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(); } catch (e) { _log('[derive] sleep-override re-derive failed: $e'); } finally { @@ -1936,40 +1907,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(); @@ -1981,15 +1918,20 @@ 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(); + // 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; } /// 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 @@ -1998,13 +1940,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; @@ -2097,7 +2038,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 @@ -2388,8 +2328,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 @@ -3380,7 +3318,6 @@ class AppState extends ChangeNotifier { '(${report.complete ? "complete" : "stopped early"}).', ); if (report.records > 0) { - dbCounts = await LocalDb.counts(); _deriveScheduler.markStoredData(); } } catch (e) { @@ -3943,7 +3880,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. @@ -3984,14 +3921,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 @@ -4119,7 +4054,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. @@ -4182,7 +4116,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(); @@ -4228,7 +4161,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(); } @@ -5180,7 +5112,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(); diff --git a/lib/ui2/activity/live.dart b/lib/ui2/activity/live.dart index 62fa399b..f84ae00c 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,18 @@ 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. + // + // 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; - setState(() => hold = hold > 0 ? hold - 1 : 30); + if (!mounted || LiveDraft.current?.pausedAt != null) return; + hold = hold > 0 ? hold - 1 : 30; }); } 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, 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(); 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/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); 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,