From d6e42de276ec7ecac188cbe2e621056b579d1dc3 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 9 Aug 2026 17:11:09 +0530 Subject: [PATCH 1/7] correlations that read numbers, blood work in your profile, backups, naps you can correct, and breathing you can do with your eyes shut MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five things, in the order they became possible. The typed journal fields shipped with nowhere to go — recorded, displayed, correlated with nothing. They correlate now, on a rank statistic with a slope in the outcome's own units, so the finding reads "about 4 ms less HRV per extra coffee" rather than a coefficient nobody can act on. Kept apart from the tag findings: a tag says those days were different, a dose says more of this goes with less of that, and they carry different evidence. Weight, height and — on Apple, where the records exist — sex and date of birth can be pulled from the health store. Weight is the one that drifts, and three things the app computes depend on it. Weight and height are adopted because they change; age and sex only fill a gap, because they do not, and another app's record does not get to overrule something you set on purpose. Automatic backup, into a folder you can reach from Files and point iCloud or Nextcloud at. It runs when you open the app rather than claiming a schedule the OS will not honour, and it says so. Naps you can correct. The detector's answer is a proposal now: log one it missed, delete one it invented. Edits are stored apart from the detection and replayed over it, so a better detector later still respects "there was no nap here", and a deletion matches by overlap rather than exact bounds, because those bounds move between runs. Logged naps count toward sleep totals, sleep debt and readiness exactly as detected ones do, which is why this bumps the algorithm version. Breathing gained the three things it was missing. Four patterns instead of one, a session that ends when the button said it would, and the strap buzzing each phase change so you can close your eyes — which was always the point of an exercise you are not supposed to be staring at a phone during. Sessions are kept now, so the coherence score is a trend rather than a number that vanished. Coherence stays on resonance breathing only; scoring box breathing against it would grade it on an exam it is not sitting. The interval timer rides the same phase engine, because rounds and breaths are the same problem. It records nothing — a workout already covers that. --- android/app/src/main/AndroidManifest.xml | 7 + ios/Runner/Info.plist | 9 + lib/app.dart | 5 + lib/compute/derivation_engine.dart | 105 +++++-- lib/compute/nap_edits.dart | 129 ++++++++ lib/data/auto_backup.dart | 176 +++++++++++ lib/data/db.dart | 145 ++++++++- lib/data/local_repository_impl.dart | 111 ++++++- lib/health/health_profile_import.dart | 226 ++++++++++++++ lib/state/app_state.dart | 134 +++++++- lib/state/prefs.dart | 4 + lib/ui/journal/journal_screen.dart | 98 +++++- lib/ui/profile/profile_screen.dart | 122 +++++++- lib/ui/sleep/sleep_periods_screen.dart | 159 +++++++++- lib/ui/stress/breath_phases.dart | 200 ++++++++++++ lib/ui/stress/calm_breathing_screen.dart | 374 ++++++++++++++++------- lib/ui/stress/interval_timer_screen.dart | 253 +++++++++++++++ lib/ui/workouts/workout_types.dart | 19 +- lib/ui/workouts/workouts_screen.dart | 32 +- pubspec.lock | 4 +- pubspec.yaml | 11 +- test/auto_backup_test.dart | 228 ++++++++++++++ test/breath_phases_test.dart | 183 +++++++++++ test/calm_breathing_view_test.dart | 2 +- test/db_migration_ladder_test.dart | 47 +++ test/health_profile_import_test.dart | 239 +++++++++++++++ test/nap_edits_test.dart | 209 +++++++++++++ 27 files changed, 3070 insertions(+), 161 deletions(-) create mode 100644 lib/compute/nap_edits.dart create mode 100644 lib/data/auto_backup.dart create mode 100644 lib/health/health_profile_import.dart create mode 100644 lib/ui/stress/breath_phases.dart create mode 100644 lib/ui/stress/interval_timer_screen.dart create mode 100644 test/auto_backup_test.dart create mode 100644 test/breath_phases_test.dart create mode 100644 test/health_profile_import_test.dart create mode 100644 test/nap_edits_test.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 22b93dbd..6b3b6863 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -64,6 +64,13 @@ + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index c59e3b85..75b3d356 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -61,6 +61,15 @@ NSBluetoothAlwaysUsageDescription OpenStrap connects to your WHOOP band over Bluetooth to sync your health data. + + UIFileSharingEnabled + + LSSupportsOpeningDocumentsInPlace + NSHealthShareUsageDescription OpenStrap reads your step count from Apple Health to show your daily steps, and reads back its own recent samples so it never writes duplicates. NSHealthUpdateUsageDescription diff --git a/lib/app.dart b/lib/app.dart index 241d9247..98110341 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -88,6 +88,11 @@ class _OpenStrapAppState extends State with WidgetsBindingObserver // already-running process (openAppWhenRun doesn't guarantee a fresh // launch) — the constructor-time check alone would miss that case. unawaited(app.checkPendingSiriRoute()); + // Backups run on foreground, when due — there is no background scheduler + // that works on both platforms, and a schedule that claims "daily" while + // delivering whenever the OS feels like it is worse than one that is + // honest about when it fires. + unawaited(app.runBackupIfDue()); if (app.isPaired) app.openSession(); } else if (state == AppLifecycleState.paused) { // Backgrounded: hand the band to the iOS restore path so it can wake-and-drain diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index 5e0fcc8a..2ad47368 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -26,6 +26,7 @@ import 'dart:isolate'; import 'dart:math' as math; import 'package:flutter/foundation.dart'; +import 'nap_edits.dart'; import 'package:openstrap_analytics/onehz.dart' as ana; import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_performance/firebase_performance.dart'; @@ -608,7 +609,21 @@ import 'substrate.dart'; // O(window) sum that ran before its cadence gate was checked. Both curves keep // their sampling intent; points that were previously emitted a beat or two // after a failed attempt now land on the next cadence tick instead. -const int kAlgoVersion = 60; +// v61 - NAP EDITS. The nap detector's answer is now a PROPOSAL: a nap the user +// logged is added, and one they rejected is suppressed, replayed over the +// detector's output on every derivation rather than written into it (so a +// better detector later still respects "there was no nap here"). Rejection +// matches by OVERLAP, not by exact bounds, because the detector's boundaries +// shift between runs and an edit that stopped applying when a boundary moved +// by a minute would be worse than useless. +// +// This moves numbers, which is why it is a version bump rather than a read +// path: `nap_min` is summed over the merged list, so a logged nap credits +// against sleep need and sleep debt exactly as a detected one does — that +// was the explicit product decision, not an accident of where the code sat. +// Days carrying an edit are force-derived alongside sleep-override days, so +// an edit to an already-finalized day actually takes effect. +const int kAlgoVersion = 61; // Fold idempotency, the minimum-nights warm-up, and legacy-payload handling // all live in SleepProfilePolicy (pure, unit-tested) — see @@ -1046,7 +1061,11 @@ class DerivationEngine { // FINALIZED (locked) day — it's the user's word. Force those back into the // todo set. (No-raw days are guarded in the per-day loop so we never // clobber a good manual result with an empty re-derive once raw is pruned.) - final overrideDays = await LocalDb.sleepOverrideDays(); + final overrideDays = { + ...await LocalDb.sleepOverrideDays(), + // A nap edit on a finalized day has to take effect too — same reason. + ...await LocalDb.napEditDays(), + }; final todoDays = [ for (final day in scope.targetDays) if (!finalized.contains(day) || overrideDays.contains(day)) day, @@ -2416,9 +2435,22 @@ class DerivationEngine { // Built on THIS isolate so the Isolate.run closure captures only this plain // sendable object (never `this`, `day`, or `bundle`). + // Read HERE, on the main isolate — the worker has no database. + final napEdits = [ + for (final row in await LocalDb.napEdits(day.date)) + NapEdit( + kind: row['source'] == 'rejected' + ? NapEditKind.rejected + : NapEditKind.added, + startSec: (row['start_ts'] as num).toInt(), + endSec: (row['end_ts'] as num).toInt(), + ), + ]; + final blocksInput = _DayBlocksInput( daySub: daySub, napSub: day.napSub, + napEdits: napEdits, sleepSub: sleepSub, profile: profile, onsetSec: day.sleepOnsetSec, @@ -4357,6 +4389,9 @@ class DerivationEngine { int? attributionEndSec, List> wristOff = const [], List> charging = const [], + // Read on the main isolate and carried in, like every other DB-sourced + // input here — this runs inside the compute worker, which has no database. + List napEdits = const [], }) { try { final n = s.length; @@ -4428,25 +4463,33 @@ class DerivationEngine { return t0 + nap.startSec < attributionEndSec; }).toList(); + // The detector's answer is a PROPOSAL. The user's edits — a nap it + // missed, or one it invented — are stored separately and replayed over + // it here on every derivation, so a better detector later still respects + // "there was no nap here" instead of the edit being baked into a stale + // detection. + final detected = >[ + for (final nap in naps) + { + 'start': t0 + nap.startSec, + 'end': t0 + nap.endSec, + // Minutes ASLEEP. `duration_min` kept as the asleep figure so + // existing readers do not silently switch to in-bed minutes. + 'duration_min': (nap.tstSec / 60).round(), + 'in_bed_min': (nap.tibSec / 60).round(), + 'efficiency': nap.efficiency, + 'confidence': nap.confidence, + }, + ]; + final merged = applyNapEdits(detected, napEdits); + bundle['naps'] = { - 'value': [ - for (final nap in naps) - { - 'start': t0 + nap.startSec, - 'end': t0 + nap.endSec, - // Minutes ASLEEP. `duration_min` kept as the asleep figure so - // existing readers do not silently switch to in-bed minutes. - 'duration_min': (nap.tstSec / 60).round(), - 'in_bed_min': (nap.tibSec / 60).round(), - 'efficiency': nap.efficiency, - 'confidence': nap.confidence, - }, - ], - 'count': naps.length, + 'value': merged, + 'count': merged.length, 'confidence': m.confidence, 'tier': m.tier, 'inputs_used': m.inputs_used, - 'note': m.note, + 'note': napEdits.isEmpty ? m.note : '${m.note} (edited)', }; // TST, never TIB. Crediting in-bed minutes against sleep need @@ -4455,20 +4498,21 @@ class DerivationEngine { // Rounded, matching the two display paths exactly. Truncating here while // the cards round made the credit disagree with the sum of the minutes // shown — up to a minute per nap, in a number the user can add up. - final napMin = - naps.fold(0, (a, nap) => a + (nap.tstSec / 60).round()); - scMap?['nap_min'] = napMin.toDouble(); + // Summed over the MERGED list, so a logged nap counts toward sleep need + // and sleep debt exactly as a detected one does. + scMap?['nap_min'] = napMinutes(merged).toDouble(); return [ - for (final nap in naps) + for (final nap in merged) { 'is_main': false, - 'onset_ts': t0 + nap.startSec, - 'wake_ts': t0 + nap.endSec, - 'duration_min': (nap.tstSec / 60).round(), - 'in_bed_min': (nap.tibSec / 60).round(), - 'efficiency': nap.efficiency, - 'confidence': nap.confidence, + 'onset_ts': nap['start'], + 'wake_ts': nap['end'], + 'duration_min': nap['duration_min'], + 'in_bed_min': nap['in_bed_min'], + 'efficiency': nap['efficiency'], + 'confidence': nap['confidence'], + if (nap['source'] != null) 'source': nap['source'], }, ]; } catch (e) { @@ -4711,6 +4755,7 @@ class DerivationEngine { attributionEndSec: inp.dayEndSec, wristOff: inp.wristOffSpans, charging: inp.chargingSpans, + napEdits: inp.napEdits, ); bundlePatch['sleep_periods'] = _sleepPeriods( onset, @@ -5089,6 +5134,7 @@ class DerivationEngine { int? attributionEndSec, List> wristOff = const [], List> charging = const [], + List napEdits = const [], }) => _attachNaps( bundle, @@ -5100,6 +5146,7 @@ class DerivationEngine { attributionEndSec: attributionEndSec, wristOff: wristOff, charging: charging, + napEdits: napEdits, ); void _log(String m) { @@ -5152,6 +5199,9 @@ class _DayBlocksInput { final int dynHistoryDays; final List> savedSessions; + /// The user's nap edits for this day, replayed over the detector's output. + final List napEdits; + /// Strap-reported off-wrist spans ([startSec, endSec]) over the nap window. /// A band on a table is motionless and reads as deep rest — this is the /// dominant nap false positive, and the strap already tells us about it. @@ -5191,6 +5241,7 @@ class _DayBlocksInput { required this.dynFloorG, required this.dynHistoryDays, required this.savedSessions, + this.napEdits = const [], required this.wristOffSpans, required this.chargingSpans, required this.mainTstMin, diff --git a/lib/compute/nap_edits.dart b/lib/compute/nap_edits.dart new file mode 100644 index 00000000..04a32fb9 --- /dev/null +++ b/lib/compute/nap_edits.dart @@ -0,0 +1,129 @@ +// User edits to a day's naps — the pure merge, kept out of the derivation +// engine so the rules are testable without a database or a day of substrate. +// +// Three people asked for this from two directions: "the app tracked sleep when +// I was awake, let me delete it" and "I took a two-hour nap and it wasn't +// counted". Both are the same feature — the detector's answer is a proposal, +// and the person who was actually there gets the last word. +// +// The user's edits are stored SEPARATELY from the detector's output and +// replayed over it on every derivation, rather than being written into the +// result. That matters because the detector improves: a day re-derived under a +// better stager should still respect "there was no nap here", and baking the +// edit into the output would freeze the old detection alongside it. + +import 'package:flutter/foundation.dart'; + +/// What the user did to a nap. +enum NapEditKind { + /// Added a nap the detector missed. + added, + + /// Removed one the detector invented. Stored as a window rather than an id, + /// because the detector's ids are not stable across a re-derivation — the + /// bounds are the only thing that survives a re-run. + rejected, +} + +@immutable +class NapEdit { + const NapEdit({ + required this.kind, + required this.startSec, + required this.endSec, + }); + + final NapEditKind kind; + final int startSec; + final int endSec; + + int get durationSec => endSec - startSec; + + bool overlaps(int otherStart, int otherEnd) => + startSec < otherEnd && otherStart < endSec; +} + +/// A nap in the day bundle: `start`, `end`, `duration_min`, and friends. +typedef NapMap = Map; + +/// Minimum length of a nap someone can log. Below this it is not a nap, and a +/// scatter of two-minute entries would swamp the real ones in every total. +const int kMinManualNapSec = 5 * 60; + +/// Maximum. Anything longer is a sleep, not a nap, and belongs in the main +/// sleep window where the stager can actually say something about its stages. +const int kMaxManualNapSec = 6 * 60 * 60; + +/// Whether a window can be logged as a nap. +bool manualNapWindowIsValid(int startSec, int endSec) { + final d = endSec - startSec; + return d >= kMinManualNapSec && d <= kMaxManualNapSec; +} + +/// Apply [edits] to the detector's [detected] naps. +/// +/// A rejected window removes every detected nap it OVERLAPS, not only one that +/// matches it exactly — the detector's bounds shift between runs, and an edit +/// that stopped applying the moment a boundary moved by a minute would be +/// worse than useless. +/// +/// Added naps are appended and marked, so the UI can show which came from +/// where and the user can tell their own entry apart from a detection. +/// Overlapping additions are NOT merged: two logged naps that overlap are a +/// data-entry mistake, and silently fusing them would hide it while inflating +/// the day's total. The caller rejects the overlap at entry instead. +/// +/// The result is sorted by start, so the timeline draws them in order whatever +/// order they were added in. +List applyNapEdits(List detected, List edits) { + final rejected = edits.where((e) => e.kind == NapEditKind.rejected); + final out = [ + for (final nap in detected) + if (!rejected.any( + (r) => r.overlaps( + (nap['start'] as num).toInt(), + (nap['end'] as num).toInt(), + ), + )) + nap, + for (final e in edits) + if (e.kind == NapEditKind.added) + { + 'start': e.startSec, + 'end': e.endSec, + // A logged nap has no measured sleep/wake split, so asleep and in-bed + // are the same number — the honest reading of "I slept from here to + // here". Claiming a lower TST would invent an efficiency nobody + // measured. + 'duration_min': (e.durationSec / 60).round(), + 'in_bed_min': (e.durationSec / 60).round(), + 'source': 'manual', + // No confidence: this is a report, not an estimate, and dressing it + // in the detector's confidence scale would imply it was inferred. + 'confidence': null, + }, + ]; + out.sort( + (a, b) => (a['start'] as num).compareTo(b['start'] as num), + ); + return out; +} + +/// Total asleep minutes across [naps] — the `nap_min` scalar. +/// +/// Null when there are no naps AND the day was never judged, which the caller +/// distinguishes: a written 0 is a claim that there were no naps, and absent is +/// a claim that we do not know. +int napMinutes(List naps) => + naps.fold(0, (a, n) => a + ((n['duration_min'] as num?)?.round() ?? 0)); + +/// Whether [startSec, endSec) collides with an existing entry. +/// +/// Used at entry time so two overlapping logged naps are refused rather than +/// silently double-counting the same hour of the afternoon. +bool napOverlapsExisting(int startSec, int endSec, List existing) => + existing.any( + (n) => + startSec < (n['end'] as num).toInt() && + (n['start'] as num).toInt() < endSec, + ); diff --git a/lib/data/auto_backup.dart b/lib/data/auto_backup.dart new file mode 100644 index 00000000..c990c12e --- /dev/null +++ b/lib/data/auto_backup.dart @@ -0,0 +1,176 @@ +// Automatic local backup of the database. +// +// The manual export already exists and is complete; this is the same snapshot +// on a schedule, because a backup you have to remember to take is a backup +// most people do not have. Discussion #214 asked for exactly this: years of +// health data living in one place on one phone. +// +// WHERE IT WRITES, and why not a folder you pick. The files go into the app's +// own documents directory, which is exposed to Files on iOS +// (`UIFileSharingEnabled` + `LSSupportsOpeningDocumentsInPlace`) and to the +// file manager on Android. Anything that syncs a folder — iCloud Drive, +// Synology Drive, Nextcloud — can be pointed at it. A user-chosen folder would +// need a persisted SAF tree URI or a security-scoped bookmark, both of which +// silently expire, and a backup that quietly stopped working is worse than one +// that lives somewhere slightly less convenient. +// +// WHEN IT RUNS. On foreground, when due. There is no background scheduler that +// works on both platforms — Workmanager is Android-only here and iOS's +// BGProcessingTask is best-effort — and a backup that fires when you open the +// app is honest about that. The alternative is a schedule that claims "daily" +// and delivers whenever the OS feels like it. + +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import 'db.dart'; + +/// How often a backup is taken. Off is the default: this writes an unencrypted +/// copy of everything the app knows about you into a folder other apps can +/// reach, and that is a choice to make deliberately rather than one to +/// discover later. +enum BackupCadence { + off, + daily, + weekly; + + String get label => switch (this) { + BackupCadence.off => 'Off', + BackupCadence.daily => 'Daily', + BackupCadence.weekly => 'Weekly', + }; + + Duration? get interval => switch (this) { + BackupCadence.off => null, + BackupCadence.daily => const Duration(days: 1), + BackupCadence.weekly => const Duration(days: 7), + }; + + static BackupCadence fromName(String? name) => BackupCadence.values + .firstWhere((c) => c.name == name, orElse: () => BackupCadence.off); +} + +/// Folder name under the documents directory. Named so it is obvious what it +/// is when someone finds it in Files. +const kBackupDirName = 'OpenStrap Backups'; + +/// How many backups are kept. Enough to survive noticing a problem a few days +/// late, few enough that the folder does not grow without bound — each file is +/// a full copy of the database. +const kBackupsKept = 5; + +/// Whether a backup is due. +/// +/// Pure, and the only place the schedule is decided. A null [lastRun] means +/// one has never been taken, which is always due — otherwise switching the +/// setting on would do nothing visible until tomorrow, and the user would +/// reasonably conclude it was broken. +bool backupIsDue({ + required BackupCadence cadence, + required DateTime? lastRun, + required DateTime now, +}) { + final interval = cadence.interval; + if (interval == null) return false; + if (lastRun == null) return true; + // A clock that moved backwards (timezone change, NTP correction, a user + // setting the date) must not park the schedule in the future forever. + if (lastRun.isAfter(now)) return true; + return now.difference(lastRun) >= interval; +} + +/// Filename for a backup taken at [when]. Sorts chronologically as text, so +/// retention can order by name without parsing. +String backupFileName(DateTime when) { + String two(int v) => v.toString().padLeft(2, '0'); + return 'openstrap-${when.year}${two(when.month)}${two(when.day)}' + '-${two(when.hour)}${two(when.minute)}.db'; +} + +/// Existing backups, newest first. +List sortBackupsNewestFirst(Iterable entries) { + final files = entries + .whereType() + .where((f) => p.basename(f.path).startsWith('openstrap-')) + .where((f) => p.extension(f.path) == '.db') + .toList(); + files.sort((a, b) => p.basename(b.path).compareTo(p.basename(a.path))); + return files; +} + +/// What a backup attempt did. +class BackupOutcome { + const BackupOutcome({this.path, this.error, this.skipped = false}); + + /// The file written, or null when nothing was. + final String? path; + + /// Why it failed, or null. A failure is REPORTED rather than swallowed — + /// a backup silently not happening is the failure mode this whole feature + /// exists to prevent. + final String? error; + + /// Not due yet. Distinct from both success and failure. + final bool skipped; + + bool get succeeded => path != null; +} + +/// The backup directory, created if missing. +Future backupDirectory() async { + final docs = await getApplicationDocumentsDirectory(); + final dir = Directory(p.join(docs.path, kBackupDirName)); + if (!await dir.exists()) await dir.create(recursive: true); + return dir; +} + +/// Take a backup now, regardless of schedule, and prune old ones. +Future runBackup({DateTime? now}) async { + final when = now ?? DateTime.now(); + try { + final dir = await backupDirectory(); + // `exportCopy` is VACUUM INTO — a transactionally consistent snapshot, + // not a file copy of a database that may be mid-write. + final snapshot = await LocalDb.exportCopy(); + final dest = File(p.join(dir.path, backupFileName(when))); + await File(snapshot).rename(dest.path); + // Best-effort: the snapshot lives in temp and a failed delete is harmless. + try { + final tmp = File(snapshot); + if (await tmp.exists()) await tmp.delete(); + } catch (_) {} + + await pruneBackups(dir, keep: kBackupsKept); + return BackupOutcome(path: dest.path); + } catch (e) { + return BackupOutcome(error: e.toString()); + } +} + +/// Delete all but the [keep] newest backups. +Future pruneBackups(Directory dir, {required int keep}) async { + try { + final files = sortBackupsNewestFirst(dir.listSync()); + for (final old in files.skip(keep)) { + await old.delete(); + } + } catch (_) { + // Housekeeping only — never fail a backup over cleanup. + } +} + +/// Run a backup if [cadence] says one is due. Returns a skipped outcome +/// otherwise, so the caller can tell "not yet" from "it broke". +Future runBackupIfDue({ + required BackupCadence cadence, + required DateTime? lastRun, + DateTime? now, +}) async { + final when = now ?? DateTime.now(); + if (!backupIsDue(cadence: cadence, lastRun: lastRun, now: when)) { + return const BackupOutcome(skipped: true); + } + return runBackup(now: when); +} diff --git a/lib/data/db.dart b/lib/data/db.dart index 676e2a7c..0f4bacb7 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -91,7 +91,7 @@ class LocalDb { /// pass it: sqflite throws `ArgumentError('onCreate must be null if no /// version is specified')` BEFORE opening anything when `onCreate` is given /// without `version` (sqflite_common database_mixin.dart). - static const int schemaVersion = 29; + static const int schemaVersion = 31; /// SQLite caps host parameters per statement (`SQLITE_MAX_VARIABLE_NUMBER` — /// only 999 on the builds shipped with older Android/iOS). Any `IN (?, ?, …)` @@ -163,6 +163,7 @@ class LocalDb { await _createLiveCoverage(db); await _createWorkoutSuggestions(db); await _createSleepOverride(db); + await _createSleepNap(db); await _createWorkoutRoute(db); await _createNotifFired(db); await _ensureCoachViews(db); @@ -416,6 +417,15 @@ class LocalDb { // read or rewritten. await _createLabTables(db); } + if (oldV < 30) { + // Paced-breathing history. New table only. + await _createBreathingSessions(db); + } + if (oldV < 31) { + // User edits to a day's naps. New table only — the detector's own + // output is untouched and the edits replay over it. + await _createSleepNap(db); + } }, onOpen: (db) async { await _repairOpenSchema(db); @@ -466,6 +476,7 @@ class LocalDb { await _ensureSyncStateSchema(db); await _createWorkoutSuggestions(db); await _createSleepOverride(db); + await _createSleepNap(db); await _createWorkoutRoute(db); await _ensureWorkoutRouteSpeed(db); await _ensureDayResultSkippedColumn(db); @@ -700,6 +711,34 @@ class LocalDb { ); } + /// sleep_nap — the user's edits to a day's naps. + /// + /// Separate from `sleep_override` on purpose: that table means "the main + /// sleep window for this day", which is one thing, while naps are a list. + /// Widening its primary key would have made "the main sleep" and "a nap" + /// indistinguishable in storage. + /// + /// Edits are stored SEPARATELY from the detector's output and replayed over + /// it on every derivation. The detector improves; a day re-derived under a + /// better stager should still respect "there was no nap here", and baking + /// the edit into the result would freeze the old detection alongside it. + /// + /// `source` is 'manual' (a nap the user logged) or 'rejected' (a detected + /// one they removed — the window is stored so it keeps suppressing that nap + /// even after the detector's bounds shift by a minute). + static Future _createSleepNap(Database db) async { + await db.execute(''' + CREATE TABLE IF NOT EXISTS sleep_nap ( + day_id TEXT NOT NULL, + start_ts INTEGER NOT NULL, + end_ts INTEGER NOT NULL, + source TEXT NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (day_id, start_ts) + ) + '''); + } + static Future _createSleepOverride(Database db) async { await db.execute(''' CREATE TABLE IF NOT EXISTS sleep_override ( @@ -1418,6 +1457,27 @@ class LocalDb { '''); } + /// breathing_session — one row per completed paced-breathing session. + /// + /// The coherence score was computed live and then thrown away, so the + /// feature could tell you how a session went and never whether it was going + /// anywhere. A score is only meaningful for a pattern that is TRYING to + /// drive heart-rate oscillation at the paced frequency, so `coherence` is + /// null for the others rather than a number that grades box breathing on + /// resonance breathing's exam. + static Future _createBreathingSessions(Database db) async { + await db.execute(''' + CREATE TABLE IF NOT EXISTS breathing_session ( + started_at INTEGER PRIMARY KEY, + ended_at INTEGER NOT NULL, + pattern TEXT NOT NULL, + seconds INTEGER NOT NULL, + coherence REAL, + confidence REAL + ) + '''); + } + // ── USER-DATA STORE (journal / cycle / workouts / notifications) ──────────── // On-device user-entered + locally-generated data. All keyed for idempotent // upserts; none of it round-trips to a server (cloud excised). @@ -1434,6 +1494,7 @@ class LocalDb { await _createJournalMetric(db); await _createJournalFieldDef(db); await _createLabTables(db); + await _createBreathingSessions(db); // cycle_log — menstrual cycle markers; `kind` is 'start' (cycle start) etc. await db.execute(''' CREATE TABLE IF NOT EXISTS cycle_log ( @@ -3637,6 +3698,7 @@ class LocalDb { await deleteByIn(txn, 'cycle_symptom', 'date', sorted); await deleteByIn(txn, 'workout_suggestions', 'date', sorted); await deleteByIn(txn, 'sleep_override', 'day_id', sorted); + await deleteByIn(txn, 'sleep_nap', 'day_id', sorted); }); return deleted; } @@ -3670,6 +3732,7 @@ class LocalDb { 'journal_field_def', 'lab_result', 'lab_marker_def', + 'breathing_session', 'cycle_log', 'notifications', 'baselines', @@ -3980,6 +4043,7 @@ class LocalDb { 'journal_field_def', 'lab_result', 'lab_marker_def', + 'breathing_session', 'cycle_log', 'notifications', 'sync_cursor', @@ -4773,6 +4837,85 @@ class LocalDb { }, conflictAlgorithm: ConflictAlgorithm.replace); } + // ── nap edits ───────────────────────────────────────────────────────────── + + /// Log a nap the detector missed, or suppress one it invented. + static Future putNapEdit({ + required String dayId, + required int startTs, + required int endTs, + required String source, + }) async { + final db = await instance; + await db.insert('sleep_nap', { + 'day_id': dayId, + 'start_ts': startTs, + 'end_ts': endTs, + 'source': source, + 'created_at': DateTime.now().millisecondsSinceEpoch ~/ 1000, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + + static Future deleteNapEdit(String dayId, int startTs) async { + final db = await instance; + await db.delete( + 'sleep_nap', + where: 'day_id = ? AND start_ts = ?', + whereArgs: [dayId, startTs], + ); + } + + static Future>> napEdits(String dayId) async { + final db = await instance; + return db.query( + 'sleep_nap', + where: 'day_id = ?', + whereArgs: [dayId], + orderBy: 'start_ts ASC', + ); + } + + /// Every day carrying a nap edit. Force-derived alongside the sleep-override + /// days for the same reason: an edit to a finalized day has to take effect. + static Future> napEditDays() async { + final db = await instance; + final rows = await db.query('sleep_nap', columns: ['day_id']); + return {for (final r in rows) r['day_id'] as String}; + } + + // ── breathing sessions ──────────────────────────────────────────────────── + + static Future putBreathingSession({ + required int startedAt, + required int endedAt, + required String pattern, + required int seconds, + double? coherence, + double? confidence, + }) async { + final db = await instance; + await db.insert('breathing_session', { + 'started_at': startedAt, + 'ended_at': endedAt, + 'pattern': pattern, + 'seconds': seconds, + 'coherence': coherence, + 'confidence': confidence, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + + /// Recent sessions, newest first. + static Future>> breathingSessions({ + int limit = 30, + }) async { + final db = await instance; + return db.query( + 'breathing_session', + orderBy: 'started_at DESC', + limit: limit, + ); + } + // ── lab results ─────────────────────────────────────────────────────────── /// Upsert one result. Idempotent on (marker, date drawn), so re-entering a diff --git a/lib/data/local_repository_impl.dart b/lib/data/local_repository_impl.dart index 59ee9257..c83995c3 100644 --- a/lib/data/local_repository_impl.dart +++ b/lib/data/local_repository_impl.dart @@ -2761,7 +2761,15 @@ class LocalRepositoryImpl extends LocalRepository { }) async { final since = _rangeSinceLabel(range); final journal = await LocalDb.journalRows(sinceDaysEpoch: since); - if (journal.isEmpty) return const {'insights': []}; + final metricsByDay = await LocalDb.journalMetricsByDay( + sinceDaysEpoch: since, + ); + // Read independently of each other: a day can carry numbers with no tags, + // and returning early on an empty tag set would silently hide every + // numeric finding. + if (journal.isEmpty && metricsByDay.isEmpty) { + return const {'insights': [], 'numeric_insights': []}; + } // Outcome series we correlate behaviours against. Each is read from // metric_series and indexed by date. Direction (does HIGHER help?) is encoded @@ -2806,7 +2814,19 @@ class LocalRepositoryImpl extends LocalRepository { for (final j in journal) if (j['date'] is String) j['date'] as String, }.toList()..sort(); - if (dates.length < 4) return const {'insights': []}; + + final numericInsights = await _numericJournalInsights( + metricsByDay: metricsByDay, + outcomeDefs: outcomeDefs, + maps: maps, + ); + + // The tag pass needs four tagged days before it says anything. The numeric + // pass has its own, stricter floor and is already computed, so an early + // return here must not take it down with it. + if (dates.length < 4) { + return {'insights': const [], 'numeric_insights': numericInsights}; + } final tagsByDate = >{}; for (final j in journal) { @@ -2866,7 +2886,92 @@ class LocalRepositoryImpl extends LocalRepository { (a['delta_pct'] as double).abs(), ), ); - return {'insights': insights}; + return {'insights': insights, 'numeric_insights': numericInsights}; + } + + /// Rank correlations between the numeric journal fields and each outcome. + /// + /// Deliberately a SEPARATE pass from the tag correlations rather than more + /// rows in the same list. A tag answers "were those days different"; a dose + /// answers "does more of this go with worse recovery", and they carry + /// different evidence (a difference of means with a Cohen's d, versus a rank + /// correlation with a confidence interval). Flattening them into one list + /// would force one phrasing onto both and lose the distinction. + /// + /// Its date axis is the days a NUMBER was recorded, which is not the same + /// set as the days a tag was — using the tag axis would drop every day the + /// user logged only numbers. + Future>> _numericJournalInsights({ + required Map> metricsByDay, + required List> outcomeDefs, + required Map> maps, + }) async { + if (metricsByDay.isEmpty) return const []; + + final dates = metricsByDay.keys.toList()..sort(); + final days = [ + for (final d in dates) + ana.JournalNumericDay(d, { + for (final e in metricsByDay[d]!.entries) e.key: e.value.value, + }), + ]; + final outcomes = >{ + for (final od in outcomeDefs) + (od['key'] as String): [for (final d in dates) maps[od['key']]![d]], + }; + + final corr = ana.journalNumericCorrelations( + journal: days, + dates: dates, + outcomes: outcomes, + ); + + // Custom field definitions so a user-invented field reads by its own name + // and unit rather than its storage key. + final customs = (await getJournalFields()).where((f) => f.custom).toList(); + final betterOf = { + for (final od in outcomeDefs) + od['key'] as String: od['higherBetter'] as bool, + }; + final labelOf = { + for (final od in outcomeDefs) od['key'] as String: od['label'] as String, + }; + final unitOf = { + for (final od in outcomeDefs) od['key'] as String: od['unit'], + }; + + final out = >[]; + for (final f in corr) { + final spec = journalFieldSpec(f.field, custom: customs); + for (final e in f.effects) { + if (e.insufficient || !e.meaningful || e.rho == null) continue; + final higherBetter = betterOf[e.outcome] ?? true; + out.add({ + 'field': f.field, + 'field_label': spec?.label ?? f.field, + 'field_unit': spec?.unit ?? '', + 'outcome': e.outcome, + 'outcome_label': labelOf[e.outcome], + 'unit': unitOf[e.outcome], + 'rho': e.rho, + // Outcome units per one unit of the field — the interpretable half. + // Null when Theil-Sen could not fit, in which case the UI shows the + // direction without a magnitude rather than inventing one. + 'slope_per_unit': e.slopePerUnit, + 'rho_low': e.rhoLow, + 'rho_high': e.rhoHigh, + 'n': e.n, + // More of it moved the outcome the good way. + 'helped': (e.rho! > 0) == higherBetter, + }); + } + } + // Strongest relationship first. + out.sort( + (a, b) => + (b['rho'] as double).abs().compareTo((a['rho'] as double).abs()), + ); + return out; } List _decodeStrList(Object? json) => [ diff --git a/lib/health/health_profile_import.dart b/lib/health/health_profile_import.dart new file mode 100644 index 00000000..0118aa05 --- /dev/null +++ b/lib/health/health_profile_import.dart @@ -0,0 +1,226 @@ +// Read body metrics from the platform health store into the local profile. +// +// Everything else in lib/health writes OUT. This reads IN, for one reason: +// weight changes, and three things the app computes depend on it — calories +// (Keytel), BMR, and the strain anchors. A profile typed in once at onboarding +// and never touched again quietly gets more wrong every month, and most people +// already keep their weight current somewhere else. +// +// PLATFORM ASYMMETRY, and it is not symmetrical by accident: +// height + weight — both platforms. +// sex + date of birth — APPLE ONLY. Health Connect has no characteristic +// record for either, and the plugin's Android type list does not contain +// them. Requesting them there is the issue #184 shape all over again, so the +// requested set is built per platform rather than filtered afterwards. + +import 'dart:io' show Platform; + +import 'package:flutter/foundation.dart'; +import 'package:health/health.dart'; + +/// What a read found. Every field is nullable: an empty health store, a denied +/// permission and a never-recorded metric are all "we don't know", and none of +/// them may become a number. +@immutable +class HealthProfileSnapshot { + const HealthProfileSnapshot({ + this.weightKg, + this.heightCm, + this.ageYears, + this.sex, + }); + + final double? weightKg; + final double? heightCm; + final int? ageYears; + + /// 'm' | 'f', matching the local profile map. Null for unset or for a value + /// HealthKit reports as "other" — the formulas that read this have exactly + /// two constants, so the honest response is no answer rather than a coin + /// flip. + final String? sex; + + bool get isEmpty => + weightKg == null && heightCm == null && ageYears == null && sex == null; + + /// Field names that were found, for a "read your weight and height" message + /// that says what actually happened. + List get found => [ + if (weightKg != null) 'weight', + if (heightCm != null) 'height', + if (ageYears != null) 'age', + if (sex != null) 'sex', + ]; +} + +/// Merge a snapshot into the existing profile map and return the result. +/// +/// The rule differs per field, deliberately: +/// weight, height — the health store WINS. They change, and keeping them +/// current is the entire point of the feature. +/// age, sex — only fill a GAP. Neither drifts, so a value already in the +/// profile is a deliberate choice by the user, and overwriting it from +/// another app's record would be presumptuous. Age is also the one the user +/// is most likely to have entered as an approximation on purpose. +/// +/// Pure, so the policy is testable without a health store. +Map mergeHealthProfile( + Map? existing, + HealthProfileSnapshot snap, +) { + final out = Map.from(existing ?? const {}); + if (snap.weightKg != null) out['weight_kg'] = snap.weightKg; + if (snap.heightCm != null) out['height_cm'] = snap.heightCm; + if (snap.ageYears != null && out['age'] == null) out['age'] = snap.ageYears; + if (snap.sex != null && out['sex'] == null) out['sex'] = snap.sex; + return out; +} + +/// Which fields [snap] would actually change in [existing], under +/// [mergeHealthProfile]. Used so the confirmation can name them rather than +/// claiming an import that changes nothing. +List healthProfileChanges( + Map? existing, + HealthProfileSnapshot snap, +) { + final before = Map.from(existing ?? const {}); + final after = mergeHealthProfile(existing, snap); + const labels = { + 'weight_kg': 'weight', + 'height_cm': 'height', + 'age': 'age', + 'sex': 'sex', + }; + return [ + for (final e in labels.entries) + if (after[e.key] != before[e.key]) e.value, + ]; +} + +class HealthProfileImporter { + HealthProfileImporter({Health? health, bool? isApple}) + : _health = health ?? Health(), + _isApple = isApple ?? (Platform.isIOS || Platform.isMacOS); + + final Health _health; + final bool _isApple; + + /// Types to request, per platform. Sex and date of birth exist only on + /// Apple; asking Health Connect for them throws before the platform channel. + List get types => [ + HealthDataType.WEIGHT, + HealthDataType.HEIGHT, + if (_isApple) ...[HealthDataType.GENDER, HealthDataType.BIRTH_DATE], + ]; + + /// Ask for READ access. Safe to call repeatedly. + Future requestPermission() async { + try { + await _health.configure(); + final already = await _health.hasPermissions( + types, + permissions: [for (final _ in types) HealthDataAccess.READ], + ); + if (already == true) return true; + return await _health.requestAuthorization( + types, + permissions: [for (final _ in types) HealthDataAccess.READ], + ); + } catch (e) { + debugPrint('[health_profile] permission: $e'); + return false; + } + } + + /// Read the most recent value of each field. + /// + /// Never throws: a locked store, a denied permission or a store with nothing + /// in it all come back as an empty snapshot, which the caller reports as + /// "nothing to import" rather than as a failure. + Future read({DateTime? now}) async { + try { + await _health.configure(); + final end = now ?? DateTime.now(); + // Ten years back. A body metric older than that is not worth adopting + // silently, and characteristics (sex, DOB) ignore the window anyway. + final start = DateTime(end.year - 10, end.month, end.day); + final points = await _health.getHealthDataFromTypes( + types: types, + startTime: start, + endTime: end, + ); + return _snapshotFrom(points, now: end); + } catch (e) { + debugPrint('[health_profile] read: $e'); + return const HealthProfileSnapshot(); + } + } + + /// Fold raw points into a snapshot, newest wins per type. + @visibleForTesting + HealthProfileSnapshot snapshotFrom( + List points, { + DateTime? now, + }) => _snapshotFrom(points, now: now ?? DateTime.now()); + + HealthProfileSnapshot _snapshotFrom( + List points, { + required DateTime now, + }) { + HealthDataPoint? newest(HealthDataType t) { + HealthDataPoint? best; + for (final p in points) { + if (p.type != t) continue; + if (best == null || p.dateTo.isAfter(best.dateTo)) best = p; + } + return best; + } + + double? numeric(HealthDataType t) { + final v = newest(t)?.value; + return v is NumericHealthValue ? v.numericValue.toDouble() : null; + } + + final weight = numeric(HealthDataType.WEIGHT); + // The plugin normalises HEIGHT to METRES; the profile stores centimetres. + final heightM = numeric(HealthDataType.HEIGHT); + + int? age; + final dob = newest(HealthDataType.BIRTH_DATE)?.value; + if (dob is NumericHealthValue) { + final born = DateTime.fromMillisecondsSinceEpoch( + dob.numericValue.toInt(), + ); + var years = now.year - born.year; + // Not yet had this year's birthday. + if (now.month < born.month || + (now.month == born.month && now.day < born.day)) { + years--; + } + if (years > 0 && years < 120) age = years; + } + + // HKBiologicalSex raw values: 0 notSet, 1 female, 2 male, 3 other. Only 1 + // and 2 map — "other" and "not set" leave this null, because the formulas + // downstream carry one constant per sex and nothing sensible for a third. + String? sex; + final g = newest(HealthDataType.GENDER)?.value; + if (g is NumericHealthValue) { + final raw = g.numericValue.toInt(); + if (raw == 1) sex = 'f'; + if (raw == 2) sex = 'm'; + } + + return HealthProfileSnapshot( + // Bounds are a sanity floor on a value being adopted without review, not + // a claim about human bodies: a zero or a wildly out-of-range reading is + // a bad record, and it would land straight in the calorie formula. + weightKg: (weight != null && weight > 20 && weight < 400) ? weight : null, + heightCm: (heightM != null && heightM > 0.5 && heightM < 2.6) + ? heightM * 100 + : null, + ageYears: age, + sex: sex, + ); + } +} diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 75f17d0f..0f905bc1 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -41,6 +41,13 @@ import '../compute/manual_session.dart' show strainFromPerMinuteHr; import '../compute/hr_max.dart'; import '../compute/profile.dart'; import '../data/day_label.dart'; +import '../data/auto_backup.dart' + show BackupCadence, BackupOutcome, runBackup; +import '../ui/stress/breath_phases.dart'; +// `runBackupIfDue` is also the name of the AppState method below, so the pure +// scheduler is imported under an alias rather than shadowed by it. +import '../data/auto_backup.dart' as backup show runBackupIfDue; +import 'prefs.dart'; import '../data/db.dart'; import '../data/live_coverage_policy.dart'; import '../data/local_repository.dart'; @@ -758,6 +765,59 @@ class AppState extends ChangeNotifier { return user!; } + // ── automatic backup ──────────────────────────────────────────────────────── + + BackupCadence get backupCadence => + BackupCadence.fromName(Prefs.getString(Prefs.backupCadence, '')); + + DateTime? get lastBackupAt { + final ms = Prefs.getInt(Prefs.backupLastRunMs, 0); + return ms == 0 ? null : DateTime.fromMillisecondsSinceEpoch(ms); + } + + /// Change the cadence. Switching it ON takes a backup immediately rather + /// than waiting for the interval — otherwise nothing visible happens and the + /// setting looks broken. + Future setBackupCadence(BackupCadence cadence) async { + Prefs.setString(Prefs.backupCadence, cadence.name); + notifyListeners(); + if (cadence != BackupCadence.off) await runBackupNow(); + } + + /// Take one now, whatever the schedule says. Returns what happened so the + /// caller can say so — a backup that silently did not happen is the failure + /// this feature exists to prevent. + Future runBackupNow() async { + final outcome = await runBackup(); + if (outcome.succeeded) { + Prefs.setInt( + Prefs.backupLastRunMs, + DateTime.now().millisecondsSinceEpoch, + ); + notifyListeners(); + } + return outcome; + } + + /// Foreground hook. Silent unless it actually writes something. + Future runBackupIfDue() async { + final cadence = backupCadence; + if (cadence == BackupCadence.off) return; + final outcome = await backup.runBackupIfDue( + cadence: cadence, + lastRun: lastBackupAt, + ); + if (outcome.succeeded) { + Prefs.setInt( + Prefs.backupLastRunMs, + DateTime.now().millisecondsSinceEpoch, + ); + notifyListeners(); + } else if (outcome.error != null) { + _log('Backup failed: ${outcome.error}'); + } + } + /// Clear the local profile + unpair the band (the former "sign out", now purely /// local — there is no session to end). Future signOut() async { @@ -1593,6 +1653,12 @@ class AppState extends ChangeNotifier { } } + /// Re-derive after a nap edit. Same machinery as a sleep-override change — + /// nap minutes feed sleep need and sleep debt, so an edit is a recompute + /// rather than a redraw, and the engine force-includes nap-edit days even + /// when they are finalized. + Future reanalyzeForNapEdit() => _reanalyzeForOverride(); + Future reanalyzeDays(Set days) async { if (days.isEmpty || reanalyzing) return 0; reanalyzing = true; @@ -3778,6 +3844,14 @@ class AppState extends ChangeNotifier { static const double breathingPacedHz = 1000.0 / 10900.0; static const Duration _breathingRecomputeInterval = Duration(seconds: 20); bool breathingActive = false; + + /// The pattern the running session is pacing to. Coherence is only computed + /// for a pattern that claims a resonance frequency — see + /// [BreathPattern.coherenceRated]. + BreathPattern breathingPattern = kBreathPatterns.first; + + /// When the running session started, for the persisted history row. + DateTime? _breathingStartedAt; Map? breathingResult; // last {ok, ratio, score, peak_hz, n_beats, confidence, tier, note} String? breathingError; @@ -3786,17 +3860,19 @@ class AppState extends ChangeNotifier { bool _breathingEnabledStreams = false; /// Begin a guided-breathing session. Requires a connected band. - Future startBreathingSession() async { + Future startBreathingSession({BreathPattern? pattern}) async { if (breathingActive) return; if (!isConnected) { breathingError = 'Connect your band first.'; notifyListeners(); return; } + breathingPattern = pattern ?? breathingPattern; breathingActive = true; breathingResult = null; breathingError = null; _breathingFrames.clear(); + _breathingStartedAt = DateTime.now(); notifyListeners(); unawaited(BreathingLiveActivity.start(startedAt: DateTime.now())); try { @@ -3818,7 +3894,11 @@ class AppState extends ChangeNotifier { }); } - /// End the guided-breathing session. + /// End the guided-breathing session and bank it. + /// + /// A session shorter than a minute is NOT recorded. Opening the screen and + /// closing it again is not a breathing session, and a history full of + /// 4-second entries would bury the real ones. Future stopBreathingSession() async { if (!breathingActive) return; _breathingRecomputeTimer?.cancel(); @@ -3826,9 +3906,54 @@ class AppState extends ChangeNotifier { breathingActive = false; _stopBreathingStreams(); unawaited(BreathingLiveActivity.end()); + + final started = _breathingStartedAt; + _breathingStartedAt = null; + if (started != null) { + final ended = DateTime.now(); + final seconds = ended.difference(started).inSeconds; + if (seconds >= 60) { + final res = breathingResult; + final scored = res != null && res['ok'] == true; + // Null unless the pattern is one a coherence score means something + // for AND the estimator actually produced one. + final rated = breathingPattern.coherenceRated && scored; + unawaited( + LocalDb.putBreathingSession( + startedAt: started.millisecondsSinceEpoch, + endedAt: ended.millisecondsSinceEpoch, + pattern: breathingPattern.key, + seconds: seconds, + coherence: rated ? (res['score'] as num?)?.toDouble() : null, + confidence: rated ? (res['confidence'] as num?)?.toDouble() : null, + ), + ); + } + } notifyListeners(); } + /// Past sessions, newest first. + Future>> breathingHistory({int limit = 30}) => + LocalDb.breathingSessions(limit: limit); + + /// Buzz the strap at a breathing or interval phase boundary. + /// + /// Distinct patterns per phase so the cue is legible without looking: a + /// longer buzz to breathe in, a shorter one to breathe out, a double for a + /// hold. Never throws and never awaits the caller — this fires from a frame + /// callback, and a momentary disconnect must not interrupt the session or + /// stall the animation. + void buzzBreathPhase(BreathPhaseKind kind) { + if (!isConnected) return; + final pattern = switch (kind) { + BreathPhaseKind.inhale || BreathPhaseKind.work => 1, + BreathPhaseKind.exhale || BreathPhaseKind.rest => 0, + BreathPhaseKind.holdIn || BreathPhaseKind.holdOut => 2, + }; + unawaited(engine.buzzPattern(pattern).catchError((_) {})); + } + Future _recomputeBreathingCoherence() async { if (!breathingActive || repo == null) return; final frames = List.from(_breathingFrames); @@ -3836,7 +3961,10 @@ class AppState extends ChangeNotifier { try { final res = await repo!.breathingCoherence( frames, - pacedHz: breathingPacedHz, + // The pattern's own paced frequency, not a constant — box breathing at + // 3.75 breaths/min scored against a 5.5 breaths/min target would read + // as incoherent no matter how well it was done. + pacedHz: breathingPattern.pacedHz, ); if (!breathingActive) return; // session ended while we awaited breathingResult = res; diff --git a/lib/state/prefs.dart b/lib/state/prefs.dart index 9740d7c8..e64adbda 100644 --- a/lib/state/prefs.dart +++ b/lib/state/prefs.dart @@ -49,6 +49,10 @@ class Prefs { static const String recapRange = 'ui.recap_range'; static const String workoutsRange = 'ui.workouts_range'; + /// Automatic local backup: the chosen cadence, and when one last ran. + static const String backupCadence = 'backup.cadence'; + static const String backupLastRunMs = 'backup.last_run_ms'; + /// Per-metric range toggle on the shared MetricScreen (Today/Week/Month/3M). /// Keyed by the metric id so Sleep / Heart / Body each remember independently. static String metricTab(String metric) => 'ui.metric_tab.$metric'; diff --git a/lib/ui/journal/journal_screen.dart b/lib/ui/journal/journal_screen.dart index b333e36e..0d9561cd 100644 --- a/lib/ui/journal/journal_screen.dart +++ b/lib/ui/journal/journal_screen.dart @@ -51,6 +51,12 @@ class _JournalScreenState extends State { List<_JournalRow> _rows = const []; List> _insights = const []; + /// Rank correlations over the numeric fields. Kept apart from [_insights] + /// because they answer a different question and carry different evidence — + /// a tag says "those days were different", a dose says "more of this goes + /// with less of that". + List> _numericInsights = const []; + bool _loading = true; bool _saving = false; String? _error; // network/load error @@ -92,12 +98,17 @@ class _JournalScreenState extends State { final rows = journal.map(_JournalRow.fromJson).toList(); List> insights = const []; + List> numeric = const []; try { final ins = await api.getJournalInsights(range: '90d'); insights = ((ins['insights'] as List?) ?? const []) .whereType() .map((e) => e.cast()) .toList(); + numeric = ((ins['numeric_insights'] as List?) ?? const []) + .whereType() + .map((e) => e.cast()) + .toList(); } catch (_) { // Insights are optional — never fail the screen for them. } @@ -108,6 +119,7 @@ class _JournalScreenState extends State { setState(() { _rows = rows; _insights = insights; + _numericInsights = numeric; _fieldSpecs = specs; _loading = false; }); @@ -405,15 +417,15 @@ class _JournalScreenState extends State { // ── insights ──────────────────────────────────────────────────────────────── List _insightsSection() { - if (_insights.isEmpty) { + if (_insights.isEmpty && _numericInsights.isEmpty) { return const [ StateCard( icon: OsIcon.activity, title: 'Insights build over time', message: - 'Tag at least 3 days with how you lived, and OpenStrap starts ' + 'Log a few days — tags, numbers or both — and OpenStrap starts ' 'surfacing how each habit tracks with your recovery, sleep and ' - 'heart rate — drawn from your own data.', + 'heart rate, drawn from your own data.', ), ]; } @@ -422,6 +434,16 @@ class _JournalScreenState extends State { JournalInsightCard(insight: _insights[i]).dsEnter(index: i), if (i != _insights.length - 1) const SizedBox(height: Sp.x3), ], + if (_numericInsights.isNotEmpty) ...[ + if (_insights.isNotEmpty) const SizedBox(height: Sp.x4), + const SectionHeader('How much of it'), + for (var i = 0; i < _numericInsights.length; i++) ...[ + JournalDoseInsightCard( + insight: _numericInsights[i], + ).dsEnter(index: i), + if (i != _numericInsights.length - 1) const SizedBox(height: Sp.x3), + ], + ], const SizedBox(height: Sp.x4), Center( child: Text( @@ -614,3 +636,73 @@ class _JournalRow { (j['note'] ?? '').toString(), ); } + +/// One numeric-field finding: "each extra coffee, about 4 ms less HRV". +/// +/// Deliberately leads with the SLOPE in the outcome's own units, not with the +/// correlation coefficient. Rho carries whether the relationship holds at all +/// and is shown as supporting detail; "0.62" is not a sentence anybody can act +/// on, and "about 4 ms per cup" is. +class JournalDoseInsightCard extends StatelessWidget { + final Map insight; + const JournalDoseInsightCard({super.key, required this.insight}); + + @override + Widget build(BuildContext context) { + final field = (insight['field_label'] ?? '').toString(); + final fieldUnit = (insight['field_unit'] ?? '').toString(); + final outcome = (insight['outcome_label'] ?? '').toString(); + final outcomeUnit = (insight['unit'] ?? '').toString(); + final slope = (insight['slope_per_unit'] as num?)?.toDouble(); + final rho = (insight['rho'] as num?)?.toDouble() ?? 0; + final n = (insight['n'] as num?)?.toInt() ?? 0; + final helped = insight['helped'] == true; + final tint = helped ? AppColors.good : AppColors.warn; + + // "per 250 ml", "per unit", or just "per point" for a 1–5 rating. + final per = fieldUnit.isEmpty ? 'point' : fieldUnit; + + return SurfaceCard( + padding: const EdgeInsets.all(Sp.x4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded(child: Text(field, style: AppText.title)), + Text( + '$n days', + style: AppText.caption.copyWith(color: AppColors.inkMuted), + ), + ], + ), + const SizedBox(height: Sp.x2), + Text( + slope == null + // Theil–Sen could not fit a slope. The direction still holds, + // so say that and nothing more rather than invent a magnitude. + ? '${rho > 0 ? 'More' : 'Less'} $field goes with ' + 'higher $outcome' + : 'About ${_fmtSlope(slope)}' + '${outcomeUnit.isEmpty ? '' : ' $outcomeUnit'} ' + '$outcome per extra $per', + style: AppText.body.copyWith(color: tint), + ), + const SizedBox(height: Sp.x1), + Text( + 'rank correlation ${rho.toStringAsFixed(2)}', + style: AppText.captionMuted, + ), + ], + ), + ); + } + + /// Signed, so "−4 ms" reads as a fall rather than needing the sentence to + /// carry the direction separately. + String _fmtSlope(double v) { + final a = v.abs(); + final s = a >= 10 ? a.round().toString() : a.toStringAsFixed(1); + return v < 0 ? '−$s' : '+$s'; + } +} diff --git a/lib/ui/profile/profile_screen.dart b/lib/ui/profile/profile_screen.dart index 2bbb10d1..b828f880 100644 --- a/lib/ui/profile/profile_screen.dart +++ b/lib/ui/profile/profile_screen.dart @@ -32,13 +32,112 @@ import '../import/import_screen.dart'; import '../today/step_goal_screen.dart'; import 'about_screen.dart'; import 'advanced_data_screen.dart'; +import '../../data/auto_backup.dart'; import '../../data/csv_export.dart'; +import '../../health/health_profile_import.dart'; import '../labs/labs_screen.dart'; import 'data_history_screen.dart'; import 'gesture_section.dart'; import 'notification_relay_section.dart'; import 'notification_settings_screen.dart'; +/// Choose how often the database is copied into the app's Documents folder. +Future _backupSheet(BuildContext ctx, AppState app) async { + final messenger = ScaffoldMessenger.of(ctx); + final chosen = await showModalBottomSheet( + context: ctx, + isScrollControlled: true, + builder: (sheetCtx) => SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.all(Sp.x5), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Automatic backup', style: AppText.h2), + const SizedBox(height: Sp.x3), + Text( + 'A full copy of your database, kept in an OpenStrap Backups ' + 'folder you can reach from Files. Point iCloud Drive, Synology ' + 'or Nextcloud at it and your history lives somewhere other than ' + 'this phone. The last $kBackupsKept are kept.\n\n' + 'It is not encrypted, and it runs when you open the app rather ' + 'than in the background.', + style: AppText.bodySoft.copyWith(color: AppColors.inkSoft), + ), + const SizedBox(height: Sp.x4), + Wrap( + spacing: Sp.x2, + runSpacing: Sp.x2, + children: [ + for (final c in BackupCadence.values) + ToggleChip( + c.label, + selected: app.backupCadence == c, + onTap: () => Navigator.pop(sheetCtx, c), + ), + ], + ), + const SizedBox(height: Sp.x4), + ], + ), + ), + ), + ); + if (chosen == null) return; + await app.setBackupCadence(chosen); + if (!ctx.mounted) return; + messenger.showSnackBar( + SnackBar( + content: Text( + chosen == BackupCadence.off + ? 'Automatic backup off' + : 'Backing up ${chosen.label.toLowerCase()}', + ), + ), + ); +} + +/// Pull weight/height (and on Apple, sex and date of birth) from the platform +/// health store into the local profile. +/// +/// Reports what it actually changed rather than claiming success: a store with +/// nothing in it, a denied permission, and values that already match all look +/// identical from the outside and would otherwise all say "imported". +Future _importHealthProfile(BuildContext ctx, AppState app) async { + final messenger = ScaffoldMessenger.of(ctx); + final store = app.healthStoreName; + final importer = HealthProfileImporter(); + if (!await importer.requestPermission()) { + if (!ctx.mounted) return; + messenger.showSnackBar( + SnackBar(content: Text('$store did not grant access')), + ); + return; + } + final snap = await importer.read(); + if (!ctx.mounted) return; + if (snap.isEmpty) { + messenger.showSnackBar( + SnackBar(content: Text('Nothing to read from $store')), + ); + return; + } + final changed = healthProfileChanges(app.user, snap); + if (changed.isEmpty) { + messenger.showSnackBar( + const SnackBar(content: Text('Your profile already matches')), + ); + return; + } + await app.updateProfile(mergeHealthProfile(app.user, snap)); + if (!ctx.mounted) return; + messenger.showSnackBar( + SnackBar(content: Text('Updated ${changed.join(', ')}')), + ); +} + /// True while a CSV export is being handed to the share sheet. /// /// File-scoped rather than widget state on purpose: the resource being guarded @@ -149,10 +248,22 @@ class ProfileScreen extends StatelessWidget { value: user['weight_kg'] != null ? units.weight(user['weight_kg'] as num?) : 'Add', + divider: true, onTap: () => _editProfileSheet(context, app), ), + Builder( + builder: (rowCtx) => ListRow( + icon: OsIcon.sync, + title: 'Fill from ${app.healthStoreName}', + value: 'Import', + onTap: () => _importHealthProfile(rowCtx, app), + ), + ), ]), - const _CardNote('Body metrics improve your calorie estimate.'), + const _CardNote( + 'Body metrics improve your calorie estimate. Weight is the one ' + 'that drifts — importing keeps it current.', + ), const SizedBox(height: Sp.x6), @@ -253,6 +364,15 @@ class ProfileScreen extends StatelessWidget { divider: true, ), ), + Builder( + builder: (rowCtx) => ListRow( + icon: OsIcon.history, + title: 'Automatic backup', + value: app.backupCadence.label, + divider: true, + onTap: () => _backupSheet(rowCtx, app), + ), + ), // Blood work lives here rather than on a daily screen: it is a // record you consult, not a number that changes overnight. ListRow( diff --git a/lib/ui/sleep/sleep_periods_screen.dart b/lib/ui/sleep/sleep_periods_screen.dart index bee05794..11cdc614 100644 --- a/lib/ui/sleep/sleep_periods_screen.dart +++ b/lib/ui/sleep/sleep_periods_screen.dart @@ -9,6 +9,8 @@ import 'package:provider/provider.dart'; import '../../data/local_repository.dart'; import '../../state/app_state.dart'; +import '../../compute/nap_edits.dart'; +import '../../data/db.dart'; import '../design/design.dart'; class SleepPeriodsScreen extends StatefulWidget { @@ -78,11 +80,137 @@ class _SleepPeriodsScreenState extends State { int? get _totalAsleep => _num(_data['total_asleep_min'])?.toInt(); bool get _beta => _data['stages_beta'] == true; + /// Log a nap the detector missed. Two time pickers rather than a duration, + /// because people remember when they lay down, not how long they were out. + Future _addNap() async { + final start = await showTimePicker( + context: context, + initialTime: const TimeOfDay(hour: 14, minute: 0), + helpText: 'Nap started', + ); + if (start == null || !mounted) return; + final end = await showTimePicker( + context: context, + initialTime: TimeOfDay( + hour: (start.hour + 1) % 24, + minute: start.minute, + ), + helpText: 'Nap ended', + ); + if (end == null || !mounted) return; + + final day = DateTime.parse(widget.date); + final startTs = + DateTime(day.year, day.month, day.day, start.hour, start.minute) + .millisecondsSinceEpoch ~/ + 1000; + var endTs = + DateTime(day.year, day.month, day.day, end.hour, end.minute) + .millisecondsSinceEpoch ~/ + 1000; + // An end before the start means it ran past midnight. + if (endTs <= startTs) endTs += 24 * 3600; + + if (!manualNapWindowIsValid(startTs, endTs)) { + _say( + 'A nap runs from ${kMinManualNapSec ~/ 60} minutes to ' + '${kMaxManualNapSec ~/ 3600} hours. Longer than that belongs in your ' + 'main sleep.', + ); + return; + } + if (napOverlapsExisting(startTs, endTs, _napWindows)) { + // Silently merging two overlapping entries would inflate the day's total + // while hiding the mistake. + _say('That overlaps a sleep already on this day.'); + return; + } + + await LocalDb.putNapEdit( + dayId: widget.date, + startTs: startTs, + endTs: endTs, + source: 'manual', + ); + await _rederive(); + } + + /// Remove a period. A detected nap is SUPPRESSED (its window is remembered, + /// so it stays gone when the detector runs again); a logged one is deleted + /// outright. + Future _removeNap(Map period) async { + final start = (period['onset_ts'] as num?)?.toInt(); + final end = (period['wake_ts'] as num?)?.toInt(); + if (start == null || end == null) return; + if (period['source'] == 'manual') { + await LocalDb.deleteNapEdit(widget.date, start); + } else { + await LocalDb.putNapEdit( + dayId: widget.date, + startTs: start, + endTs: end, + source: 'rejected', + ); + } + await _rederive(); + } + + List> get _napWindows => [ + for (final p in _periods) + if (p['is_main'] != true) + {'start': p['onset_ts'], 'end': p['wake_ts']}, + ]; + + Future _rederive() async { + // The edit only shows up once the day is re-derived — nap minutes feed + // sleep need and sleep debt, so this is a recompute, not a redraw. + await context.read().reanalyzeForNapEdit(); + if (mounted) await _load(); + } + + void _say(String message) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } + @override Widget build(BuildContext context) { return AppScaffold( title: 'Sleep periods', subtitle: 'Every sleep, naps included', + actions: [ + Semantics( + button: true, + label: 'Log a nap', + child: Pressable( + pressedScale: 0.94, + onTap: _addNap, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: Sp.x4, + vertical: Sp.x3, + ), + decoration: BoxDecoration( + color: AppColors.tonalFill(AppColors.accent), + borderRadius: BorderRadius.circular(R.pill), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.add_rounded, size: 18, color: AppColors.accent), + const SizedBox(width: Sp.x1), + Text( + 'Nap', + style: AppText.label.copyWith(color: AppColors.accent), + ), + ], + ), + ), + ), + ), + ], body: RefreshIndicator( onRefresh: _load, color: AppColors.accent, @@ -100,12 +228,14 @@ class _SleepPeriodsScreenState extends State { const SizedBox(height: Sp.x3), Skeleton.tileRow(rows: 2), ] else if (_phase == _Phase.empty) - const StateCard( + StateCard( icon: OsIcon.bedtime, title: 'No sleep detected', message: 'Wear your strap overnight (and through any naps) and sync ' - 'to see each sleep here.', + 'to see each sleep here. You can also log a nap yourself.', + actionLabel: 'Log a nap', + onAction: _addNap, ) else if (_phase == _Phase.error) StateCard( @@ -201,11 +331,34 @@ class _SleepPeriodsScreenState extends State { children: [ Expanded( child: TileHeader( - isMain ? 'Main sleep' : 'Nap', + isMain + ? 'Main sleep' + : (p['source'] == 'manual' ? 'Nap · logged' : 'Nap'), icon: isMain ? OsIcon.sleep : OsIcon.bedtime, trailing: _beta ? const Tag('est') : null, ), ), + // Only naps can be removed. The main sleep window is edited on + // the sleep detail screen, where the override lives — deleting + // it here would leave the day with no sleep at all rather than + // with a corrected one. + if (!isMain) + Semantics( + button: true, + label: 'Remove this nap', + child: Pressable( + pressedScale: 0.9, + onTap: () => _removeNap(p), + child: Padding( + padding: const EdgeInsets.only(right: Sp.x2), + child: Icon( + Icons.close_rounded, + size: 16, + color: AppColors.inkMuted, + ), + ), + ), + ), // No dot at all when confidence is unknown — a ConfDot(0) is a // red "we are sure this is bad" dot, which is a claim. if (conf != null) ConfDot(conf), diff --git a/lib/ui/stress/breath_phases.dart b/lib/ui/stress/breath_phases.dart new file mode 100644 index 00000000..a5f1e70c --- /dev/null +++ b/lib/ui/stress/breath_phases.dart @@ -0,0 +1,200 @@ +// The phase engine behind both paced breathing and the interval timer. +// +// They are the same problem: a repeating sequence of named, timed phases, with +// something to do at each boundary (change the label, buzz the strap). Keeping +// one engine means the interval timer inherits the breathing screen's haptics +// and duration handling for free, and a fix to either lands in both. +// +// Pure — no widgets, no timers, no BLE. A phase is a function of elapsed time, +// so the UI can drive it from any clock and the tests can drive it by hand. + +import 'package:flutter/foundation.dart'; + +/// What a phase asks of you. The label is the whole instruction — a paced +/// breathing app that needs a legend has already failed. +enum BreathPhaseKind { + inhale, + holdIn, + exhale, + holdOut, + + /// Interval-timer phases. + work, + rest, +} + +extension BreathPhaseKindLabel on BreathPhaseKind { + String get label => switch (this) { + BreathPhaseKind.inhale => 'Inhale', + BreathPhaseKind.holdIn => 'Hold', + BreathPhaseKind.exhale => 'Exhale', + BreathPhaseKind.holdOut => 'Hold', + BreathPhaseKind.work => 'Work', + BreathPhaseKind.rest => 'Rest', + }; + + /// Whether the visual should be expanding, contracting, or still. Holds are + /// still on purpose: an animation that keeps moving during a hold tells you + /// to keep breathing. + double get targetScale => switch (this) { + BreathPhaseKind.inhale || BreathPhaseKind.work => 1.0, + BreathPhaseKind.exhale || BreathPhaseKind.rest => 0.0, + BreathPhaseKind.holdIn => 1.0, + BreathPhaseKind.holdOut => 0.0, + }; + + bool get isHold => + this == BreathPhaseKind.holdIn || this == BreathPhaseKind.holdOut; +} + +@immutable +class BreathPhase { + const BreathPhase(this.kind, this.seconds); + final BreathPhaseKind kind; + final double seconds; +} + +/// One cycle of a repeating protocol. +@immutable +class BreathPattern { + const BreathPattern({ + required this.key, + required this.label, + required this.description, + required this.phases, + this.coherenceRated = false, + }); + + final String key; + final String label; + + /// One line on the picker. Says what it is FOR, not what it does — the phase + /// counts are already visible. + final String description; + final List phases; + + /// Whether a cardiac-coherence score is meaningful for this pattern. + /// + /// Only true for resonance breathing. Coherence measures how strongly heart + /// rate oscillates AT THE PACED FREQUENCY, which is the entire point of + /// resonance work and is not what box breathing or 4-7-8 are trying to do. + /// Scoring them against it would grade them on someone else's exam. + final bool coherenceRated; + + double get cycleSeconds => + phases.fold(0.0, (a, p) => a + p.seconds); + + /// Breaths per minute. + double get rate => 60.0 / cycleSeconds; + + /// The paced frequency in Hz, for the coherence estimator. + double get pacedHz => 1.0 / cycleSeconds; +} + +/// Resonance breathing at ~5.5 breaths/min. The original, and the only one +/// with a coherence score attached. +const _resonance = BreathPattern( + key: 'resonance', + label: 'Resonance', + description: 'Even in and out at about 5.5 breaths a minute. The one with a ' + 'coherence score.', + phases: [ + BreathPhase(BreathPhaseKind.inhale, 5.45), + BreathPhase(BreathPhaseKind.exhale, 5.45), + ], + coherenceRated: true, +); + +const kBreathPatterns = [ + _resonance, + BreathPattern( + key: 'box', + label: 'Box', + description: 'Four counts each way, holds included. Steadying when your ' + 'head is racing.', + phases: [ + BreathPhase(BreathPhaseKind.inhale, 4), + BreathPhase(BreathPhaseKind.holdIn, 4), + BreathPhase(BreathPhaseKind.exhale, 4), + BreathPhase(BreathPhaseKind.holdOut, 4), + ], + ), + BreathPattern( + key: 'four_seven_eight', + label: '4-7-8', + description: 'A long hold and a longer exhale. Usually used to get to ' + 'sleep.', + phases: [ + BreathPhase(BreathPhaseKind.inhale, 4), + BreathPhase(BreathPhaseKind.holdIn, 7), + BreathPhase(BreathPhaseKind.exhale, 8), + ], + ), + BreathPattern( + key: 'extended_exhale', + label: 'Long exhale', + description: 'Out for twice as long as in. No holds, so it is easy to keep ' + 'up for a while.', + phases: [ + BreathPhase(BreathPhaseKind.inhale, 4), + BreathPhase(BreathPhaseKind.exhale, 8), + ], + ), +]; + +final Map kBreathPatternsByKey = { + for (final p in kBreathPatterns) p.key: p, +}; + +/// Where a repeating [pattern] is at [elapsed], and how far through that phase. +/// +/// Returns null for a non-positive cycle, which would otherwise divide by zero +/// — reachable only from a malformed pattern, but this runs every frame. +({BreathPhase phase, double progress, int cycle})? phaseAt( + BreathPattern pattern, + Duration elapsed, +) { + final cycle = pattern.cycleSeconds; + if (cycle <= 0 || pattern.phases.isEmpty) return null; + final t = elapsed.inMicroseconds / 1e6; + if (t < 0) return null; + final cycleIndex = (t / cycle).floor(); + var within = t - cycleIndex * cycle; + for (final phase in pattern.phases) { + if (within < phase.seconds) { + return ( + phase: phase, + progress: phase.seconds <= 0 ? 1.0 : within / phase.seconds, + cycle: cycleIndex, + ); + } + within -= phase.seconds; + } + // Floating-point drift can land a hair past the last phase. + return (phase: pattern.phases.last, progress: 1.0, cycle: cycleIndex); +} + +/// An interval timer expressed as a [BreathPattern], so it runs on the same +/// engine — boxing rounds, HIIT, rest between sets. +BreathPattern intervalPattern({ + required Duration work, + required Duration rest, +}) => BreathPattern( + key: 'interval', + label: 'Interval', + description: 'Work and rest, buzzed at each change.', + phases: [ + BreathPhase(BreathPhaseKind.work, work.inMilliseconds / 1000), + if (rest > Duration.zero) + BreathPhase(BreathPhaseKind.rest, rest.inMilliseconds / 1000), + ], +); + +/// Elapsed time at which a session of [rounds] cycles ends, or null for an +/// open-ended one. +Duration? sessionEnd(BreathPattern pattern, int? rounds) { + if (rounds == null || rounds <= 0) return null; + return Duration( + microseconds: (pattern.cycleSeconds * rounds * 1e6).round(), + ); +} diff --git a/lib/ui/stress/calm_breathing_screen.dart b/lib/ui/stress/calm_breathing_screen.dart index f8823605..4a0e8f87 100644 --- a/lib/ui/stress/calm_breathing_screen.dart +++ b/lib/ui/stress/calm_breathing_screen.dart @@ -1,5 +1,10 @@ -// Guided resonance breathing — a paced-breathing circle (5.5 breaths/min, -// the classic HRV-resonance pace) with a REAL cardiac-coherence readout. +// Guided paced breathing — a breathing circle with a REAL cardiac-coherence +// readout, four patterns, a session that actually ends, and a strap that +// buzzes each phase change so you can shut your eyes. +// +// The circle is driven by the shared phase engine (breath_phases.dart), the +// same one behind the interval timer: both are a repeating sequence of named, +// timed phases with something to do at each boundary. // // The coherence score is computed on-device from live beat-to-beat RR // (McCraty & Zayas 2014 — see openstrap_analytics's cardiacCoherence) via @@ -16,6 +21,7 @@ import 'package:provider/provider.dart'; import '../../state/app_state.dart'; import '../design/design.dart'; +import 'breath_phases.dart'; class CalmBreathingScreen extends StatelessWidget { /// Auto-begin the session the moment this screen mounts — used when opened @@ -47,8 +53,13 @@ class CalmBreathingScreen extends StatelessWidget { active: app.breathingActive, result: app.breathingResult, error: app.breathingError, + pattern: app.breathingPattern, onStart: app.startBreathingSession, onStop: app.stopBreathingSession, + // Buzzing the strap is what makes this usable with your eyes closed, + // which is the whole point of a breathing exercise you are not supposed + // to be staring at a phone during. + onPhaseChange: app.buzzBreathPhase, onBack: () { if (app.breathingActive) app.stopBreathingSession(); Navigator.of(context).maybePop(); @@ -63,8 +74,15 @@ class CalmBreathingView extends StatefulWidget { final bool active; final Map? result; final String? error; - final VoidCallback? onStart; + + /// The pattern being paced. Defaults to resonance, which is what this screen + /// has always run and the only one carrying a coherence score. + final BreathPattern pattern; + final void Function({BreathPattern? pattern})? onStart; final VoidCallback? onStop; + + /// Called once per phase boundary, to buzz the strap. + final ValueChanged? onPhaseChange; final VoidCallback? onBack; const CalmBreathingView({ @@ -73,8 +91,10 @@ class CalmBreathingView extends StatefulWidget { required this.active, this.result, this.error, + this.pattern = _defaultPattern, this.onStart, this.onStop, + this.onPhaseChange, this.onBack, }); @@ -82,58 +102,124 @@ class CalmBreathingView extends StatefulWidget { State createState() => _CalmBreathingViewState(); } +/// Resonance, the pace this screen has always run. +const BreathPattern _defaultPattern = BreathPattern( + key: 'resonance', + label: 'Resonance', + description: '', + phases: [ + BreathPhase(BreathPhaseKind.inhale, 5.45), + BreathPhase(BreathPhaseKind.exhale, 5.45), + ], + coherenceRated: true, +); + +/// Session lengths offered. "Open" runs until you stop it. +const _durationChoices = [2, 5, 10, null]; + class _CalmBreathingViewState extends State with SingleTickerProviderStateMixin { - late AnimationController _controller; - String _phaseText = "Inhale"; + /// Drives the circle. Its VALUE is ignored — the phase engine decides what + /// is happening; this just gives us a per-frame tick. + late AnimationController _ticker; + final _clock = Stopwatch(); + + /// Chosen before starting. + BreathPattern _pattern = _defaultPattern; + int? _minutes = 2; + + BreathPhaseKind? _lastPhase; + int _lastCycle = -1; @override void initState() { super.initState(); - // A standard resonance frequency is ~5.5 breaths per minute. - // 5.5 breaths/min = ~10.9 seconds per breath cycle. - // So 5.45 seconds inhale, 5.45 seconds exhale. - _controller = AnimationController( + _pattern = widget.pattern; + // A repeating controller used purely as a frame ticker, so the circle is + // interpolated from real elapsed time rather than from an animation whose + // own duration would have to be kept in sync with the pattern. + _ticker = AnimationController( vsync: this, - duration: const Duration(milliseconds: 5450), - ); - - _controller.addStatusListener((status) { - if (status == AnimationStatus.completed) { - setState(() => _phaseText = "Exhale"); - _controller.reverse(); - } else if (status == AnimationStatus.dismissed) { - setState(() => _phaseText = "Inhale"); - _controller.forward(); - } - }); + duration: const Duration(seconds: 1), + )..addListener(_onTick); } @override void didUpdateWidget(covariant CalmBreathingView oldWidget) { super.didUpdateWidget(oldWidget); if (widget.active && !oldWidget.active) { - _controller.forward(); + _lastPhase = null; + _lastCycle = -1; + _clock + ..reset() + ..start(); + _ticker.repeat(); } else if (!widget.active && oldWidget.active) { - _controller.stop(); - _controller.value = 0.0; - setState(() => _phaseText = "Inhale"); + _ticker.stop(); + _clock.stop(); + setState(() {}); } } @override void dispose() { - _controller.dispose(); + _ticker + ..removeListener(_onTick) + ..dispose(); super.dispose(); } + Duration get _elapsed => _clock.elapsed; + + Duration? get _target => + _minutes == null ? null : Duration(minutes: _minutes!); + + Duration? get _remaining { + final t = _target; + return t == null ? null : t - _elapsed; + } + + void _onTick() { + if (!widget.active) return; + final at = phaseAt(_pattern, _elapsed); + if (at != null) { + final changed = at.phase.kind != _lastPhase || at.cycle != _lastCycle; + if (changed) { + _lastPhase = at.phase.kind; + _lastCycle = at.cycle; + widget.onPhaseChange?.call(at.phase.kind); + } + } + final remaining = _remaining; + if (remaining != null && remaining <= Duration.zero) { + // The button said two minutes, so two minutes is what it runs. It used + // to say that and run until you stopped it. + widget.onStop?.call(); + return; + } + setState(() {}); + } + bool get _hasResult => widget.result?['ok'] == true; // Coherence isn't a published 0-100 scale (see cardiacCoherence's own // honesty note) — this threshold is a display choice, not a cited boundary. bool _isGood(num score) => score > 60; + String _fmt(Duration d) { + final s = d.inSeconds.clamp(0, 86400); + return '${s ~/ 60}:${(s % 60).toString().padLeft(2, '0')}'; + } + @override Widget build(BuildContext context) { + final at = widget.active ? phaseAt(_pattern, _elapsed) : null; + final kind = at?.phase.kind; + // A hold holds the circle where it was, rather than continuing to move and + // silently telling you to keep breathing. + final progress = at == null + ? 0.0 + : (kind!.isHold ? kind.targetScale : _scaleFor(kind, at.progress)); + return Scaffold( backgroundColor: AppColors.background, appBar: AppBar( @@ -144,102 +230,164 @@ class _CalmBreathingViewState extends State onPressed: widget.onBack, ), ), - body: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - 'Resonance Breathing', - style: AppText.h2, - ), - const SizedBox(height: Sp.x2), - Padding( - padding: const EdgeInsets.symmetric(horizontal: Sp.x8), - child: Text( - 'Sync your breath with the circle to maximize your HRV and lower sympathetic stress.', - style: AppText.bodySoft, - textAlign: TextAlign.center, + body: SingleChildScrollView( + padding: const EdgeInsets.symmetric(vertical: Sp.x6), + child: Column( + children: [ + Text(_pattern.label, style: AppText.h2), + const SizedBox(height: Sp.x2), + Padding( + padding: const EdgeInsets.symmetric(horizontal: Sp.x8), + child: Text( + widget.active + ? 'Follow the circle. Your strap buzzes each change, so ' + 'you can close your eyes.' + : _pattern.description, + style: AppText.bodySoft, + textAlign: TextAlign.center, + ), ), - ), - const SizedBox(height: 64), - Center( - child: AnimatedBuilder( - animation: _controller, - builder: (context, child) { - // Scale from 1.0 to 2.0 - final scale = 1.0 + (_controller.value * 1.0); - return Transform.scale( - scale: scale, - child: Container( - width: 120, - height: 120, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: DomainAccent.recovery.withValues(alpha: 0.2 + (_controller.value * 0.3)), - border: Border.all( - color: DomainAccent.recovery, - width: 2, - ), - ), - alignment: Alignment.center, - child: Transform.scale( - scale: 1.0 / scale, // keep text unscaled - child: Text( - widget.active ? _phaseText : "Ready", - style: AppText.h2.copyWith( - color: DomainAccent.recovery, - ), - ), + const SizedBox(height: Sp.x8), + Center(child: _circle(progress, kind)), + const SizedBox(height: Sp.x8), + if (widget.active) ...[ + if (_remaining != null) + Text( + _fmt(_remaining!), + style: AppText.h2.copyWith(color: AppColors.inkSoft), + ) + else + Text(_fmt(_elapsed), style: AppText.h2.copyWith( + color: AppColors.inkSoft, + )), + const SizedBox(height: Sp.x5), + if (_pattern.coherenceRated) ...[ + Text('Coherence Score', style: AppText.caption), + const SizedBox(height: Sp.x2), + if (_hasResult) + Text( + '${(widget.result!['score'] as num).round()}%', + style: AppText.h1.copyWith( + color: _isGood(widget.result!['score'] as num) + ? AppColors.good + : AppColors.warn, ), + ) + else + // Honest: no fabricated number until enough clean live RR + // has accumulated (first recompute lands ~20s in). + Text( + 'Calibrating…', + style: AppText.h2.copyWith(color: AppColors.inkMuted), + ), + ] else + // Coherence measures oscillation at the RESONANCE frequency. + // Scoring the other patterns against it would grade them on an + // exam they are not sitting. + Padding( + padding: const EdgeInsets.symmetric(horizontal: Sp.x8), + child: Text( + 'No coherence score for this pattern — it only means ' + 'something for resonance breathing.', + style: AppText.captionMuted, + textAlign: TextAlign.center, ), - ); - }, - ), - ), - const SizedBox(height: 64), - if (widget.active) ...[ - Text( - 'Coherence Score', - style: AppText.caption, - ), - const SizedBox(height: Sp.x2), - if (_hasResult) - Text( - '${(widget.result!['score'] as num).round()}%', - style: AppText.h1.copyWith( - color: _isGood(widget.result!['score'] as num) - ? AppColors.good - : AppColors.warn, ), - ) - else - // Honest: no fabricated number until enough clean live RR has - // accumulated (first recompute lands ~20s in — see AppState's - // _breathingRecomputeInterval). - Text( - 'Calibrating…', - style: AppText.h2.copyWith(color: AppColors.inkMuted), + const SizedBox(height: Sp.x8), + OutlinedButton( + onPressed: widget.onStop, + child: const Text('Stop Session'), ), - const SizedBox(height: Sp.x8), - OutlinedButton( - onPressed: widget.onStop, - child: const Text('Stop Session'), - ), - ] else ...[ - FilledButton( - onPressed: widget.connected ? widget.onStart : null, - child: const Text('Begin 2-Minute Session'), - ), - if (!widget.connected || widget.error != null) ...[ - const SizedBox(height: Sp.x3), - Text( - widget.error ?? 'Connect your band to start a session.', - style: AppText.captionMuted, - textAlign: TextAlign.center, + ] else ...[ + _patternPicker(), + const SizedBox(height: Sp.x5), + _durationPicker(), + const SizedBox(height: Sp.x6), + FilledButton( + onPressed: widget.connected + ? () => widget.onStart?.call(pattern: _pattern) + : null, + child: Text( + _minutes == null + ? 'Begin' + : 'Begin $_minutes-Minute Session', + ), ), + if (!widget.connected || widget.error != null) ...[ + const SizedBox(height: Sp.x3), + Text( + widget.error ?? 'Connect your band to start a session.', + style: AppText.captionMuted, + textAlign: TextAlign.center, + ), + ], ], ], - ], + ), ), ); } + + /// 0 at rest, 1 fully expanded. + double _scaleFor(BreathPhaseKind kind, double progress) => switch (kind) { + BreathPhaseKind.inhale || BreathPhaseKind.work => progress, + BreathPhaseKind.exhale || BreathPhaseKind.rest => 1 - progress, + BreathPhaseKind.holdIn => 1, + BreathPhaseKind.holdOut => 0, + }; + + Widget _circle(double t, BreathPhaseKind? kind) { + final scale = 1.0 + t; + return Transform.scale( + scale: scale, + child: Container( + width: 120, + height: 120, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: DomainAccent.recovery.withValues(alpha: 0.2 + (t * 0.3)), + border: Border.all(color: DomainAccent.recovery, width: 2), + ), + alignment: Alignment.center, + child: Transform.scale( + scale: 1.0 / scale, // keep the text unscaled + child: Text( + widget.active ? (kind?.label ?? '') : 'Ready', + style: AppText.h2.copyWith(color: DomainAccent.recovery), + ), + ), + ), + ); + } + + Widget _patternPicker() => Padding( + padding: const EdgeInsets.symmetric(horizontal: Sp.x5), + child: Wrap( + spacing: Sp.x2, + runSpacing: Sp.x2, + alignment: WrapAlignment.center, + children: [ + for (final p in kBreathPatterns) + ToggleChip( + p.label, + selected: _pattern.key == p.key, + onTap: () => setState(() => _pattern = p), + ), + ], + ), + ); + + Widget _durationPicker() => Wrap( + spacing: Sp.x2, + runSpacing: Sp.x2, + alignment: WrapAlignment.center, + children: [ + for (final m in _durationChoices) + ToggleChip( + m == null ? 'Open' : '$m min', + selected: _minutes == m, + onTap: () => setState(() => _minutes = m), + ), + ], + ); } diff --git a/lib/ui/stress/interval_timer_screen.dart b/lib/ui/stress/interval_timer_screen.dart new file mode 100644 index 00000000..adc5f6d9 --- /dev/null +++ b/lib/ui/stress/interval_timer_screen.dart @@ -0,0 +1,253 @@ +// Interval timer — boxing rounds, HIIT, rest between sets. +// +// Runs on the same phase engine as paced breathing (breath_phases.dart), +// because they are the same problem: a repeating sequence of named, timed +// phases with something to do at each boundary. The strap buzzing at each +// change is the entire reason this belongs in this app rather than being any +// of the hundred timer apps — you do not have to look at the phone. +// +// Deliberately NOT a workout. It records nothing and starts no session: an +// auto-detected effort or a manually started workout already covers that, and +// a timer that quietly created sessions would double-count every round. + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../state/app_state.dart'; +import '../design/design.dart'; +import '../../gps/screen_wake.dart'; +import 'breath_phases.dart'; + +/// Work-interval choices, in seconds. +const _workChoices = [20, 30, 45, 60, 120, 180]; + +/// Rest choices. Zero means continuous rounds with a buzz at each boundary. +const _restChoices = [0, 10, 15, 30, 60]; + +/// Round counts. Null runs until stopped. +const _roundChoices = [3, 5, 8, 12, null]; + +class IntervalTimerScreen extends StatefulWidget { + const IntervalTimerScreen({super.key}); + + @override + State createState() => _IntervalTimerScreenState(); +} + +class _IntervalTimerScreenState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _ticker; + final _clock = Stopwatch(); + + int _work = 180; + int _rest = 60; + int? _rounds = 3; + bool _running = false; + + BreathPhaseKind? _lastPhase; + int _lastCycle = -1; + + @override + void initState() { + super.initState(); + _ticker = AnimationController( + vsync: this, + duration: const Duration(seconds: 1), + )..addListener(_onTick); + } + + @override + void dispose() { + _ticker + ..removeListener(_onTick) + ..dispose(); + super.dispose(); + } + + BreathPattern get _pattern => intervalPattern( + work: Duration(seconds: _work), + rest: Duration(seconds: _rest), + ); + + Duration? get _sessionEnd => sessionEnd(_pattern, _rounds); + + void _start() { + setState(() { + _running = true; + _lastPhase = null; + _lastCycle = -1; + }); + _clock + ..reset() + ..start(); + _ticker.repeat(); + // The keep-awake is the difference between a timer you can use and one + // that dies mid-round because the screen locked. + ScreenWake.enable(); + } + + void _stop() { + _ticker.stop(); + _clock.stop(); + ScreenWake.release(); + if (mounted) setState(() => _running = false); + } + + void _onTick() { + if (!_running) return; + final elapsed = _clock.elapsed; + final end = _sessionEnd; + if (end != null && elapsed >= end) { + // Three buzzes to say the whole thing is over, distinct from the single + // cue at a phase change. + final app = context.read(); + for (final kind in [ + BreathPhaseKind.work, + BreathPhaseKind.work, + BreathPhaseKind.work, + ]) { + app.buzzBreathPhase(kind); + } + _stop(); + return; + } + final at = phaseAt(_pattern, elapsed); + if (at != null && + (at.phase.kind != _lastPhase || at.cycle != _lastCycle)) { + _lastPhase = at.phase.kind; + _lastCycle = at.cycle; + context.read().buzzBreathPhase(at.phase.kind); + } + setState(() {}); + } + + String _fmt(Duration d) { + final s = d.inSeconds.clamp(0, 86400); + return '${s ~/ 60}:${(s % 60).toString().padLeft(2, '0')}'; + } + + @override + Widget build(BuildContext context) { + final connected = context.select((a) => a.isConnected); + final elapsed = _clock.elapsed; + final at = _running ? phaseAt(_pattern, elapsed) : null; + final kind = at?.phase.kind; + final phaseLeft = at == null + ? null + : Duration( + milliseconds: + ((at.phase.seconds * (1 - at.progress)) * 1000).round(), + ); + + return AppScaffold( + title: 'Interval timer', + body: SingleChildScrollView( + padding: EdgeInsets.fromLTRB( + Sp.screen, + Sp.x4, + Sp.screen, + dsBottomGutter(context), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (_running) ...[ + Center( + child: Text( + kind?.label ?? '', + style: AppText.h2.copyWith( + color: kind == BreathPhaseKind.rest + ? AppColors.inkSoft + : AppColors.accent, + ), + ), + ), + const SizedBox(height: Sp.x3), + Center( + child: Text( + _fmt(phaseLeft ?? Duration.zero), + style: AppText.h1.copyWith(fontSize: 72), + ), + ), + const SizedBox(height: Sp.x3), + Center( + child: Text( + _rounds == null + ? 'Round ${(at?.cycle ?? 0) + 1}' + : 'Round ${(at?.cycle ?? 0) + 1} of $_rounds', + style: AppText.bodySoft, + ), + ), + const SizedBox(height: Sp.x8), + OutlinedButton(onPressed: _stop, child: const Text('Stop')), + ] else ...[ + _label('Work'), + _chips( + _workChoices.map((s) => (s, _durLabel(s))).toList(), + _work, + (v) => setState(() => _work = v), + ), + const SizedBox(height: Sp.x4), + _label('Rest'), + _chips( + _restChoices + .map((s) => (s, s == 0 ? 'None' : _durLabel(s))) + .toList(), + _rest, + (v) => setState(() => _rest = v), + ), + const SizedBox(height: Sp.x4), + _label('Rounds'), + _chips( + _roundChoices.map((r) => (r, r?.toString() ?? 'Open')).toList(), + _rounds, + (v) => setState(() => _rounds = v), + ), + const SizedBox(height: Sp.x6), + FilledButton( + onPressed: connected ? _start : null, + child: const Text('Start'), + ), + const SizedBox(height: Sp.x3), + Text( + connected + ? 'Your strap buzzes at every change, so you can leave the ' + 'phone alone. Nothing is recorded — start a workout ' + 'if you want the session logged.' + : 'Connect your band — the buzz is the point.', + style: AppText.captionMuted, + textAlign: TextAlign.center, + ), + ], + ], + ), + ), + ); + } + + static String _durLabel(int seconds) => seconds < 60 + ? '${seconds}s' + : (seconds % 60 == 0 ? '${seconds ~/ 60}m' : '${seconds ~/ 60}m ${seconds % 60}s'); + + Widget _label(String text) => Padding( + padding: const EdgeInsets.only(bottom: Sp.x2), + child: Text(text, style: AppText.label.copyWith(color: AppColors.inkSoft)), + ); + + Widget _chips( + List<(T, String)> options, + T selected, + ValueChanged onPick, + ) => Wrap( + spacing: Sp.x2, + runSpacing: Sp.x2, + children: [ + for (final o in options) + ToggleChip( + o.$2, + selected: selected == o.$1, + onTap: () => onPick(o.$1), + ), + ], + ); +} diff --git a/lib/ui/workouts/workout_types.dart b/lib/ui/workouts/workout_types.dart index 3eb78e35..86861bfa 100644 --- a/lib/ui/workouts/workout_types.dart +++ b/lib/ui/workouts/workout_types.dart @@ -191,7 +191,13 @@ Widget workoutTypeGrid(BuildContext context) => Wrap( /// that the moment it went past nine tiles, and the overflow is invisible in /// release — the last rows are simply clipped off and untappable, with no /// overflow stripes to give it away. -Widget workoutTypeSheet(BuildContext context, String title) { +Widget workoutTypeSheet( + BuildContext context, + String title, { + /// Optional row under the grid. Only safe because the sheet scrolls — see + /// the note above; a fixed sheet clipped anything past the grid. + Widget? footer, +}) { final maxHeight = MediaQuery.sizeOf(context).height * 0.75; return SafeArea( top: false, @@ -207,7 +213,16 @@ Widget workoutTypeSheet(BuildContext context, String title) { const SizedBox(height: Sp.x4), Flexible( child: SingleChildScrollView( - child: Builder(builder: workoutTypeGrid), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Builder(builder: workoutTypeGrid), + if (footer != null) ...[ + const SizedBox(height: Sp.x4), + footer, + ], + ], + ), ), ), const SizedBox(height: Sp.x4), diff --git a/lib/ui/workouts/workouts_screen.dart b/lib/ui/workouts/workouts_screen.dart index f0711619..fd1876b0 100644 --- a/lib/ui/workouts/workouts_screen.dart +++ b/lib/ui/workouts/workouts_screen.dart @@ -25,6 +25,7 @@ import '../kit/route_map.dart'; import '../screens/detail_cards.dart' show hm; import '../../gps/route_models.dart'; import 'manual_workout_screen.dart'; +import '../stress/interval_timer_screen.dart'; import 'workout_filter.dart'; import 'workout_filter_sheet.dart'; import 'workout_types.dart'; @@ -92,7 +93,36 @@ Future startWorkoutFlow(BuildContext context) async { final type = await showModalBottomSheet( context: context, isScrollControlled: true, - builder: (ctx) => workoutTypeSheet(ctx, 'Start a workout'), + builder: (ctx) => workoutTypeSheet( + ctx, + 'Start a workout', + // Reachable from here because this is where someone is standing when + // they want one — rounds, HIIT, rest between sets. It records nothing, + // so it is not a workout type; it sits under them. + footer: Pressable( + pressedScale: 0.96, + onTap: () { + Navigator.pop(ctx); + Navigator.of(context).push( + themedRoute( + (_) => const IntervalTimerScreen(), + name: 'IntervalTimerScreen', + ), + ); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.timer_outlined, size: 18, color: AppColors.accent), + const SizedBox(width: Sp.x2), + Text( + 'Interval timer', + style: AppText.label.copyWith(color: AppColors.accent), + ), + ], + ), + ), + ), ); if (type == null || !context.mounted) return; final app = context.read(); diff --git a/pubspec.lock b/pubspec.lock index a8d19eff..e76d6ac9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -964,8 +964,8 @@ packages: dependency: "direct main" description: path: "." - ref: "1fb34dce64e6df833f583ee60cbccfff6751571f" - resolved-ref: "1fb34dce64e6df833f583ee60cbccfff6751571f" + ref: ebe45da6271ec99fbda353067b6deadc757ff2d8 + resolved-ref: ebe45da6271ec99fbda353067b6deadc757ff2d8 url: "https://github.com/OpenStrap/analytics.git" source: git version: "1.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index 964b27ad..196c12cd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -79,7 +79,16 @@ dependencies: # # Verified against the SHA: `dailyStepEstimate` absent, # `dailyActiveMinutes` present in lib/src/onehz/motion/steps.dart. - ref: 1fb34dce64e6df833f583ee60cbccfff6751571f + # + # analytics main @ #41 merge (ebe45da). Adds `journalNumericCorrelations` + # — Spearman rho + Theil-Sen slope over journal fields that carry a dose, + # which the typed journal entries shipped in edge #218 had no way to + # correlate. Nothing else changed; #41 only appends to coaching.dart. + # + # Verified against the SHA: + # `git show ebe45da:lib/src/onehz/human/coaching.dart | + # grep -E 'journalNumericCorrelations|class JournalNumericDay'` + ref: ebe45da6271ec99fbda353067b6deadc757ff2d8 # BLE — flutter_blue_plus is the maintained cross-platform GATT client. flutter_blue_plus: ^1.36.8 diff --git a/test/auto_backup_test.dart b/test/auto_backup_test.dart new file mode 100644 index 00000000..68f2ca71 --- /dev/null +++ b/test/auto_backup_test.dart @@ -0,0 +1,228 @@ +// Automatic backup scheduling and retention. +// +// The scheduling half is pure and is where the interesting cases live: never +// backed up, clock moved backwards, and the boundary. The retention half has +// to keep the NEWEST files, which means the filename ordering has to be right +// — getting it backwards would delete exactly the backups worth having. + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/data/auto_backup.dart'; +import 'package:path/path.dart' as p; + +void main() { + group('backupIsDue', () { + final now = DateTime(2026, 8, 9, 12); + + test('off is never due', () { + expect( + backupIsDue(cadence: BackupCadence.off, lastRun: null, now: now), + isFalse, + ); + expect( + backupIsDue( + cadence: BackupCadence.off, + lastRun: DateTime(2020), + now: now, + ), + isFalse, + ); + }); + + test('never backed up is always due', () { + // Otherwise switching the setting on does nothing visible until + // tomorrow, and the user reasonably concludes it is broken. + for (final c in [BackupCadence.daily, BackupCadence.weekly]) { + expect(backupIsDue(cadence: c, lastRun: null, now: now), isTrue); + } + }); + + test('daily waits a day, and the boundary counts', () { + expect( + backupIsDue( + cadence: BackupCadence.daily, + lastRun: now.subtract(const Duration(hours: 23, minutes: 59)), + now: now, + ), + isFalse, + ); + expect( + backupIsDue( + cadence: BackupCadence.daily, + lastRun: now.subtract(const Duration(days: 1)), + now: now, + ), + isTrue, + ); + }); + + test('weekly waits a week', () { + expect( + backupIsDue( + cadence: BackupCadence.weekly, + lastRun: now.subtract(const Duration(days: 6)), + now: now, + ), + isFalse, + ); + expect( + backupIsDue( + cadence: BackupCadence.weekly, + lastRun: now.subtract(const Duration(days: 7)), + now: now, + ), + isTrue, + ); + }); + + test('a last run in the future is due, not parked forever', () { + // Reachable from a timezone change, an NTP correction, or a user setting + // the date forward and back. Without this the schedule silently stops. + expect( + backupIsDue( + cadence: BackupCadence.daily, + lastRun: now.add(const Duration(days: 30)), + now: now, + ), + isTrue, + ); + }); + }); + + group('BackupCadence', () { + test('an unknown stored name falls back to off, not to backing up', () { + // A pref written by a newer build must not silently start writing + // unencrypted copies of everything on an older one. + expect(BackupCadence.fromName('fortnightly'), BackupCadence.off); + expect(BackupCadence.fromName(null), BackupCadence.off); + expect(BackupCadence.fromName(''), BackupCadence.off); + expect(BackupCadence.fromName('weekly'), BackupCadence.weekly); + }); + + test('off has no interval', () { + expect(BackupCadence.off.interval, isNull); + expect(BackupCadence.daily.interval, const Duration(days: 1)); + }); + }); + + group('backupFileName', () { + test('sorts chronologically as plain text', () { + // Retention orders by name, so this has to hold without parsing. + final names = [ + backupFileName(DateTime(2026, 8, 9, 9, 5)), + backupFileName(DateTime(2026, 12, 1, 23, 59)), + backupFileName(DateTime(2026, 8, 9, 10, 0)), + backupFileName(DateTime(2025, 1, 1, 0, 0)), + ]..sort(); + expect(names.first, contains('20250101')); + expect(names.last, contains('20261201')); + expect(names[1], contains('20260809-0905')); + expect(names[2], contains('20260809-1000')); + }); + + test('is zero-padded so widths match', () { + expect(backupFileName(DateTime(2026, 1, 2, 3, 4)), + 'openstrap-20260102-0304.db'); + }); + }); + + group('sortBackupsNewestFirst', () { + late Directory tmp; + + setUp(() async { + tmp = await Directory.systemTemp.createTemp('openstrap_backup_test_'); + }); + + tearDown(() async { + if (await tmp.exists()) await tmp.delete(recursive: true); + }); + + File touch(String name) => + File(p.join(tmp.path, name))..writeAsStringSync('x'); + + test('orders newest first', () { + touch('openstrap-20260101-0000.db'); + touch('openstrap-20260301-0000.db'); + touch('openstrap-20260201-0000.db'); + final out = sortBackupsNewestFirst(tmp.listSync()); + expect( + out.map((f) => p.basename(f.path)), + [ + 'openstrap-20260301-0000.db', + 'openstrap-20260201-0000.db', + 'openstrap-20260101-0000.db', + ], + ); + }); + + test('ignores anything that is not one of ours', () { + // The documents directory is shared with whatever the user drops in it. + touch('openstrap-20260101-0000.db'); + touch('holiday-photos.zip'); + touch('openstrap-notes.txt'); + touch('random.db'); + final out = sortBackupsNewestFirst(tmp.listSync()); + expect(out.map((f) => p.basename(f.path)), [ + 'openstrap-20260101-0000.db', + ]); + }); + + test('an empty directory is empty, not an error', () { + expect(sortBackupsNewestFirst(tmp.listSync()), isEmpty); + }); + }); + + group('pruneBackups', () { + late Directory tmp; + + setUp(() async { + tmp = await Directory.systemTemp.createTemp('openstrap_prune_test_'); + }); + + tearDown(() async { + if (await tmp.exists()) await tmp.delete(recursive: true); + }); + + test('keeps the newest and deletes the rest', () async { + for (final d in ['0101', '0201', '0301', '0401', '0501']) { + File(p.join(tmp.path, 'openstrap-2026$d-0000.db')) + .writeAsStringSync('x'); + } + await pruneBackups(tmp, keep: 2); + expect( + sortBackupsNewestFirst(tmp.listSync()).map((f) => p.basename(f.path)), + ['openstrap-20260501-0000.db', 'openstrap-20260401-0000.db'], + ); + }); + + test('never touches a file that is not a backup', () async { + File(p.join(tmp.path, 'openstrap-20260101-0000.db')) + .writeAsStringSync('x'); + final other = File(p.join(tmp.path, 'important.txt')) + ..writeAsStringSync('x'); + await pruneBackups(tmp, keep: 0); + expect(other.existsSync(), isTrue); + expect(sortBackupsNewestFirst(tmp.listSync()), isEmpty); + }); + + test('fewer backups than the limit is a no-op', () async { + File(p.join(tmp.path, 'openstrap-20260101-0000.db')) + .writeAsStringSync('x'); + await pruneBackups(tmp, keep: 5); + expect(sortBackupsNewestFirst(tmp.listSync()), hasLength(1)); + }); + }); + + test('runBackupIfDue skips rather than failing when nothing is due', + () async { + final outcome = await runBackupIfDue( + cadence: BackupCadence.daily, + lastRun: DateTime(2026, 8, 9, 11), + now: DateTime(2026, 8, 9, 12), + ); + expect(outcome.skipped, isTrue); + expect(outcome.succeeded, isFalse); + expect(outcome.error, isNull, reason: 'not due is not a failure'); + }); +} diff --git a/test/breath_phases_test.dart b/test/breath_phases_test.dart new file mode 100644 index 00000000..5ea805a8 --- /dev/null +++ b/test/breath_phases_test.dart @@ -0,0 +1,183 @@ +// The phase engine shared by paced breathing and the interval timer. +// +// A phase is a pure function of elapsed time, so everything interesting can be +// asserted without a clock: boundaries land on the right side, holds stay +// still, and a malformed pattern returns nothing rather than dividing by zero +// in a per-frame call. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ui/stress/breath_phases.dart'; + +void main() { + final box = kBreathPatternsByKey['box']!; + final resonance = kBreathPatternsByKey['resonance']!; + + group('the pattern table', () { + test('keys are unique and every pattern has real phases', () { + final keys = kBreathPatterns.map((p) => p.key).toList(); + expect(keys.toSet().length, keys.length); + for (final p in kBreathPatterns) { + expect(p.phases, isNotEmpty, reason: p.key); + expect(p.cycleSeconds, greaterThan(0), reason: p.key); + for (final ph in p.phases) { + expect(ph.seconds, greaterThan(0), reason: '${p.key} has a 0 phase'); + } + } + }); + + test('only resonance carries a coherence score', () { + // Coherence measures oscillation AT THE PACED FREQUENCY, which is what + // resonance work is for. Box breathing and 4-7-8 are trying to do + // something else, and scoring them against it grades them on someone + // else's exam. + final rated = kBreathPatterns.where((p) => p.coherenceRated); + expect(rated.map((p) => p.key), ['resonance']); + }); + + test('resonance is still the ~5.5 breaths a minute it always was', () { + expect(resonance.rate, closeTo(5.5, 0.05)); + expect(resonance.pacedHz, closeTo(1 / 10.9, 1e-6)); + }); + + test('box is symmetric and a minute long every four breaths', () { + expect(box.cycleSeconds, 16); + expect(box.rate, closeTo(3.75, 1e-9)); + }); + }); + + group('phaseAt', () { + test('walks the phases in order', () { + expect( + phaseAt(box, const Duration(seconds: 1))!.phase.kind, + BreathPhaseKind.inhale, + ); + expect( + phaseAt(box, const Duration(seconds: 5))!.phase.kind, + BreathPhaseKind.holdIn, + ); + expect( + phaseAt(box, const Duration(seconds: 9))!.phase.kind, + BreathPhaseKind.exhale, + ); + expect( + phaseAt(box, const Duration(seconds: 13))!.phase.kind, + BreathPhaseKind.holdOut, + ); + }); + + test('a boundary belongs to the phase it starts', () { + // Off by one here means the strap buzzes a beat late every cycle. + expect( + phaseAt(box, const Duration(seconds: 4))!.phase.kind, + BreathPhaseKind.holdIn, + ); + expect( + phaseAt(box, const Duration(milliseconds: 3999))!.phase.kind, + BreathPhaseKind.inhale, + ); + }); + + test('repeats, and reports which cycle', () { + final first = phaseAt(box, const Duration(seconds: 1))!; + final third = phaseAt(box, const Duration(seconds: 33))!; + expect(third.phase.kind, first.phase.kind); + expect(first.cycle, 0); + expect(third.cycle, 2); + }); + + test('progress runs 0 to 1 within a phase', () { + expect(phaseAt(box, Duration.zero)!.progress, 0); + expect( + phaseAt(box, const Duration(seconds: 2))!.progress, + closeTo(0.5, 1e-9), + ); + expect( + phaseAt(box, const Duration(milliseconds: 3999))!.progress, + closeTo(1.0, 0.001), + ); + }); + + test('a negative elapsed returns nothing rather than a wrong phase', () { + expect(phaseAt(box, const Duration(seconds: -1)), isNull); + }); + + test('a degenerate pattern returns null instead of dividing by zero', () { + // This runs every frame, so a malformed pattern must not crash the + // screen. + const empty = BreathPattern( + key: 'empty', + label: 'Empty', + description: '', + phases: [], + ); + expect(phaseAt(empty, const Duration(seconds: 1)), isNull); + }); + }); + + group('phase presentation', () { + test('holds are still, so the animation stops asking you to breathe', () { + expect(BreathPhaseKind.holdIn.isHold, isTrue); + expect(BreathPhaseKind.holdOut.isHold, isTrue); + expect(BreathPhaseKind.inhale.isHold, isFalse); + expect(BreathPhaseKind.holdIn.targetScale, + BreathPhaseKind.inhale.targetScale); + expect(BreathPhaseKind.holdOut.targetScale, + BreathPhaseKind.exhale.targetScale); + }); + + test('both holds read as "Hold" without saying which', () { + expect(BreathPhaseKind.holdIn.label, 'Hold'); + expect(BreathPhaseKind.holdOut.label, 'Hold'); + }); + }); + + group('interval timer on the same engine', () { + test('work and rest alternate', () { + final p = intervalPattern( + work: const Duration(minutes: 3), + rest: const Duration(minutes: 1), + ); + expect(p.cycleSeconds, 240); + expect( + phaseAt(p, const Duration(minutes: 1))!.phase.kind, + BreathPhaseKind.work, + ); + expect( + phaseAt(p, const Duration(minutes: 3, seconds: 30))!.phase.kind, + BreathPhaseKind.rest, + ); + expect( + phaseAt(p, const Duration(minutes: 4, seconds: 1))!.phase.kind, + BreathPhaseKind.work, + ); + }); + + test('no rest means continuous work rounds', () { + final p = intervalPattern( + work: const Duration(seconds: 30), + rest: Duration.zero, + ); + expect(p.phases, hasLength(1)); + expect( + phaseAt(p, const Duration(seconds: 45))!.phase.kind, + BreathPhaseKind.work, + ); + expect(phaseAt(p, const Duration(seconds: 45))!.cycle, 1); + }); + }); + + group('sessionEnd', () { + test('is null for an open-ended session', () { + expect(sessionEnd(box, null), isNull); + expect(sessionEnd(box, 0), isNull); + }); + + test('is a whole number of cycles', () { + expect(sessionEnd(box, 4), const Duration(seconds: 64)); + expect( + sessionEnd(resonance, 5)!.inMilliseconds, + (resonance.cycleSeconds * 5 * 1000).round(), + ); + }); + }); +} diff --git a/test/calm_breathing_view_test.dart b/test/calm_breathing_view_test.dart index ce17a0a9..725a0f5a 100644 --- a/test/calm_breathing_view_test.dart +++ b/test/calm_breathing_view_test.dart @@ -36,7 +36,7 @@ void main() { await tester.pumpWidget(_host(CalmBreathingView( connected: true, active: false, - onStart: () => started = true, + onStart: ({pattern}) => started = true, ))); await tester.tap(find.byType(FilledButton)); expect(started, isTrue); diff --git a/test/db_migration_ladder_test.dart b/test/db_migration_ladder_test.dart index f4a67186..4b471d5c 100644 --- a/test/db_migration_ladder_test.dart +++ b/test/db_migration_ladder_test.dart @@ -370,4 +370,51 @@ void main() { expect(health['ok'], isTrue, reason: '$health'); }, ); + + test( + 'upgrade from v27 runs the whole ladder to 31 and every new table works', + () async { + const name = 'migrate_from_v27_to_31_test.db'; + created.add(name); + await _seedOldDb(name, 27, _v5DerivedDdl); + + expect(await _openThroughLocalDb(name), LocalDb.schemaVersion); + + // Each rung's table, exercised rather than merely present — a CREATE + // that ran with a typo still leaves a table that nothing can write to. + await LocalDb.putJournalMetrics('2026-06-01', { + 'mood': const JournalMetricValue(4), + }); + await LocalDb.putLabResult( + marker: 'ferritin', + takenOn: '2026-03-04', + value: 42, + unit: 'ng/mL', + ); + await LocalDb.putBreathingSession( + startedAt: 1000, + endedAt: 2000, + pattern: 'resonance', + seconds: 120, + ); + await LocalDb.putNapEdit( + dayId: '2026-06-01', + startTs: 1000, + endTs: 4600, + source: 'manual', + ); + + expect( + (await LocalDb.journalMetricsForDay('2026-06-01'))['mood']!.value, + 4, + ); + expect((await LocalDb.labResults()).single['value'], 42.0); + expect((await LocalDb.breathingSessions()).single['seconds'], 120); + expect((await LocalDb.napEdits('2026-06-01')).single['source'], 'manual'); + expect(await LocalDb.napEditDays(), {'2026-06-01'}); + + final health = await LocalDb.schemaHealth(); + expect(health['ok'], isTrue, reason: '$health'); + }, + ); } diff --git a/test/health_profile_import_test.dart b/test/health_profile_import_test.dart new file mode 100644 index 00000000..9c81d225 --- /dev/null +++ b/test/health_profile_import_test.dart @@ -0,0 +1,239 @@ +// Reading body metrics from the platform health store. +// +// Two things carry the weight here. The merge policy differs per field on +// purpose — weight and height are adopted because they drift, while age and +// sex only fill a gap because they do not, and overwriting a value the user +// deliberately set from another app's record would be presumptuous. And the +// requested type set is built PER PLATFORM: sex and date of birth exist only +// on Apple, and asking Health Connect for them is the issue #184 shape again. + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:health/health.dart'; +import 'package:openstrap_edge/health/health_profile_import.dart'; + +HealthDataPoint _point(HealthDataType type, num value, DateTime at) => + HealthDataPoint( + uuid: '$type-$value', + value: NumericHealthValue(numericValue: value), + type: type, + unit: HealthDataUnit.NO_UNIT, + dateFrom: at, + dateTo: at, + sourcePlatform: HealthPlatformType.appleHealth, + sourceDeviceId: 'test', + sourceId: 'test', + sourceName: 'test', + ); + +void main() { + group('requested types', () { + test('Apple asks for sex and date of birth', () { + final t = HealthProfileImporter(isApple: true).types; + expect(t, contains(HealthDataType.GENDER)); + expect(t, contains(HealthDataType.BIRTH_DATE)); + }); + + test('Android asks for neither — Health Connect has no such record', () { + // Requesting an unsupported type throws before the platform channel, + // which is exactly how issue #184 lost every strength workout. + final t = HealthProfileImporter(isApple: false).types; + expect(t, isNot(contains(HealthDataType.GENDER))); + expect(t, isNot(contains(HealthDataType.BIRTH_DATE))); + expect(t, contains(HealthDataType.WEIGHT)); + expect(t, contains(HealthDataType.HEIGHT)); + }); + }); + + group('reading a snapshot', () { + final importer = HealthProfileImporter(isApple: true); + final now = DateTime(2026, 8, 9); + + test('takes the newest value per type', () { + final snap = importer.snapshotFrom([ + _point(HealthDataType.WEIGHT, 80, DateTime(2026, 1, 1)), + _point(HealthDataType.WEIGHT, 74, DateTime(2026, 7, 1)), + _point(HealthDataType.WEIGHT, 77, DateTime(2026, 4, 1)), + ], now: now); + expect(snap.weightKg, 74); + }); + + test('converts height from metres to centimetres', () { + final snap = importer.snapshotFrom([ + _point(HealthDataType.HEIGHT, 1.78, DateTime(2026, 1, 1)), + ], now: now); + expect(snap.heightCm, closeTo(178, 0.001)); + }); + + test('turns a date of birth into an age, respecting the birthday', () { + final beforeBirthday = importer.snapshotFrom([ + _point( + HealthDataType.BIRTH_DATE, + DateTime(1990, 12, 25).millisecondsSinceEpoch, + DateTime(2026, 1, 1), + ), + ], now: now); + expect(beforeBirthday.ageYears, 35); + + final afterBirthday = importer.snapshotFrom([ + _point( + HealthDataType.BIRTH_DATE, + DateTime(1990, 1, 5).millisecondsSinceEpoch, + DateTime(2026, 1, 1), + ), + ], now: now); + expect(afterBirthday.ageYears, 36); + }); + + test('maps only the two sexes the formulas have constants for', () { + HealthProfileSnapshot withGender(int raw) => importer.snapshotFrom([ + _point(HealthDataType.GENDER, raw, DateTime(2026, 1, 1)), + ], now: now); + + expect(withGender(1).sex, 'f'); + expect(withGender(2).sex, 'm'); + // 0 = not set, 3 = other. Every formula downstream carries one constant + // per sex and nothing sensible for a third, so no answer beats a guess. + expect(withGender(0).sex, isNull); + expect(withGender(3).sex, isNull); + }); + + test('rejects an implausible reading rather than adopting it', () { + // These land straight in the calorie formula without review. + final zero = importer.snapshotFrom([ + _point(HealthDataType.WEIGHT, 0, DateTime(2026, 1, 1)), + _point(HealthDataType.HEIGHT, 0, DateTime(2026, 1, 1)), + ], now: now); + expect(zero.weightKg, isNull); + expect(zero.heightCm, isNull); + + final absurd = importer.snapshotFrom([ + _point(HealthDataType.WEIGHT, 900, DateTime(2026, 1, 1)), + _point(HealthDataType.HEIGHT, 9, DateTime(2026, 1, 1)), + ], now: now); + expect(absurd.weightKg, isNull); + expect(absurd.heightCm, isNull); + }); + + test('an empty store is empty, not zeroes', () { + const empty = HealthProfileSnapshot(); + expect(empty.isEmpty, isTrue); + expect(empty.weightKg, isNull); + expect(importer.snapshotFrom(const [], now: now).isEmpty, isTrue); + }); + + test('reports what it found', () { + final snap = importer.snapshotFrom([ + _point(HealthDataType.WEIGHT, 74, DateTime(2026, 1, 1)), + _point(HealthDataType.HEIGHT, 1.78, DateTime(2026, 1, 1)), + ], now: now); + expect(snap.found, ['weight', 'height']); + }); + }); + + group('mergeHealthProfile', () { + const snap = HealthProfileSnapshot( + weightKg: 74, + heightCm: 178, + ageYears: 36, + sex: 'm', + ); + + test('fills an empty profile completely', () { + final out = mergeHealthProfile(null, snap); + expect(out['weight_kg'], 74); + expect(out['height_cm'], 178); + expect(out['age'], 36); + expect(out['sex'], 'm'); + }); + + test('weight and height are overwritten — that is the point', () { + final out = mergeHealthProfile( + {'weight_kg': 80.0, 'height_cm': 175.0}, + snap, + ); + expect(out['weight_kg'], 74); + expect(out['height_cm'], 178); + }); + + test('age and sex only fill a gap', () { + // Neither drifts, so a value already there is a deliberate choice and + // another app's record does not get to override it. + final out = mergeHealthProfile({'age': 40, 'sex': 'f'}, snap); + expect(out['age'], 40); + expect(out['sex'], 'f'); + }); + + test('an absent field never clears an existing one', () { + final out = mergeHealthProfile( + {'weight_kg': 80.0, 'age': 40}, + const HealthProfileSnapshot(heightCm: 178), + ); + expect(out['weight_kg'], 80.0); + expect(out['age'], 40); + expect(out['height_cm'], 178); + }); + + test('unrelated profile fields survive', () { + final out = mergeHealthProfile({'name': 'Sam'}, snap); + expect(out['name'], 'Sam'); + }); + + test('does not mutate the map it was given', () { + final before = {'weight_kg': 80.0}; + mergeHealthProfile(before, snap); + expect(before['weight_kg'], 80.0); + }); + }); + + group('healthProfileChanges', () { + test('names only what actually changes', () { + final changes = healthProfileChanges( + {'weight_kg': 80.0, 'height_cm': 178.0, 'age': 40, 'sex': 'f'}, + const HealthProfileSnapshot( + weightKg: 74, + heightCm: 178, + ageYears: 36, + sex: 'm', + ), + ); + // Height matches, and age/sex are already set so they are not touched. + expect(changes, ['weight']); + }); + + test('is empty when the profile already matches', () { + // Otherwise the UI would report an import that did nothing. + expect( + healthProfileChanges( + {'weight_kg': 74.0}, + const HealthProfileSnapshot(weightKg: 74), + ), + isEmpty, + ); + }); + }); + + + test('Android declares the read permissions the import needs', () { + // Health Connect silently returns NOTHING for an undeclared permission + // rather than failing, so a missing line here is indistinguishable from an + // empty health store — the import would just never work on Android and + // nobody would know why. + final manifest = File( + 'android/app/src/main/AndroidManifest.xml', + ).readAsStringSync(); + for (final perm in ['READ_WEIGHT', 'READ_HEIGHT']) { + expect( + manifest, + contains('android.permission.health.$perm'), + reason: '$perm is requested by HealthProfileImporter', + ); + } + // And no write counterparts: this app reads body metrics, it does not + // write them back. + for (final perm in ['WRITE_WEIGHT', 'WRITE_HEIGHT']) { + expect(manifest, isNot(contains('android.permission.health.$perm'))); + } + }); +} diff --git a/test/nap_edits_test.dart b/test/nap_edits_test.dart new file mode 100644 index 00000000..cab2c95e --- /dev/null +++ b/test/nap_edits_test.dart @@ -0,0 +1,209 @@ +// User edits replayed over the nap detector's output. +// +// The rule that carries the most weight: a rejection matches by OVERLAP, not +// by exact bounds. The detector's boundaries move between runs, and an edit +// that stopped applying the moment a boundary shifted by a minute would be +// worse than useless — the nap the user deleted would quietly come back. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/compute/nap_edits.dart'; + +Map nap(int start, int end, {double? confidence}) => { + 'start': start, + 'end': end, + 'duration_min': ((end - start) / 60).round(), + 'in_bed_min': ((end - start) / 60).round(), + 'confidence': confidence ?? 0.8, +}; + +void main() { + const hour = 3600; + + group('rejection', () { + test('removes a detected nap it overlaps', () { + final out = applyNapEdits( + [nap(13 * hour, 14 * hour)], + const [ + NapEdit( + kind: NapEditKind.rejected, + startSec: 13 * hour, + endSec: 14 * hour, + ), + ], + ); + expect(out, isEmpty); + }); + + test('still applies when the detector shifts its bounds', () { + // The whole reason it matches by overlap. A re-derivation that moves the + // start by four minutes must not resurrect a nap the user deleted. + final out = applyNapEdits( + [nap(13 * hour + 240, 14 * hour - 120)], + const [ + NapEdit( + kind: NapEditKind.rejected, + startSec: 13 * hour, + endSec: 14 * hour, + ), + ], + ); + expect(out, isEmpty); + }); + + test('leaves a nap it does not touch', () { + final out = applyNapEdits( + [nap(13 * hour, 14 * hour), nap(17 * hour, 18 * hour)], + const [ + NapEdit( + kind: NapEditKind.rejected, + startSec: 13 * hour, + endSec: 14 * hour, + ), + ], + ); + expect(out, hasLength(1)); + expect(out.single['start'], 17 * hour); + }); + + test('abutting is not overlapping', () { + // A nap that starts exactly when another ends is a different nap. + final out = applyNapEdits( + [nap(14 * hour, 15 * hour)], + const [ + NapEdit( + kind: NapEditKind.rejected, + startSec: 13 * hour, + endSec: 14 * hour, + ), + ], + ); + expect(out, hasLength(1)); + }); + }); + + group('addition', () { + test('is appended and marked as the user’s own', () { + final out = applyNapEdits( + [nap(13 * hour, 14 * hour)], + const [ + NapEdit( + kind: NapEditKind.added, + startSec: 16 * hour, + endSec: 17 * hour, + ), + ], + ); + expect(out, hasLength(2)); + final added = out.firstWhere((n) => n['source'] == 'manual'); + expect(added['start'], 16 * hour); + expect(added['duration_min'], 60); + }); + + test('asleep and in-bed are the same, and confidence is absent', () { + // A logged nap has no measured sleep/wake split. Claiming a lower TST + // would invent an efficiency nobody measured, and giving it a confidence + // would dress a report up as an estimate. + final out = applyNapEdits(const [], const [ + NapEdit( + kind: NapEditKind.added, + startSec: 16 * 3600, + endSec: 16 * 3600 + 1800, + ), + ]); + expect(out.single['duration_min'], 30); + expect(out.single['in_bed_min'], 30); + expect(out.single['confidence'], isNull); + }); + + test('overlapping additions are kept apart, not fused', () { + // Fusing them would hide a data-entry mistake while inflating the total. + // Entry rejects the overlap; the merge does not paper over it. + final out = applyNapEdits(const [], const [ + NapEdit(kind: NapEditKind.added, startSec: 3600, endSec: 7200), + NapEdit(kind: NapEditKind.added, startSec: 5400, endSec: 9000), + ]); + expect(out, hasLength(2)); + }); + }); + + test('the result is ordered by start whatever order edits arrived in', () { + final out = applyNapEdits( + [nap(15 * hour, 16 * hour)], + const [ + NapEdit(kind: NapEditKind.added, startSec: 20 * hour, endSec: 21 * hour), + NapEdit(kind: NapEditKind.added, startSec: 9 * hour, endSec: 10 * hour), + ], + ); + expect( + out.map((n) => n['start']), + [9 * hour, 15 * hour, 20 * hour], + ); + }); + + test('no edits changes nothing', () { + final detected = [nap(13 * hour, 14 * hour), nap(17 * hour, 18 * hour)]; + expect(applyNapEdits(detected, const []), detected); + }); + + group('napMinutes', () { + test('sums asleep minutes across detected and logged alike', () { + // A logged nap credits against sleep need exactly as a detected one + // does — that was the explicit decision, not an accident. + final merged = applyNapEdits( + [nap(13 * hour, 14 * hour)], + const [ + NapEdit(kind: NapEditKind.added, startSec: 16 * 3600, endSec: 17 * 3600), + ], + ); + expect(napMinutes(merged), 120); + }); + + test('is zero for an empty list — the caller decides absent vs zero', () { + expect(napMinutes(const []), 0); + }); + }); + + group('manualNapWindowIsValid', () { + test('accepts a real nap', () { + expect(manualNapWindowIsValid(0, 30 * 60), isTrue); + expect(manualNapWindowIsValid(0, 2 * hour), isTrue); + }); + + test('rejects one too short to be a nap', () { + expect(manualNapWindowIsValid(0, 60), isFalse); + expect(manualNapWindowIsValid(0, kMinManualNapSec - 1), isFalse); + expect(manualNapWindowIsValid(0, kMinManualNapSec), isTrue); + }); + + test('rejects one long enough to be a night', () { + // Longer than this belongs in the main sleep window, where the stager + // can actually say something about it. + expect(manualNapWindowIsValid(0, kMaxManualNapSec + 1), isFalse); + expect(manualNapWindowIsValid(0, kMaxManualNapSec), isTrue); + }); + + test('rejects a backwards or empty window', () { + expect(manualNapWindowIsValid(100, 100), isFalse); + expect(manualNapWindowIsValid(200, 100), isFalse); + }); + }); + + group('napOverlapsExisting', () { + final existing = [nap(13 * hour, 14 * hour)]; + + test('catches an overlap at either edge and containment', () { + expect(napOverlapsExisting(13 * hour + 60, 15 * hour, existing), isTrue); + expect(napOverlapsExisting(12 * hour, 13 * hour + 60, existing), isTrue); + expect( + napOverlapsExisting(13 * hour + 600, 13 * hour + 900, existing), + isTrue, + ); + expect(napOverlapsExisting(12 * hour, 15 * hour, existing), isTrue); + }); + + test('allows a window that merely touches', () { + expect(napOverlapsExisting(14 * hour, 15 * hour, existing), isFalse); + expect(napOverlapsExisting(12 * hour, 13 * hour, existing), isFalse); + }); + }); +} From c7c949f9a8ee81dab9d56970c7ee926532fc902f Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 9 Aug 2026 17:39:21 +0530 Subject: [PATCH 2/7] the Android backup folder was one nobody could open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getApplicationDocumentsDirectory resolves to app-private storage on Android, so backups were being written where no file manager and no sync app could reach them — the feature worked and was pointless. Android writes to app-specific external storage now, which is browsable and needs no permission, and the move handles the two being different filesystems. Retention matched openstrap-*.db, which would have deleted someone's own openstrap-notes.db sitting in the same folder. It matches the exact filename shape now. Filenames carry seconds, because two runs in one minute shared a name and the second overwrote the first, and a backup cannot start while one is already running. Health Connect caps third-party reads at 30 days without a history permission the pinned health package cannot request, so asking Android for ten years returned 30 days while implying otherwise. It asks for what it can have. The finite breathing session called stop on every frame between the timer expiring and the async stop landing — sixty a second, each banking a session row. Latched. The screen also kept its own copy of the resonance pattern, which could drift from the table and shipped an empty description that rendered as a blank line. The interval timer leaked its wake lock when the screen was popped mid-round. And the journal insight note still said tagged days only. --- lib/data/auto_backup.dart | 95 ++++++++++++++++++------ lib/health/health_profile_import.dart | 15 +++- lib/ui/journal/journal_screen.dart | 9 ++- lib/ui/stress/calm_breathing_screen.dart | 36 ++++----- lib/ui/stress/interval_timer_screen.dart | 4 + test/auto_backup_test.dart | 49 ++++++++---- test/breath_phases_test.dart | 16 ++++ test/health_profile_import_test.dart | 66 ++++++++++++++++ 8 files changed, 230 insertions(+), 60 deletions(-) diff --git a/lib/data/auto_backup.dart b/lib/data/auto_backup.dart index c990c12e..f8b1aa70 100644 --- a/lib/data/auto_backup.dart +++ b/lib/data/auto_backup.dart @@ -5,14 +5,13 @@ // most people do not have. Discussion #214 asked for exactly this: years of // health data living in one place on one phone. // -// WHERE IT WRITES, and why not a folder you pick. The files go into the app's -// own documents directory, which is exposed to Files on iOS -// (`UIFileSharingEnabled` + `LSSupportsOpeningDocumentsInPlace`) and to the -// file manager on Android. Anything that syncs a folder — iCloud Drive, -// Synology Drive, Nextcloud — can be pointed at it. A user-chosen folder would -// need a persisted SAF tree URI or a security-scoped bookmark, both of which -// silently expire, and a backup that quietly stopped working is worse than one -// that lives somewhere slightly less convenient. +// WHERE IT WRITES, and why not a folder you pick. Somewhere the user can +// actually reach — see [backupDirectory], which is per-platform for exactly +// that reason. Anything that syncs a folder (iCloud Drive, Synology Drive, +// Nextcloud) can be pointed at it. A user-chosen folder would need a persisted +// SAF tree URI or a security-scoped bookmark, both of which silently expire, +// and a backup that quietly stopped working is worse than one that lives +// somewhere slightly less convenient. // // WHEN IT RUNS. On foreground, when due. There is no background scheduler that // works on both platforms — Workmanager is Android-only here and iOS's @@ -52,8 +51,8 @@ enum BackupCadence { .firstWhere((c) => c.name == name, orElse: () => BackupCadence.off); } -/// Folder name under the documents directory. Named so it is obvious what it -/// is when someone finds it in Files. +/// Folder name. Spelled out so it is obvious what it is when someone finds it +/// in Files or a file manager. const kBackupDirName = 'OpenStrap Backups'; /// How many backups are kept. Enough to survive noticing a problem a few days @@ -83,18 +82,27 @@ bool backupIsDue({ /// Filename for a backup taken at [when]. Sorts chronologically as text, so /// retention can order by name without parsing. +/// +/// Seconds are included: two runs inside the same minute would otherwise land +/// on one name and the second would overwrite the first. String backupFileName(DateTime when) { String two(int v) => v.toString().padLeft(2, '0'); return 'openstrap-${when.year}${two(when.month)}${two(when.day)}' - '-${two(when.hour)}${two(when.minute)}.db'; + '-${two(when.hour)}${two(when.minute)}${two(when.second)}.db'; } +/// EXACTLY the shape [backupFileName] emits, and nothing else. +/// +/// Retention DELETES what this matches, and it runs in a directory the user +/// can put files into. A loose `openstrap-*.db` glob would happily eat +/// someone's `openstrap-notes.db`. +final _backupNamePattern = RegExp(r'^openstrap-\d{8}-\d{6}\.db$'); + /// Existing backups, newest first. List sortBackupsNewestFirst(Iterable entries) { final files = entries .whereType() - .where((f) => p.basename(f.path).startsWith('openstrap-')) - .where((f) => p.extension(f.path) == '.db') + .where((f) => _backupNamePattern.hasMatch(p.basename(f.path))) .toList(); files.sort((a, b) => p.basename(b.path).compareTo(p.basename(a.path))); return files; @@ -119,15 +127,54 @@ class BackupOutcome { } /// The backup directory, created if missing. +/// +/// PLATFORM SPLIT, and it decides whether this feature works at all: +/// iOS — the app's Documents directory, which `UIFileSharingEnabled` + +/// `LSSupportsOpeningDocumentsInPlace` expose in Files. +/// Android — app-specific EXTERNAL storage. `getApplicationDocumentsDirectory` +/// resolves to `/data/user/0//app_flutter` there, which no file manager +/// and no sync app can reach, so backups would have been written somewhere +/// the user could never get at them. External storage needs no permission on +/// modern Android and is browsable. +/// +/// Falls back to the documents directory if external storage is unavailable +/// (no shared volume) — a backup somewhere awkward beats no backup. Future backupDirectory() async { - final docs = await getApplicationDocumentsDirectory(); - final dir = Directory(p.join(docs.path, kBackupDirName)); + Directory? root; + if (Platform.isAndroid) { + try { + root = await getExternalStorageDirectory(); + } catch (_) { + root = null; + } + } + root ??= await getApplicationDocumentsDirectory(); + final dir = Directory(p.join(root.path, kBackupDirName)); if (!await dir.exists()) await dir.create(recursive: true); return dir; } +/// In-flight guard. A resume can fire more than once, and a cadence change +/// during a foreground backup would otherwise start a second export against +/// the same directory while the first was still copying and pruning. +Future? _inFlight; + /// Take a backup now, regardless of schedule, and prune old ones. -Future runBackup({DateTime? now}) async { +/// +/// Serialized: a second caller while one is running joins the first rather +/// than starting its own. +Future runBackup({DateTime? now}) { + final current = _inFlight; + if (current != null) return current; + late final Future op; + op = _runBackup(now: now).whenComplete(() { + if (identical(_inFlight, op)) _inFlight = null; + }); + _inFlight = op; + return op; +} + +Future _runBackup({DateTime? now}) async { final when = now ?? DateTime.now(); try { final dir = await backupDirectory(); @@ -135,12 +182,18 @@ Future runBackup({DateTime? now}) async { // not a file copy of a database that may be mid-write. final snapshot = await LocalDb.exportCopy(); final dest = File(p.join(dir.path, backupFileName(when))); - await File(snapshot).rename(dest.path); - // Best-effort: the snapshot lives in temp and a failed delete is harmless. + final tmp = File(snapshot); try { - final tmp = File(snapshot); - if (await tmp.exists()) await tmp.delete(); - } catch (_) {} + await tmp.rename(dest.path); + } on FileSystemException { + // The temp directory and external storage are different filesystems on + // Android, where rename fails outright — copy across, then drop the + // source. + await tmp.copy(dest.path); + try { + if (await tmp.exists()) await tmp.delete(); + } catch (_) {} + } await pruneBackups(dir, keep: kBackupsKept); return BackupOutcome(path: dest.path); diff --git a/lib/health/health_profile_import.dart b/lib/health/health_profile_import.dart index 0118aa05..f2ff713e 100644 --- a/lib/health/health_profile_import.dart +++ b/lib/health/health_profile_import.dart @@ -141,9 +141,18 @@ class HealthProfileImporter { try { await _health.configure(); final end = now ?? DateTime.now(); - // Ten years back. A body metric older than that is not worth adopting - // silently, and characteristics (sex, DOB) ignore the window anyway. - final start = DateTime(end.year - 10, end.month, end.day); + // Ten years back on Apple. A body metric older than that is not worth + // adopting silently, and characteristics (sex, DOB) ignore the window. + // + // Health Connect caps third-party reads at the last 30 DAYS unless the + // user grants `READ_HEALTH_DATA_HISTORY`, and the pinned `health` 11.1.1 + // exposes no API to request it — so asking for ten years on Android + // returns the same 30 days while implying otherwise. Ask for what we can + // actually have. Someone who weighs themselves less often than monthly + // gets nothing, which reports honestly as "nothing to read". + final start = _isApple + ? DateTime(end.year - 10, end.month, end.day) + : end.subtract(const Duration(days: 30)); final points = await _health.getHealthDataFromTypes( types: types, startTime: start, diff --git a/lib/ui/journal/journal_screen.dart b/lib/ui/journal/journal_screen.dart index 0d9561cd..bf986636 100644 --- a/lib/ui/journal/journal_screen.dart +++ b/lib/ui/journal/journal_screen.dart @@ -285,9 +285,12 @@ class _JournalScreenState extends State { InfoDot( title: 'What moves your body', body: - 'How each tag tracks with your recovery, sleep and heart data — ' - 'computed from your own tagged days only.', - methodNote: 'Correlation, not cause · needs ≥3 tagged days per tag', + 'How what you log tracks with your recovery, sleep and heart ' + 'data — computed from your own days only. Tags are compared as ' + 'happened-or-not; numbers are ranked, so five coffees and one ' + 'are not the same day.', + methodNote: 'Correlation, not cause · tags need ≥3 tagged days, ' + 'numbers need ≥8 days with a value', ), ], ), diff --git a/lib/ui/stress/calm_breathing_screen.dart b/lib/ui/stress/calm_breathing_screen.dart index 4a0e8f87..2fa5215f 100644 --- a/lib/ui/stress/calm_breathing_screen.dart +++ b/lib/ui/stress/calm_breathing_screen.dart @@ -75,9 +75,9 @@ class CalmBreathingView extends StatefulWidget { final Map? result; final String? error; - /// The pattern being paced. Defaults to resonance, which is what this screen - /// has always run and the only one carrying a coherence score. - final BreathPattern pattern; + /// The pattern being paced. Null falls back to resonance, which is what this + /// screen has always run and the only one carrying a coherence score. + final BreathPattern? pattern; final void Function({BreathPattern? pattern})? onStart; final VoidCallback? onStop; @@ -91,7 +91,7 @@ class CalmBreathingView extends StatefulWidget { required this.active, this.result, this.error, - this.pattern = _defaultPattern, + this.pattern, this.onStart, this.onStop, this.onPhaseChange, @@ -102,17 +102,10 @@ class CalmBreathingView extends StatefulWidget { State createState() => _CalmBreathingViewState(); } -/// Resonance, the pace this screen has always run. -const BreathPattern _defaultPattern = BreathPattern( - key: 'resonance', - label: 'Resonance', - description: '', - phases: [ - BreathPhase(BreathPhaseKind.inhale, 5.45), - BreathPhase(BreathPhaseKind.exhale, 5.45), - ], - coherenceRated: true, -); +/// Resonance, the pace this screen has always run — the table's own entry, not +/// a copy of it. A duplicate would drift on any future edit, and its empty +/// description rendered a blank line on the idle screen. +final BreathPattern _defaultPattern = kBreathPatterns.first; /// Session lengths offered. "Open" runs until you stop it. const _durationChoices = [2, 5, 10, null]; @@ -131,10 +124,15 @@ class _CalmBreathingViewState extends State BreathPhaseKind? _lastPhase; int _lastCycle = -1; + /// One-shot, because `onStop` is asynchronous. Without it every frame between + /// the timer expiring and the parent flipping `active` false fired another + /// stop — sixty of them a second, each one banking a session row. + bool _finished = false; + @override void initState() { super.initState(); - _pattern = widget.pattern; + _pattern = widget.pattern ?? _defaultPattern; // A repeating controller used purely as a frame ticker, so the circle is // interpolated from real elapsed time rather than from an animation whose // own duration would have to be kept in sync with the pattern. @@ -150,6 +148,7 @@ class _CalmBreathingViewState extends State if (widget.active && !oldWidget.active) { _lastPhase = null; _lastCycle = -1; + _finished = false; _clock ..reset() ..start(); @@ -180,7 +179,7 @@ class _CalmBreathingViewState extends State } void _onTick() { - if (!widget.active) return; + if (!widget.active || _finished) return; final at = phaseAt(_pattern, _elapsed); if (at != null) { final changed = at.phase.kind != _lastPhase || at.cycle != _lastCycle; @@ -194,6 +193,9 @@ class _CalmBreathingViewState extends State if (remaining != null && remaining <= Duration.zero) { // The button said two minutes, so two minutes is what it runs. It used // to say that and run until you stopped it. + _finished = true; + _ticker.stop(); + _clock.stop(); widget.onStop?.call(); return; } diff --git a/lib/ui/stress/interval_timer_screen.dart b/lib/ui/stress/interval_timer_screen.dart index adc5f6d9..04d6efa5 100644 --- a/lib/ui/stress/interval_timer_screen.dart +++ b/lib/ui/stress/interval_timer_screen.dart @@ -58,6 +58,10 @@ class _IntervalTimerScreenState extends State @override void dispose() { + // Releasing here as well as in _stop: popping the screen mid-round never + // called _stop, so the wake lock leaked and the phone stayed awake until + // something else happened to release it. + if (_running) ScreenWake.release(); _ticker ..removeListener(_onTick) ..dispose(); diff --git a/test/auto_backup_test.dart b/test/auto_backup_test.dart index 68f2ca71..556d0840 100644 --- a/test/auto_backup_test.dart +++ b/test/auto_backup_test.dart @@ -122,8 +122,16 @@ void main() { }); test('is zero-padded so widths match', () { - expect(backupFileName(DateTime(2026, 1, 2, 3, 4)), - 'openstrap-20260102-0304.db'); + expect(backupFileName(DateTime(2026, 1, 2, 3, 4, 5)), + 'openstrap-20260102-030405.db'); + }); + + test('two runs in the same minute get different names', () { + // Same-minute collisions would silently overwrite the earlier backup. + expect( + backupFileName(DateTime(2026, 1, 2, 3, 4, 5)), + isNot(backupFileName(DateTime(2026, 1, 2, 3, 4, 6))), + ); }); }); @@ -142,29 +150,33 @@ void main() { File(p.join(tmp.path, name))..writeAsStringSync('x'); test('orders newest first', () { - touch('openstrap-20260101-0000.db'); - touch('openstrap-20260301-0000.db'); - touch('openstrap-20260201-0000.db'); + touch('openstrap-20260101-000000.db'); + touch('openstrap-20260301-000000.db'); + touch('openstrap-20260201-000000.db'); final out = sortBackupsNewestFirst(tmp.listSync()); expect( out.map((f) => p.basename(f.path)), [ - 'openstrap-20260301-0000.db', - 'openstrap-20260201-0000.db', - 'openstrap-20260101-0000.db', + 'openstrap-20260301-000000.db', + 'openstrap-20260201-000000.db', + 'openstrap-20260101-000000.db', ], ); }); - test('ignores anything that is not one of ours', () { - // The documents directory is shared with whatever the user drops in it. - touch('openstrap-20260101-0000.db'); + test('matches only the exact shape it emits', () { + // This list is what retention DELETES, in a folder the user can drop + // files into. A loose openstrap-*.db glob would eat their notes. + touch(backupFileName(DateTime(2026, 1, 1, 0, 0, 0))); touch('holiday-photos.zip'); touch('openstrap-notes.txt'); + touch('openstrap-notes.db'); + touch('openstrap-2026.db'); + touch('openstrap-20260101.db'); touch('random.db'); final out = sortBackupsNewestFirst(tmp.listSync()); expect(out.map((f) => p.basename(f.path)), [ - 'openstrap-20260101-0000.db', + 'openstrap-20260101-000000.db', ]); }); @@ -186,28 +198,33 @@ void main() { test('keeps the newest and deletes the rest', () async { for (final d in ['0101', '0201', '0301', '0401', '0501']) { - File(p.join(tmp.path, 'openstrap-2026$d-0000.db')) + File(p.join(tmp.path, 'openstrap-2026$d-000000.db')) .writeAsStringSync('x'); } await pruneBackups(tmp, keep: 2); expect( sortBackupsNewestFirst(tmp.listSync()).map((f) => p.basename(f.path)), - ['openstrap-20260501-0000.db', 'openstrap-20260401-0000.db'], + ['openstrap-20260501-000000.db', 'openstrap-20260401-000000.db'], ); }); test('never touches a file that is not a backup', () async { - File(p.join(tmp.path, 'openstrap-20260101-0000.db')) + File(p.join(tmp.path, 'openstrap-20260101-000000.db')) .writeAsStringSync('x'); final other = File(p.join(tmp.path, 'important.txt')) ..writeAsStringSync('x'); + // Deliberately close to ours: this is the one retention would have + // deleted under a prefix match. + final lookalike = File(p.join(tmp.path, 'openstrap-notes.db')) + ..writeAsStringSync('x'); await pruneBackups(tmp, keep: 0); expect(other.existsSync(), isTrue); + expect(lookalike.existsSync(), isTrue); expect(sortBackupsNewestFirst(tmp.listSync()), isEmpty); }); test('fewer backups than the limit is a no-op', () async { - File(p.join(tmp.path, 'openstrap-20260101-0000.db')) + File(p.join(tmp.path, 'openstrap-20260101-000000.db')) .writeAsStringSync('x'); await pruneBackups(tmp, keep: 5); expect(sortBackupsNewestFirst(tmp.listSync()), hasLength(1)); diff --git a/test/breath_phases_test.dart b/test/breath_phases_test.dart index 5ea805a8..e41314da 100644 --- a/test/breath_phases_test.dart +++ b/test/breath_phases_test.dart @@ -166,6 +166,22 @@ void main() { }); }); + group('the screen default is the table entry, not a copy', () { + test('resonance is first, so a default of kBreathPatterns.first is it', () { + // The calm screen defaults to `kBreathPatterns.first`. It used to carry + // its own copy of the resonance phases, which could drift on any edit + // here and shipped an empty description that rendered as a blank line. + expect(kBreathPatterns.first.key, 'resonance'); + expect(kBreathPatterns.first.description, isNotEmpty); + }); + + test('every pattern has a description to show', () { + for (final p in kBreathPatterns) { + expect(p.description, isNotEmpty, reason: p.key); + } + }); + }); + group('sessionEnd', () { test('is null for an open-ended session', () { expect(sessionEnd(box, null), isNull); diff --git a/test/health_profile_import_test.dart b/test/health_profile_import_test.dart index 9c81d225..7a1650e3 100644 --- a/test/health_profile_import_test.dart +++ b/test/health_profile_import_test.dart @@ -13,6 +13,34 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:health/health.dart'; import 'package:openstrap_edge/health/health_profile_import.dart'; +/// Records the window each read asks for, so the platform-specific limits can +/// be asserted without a health store. +class _RecordingHealth implements Health { + _RecordingHealth(this.windows); + final List<(DateTime, DateTime)> windows; + + @override + Future configure() async {} + + @override + Future> getHealthDataFromTypes({ + required List types, + required DateTime startTime, + required DateTime endTime, + List recordingMethodsToFilter = const [], + }) async { + windows.add((startTime, endTime)); + return const []; + } + + // Only the two members the importer actually calls are implemented. Anything + // else reaching this stub means `read()` changed shape, and that should fail + // loudly rather than quietly returning null. + @override + dynamic noSuchMethod(Invocation invocation) => + throw UnimplementedError('${invocation.memberName}'); +} + HealthDataPoint _point(HealthDataType type, num value, DateTime at) => HealthDataPoint( uuid: '$type-$value', @@ -46,6 +74,44 @@ void main() { }); }); + group('the read window', () { + test('Android asks only for what Health Connect will give', () { + // Health Connect caps third-party reads at 30 days unless the user grants + // READ_HEALTH_DATA_HISTORY, and the pinned health 11.1.1 has no API to + // request it — so a ten-year window would return the same 30 days while + // implying otherwise. + final windows = <(DateTime, DateTime)>[]; + final importer = HealthProfileImporter( + isApple: false, + health: _RecordingHealth(windows), + ); + // The read is wrapped in its own try/catch, so a stub that returns + // nothing still exercises the window calculation. + return importer.read(now: DateTime(2026, 8, 9)).then((_) { + expect(windows, hasLength(1)); + expect( + windows.single.$2.difference(windows.single.$1).inDays, + 30, + ); + }); + }); + + test('Apple asks for ten years', () { + final windows = <(DateTime, DateTime)>[]; + final importer = HealthProfileImporter( + isApple: true, + health: _RecordingHealth(windows), + ); + return importer.read(now: DateTime(2026, 8, 9)).then((_) { + expect(windows, hasLength(1)); + expect( + windows.single.$2.year - windows.single.$1.year, + 10, + ); + }); + }); + }); + group('reading a snapshot', () { final importer = HealthProfileImporter(isApple: true); final now = DateTime(2026, 8, 9); From f707b6b78d7d5df49ad6941909f3cc73e477dcc9 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 9 Aug 2026 17:49:00 +0530 Subject: [PATCH 3/7] the backup lock has to cover the timestamp too, not just the export Guarding only the export left a window: a second trigger could read the old last-run timestamp, judge a backup due, and start another one while the first was finishing. Reading the timestamp, deciding, writing and recording are one sequence now, and the caller passes callbacks rather than a value so the read happens inside the lock rather than before queueing. Two runs in the same second also shared a filename, so the first was silently replaced. The destination is allocated against what is already on disk. --- lib/data/auto_backup.dart | 79 ++++++++++++++------- lib/state/app_state.dart | 31 ++++----- test/auto_backup_test.dart | 136 ++++++++++++++++++++++++++++++++++--- 3 files changed, 195 insertions(+), 51 deletions(-) diff --git a/lib/data/auto_backup.dart b/lib/data/auto_backup.dart index f8b1aa70..80d931ae 100644 --- a/lib/data/auto_backup.dart +++ b/lib/data/auto_backup.dart @@ -154,25 +154,29 @@ Future backupDirectory() async { return dir; } -/// In-flight guard. A resume can fire more than once, and a cadence change -/// during a foreground backup would otherwise start a second export against -/// the same directory while the first was still copying and pruning. -Future? _inFlight; +/// The whole backup transaction runs under this, one at a time. +/// +/// It has to cover MORE than the export. Reading `lastRun`, deciding whether a +/// backup is due, writing the file, pruning, and persisting the new `lastRun` +/// are one indivisible sequence: a guard that ended when the export finished +/// still left a window where a second trigger read the stale timestamp, judged +/// it due, and started another export. A resume can fire more than once, and a +/// cadence change lands on the same path. +Future _tail = Future.value(); + +Future _serialize(Future Function() body) { + final result = _tail.then((_) => body()); + // The queue must survive a failed run, or one error wedges every later + // backup for the life of the process. + _tail = result.then((_) {}, onError: (_) {}); + return result; +} /// Take a backup now, regardless of schedule, and prune old ones. /// -/// Serialized: a second caller while one is running joins the first rather -/// than starting its own. -Future runBackup({DateTime? now}) { - final current = _inFlight; - if (current != null) return current; - late final Future op; - op = _runBackup(now: now).whenComplete(() { - if (identical(_inFlight, op)) _inFlight = null; - }); - _inFlight = op; - return op; -} +/// Serialized against every other backup path. +Future runBackup({DateTime? now}) => + _serialize(() => _runBackup(now: now)); Future _runBackup({DateTime? now}) async { final when = now ?? DateTime.now(); @@ -181,7 +185,7 @@ Future _runBackup({DateTime? now}) async { // `exportCopy` is VACUUM INTO — a transactionally consistent snapshot, // not a file copy of a database that may be mid-write. final snapshot = await LocalDb.exportCopy(); - final dest = File(p.join(dir.path, backupFileName(when))); + final dest = _uniqueDestination(dir, when); final tmp = File(snapshot); try { await tmp.rename(dest.path); @@ -214,16 +218,43 @@ Future pruneBackups(Directory dir, {required int keep}) async { } } -/// Run a backup if [cadence] says one is due. Returns a skipped outcome -/// otherwise, so the caller can tell "not yet" from "it broke". +/// A free filename in [dir] for a backup taken at [when]. +/// +/// Seconds make a collision rare, not impossible — two manual runs inside one +/// second would otherwise share a name and the second would overwrite the +/// first. The suffix is bounded; past it the timestamp is not the problem. +File _uniqueDestination(Directory dir, DateTime when) { + final base = backupFileName(when); + var candidate = File(p.join(dir.path, base)); + for (var i = 2; candidate.existsSync() && i < 100; i++) { + final stem = base.substring(0, base.length - 3); // drop '.db' + candidate = File(p.join(dir.path, '$stem-$i.db')); + } + return candidate; +} + +/// Run a backup if [cadence] says one is due. +/// +/// [lastRun] and [markRun] are CALLBACKS rather than values, so reading the +/// timestamp, deciding, exporting and persisting the new one all happen inside +/// the same lock. Passing `lastRun` in as a value would reintroduce exactly +/// the race this serialization exists to close: the caller would have read it +/// before queueing, and a backup that finished in the meantime would be +/// invisible to that decision. +/// +/// Returns a skipped outcome when nothing was due, so the caller can tell +/// "not yet" from "it broke". Future runBackupIfDue({ required BackupCadence cadence, - required DateTime? lastRun, + required DateTime? Function() lastRun, + required Future Function(DateTime) markRun, DateTime? now, -}) async { +}) => _serialize(() async { final when = now ?? DateTime.now(); - if (!backupIsDue(cadence: cadence, lastRun: lastRun, now: when)) { + if (!backupIsDue(cadence: cadence, lastRun: lastRun(), now: when)) { return const BackupOutcome(skipped: true); } - return runBackup(now: when); -} + final outcome = await _runBackup(now: when); + if (outcome.succeeded) await markRun(when); + return outcome; +}); diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 0f905bc1..9e9a621c 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -784,38 +784,35 @@ class AppState extends ChangeNotifier { if (cadence != BackupCadence.off) await runBackupNow(); } + void _markBackupRun(DateTime when) { + Prefs.setInt(Prefs.backupLastRunMs, when.millisecondsSinceEpoch); + notifyListeners(); + } + /// Take one now, whatever the schedule says. Returns what happened so the /// caller can say so — a backup that silently did not happen is the failure /// this feature exists to prevent. Future runBackupNow() async { final outcome = await runBackup(); - if (outcome.succeeded) { - Prefs.setInt( - Prefs.backupLastRunMs, - DateTime.now().millisecondsSinceEpoch, - ); - notifyListeners(); - } + if (outcome.succeeded) _markBackupRun(DateTime.now()); return outcome; } /// Foreground hook. Silent unless it actually writes something. + /// + /// The timestamp is read and written INSIDE the backup lock, via these + /// callbacks — reading it here and passing the value in would let a second + /// resume decide against a stale timestamp while the first backup was still + /// finishing, and start a duplicate export. Future runBackupIfDue() async { final cadence = backupCadence; if (cadence == BackupCadence.off) return; final outcome = await backup.runBackupIfDue( cadence: cadence, - lastRun: lastBackupAt, + lastRun: () => lastBackupAt, + markRun: (when) async => _markBackupRun(when), ); - if (outcome.succeeded) { - Prefs.setInt( - Prefs.backupLastRunMs, - DateTime.now().millisecondsSinceEpoch, - ); - notifyListeners(); - } else if (outcome.error != null) { - _log('Backup failed: ${outcome.error}'); - } + if (outcome.error != null) _log('Backup failed: ${outcome.error}'); } /// Clear the local profile + unpair the band (the former "sign out", now purely diff --git a/test/auto_backup_test.dart b/test/auto_backup_test.dart index 556d0840..0e1aa17b 100644 --- a/test/auto_backup_test.dart +++ b/test/auto_backup_test.dart @@ -9,7 +9,31 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:openstrap_edge/data/auto_backup.dart'; +import 'package:openstrap_edge/data/db.dart'; import 'package:path/path.dart' as p; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +/// A real backup writes a real file, so the serialization tests need somewhere +/// to write and a database to snapshot. +class _FakePathProvider extends PathProviderPlatform { + _FakePathProvider(this.root); + final String root; + @override + Future getTemporaryPath() async => root; + @override + Future getApplicationSupportPath() async => root; + @override + Future getApplicationDocumentsPath() async => root; + @override + Future getApplicationCachePath() async => root; + @override + Future getLibraryPath() async => root; + @override + Future getDownloadsPath() async => root; + @override + Future getExternalStoragePath() async => root; +} void main() { group('backupIsDue', () { @@ -231,15 +255,107 @@ void main() { }); }); - test('runBackupIfDue skips rather than failing when nothing is due', - () async { - final outcome = await runBackupIfDue( - cadence: BackupCadence.daily, - lastRun: DateTime(2026, 8, 9, 11), - now: DateTime(2026, 8, 9, 12), - ); - expect(outcome.skipped, isTrue); - expect(outcome.succeeded, isFalse); - expect(outcome.error, isNull, reason: 'not due is not a failure'); + group('runBackupIfDue', () { + late Directory tmp; + + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + tmp = await Directory.systemTemp.createTemp('openstrap_backup_run_'); + PathProviderPlatform.instance = _FakePathProvider(tmp.path); + LocalDb.dbName = 'openstrap_backup_run_test.db'; + await databaseFactory.deleteDatabase( + p.join(await databaseFactory.getDatabasesPath(), LocalDb.dbName), + ); + await LocalDb.instance; + }); + + tearDownAll(() async { + await LocalDb.close(); + if (await tmp.exists()) await tmp.delete(recursive: true); + }); + + test('skips rather than failing when nothing is due', () async { + var marked = 0; + final outcome = await runBackupIfDue( + cadence: BackupCadence.daily, + lastRun: () => DateTime(2026, 8, 9, 11), + markRun: (_) async => marked++, + now: DateTime(2026, 8, 9, 12), + ); + expect(outcome.skipped, isTrue); + expect(outcome.succeeded, isFalse); + expect(outcome.error, isNull, reason: 'not due is not a failure'); + expect(marked, 0, reason: 'a skip is not a run'); + }); + + test('reads the timestamp inside the lock, not before queueing', () async { + // THE RACE this serialization exists to close. Two triggers arrive + // together; the first backs up and records it. The second must see that + // record when its turn comes, rather than the value it would have read + // at call time — which is what a plain `lastRun` VALUE parameter gave it, + // and what produced a duplicate export. + DateTime? last; + final reads = []; + final now = DateTime(2026, 8, 9, 12); + + Future trigger() => runBackupIfDue( + cadence: BackupCadence.daily, + lastRun: () { + reads.add(last); + return last; + }, + markRun: (when) async => last = when, + now: now, + ); + + // Fired without awaiting the first, exactly as two resume events would. + final results = await Future.wait([trigger(), trigger()]); + + expect(reads, hasLength(2)); + expect( + reads.last, + isNotNull, + reason: 'the second read must see the first run, not a stale null', + ); + expect( + results.where((r) => r.skipped), + hasLength(1), + reason: 'exactly one of the two should have decided it was due', + ); + }); + + test('two runs in the same second get two files, not one overwritten', + () async { + // Seconds make a collision rare, not impossible. Two manual taps inside + // one second would otherwise share a destination and the first snapshot + // would be silently replaced by the second. + final when = DateTime(2026, 8, 9, 12, 30, 15); + final first = await runBackup(now: when); + final second = await runBackup(now: when); + + expect(first.succeeded, isTrue, reason: '${first.error}'); + expect(second.succeeded, isTrue, reason: '${second.error}'); + expect(first.path, isNot(second.path)); + expect(File(first.path!).existsSync(), isTrue); + expect(File(second.path!).existsSync(), isTrue); + }); + + test('one failure does not wedge every later backup', () async { + // The queue is chained; without an error guard on the tail, a single + // throw would leave every subsequent call waiting on a failed future. + final before = await runBackupIfDue( + cadence: BackupCadence.off, + lastRun: () => null, + markRun: (_) async {}, + ); + expect(before.skipped, isTrue); + final after = await runBackupIfDue( + cadence: BackupCadence.off, + lastRun: () => null, + markRun: (_) async {}, + ); + expect(after.skipped, isTrue); + }); }); } From 10137848d10070ffc649aaf4bd6a445ede411af2 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 9 Aug 2026 18:27:19 +0530 Subject: [PATCH 4/7] turning backup off while one is queued should stop the next one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cadence was read before the call entered the queue, so a call waiting behind a running export acted on the setting as it was when it queued. Someone who switched backup off in the meantime still got another unencrypted copy of their health data written after they had disabled it. It re-reads inside the lock now, like the timestamp already did. The unique-filename search returned its last candidate when every one was taken, handing the next backup a real snapshot to overwrite — the opposite of the point. It returns an error instead. And the queue-recovery test proved nothing: both its calls were cadence off, so neither reached the export and neither could fail. The exporter is injectable now, so a failure is actually reachable, and the test asserts the backup after it still runs. --- lib/data/auto_backup.dart | 66 ++++++++++++++++++++++---------- lib/state/app_state.dart | 9 +++-- test/auto_backup_test.dart | 78 +++++++++++++++++++++++++++++++------- 3 files changed, 116 insertions(+), 37 deletions(-) diff --git a/lib/data/auto_backup.dart b/lib/data/auto_backup.dart index 80d931ae..1bdd1c12 100644 --- a/lib/data/auto_backup.dart +++ b/lib/data/auto_backup.dart @@ -175,17 +175,29 @@ Future _serialize(Future Function() body) { /// Take a backup now, regardless of schedule, and prune old ones. /// /// Serialized against every other backup path. -Future runBackup({DateTime? now}) => - _serialize(() => _runBackup(now: now)); +Future runBackup({ + DateTime? now, + Future Function()? exportSnapshot, +}) => _serialize(() => _runBackup(now: now, exportSnapshot: exportSnapshot)); -Future _runBackup({DateTime? now}) async { +Future _runBackup({ + DateTime? now, + // Test seam. A failing export is otherwise unreachable from a test, which + // left the queue-recovery case unverifiable. + Future Function()? exportSnapshot, +}) async { final when = now ?? DateTime.now(); try { final dir = await backupDirectory(); // `exportCopy` is VACUUM INTO — a transactionally consistent snapshot, // not a file copy of a database that may be mid-write. - final snapshot = await LocalDb.exportCopy(); + final snapshot = await (exportSnapshot ?? LocalDb.exportCopy)(); final dest = _uniqueDestination(dir, when); + if (dest == null) { + return const BackupOutcome( + error: 'no free backup filename for this second', + ); + } final tmp = File(snapshot); try { await tmp.rename(dest.path); @@ -218,43 +230,55 @@ Future pruneBackups(Directory dir, {required int keep}) async { } } -/// A free filename in [dir] for a backup taken at [when]. +/// A FREE filename in [dir] for a backup taken at [when], or null when the +/// bounded search found none. /// /// Seconds make a collision rare, not impossible — two manual runs inside one /// second would otherwise share a name and the second would overwrite the -/// first. The suffix is bounded; past it the timestamp is not the problem. -File _uniqueDestination(Directory dir, DateTime when) { +/// first. Null rather than the last candidate: returning an occupied path +/// would hand back a real snapshot for the next backup to overwrite, which is +/// the exact data loss this function exists to prevent. +File? _uniqueDestination(Directory dir, DateTime when) { final base = backupFileName(when); - var candidate = File(p.join(dir.path, base)); - for (var i = 2; candidate.existsSync() && i < 100; i++) { - final stem = base.substring(0, base.length - 3); // drop '.db' - candidate = File(p.join(dir.path, '$stem-$i.db')); + final stem = base.substring(0, base.length - 3); // drop '.db' + for (var i = 1; i < 100; i++) { + final candidate = File( + p.join(dir.path, i == 1 ? base : '$stem-$i.db'), + ); + if (!candidate.existsSync()) return candidate; } - return candidate; + return null; } /// Run a backup if [cadence] says one is due. /// -/// [lastRun] and [markRun] are CALLBACKS rather than values, so reading the -/// timestamp, deciding, exporting and persisting the new one all happen inside -/// the same lock. Passing `lastRun` in as a value would reintroduce exactly -/// the race this serialization exists to close: the caller would have read it -/// before queueing, and a backup that finished in the meantime would be -/// invisible to that decision. +/// [cadence], [lastRun] and [markRun] are all CALLBACKS rather than values, so +/// reading the setting and the timestamp, deciding, exporting and persisting +/// happen inside the same lock. Passing either in as a value would reintroduce +/// exactly the race this serialization exists to close: the caller would have +/// read it before queueing, and both a backup that finished in the meantime +/// and a setting the user changed in the meantime would be invisible to the +/// decision. /// /// Returns a skipped outcome when nothing was due, so the caller can tell /// "not yet" from "it broke". Future runBackupIfDue({ - required BackupCadence cadence, + required BackupCadence Function() cadence, required DateTime? Function() lastRun, required Future Function(DateTime) markRun, DateTime? now, + Future Function()? exportSnapshot, }) => _serialize(() async { final when = now ?? DateTime.now(); - if (!backupIsDue(cadence: cadence, lastRun: lastRun(), now: when)) { + // Cadence is read here too, for the same reason as the timestamp: a call + // that waits behind an export would otherwise act on the setting as it was + // when it queued. Someone who switches backup OFF while one is running would + // still get another unencrypted copy of their health data written after + // they disabled it. + if (!backupIsDue(cadence: cadence(), lastRun: lastRun(), now: when)) { return const BackupOutcome(skipped: true); } - final outcome = await _runBackup(now: when); + final outcome = await _runBackup(now: when, exportSnapshot: exportSnapshot); if (outcome.succeeded) await markRun(when); return outcome; }); diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 9e9a621c..de1c8e25 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -805,10 +805,13 @@ class AppState extends ChangeNotifier { /// resume decide against a stale timestamp while the first backup was still /// finishing, and start a duplicate export. Future runBackupIfDue() async { - final cadence = backupCadence; - if (cadence == BackupCadence.off) return; + if (backupCadence == BackupCadence.off) return; final outcome = await backup.runBackupIfDue( - cadence: cadence, + // Re-read inside the lock, not captured here: a call that waits behind a + // running export would otherwise act on the setting as it was when it + // queued, and someone who switched backup off in the meantime would + // still get a copy of their health data written after disabling it. + cadence: () => backupCadence, lastRun: () => lastBackupAt, markRun: (when) async => _markBackupRun(when), ); diff --git a/test/auto_backup_test.dart b/test/auto_backup_test.dart index 0e1aa17b..4de8ba0f 100644 --- a/test/auto_backup_test.dart +++ b/test/auto_backup_test.dart @@ -278,7 +278,7 @@ void main() { test('skips rather than failing when nothing is due', () async { var marked = 0; final outcome = await runBackupIfDue( - cadence: BackupCadence.daily, + cadence: () => BackupCadence.daily, lastRun: () => DateTime(2026, 8, 9, 11), markRun: (_) async => marked++, now: DateTime(2026, 8, 9, 12), @@ -300,7 +300,7 @@ void main() { final now = DateTime(2026, 8, 9, 12); Future trigger() => runBackupIfDue( - cadence: BackupCadence.daily, + cadence: () => BackupCadence.daily, lastRun: () { reads.add(last); return last; @@ -341,21 +341,73 @@ void main() { expect(File(second.path!).existsSync(), isTrue); }); - test('one failure does not wedge every later backup', () async { + test('a cadence switched off while queued does not still write', () async { + // The privacy-shaped half of the same race. Someone turns backup off + // while one is running; the queued call must see OFF, not the setting as + // it was when it queued, or it writes an unencrypted copy of everything + // after they disabled it. + var cadence = BackupCadence.daily; + var wrote = 0; + + final running = runBackup( + exportSnapshot: () async { + // Flip the setting while the first export is in flight. + cadence = BackupCadence.off; + return LocalDb.exportCopy(); + }, + ); + final queued = runBackupIfDue( + cadence: () => cadence, + lastRun: () => null, + markRun: (_) async => wrote++, + ); + + await running; + final outcome = await queued; + expect(outcome.skipped, isTrue, reason: 'it must see the new setting'); + expect(wrote, 0); + }); + + test('a failed backup does not wedge every later one', () async { // The queue is chained; without an error guard on the tail, a single // throw would leave every subsequent call waiting on a failed future. - final before = await runBackupIfDue( - cadence: BackupCadence.off, - lastRun: () => null, - markRun: (_) async {}, + final failed = await runBackup( + exportSnapshot: () async => throw const FileSystemException('nope'), ); - expect(before.skipped, isTrue); - final after = await runBackupIfDue( - cadence: BackupCadence.off, - lastRun: () => null, - markRun: (_) async {}, + expect(failed.succeeded, isFalse); + expect(failed.error, isNotNull); + + final after = await runBackup(now: DateTime(2026, 8, 9, 13, 0, 0)); + expect( + after.succeeded, + isTrue, + reason: 'the queue must survive the failure before it: ${after.error}', ); - expect(after.skipped, isTrue); + }); + + test('an occupied destination is never handed back', () async { + // Returning the last candidate would give the next backup a real + // snapshot to overwrite — the exact loss the unique naming prevents. + final when = DateTime(2026, 8, 9, 14, 0, 0); + final dir = await backupDirectory(); + final base = backupFileName(when); + final stem = base.substring(0, base.length - 3); + for (var i = 1; i < 100; i++) { + File(p.join(dir.path, i == 1 ? base : '$stem-$i.db')) + .writeAsStringSync('occupied'); + } + + final outcome = await runBackup(now: when); + expect(outcome.succeeded, isFalse); + expect(outcome.error, isNotNull); + // Every pre-existing file is untouched. + for (var i = 1; i < 100; i++) { + final f = File(p.join(dir.path, i == 1 ? base : '$stem-$i.db')); + expect(f.readAsStringSync(), 'occupied'); + } + for (var i = 1; i < 100; i++) { + File(p.join(dir.path, i == 1 ? base : '$stem-$i.db')).deleteSync(); + } }); }); } From e61c96b771f11fdc040de3b8e1989e769ec16509 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 9 Aug 2026 19:05:32 +0530 Subject: [PATCH 5/7] a nap you logged on a day the detector gave up on is the whole point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The detector abstains on short records, partial days and failures — exactly the days someone would log a nap for — and all three of those paths returned before the edits were applied. The nap vanished: no card, no minutes, and no way to delete the row that had just been created, while the edit kept force-re-deriving that day forever. Logged naps carry their own bounds, so they are published on their own now, and the day is only reported as unjudged when nothing was logged. A logged nap also supersedes a detection it overlaps. The entry screen refuses an overlap against what it can see, but that is a snapshot — log one on a day the detector abstained on, sync more raw, and it may then find the same bout and count the afternoon twice. And main sleep is in the overlap check now, or a nap could be logged inside the night and counted in both. Re-entering the breathing screen mid-session left it frozen. Swipe-back never reaches the handler that stops a session, so the next view mounted with the session already running and no state change to catch — no clock, no haptics, and a timed session that never ended. Backgrounding a session banked whatever the wall clock said. Two minutes backgrounded and resumed forty minutes later stored a forty-minute session, with a coherence score drawn mostly from breathing nobody was pacing. It clamps to what was asked for, since the pacer stops the moment the app leaves the foreground. The interval timer's end-of-session cue was three identical buzzes, which the firmware plays as one and nobody could tell from an ordinary round change. It has its own pattern. Restoring a backup dropped every sleep correction — the edits are the only copy, so a restore silently reinstated naps that had been deleted. And the Health import searched ten years back while adopting weight unconditionally, so an ancient reading could overwrite a profile someone had kept current, then feed calories and BMR. A year. --- lib/compute/derivation_engine.dart | 79 ++++++++++++++++++++---- lib/compute/nap_edits.dart | 43 ++++++++----- lib/data/db.dart | 6 ++ lib/health/health_profile_import.dart | 10 ++- lib/state/app_state.dart | 50 ++++++++++++++- lib/ui/sleep/sleep_periods_screen.dart | 29 +++++++-- lib/ui/stress/calm_breathing_screen.dart | 55 +++++++++++------ lib/ui/stress/interval_timer_screen.dart | 16 ++--- test/calm_breathing_view_test.dart | 31 +++++++++- test/health_profile_import_test.dart | 9 ++- test/nap_edits_test.dart | 36 +++++++++++ 11 files changed, 290 insertions(+), 74 deletions(-) diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index 2ad47368..f03c89dc 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -4365,6 +4365,54 @@ class DerivationEngine { /// distinguishable only by HOW the abstention happened. A reader that checks /// `bundle['naps']?['value'] == null` and one that checks /// `bundle.containsKey('naps')` would disagree. + /// Abstention path that still honours what the USER logged. + /// + /// The detector abstains on exactly the days this feature exists for — strap + /// off for part of the afternoon, a short record, a failure. Returning early + /// there dropped every logged nap on the floor: no card to see, no minutes + /// credited, and no way to delete the row the user had just created, while + /// the edit kept force-re-deriving that day forever. + /// + /// A logged nap needs nothing from the detector — it carries its own absolute + /// bounds — so it is published on its own. The day is still reported as + /// unjudged when the user logged nothing, because that is what it is. + static List>? _napsWhenUnjudged( + Map bundle, + Map? scMap, + List napEdits, + String note, + ) { + final merged = applyNapEdits(const [], napEdits); + if (merged.isEmpty) { + _writeUnknownNaps(bundle, note); + return null; + } + bundle['naps'] = { + 'value': merged, + 'count': merged.length, + // No detection confidence, because there was no detection. These are + // reported, not estimated. + 'confidence': null, + 'tier': 'reported', + 'inputs_used': const ['user'], + 'note': '$note — showing what you logged', + }; + scMap?['nap_min'] = napMinutes(merged).toDouble(); + return [ + for (final nap in merged) + { + 'is_main': false, + 'onset_ts': nap['start'], + 'wake_ts': nap['end'], + 'duration_min': nap['duration_min'], + 'in_bed_min': nap['in_bed_min'], + 'efficiency': null, + 'confidence': null, + if (nap['source'] != null) 'source': nap['source'], + }, + ]; + } + static void _writeUnknownNaps( Map bundle, String note, @@ -4396,8 +4444,12 @@ class DerivationEngine { try { final n = s.length; if (n < 60) { - _writeUnknownNaps(bundle, 'too little 1 Hz data to assess naps'); - return null; + return _napsWhenUnjudged( + bundle, + scMap, + napEdits, + 'too little 1 Hz data to assess naps', + ); } final accel = [ for (var i = 0; i < n; i++) @@ -4423,15 +4475,12 @@ class DerivationEngine { ); if (!m.present) { - bundle['naps'] = { - 'value': null, - 'count': null, - 'confidence': 0, - 'tier': m.tier, - 'inputs_used': m.inputs_used, - 'note': m.note, - }; - return null; + return _napsWhenUnjudged( + bundle, + scMap, + napEdits, + m.note ?? 'naps could not be assessed for this day', + ); } final t0 = s.tsSec.first; @@ -4517,8 +4566,12 @@ class DerivationEngine { ]; } catch (e) { if (kDebugMode) debugPrint('[derive] naps FAILED/skipped: $e'); - _writeUnknownNaps(bundle, 'nap detection failed for this day'); - return null; + return _napsWhenUnjudged( + bundle, + scMap, + napEdits, + 'nap detection failed for this day', + ); } } diff --git a/lib/compute/nap_edits.dart b/lib/compute/nap_edits.dart index 04a32fb9..facd1212 100644 --- a/lib/compute/nap_edits.dart +++ b/lib/compute/nap_edits.dart @@ -69,26 +69,36 @@ bool manualNapWindowIsValid(int startSec, int endSec) { /// /// Added naps are appended and marked, so the UI can show which came from /// where and the user can tell their own entry apart from a detection. -/// Overlapping additions are NOT merged: two logged naps that overlap are a -/// data-entry mistake, and silently fusing them would hide it while inflating -/// the day's total. The caller rejects the overlap at entry instead. +/// +/// An added nap also SUPPRESSES any detected nap it overlaps. The entry screen +/// refuses an overlap against what it can see, but that is only a snapshot: +/// log a nap on a day the detector abstained on, sync more raw, and the +/// detector may then find the same bout — leaving two periods over one +/// afternoon and double-crediting it into `nap_min`, sleep need and sleep +/// debt. The person who was there outranks the detector, so their entry wins +/// and the detection is dropped. +/// +/// Overlapping ADDITIONS are still not merged with each other: two logged naps +/// that overlap are a data-entry mistake, and silently fusing them would hide +/// it while inflating the total. /// /// The result is sorted by start, so the timeline draws them in order whatever /// order they were added in. List applyNapEdits(List detected, List edits) { final rejected = edits.where((e) => e.kind == NapEditKind.rejected); + final added = edits.where((e) => e.kind == NapEditKind.added); + bool supersedes(NapMap nap) { + final start = (nap['start'] as num).toInt(); + final end = (nap['end'] as num).toInt(); + return rejected.any((r) => r.overlaps(start, end)) || + added.any((a) => a.overlaps(start, end)); + } + final out = [ for (final nap in detected) - if (!rejected.any( - (r) => r.overlaps( - (nap['start'] as num).toInt(), - (nap['end'] as num).toInt(), - ), - )) - nap, - for (final e in edits) - if (e.kind == NapEditKind.added) - { + if (!supersedes(nap)) nap, + for (final e in added) + { 'start': e.startSec, 'end': e.endSec, // A logged nap has no measured sleep/wake split, so asleep and in-bed @@ -111,9 +121,10 @@ List applyNapEdits(List detected, List edits) { /// Total asleep minutes across [naps] — the `nap_min` scalar. /// -/// Null when there are no naps AND the day was never judged, which the caller -/// distinguishes: a written 0 is a claim that there were no naps, and absent is -/// a claim that we do not know. +/// Always a number, including 0 for an empty list. The absent-versus-zero +/// distinction is the CALLER's: it decides whether to write `nap_min` at all, +/// because a written 0 claims there were no naps while writing nothing admits +/// the day could not be judged. int napMinutes(List naps) => naps.fold(0, (a, n) => a + ((n['duration_min'] as num?)?.round() ?? 0)); diff --git a/lib/data/db.dart b/lib/data/db.dart index 0f4bacb7..2fe16077 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -3733,6 +3733,12 @@ class LocalDb { 'lab_result', 'lab_marker_def', 'breathing_session', + // The user's sleep corrections. These are the ONLY copy of them — the + // detector's output is deliberately not baked in, so a restore that + // skipped these would silently reinstate every nap the user had deleted + // and lose every one they logged. + 'sleep_override', + 'sleep_nap', 'cycle_log', 'notifications', 'baselines', diff --git a/lib/health/health_profile_import.dart b/lib/health/health_profile_import.dart index f2ff713e..e7e05098 100644 --- a/lib/health/health_profile_import.dart +++ b/lib/health/health_profile_import.dart @@ -141,8 +141,12 @@ class HealthProfileImporter { try { await _health.configure(); final end = now ?? DateTime.now(); - // Ten years back on Apple. A body metric older than that is not worth - // adopting silently, and characteristics (sex, DOB) ignore the window. + // One year back on Apple. The point of this is keeping a value CURRENT, + // and the merge adopts weight and height unconditionally — so a wider + // window would let an eight-year-old reading silently replace a profile + // the user has kept up to date, and that stale weight then feeds + // calories, BMR and the strain anchors. Characteristics (sex, DOB) + // ignore the window entirely. // // Health Connect caps third-party reads at the last 30 DAYS unless the // user grants `READ_HEALTH_DATA_HISTORY`, and the pinned `health` 11.1.1 @@ -151,7 +155,7 @@ class HealthProfileImporter { // actually have. Someone who weighs themselves less often than monthly // gets nothing, which reports honestly as "nothing to read". final start = _isApple - ? DateTime(end.year - 10, end.month, end.day) + ? DateTime(end.year - 1, end.month, end.day) : end.subtract(const Duration(days: 30)); final points = await _health.getHealthDataFromTypes( types: types, diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index de1c8e25..c93f12fb 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -806,6 +806,17 @@ class AppState extends ChangeNotifier { /// finishing, and start a duplicate export. Future runBackupIfDue() async { if (backupCadence == BackupCadence.off) return; + // Guarded: this is fired with `unawaited` from the resume hook, and + // `markRun` notifies listeners — which throws if the state was disposed + // during a long export, surfacing as an unhandled async error. + try { + await _runBackupIfDue(); + } catch (e) { + _log('Backup failed: $e'); + } + } + + Future _runBackupIfDue() async { final outcome = await backup.runBackupIfDue( // Re-read inside the lock, not captured here: a call that waits behind a // running export would otherwise act on the setting as it was when it @@ -3852,6 +3863,16 @@ class AppState extends ChangeNotifier { /// When the running session started, for the persisted history row. DateTime? _breathingStartedAt; + + /// What the session was SUPPOSED to run for, or null for an open one. + /// + /// Held because the banked duration is otherwise wall-clock: the screen's + /// ticker is muted while the app is suspended, so a two-minute session + /// backgrounded at 0:30 and resumed forty minutes later stopped on resume + /// and banked a forty-minute session, with a coherence score drawn mostly + /// from unpaced breathing. One backgrounded session would poison the trend + /// this history exists to build. + Duration? _breathingTarget; Map? breathingResult; // last {ok, ratio, score, peak_hz, n_beats, confidence, tier, note} String? breathingError; @@ -3860,7 +3881,10 @@ class AppState extends ChangeNotifier { bool _breathingEnabledStreams = false; /// Begin a guided-breathing session. Requires a connected band. - Future startBreathingSession({BreathPattern? pattern}) async { + Future startBreathingSession({ + BreathPattern? pattern, + Duration? target, + }) async { if (breathingActive) return; if (!isConnected) { breathingError = 'Connect your band first.'; @@ -3868,6 +3892,7 @@ class AppState extends ChangeNotifier { return; } breathingPattern = pattern ?? breathingPattern; + _breathingTarget = target; breathingActive = true; breathingResult = null; breathingError = null; @@ -3908,10 +3933,19 @@ class AppState extends ChangeNotifier { unawaited(BreathingLiveActivity.end()); final started = _breathingStartedAt; + final target = _breathingTarget; _breathingStartedAt = null; + _breathingTarget = null; if (started != null) { final ended = DateTime.now(); - final seconds = ended.difference(started).inSeconds; + var seconds = ended.difference(started).inSeconds; + // Clamped to what was asked for. Overshoot is always suspension, never + // extra breathing — the pacer stops the moment the app leaves the + // foreground, so any second past the target was spent doing something + // else. + if (target != null && seconds > target.inSeconds) { + seconds = target.inSeconds; + } if (seconds >= 60) { final res = breathingResult; final scored = res != null && res['ok'] == true; @@ -3954,6 +3988,18 @@ class AppState extends ChangeNotifier { unawaited(engine.buzzPattern(pattern).catchError((_) {})); } + /// The whole session is over, as opposed to one phase of it. + /// + /// Its own pattern rather than a repeat of the phase cue: repeated + /// `runHapticsPattern` frames serialize on the BLE write chain and arrive + /// milliseconds apart, re-triggering the firmware's haptic engine while it + /// is still playing — so N of them are felt as one, and the user cannot tell + /// "round over" from "session over". + void buzzSessionComplete() { + if (!isConnected) return; + unawaited(engine.buzzPattern(4).catchError((_) {})); + } + Future _recomputeBreathingCoherence() async { if (!breathingActive || repo == null) return; final frames = List.from(_breathingFrames); diff --git a/lib/ui/sleep/sleep_periods_screen.dart b/lib/ui/sleep/sleep_periods_screen.dart index 11cdc614..3df323ff 100644 --- a/lib/ui/sleep/sleep_periods_screen.dart +++ b/lib/ui/sleep/sleep_periods_screen.dart @@ -119,7 +119,7 @@ class _SleepPeriodsScreenState extends State { ); return; } - if (napOverlapsExisting(startTs, endTs, _napWindows)) { + if (napOverlapsExisting(startTs, endTs, _sleepWindows)) { // Silently merging two overlapping entries would inflate the day's total // while hiding the mistake. _say('That overlaps a sleep already on this day.'); @@ -132,7 +132,7 @@ class _SleepPeriodsScreenState extends State { endTs: endTs, source: 'manual', ); - await _rederive(); + await _rederive(expectStart: startTs); } /// Remove a period. A detected nap is SUPPRESSED (its window is remembered, @@ -155,17 +155,34 @@ class _SleepPeriodsScreenState extends State { await _rederive(); } - List> get _napWindows => [ + /// Every sleep already on the day, MAIN INCLUDED. + /// + /// The main sleep has to be in here or a nap can be logged inside it — main + /// 23:00–07:00 and a logged 06:00–07:00 would both be accepted, and that + /// hour would be counted once in the night's TST and again in nap minutes. + List> get _sleepWindows => [ for (final p in _periods) - if (p['is_main'] != true) + if (p['onset_ts'] != null && p['wake_ts'] != null) {'start': p['onset_ts'], 'end': p['wake_ts']}, ]; - Future _rederive() async { + Future _rederive({int? expectStart}) async { // The edit only shows up once the day is re-derived — nap minutes feed // sleep need and sleep debt, so this is a recompute, not a redraw. await context.read().reanalyzeForNapEdit(); - if (mounted) await _load(); + if (!mounted) return; + await _load(); + if (!mounted || expectStart == null) return; + final landed = _periods.any( + (p) => (p['onset_ts'] as num?)?.toInt() == expectStart, + ); + if (!landed) { + // A day whose raw data has aged out cannot be re-derived, and a derive + // already in flight is skipped rather than queued. Either way the row is + // saved and will apply next time that day is rebuilt — saying nothing + // would look like the tap did nothing at all. + _say('Saved. It will show once this day is rebuilt.'); + } } void _say(String message) { diff --git a/lib/ui/stress/calm_breathing_screen.dart b/lib/ui/stress/calm_breathing_screen.dart index 2fa5215f..896bce58 100644 --- a/lib/ui/stress/calm_breathing_screen.dart +++ b/lib/ui/stress/calm_breathing_screen.dart @@ -78,7 +78,7 @@ class CalmBreathingView extends StatefulWidget { /// The pattern being paced. Null falls back to resonance, which is what this /// screen has always run and the only one carrying a coherence score. final BreathPattern? pattern; - final void Function({BreathPattern? pattern})? onStart; + final void Function({BreathPattern? pattern, Duration? target})? onStart; final VoidCallback? onStop; /// Called once per phase boundary, to buzz the strap. @@ -140,19 +140,21 @@ class _CalmBreathingViewState extends State vsync: this, duration: const Duration(seconds: 1), )..addListener(_onTick); + // AFTER the ticker exists — `_begin` repeats it. Started here, not only on + // the false→true edge in didUpdateWidget: a session survives leaving the + // screen (swipe-back and system back never reach `onBack`, which is the + // only thing that stops it), so re-entering mounts a fresh view with + // `active` ALREADY true and no edge to catch. The whole session lifecycle + // hangs off this clock, so missing it left a frozen circle, no haptics, + // and a timed session that never ended. + if (widget.active) _begin(); } @override void didUpdateWidget(covariant CalmBreathingView oldWidget) { super.didUpdateWidget(oldWidget); if (widget.active && !oldWidget.active) { - _lastPhase = null; - _lastCycle = -1; - _finished = false; - _clock - ..reset() - ..start(); - _ticker.repeat(); + _begin(); } else if (!widget.active && oldWidget.active) { _ticker.stop(); _clock.stop(); @@ -160,6 +162,16 @@ class _CalmBreathingViewState extends State } } + void _begin() { + _lastPhase = null; + _lastCycle = -1; + _finished = false; + _clock + ..reset() + ..start(); + _ticker.repeat(); + } + @override void dispose() { _ticker @@ -180,15 +192,10 @@ class _CalmBreathingViewState extends State void _onTick() { if (!widget.active || _finished) return; - final at = phaseAt(_pattern, _elapsed); - if (at != null) { - final changed = at.phase.kind != _lastPhase || at.cycle != _lastCycle; - if (changed) { - _lastPhase = at.phase.kind; - _lastCycle = at.cycle; - widget.onPhaseChange?.call(at.phase.kind); - } - } + // Expiry FIRST. The Stopwatch keeps counting while the app is suspended + // but the ticker does not, so the first tick after a resume can be long + // past the end — buzzing on the way out would be a startling cue for a + // session that is already over. final remaining = _remaining; if (remaining != null && remaining <= Duration.zero) { // The button said two minutes, so two minutes is what it runs. It used @@ -199,6 +206,15 @@ class _CalmBreathingViewState extends State widget.onStop?.call(); return; } + final at = phaseAt(_pattern, _elapsed); + if (at != null) { + final changed = at.phase.kind != _lastPhase || at.cycle != _lastCycle; + if (changed) { + _lastPhase = at.phase.kind; + _lastCycle = at.cycle; + widget.onPhaseChange?.call(at.phase.kind); + } + } setState(() {}); } @@ -307,7 +323,10 @@ class _CalmBreathingViewState extends State const SizedBox(height: Sp.x6), FilledButton( onPressed: widget.connected - ? () => widget.onStart?.call(pattern: _pattern) + ? () => widget.onStart?.call( + pattern: _pattern, + target: _target, + ) : null, child: Text( _minutes == null diff --git a/lib/ui/stress/interval_timer_screen.dart b/lib/ui/stress/interval_timer_screen.dart index 04d6efa5..cf9ab9b4 100644 --- a/lib/ui/stress/interval_timer_screen.dart +++ b/lib/ui/stress/interval_timer_screen.dart @@ -102,16 +102,12 @@ class _IntervalTimerScreenState extends State final elapsed = _clock.elapsed; final end = _sessionEnd; if (end != null && elapsed >= end) { - // Three buzzes to say the whole thing is over, distinct from the single - // cue at a phase change. - final app = context.read(); - for (final kind in [ - BreathPhaseKind.work, - BreathPhaseKind.work, - BreathPhaseKind.work, - ]) { - app.buzzBreathPhase(kind); - } + // A DISTINCT pattern, not three of the ordinary one. Three identical + // haptic frames land back-to-back at GATT speed and re-trigger the + // firmware mid-playback, so they are felt as a single buzz — + // indistinguishable from the phase cue they were meant to be different + // from. + context.read().buzzSessionComplete(); _stop(); return; } diff --git a/test/calm_breathing_view_test.dart b/test/calm_breathing_view_test.dart index 725a0f5a..7c1e8eca 100644 --- a/test/calm_breathing_view_test.dart +++ b/test/calm_breathing_view_test.dart @@ -8,6 +8,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:openstrap_edge/theme/theme.dart'; import 'package:openstrap_edge/theme/tokens.dart'; +import 'package:openstrap_edge/ui/stress/breath_phases.dart'; import 'package:openstrap_edge/ui/stress/calm_breathing_screen.dart'; Widget _host(Widget child) { @@ -36,7 +37,7 @@ void main() { await tester.pumpWidget(_host(CalmBreathingView( connected: true, active: false, - onStart: ({pattern}) => started = true, + onStart: ({pattern, target}) => started = true, ))); await tester.tap(find.byType(FilledButton)); expect(started, isTrue); @@ -75,6 +76,34 @@ void main() { expect(find.text('Calibrating…'), findsNothing); }); + testWidgets( + 'a view that mounts with a session ALREADY running still paces it', + (tester) async { + // Reachable by swiping back mid-session and re-entering: neither + // swipe-back nor system back reaches onBack, so the session keeps running + // and the next view mounts with active already true. Starting the clock + // only on the false→true edge left that view frozen — no haptics, and a + // timed session that never ended. + final phases = []; + await tester.pumpWidget(_host(CalmBreathingView( + connected: true, + active: true, + onPhaseChange: phases.add, + ))); + // Far enough in to have crossed at least one phase boundary. + await tester.pump(const Duration(seconds: 7)); + expect( + phases, + isNotEmpty, + reason: 'the clock never started, so nothing paced', + ); + // Settle the repeating ticker so the test can finish. + await tester.pumpWidget(_host(const CalmBreathingView( + connected: true, + active: false, + ))); + }); + testWidgets('tapping Stop Session calls onStop', (tester) async { var stopped = false; await tester.pumpWidget(_host(CalmBreathingView( diff --git a/test/health_profile_import_test.dart b/test/health_profile_import_test.dart index 7a1650e3..17791fd2 100644 --- a/test/health_profile_import_test.dart +++ b/test/health_profile_import_test.dart @@ -96,7 +96,7 @@ void main() { }); }); - test('Apple asks for ten years', () { + test('Apple asks for a year, not a decade', () { final windows = <(DateTime, DateTime)>[]; final importer = HealthProfileImporter( isApple: true, @@ -104,10 +104,9 @@ void main() { ); return importer.read(now: DateTime(2026, 8, 9)).then((_) { expect(windows, hasLength(1)); - expect( - windows.single.$2.year - windows.single.$1.year, - 10, - ); + // Wide enough to find a value someone records occasionally, narrow + // enough that an ancient reading cannot overwrite a current profile. + expect(windows.single.$2.year - windows.single.$1.year, 1); }); }); }); diff --git a/test/nap_edits_test.dart b/test/nap_edits_test.dart index cab2c95e..a4a913e9 100644 --- a/test/nap_edits_test.dart +++ b/test/nap_edits_test.dart @@ -115,6 +115,42 @@ void main() { expect(out.single['confidence'], isNull); }); + test('supersedes a detection it overlaps', () { + // The entry screen refuses an overlap against what it can SEE. Log a nap + // on a day the detector abstained on, sync more raw, and the detector + // may then find the same bout — two periods over one afternoon, and the + // hour double-credited into nap minutes, sleep need and sleep debt. The + // person who was there outranks the detector. + final out = applyNapEdits( + [nap(13 * hour, 14 * hour)], + const [ + NapEdit( + kind: NapEditKind.added, + startSec: 13 * hour + 600, + endSec: 14 * hour + 600, + ), + ], + ); + expect(out, hasLength(1)); + expect(out.single['source'], 'manual'); + expect(napMinutes(out), 60, reason: 'counted once, not twice'); + }); + + test('leaves a detection it does not overlap', () { + final out = applyNapEdits( + [nap(13 * hour, 14 * hour)], + const [ + NapEdit( + kind: NapEditKind.added, + startSec: 17 * hour, + endSec: 18 * hour, + ), + ], + ); + expect(out, hasLength(2)); + expect(napMinutes(out), 120); + }); + test('overlapping additions are kept apart, not fused', () { // Fusing them would hide a data-entry mistake while inflating the total. // Entry rejects the overlap; the merge does not paper over it. From db3020480eb82661e1926fac33008105a5c1adf0 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 9 Aug 2026 19:23:39 +0530 Subject: [PATCH 6/7] the session owns its deadline, not the screen that happens to be showing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Starting the clock on mount fixed a frozen re-entry and introduced a worse one: the clock restarted from zero and the length reverted to the default, so a five-minute session re-entered at 4:00 showed 2:00 and stopped almost at once. Elapsed and remaining now come from when the session actually began and what it was actually asked for, both of which belong to the session. A logged nap was published under a tier that does not exist. The vocabulary is four values; AUTH is the one that means directly measured or definitional, which is what a self-report is — the user is not estimating that they napped. The backup exported before choosing a destination, so a run that could not find a free name left a full copy of the database in temp, once per attempt. And re-deriving after a nap edit reached for the provider without checking the screen was still there, after two awaited writes. --- lib/compute/derivation_engine.dart | 9 +++-- lib/data/auto_backup.dart | 4 ++- lib/state/app_state.dart | 6 ++++ lib/ui/sleep/sleep_periods_screen.dart | 3 ++ lib/ui/stress/calm_breathing_screen.dart | 43 +++++++++++++++++++----- test/auto_backup_test.dart | 12 ++++++- test/calm_breathing_view_test.dart | 26 ++++++++++++++ test/health_profile_import_test.dart | 3 +- 8 files changed, 92 insertions(+), 14 deletions(-) diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index f03c89dc..b568d680 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -4390,10 +4390,13 @@ class DerivationEngine { bundle['naps'] = { 'value': merged, 'count': merged.length, - // No detection confidence, because there was no detection. These are - // reported, not estimated. + // No detection confidence, because there was no detection. 'confidence': null, - 'tier': 'reported', + // AUTH is the closed vocabulary's "directly measured / definitional", + // which is what a self-report is: the user is not estimating that they + // napped, they are stating it. An invented fifth tier would be a string + // no reader knows how to rank. + 'tier': ana.Tier.auth, 'inputs_used': const ['user'], 'note': '$note — showing what you logged', }; diff --git a/lib/data/auto_backup.dart b/lib/data/auto_backup.dart index 1bdd1c12..bbd4416d 100644 --- a/lib/data/auto_backup.dart +++ b/lib/data/auto_backup.dart @@ -191,13 +191,15 @@ Future _runBackup({ final dir = await backupDirectory(); // `exportCopy` is VACUUM INTO — a transactionally consistent snapshot, // not a file copy of a database that may be mid-write. - final snapshot = await (exportSnapshot ?? LocalDb.exportCopy)(); + // Destination FIRST. Exporting before checking meant a failure here left a + // full copy of the database sitting in temp, once per attempt. final dest = _uniqueDestination(dir, when); if (dest == null) { return const BackupOutcome( error: 'no free backup filename for this second', ); } + final snapshot = await (exportSnapshot ?? LocalDb.exportCopy)(); final tmp = File(snapshot); try { await tmp.rename(dest.path); diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index c93f12fb..dbca2b13 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -3864,6 +3864,12 @@ class AppState extends ChangeNotifier { /// When the running session started, for the persisted history row. DateTime? _breathingStartedAt; + /// When the running session began, for a view that mounts mid-session. + DateTime? get breathingStartedAt => _breathingStartedAt; + + /// What the running session was asked to run for, or null for an open one. + Duration? get breathingTarget => _breathingTarget; + /// What the session was SUPPOSED to run for, or null for an open one. /// /// Held because the banked duration is otherwise wall-clock: the screen's diff --git a/lib/ui/sleep/sleep_periods_screen.dart b/lib/ui/sleep/sleep_periods_screen.dart index 3df323ff..9b2c4b40 100644 --- a/lib/ui/sleep/sleep_periods_screen.dart +++ b/lib/ui/sleep/sleep_periods_screen.dart @@ -167,6 +167,9 @@ class _SleepPeriodsScreenState extends State { ]; Future _rederive({int? expectStart}) async { + // Both callers reach here after an awaited database write, so the screen + // can already be gone — a Provider lookup on a disposed context throws. + if (!mounted) return; // The edit only shows up once the day is re-derived — nap minutes feed // sleep need and sleep debt, so this is a recompute, not a redraw. await context.read().reanalyzeForNapEdit(); diff --git a/lib/ui/stress/calm_breathing_screen.dart b/lib/ui/stress/calm_breathing_screen.dart index 896bce58..6db2f4e7 100644 --- a/lib/ui/stress/calm_breathing_screen.dart +++ b/lib/ui/stress/calm_breathing_screen.dart @@ -39,6 +39,11 @@ class CalmBreathingScreen extends StatelessWidget { context.select?, String?)>( (a) => (a.isConnected, a.breathingActive, a.breathingResult, a.breathingError), ); + // Also watched: a view that mounts mid-session needs the session's own + // start and target, not the picker's defaults. + context.select( + (a) => (a.breathingStartedAt, a.breathingTarget), + ); final app = context.read(); if (autoStart && !app.breathingActive && app.isConnected) { // Guarded by breathingActive so this only ever fires once per mount — @@ -54,6 +59,8 @@ class CalmBreathingScreen extends StatelessWidget { result: app.breathingResult, error: app.breathingError, pattern: app.breathingPattern, + startedAt: app.breathingStartedAt, + target: app.breathingTarget, onStart: app.startBreathingSession, onStop: app.stopBreathingSession, // Buzzing the strap is what makes this usable with your eyes closed, @@ -83,6 +90,16 @@ class CalmBreathingView extends StatefulWidget { /// Called once per phase boundary, to buzz the strap. final ValueChanged? onPhaseChange; + + /// When the RUNNING session began, and what it was asked to run for. + /// + /// Authoritative, and owned by the session rather than by this view, because + /// the view is remounted every time someone leaves the screen and comes + /// back. Timing from a local stopwatch restarted the pacing from zero on + /// re-entry and reverted the length to the default, so a five-minute session + /// re-entered at 4:00 showed 2:00 and stopped almost immediately. + final DateTime? startedAt; + final Duration? target; final VoidCallback? onBack; const CalmBreathingView({ @@ -95,6 +112,8 @@ class CalmBreathingView extends StatefulWidget { this.onStart, this.onStop, this.onPhaseChange, + this.startedAt, + this.target, this.onBack, }); @@ -115,7 +134,10 @@ class _CalmBreathingViewState extends State /// Drives the circle. Its VALUE is ignored — the phase engine decides what /// is happening; this just gives us a per-frame tick. late AnimationController _ticker; - final _clock = Stopwatch(); + + /// Fallback start for a view driven without an authoritative one (the pure + /// widget tests). The session's own start wins whenever it is known. + DateTime? _localStart; /// Chosen before starting. BreathPattern _pattern = _defaultPattern; @@ -157,7 +179,7 @@ class _CalmBreathingViewState extends State _begin(); } else if (!widget.active && oldWidget.active) { _ticker.stop(); - _clock.stop(); + _localStart = null; setState(() {}); } } @@ -166,9 +188,9 @@ class _CalmBreathingViewState extends State _lastPhase = null; _lastCycle = -1; _finished = false; - _clock - ..reset() - ..start(); + // Only when the session cannot tell us — otherwise a remount would reset + // the clock and restart the session's timing from zero. + _localStart ??= DateTime.now(); _ticker.repeat(); } @@ -180,10 +202,16 @@ class _CalmBreathingViewState extends State super.dispose(); } - Duration get _elapsed => _clock.elapsed; + Duration get _elapsed { + final from = widget.startedAt ?? _localStart; + return from == null ? Duration.zero : DateTime.now().difference(from); + } + /// The running session's target wins over the picker, which is only a draft + /// until Begin is pressed and reverts to its default on a remount. Duration? get _target => - _minutes == null ? null : Duration(minutes: _minutes!); + widget.target ?? + (_minutes == null ? null : Duration(minutes: _minutes!)); Duration? get _remaining { final t = _target; @@ -202,7 +230,6 @@ class _CalmBreathingViewState extends State // to say that and run until you stopped it. _finished = true; _ticker.stop(); - _clock.stop(); widget.onStop?.call(); return; } diff --git a/test/auto_backup_test.dart b/test/auto_backup_test.dart index 4de8ba0f..5ea69f5c 100644 --- a/test/auto_backup_test.dart +++ b/test/auto_backup_test.dart @@ -397,9 +397,19 @@ void main() { .writeAsStringSync('occupied'); } - final outcome = await runBackup(now: when); + var exported = 0; + final outcome = await runBackup( + now: when, + exportSnapshot: () async { + exported++; + return LocalDb.exportCopy(); + }, + ); expect(outcome.succeeded, isFalse); expect(outcome.error, isNotNull); + // The destination is chosen BEFORE the export. Exporting first left a + // full copy of the database in temp on every failed attempt. + expect(exported, 0, reason: 'nothing should have been exported'); // Every pre-existing file is untouched. for (var i = 1; i < 100; i++) { final f = File(p.join(dir.path, i == 1 ? base : '$stem-$i.db')); diff --git a/test/calm_breathing_view_test.dart b/test/calm_breathing_view_test.dart index 7c1e8eca..a50e1f80 100644 --- a/test/calm_breathing_view_test.dart +++ b/test/calm_breathing_view_test.dart @@ -104,6 +104,32 @@ void main() { ))); }); + testWidgets('a remount keeps the session deadline, it does not restart it', + (tester) async { + // The view is rebuilt every time someone leaves the screen and comes back. + // Timing from a local stopwatch restarted the pacing from zero and + // reverted the length to the 2-minute default, so a five-minute session + // re-entered at 4:00 showed 2:00 and stopped almost at once. + var stopped = 0; + await tester.pumpWidget(_host(CalmBreathingView( + connected: true, + active: true, + startedAt: DateTime.now().subtract(const Duration(minutes: 4)), + target: const Duration(minutes: 5), + onStop: () => stopped++, + ))); + await tester.pump(const Duration(seconds: 1)); + + expect(stopped, 0, reason: 'a 5-minute session is not over at 4:00'); + // A minute of remaining time, not the default two. + expect(find.text('0:59'), findsOneWidget); + + await tester.pumpWidget(_host(const CalmBreathingView( + connected: true, + active: false, + ))); + }); + testWidgets('tapping Stop Session calls onStop', (tester) async { var stopped = false; await tester.pumpWidget(_host(CalmBreathingView( diff --git a/test/health_profile_import_test.dart b/test/health_profile_import_test.dart index 17791fd2..16cd201d 100644 --- a/test/health_profile_import_test.dart +++ b/test/health_profile_import_test.dart @@ -106,7 +106,8 @@ void main() { expect(windows, hasLength(1)); // Wide enough to find a value someone records occasionally, narrow // enough that an ancient reading cannot overwrite a current profile. - expect(windows.single.$2.year - windows.single.$1.year, 1); + // Pinned exactly: a year-difference check passes for any date in 2025. + expect(windows.single.$1, DateTime(2025, 8, 9)); }); }); }); From baab763fe7079e74f358cb72a61bc613d4a9f560 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 9 Aug 2026 19:32:36 +0530 Subject: [PATCH 7/7] an open-ended session is not a two-minute one A null target on a running session means it runs until you stop it. Reading that as "no answer" fell through to the picker's default, so an open session left and re-entered past two minutes showed a countdown it never had and stopped on the spot. The session's start is what says whether a target is authoritative, not whether the target happens to be null. --- lib/ui/stress/calm_breathing_screen.dart | 12 ++++++--- test/calm_breathing_view_test.dart | 31 ++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/lib/ui/stress/calm_breathing_screen.dart b/lib/ui/stress/calm_breathing_screen.dart index 6db2f4e7..750ac425 100644 --- a/lib/ui/stress/calm_breathing_screen.dart +++ b/lib/ui/stress/calm_breathing_screen.dart @@ -209,9 +209,15 @@ class _CalmBreathingViewState extends State /// The running session's target wins over the picker, which is only a draft /// until Begin is pressed and reverts to its default on a remount. - Duration? get _target => - widget.target ?? - (_minutes == null ? null : Duration(minutes: _minutes!)); + /// + /// Keyed on [CalmBreathingView.startedAt], NOT on the target being non-null: + /// a null target on a running session means OPEN-ENDED, and treating that as + /// "no answer" fell through to the picker's two minutes — so an open session + /// remounted showed a countdown it never had and stopped at 2:00. + Duration? get _target { + if (widget.startedAt != null) return widget.target; + return _minutes == null ? null : Duration(minutes: _minutes!); + } Duration? get _remaining { final t = _target; diff --git a/test/calm_breathing_view_test.dart b/test/calm_breathing_view_test.dart index a50e1f80..bfe33ae5 100644 --- a/test/calm_breathing_view_test.dart +++ b/test/calm_breathing_view_test.dart @@ -130,6 +130,37 @@ void main() { ))); }); + testWidgets('an open-ended session stays open across a remount', + (tester) async { + // A null target on a RUNNING session means open-ended. Reading that as + // "no answer" fell through to the picker's two minutes, so an open + // session remounted past 2:00 stopped immediately. + var stopped = 0; + await tester.pumpWidget(_host(CalmBreathingView( + connected: true, + active: true, + startedAt: DateTime.now().subtract(const Duration(minutes: 9)), + onStop: () => stopped++, + ))); + await tester.pump(const Duration(seconds: 1)); + + expect(stopped, 0, reason: 'an open session never expires on its own'); + // Counting UP from the session start, not down from a target it never + // had. Matched loosely because elapsed comes from the wall clock, which + // the test's pump does not control. + expect( + find.byWidgetPredicate( + (w) => w is Text && (w.data ?? '').startsWith('9:0'), + ), + findsOneWidget, + ); + + await tester.pumpWidget(_host(const CalmBreathingView( + connected: true, + active: false, + ))); + }); + testWidgets('tapping Stop Session calls onStop', (tester) async { var stopped = false; await tester.pumpWidget(_host(CalmBreathingView(