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..b568d680 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, @@ -4333,6 +4365,57 @@ 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. + 'confidence': null, + // 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', + }; + 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, @@ -4357,12 +4440,19 @@ 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; 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++) @@ -4388,15 +4478,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; @@ -4428,25 +4515,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,26 +4550,31 @@ 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) { 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', + ); } } @@ -4711,6 +4811,7 @@ class DerivationEngine { attributionEndSec: inp.dayEndSec, wristOff: inp.wristOffSpans, charging: inp.chargingSpans, + napEdits: inp.napEdits, ); bundlePatch['sleep_periods'] = _sleepPeriods( onset, @@ -5089,6 +5190,7 @@ class DerivationEngine { int? attributionEndSec, List> wristOff = const [], List> charging = const [], + List napEdits = const [], }) => _attachNaps( bundle, @@ -5100,6 +5202,7 @@ class DerivationEngine { attributionEndSec: attributionEndSec, wristOff: wristOff, charging: charging, + napEdits: napEdits, ); void _log(String m) { @@ -5152,6 +5255,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 +5297,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..facd1212 --- /dev/null +++ b/lib/compute/nap_edits.dart @@ -0,0 +1,140 @@ +// 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. +/// +/// 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 (!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 + // 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. +/// +/// 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)); + +/// 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..bbd4416d --- /dev/null +++ b/lib/data/auto_backup.dart @@ -0,0 +1,286 @@ +// 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. 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 +// 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. 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 +/// 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. +/// +/// 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)}${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) => _backupNamePattern.hasMatch(p.basename(f.path))) + .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. +/// +/// 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 { + 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; +} + +/// 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 against every other backup path. +Future runBackup({ + DateTime? now, + Future Function()? exportSnapshot, +}) => _serialize(() => _runBackup(now: now, exportSnapshot: exportSnapshot)); + +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. + // 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); + } 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); + } 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. + } +} + +/// 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. 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); + 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 null; +} + +/// Run a backup if [cadence] says one is due. +/// +/// [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 Function() cadence, + required DateTime? Function() lastRun, + required Future Function(DateTime) markRun, + DateTime? now, + Future Function()? exportSnapshot, +}) => _serialize(() async { + final when = now ?? DateTime.now(); + // 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, exportSnapshot: exportSnapshot); + if (outcome.succeeded) await markRun(when); + return outcome; +}); diff --git a/lib/data/db.dart b/lib/data/db.dart index 676e2a7c..2fe16077 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,13 @@ class LocalDb { 'journal_field_def', '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', @@ -3980,6 +4049,7 @@ class LocalDb { 'journal_field_def', 'lab_result', 'lab_marker_def', + 'breathing_session', 'cycle_log', 'notifications', 'sync_cursor', @@ -4773,6 +4843,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..e7e05098 --- /dev/null +++ b/lib/health/health_profile_import.dart @@ -0,0 +1,239 @@ +// 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(); + // 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 + // 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 - 1, end.month, end.day) + : end.subtract(const Duration(days: 30)); + 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..dbca2b13 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,70 @@ 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(); + } + + 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) _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 { + 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 + // 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), + ); + 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 +1664,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 +3855,30 @@ 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; + + /// 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 + /// 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; @@ -3786,17 +3887,23 @@ class AppState extends ChangeNotifier { bool _breathingEnabledStreams = false; /// Begin a guided-breathing session. Requires a connected band. - Future startBreathingSession() async { + Future startBreathingSession({ + BreathPattern? pattern, + Duration? target, + }) async { if (breathingActive) return; if (!isConnected) { breathingError = 'Connect your band first.'; notifyListeners(); return; } + breathingPattern = pattern ?? breathingPattern; + _breathingTarget = target; breathingActive = true; breathingResult = null; breathingError = null; _breathingFrames.clear(); + _breathingStartedAt = DateTime.now(); notifyListeners(); unawaited(BreathingLiveActivity.start(startedAt: DateTime.now())); try { @@ -3818,7 +3925,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 +3937,75 @@ class AppState extends ChangeNotifier { breathingActive = false; _stopBreathingStreams(); unawaited(BreathingLiveActivity.end()); + + final started = _breathingStartedAt; + final target = _breathingTarget; + _breathingStartedAt = null; + _breathingTarget = null; + if (started != null) { + final ended = DateTime.now(); + 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; + // 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((_) {})); + } + + /// 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); @@ -3836,7 +4013,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..bf986636 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; }); @@ -273,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', ), ], ), @@ -405,15 +420,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 +437,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 +639,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..9b2c4b40 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,157 @@ 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, _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.'); + return; + } + + await LocalDb.putNapEdit( + dayId: widget.date, + startTs: startTs, + endTs: endTs, + source: 'manual', + ); + await _rederive(expectStart: startTs); + } + + /// 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(); + } + + /// 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['onset_ts'] != null && p['wake_ts'] != null) + {'start': p['onset_ts'], 'end': p['wake_ts']}, + ]; + + 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(); + 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) { + 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 +248,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 +351,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..750ac425 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 @@ -33,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 — @@ -47,8 +58,15 @@ class CalmBreathingScreen extends StatelessWidget { active: app.breathingActive, 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, + // 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 +81,25 @@ class CalmBreathingView extends StatefulWidget { final bool active; final Map? result; final String? error; - final VoidCallback? onStart; + + /// 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, Duration? target})? onStart; final VoidCallback? onStop; + + /// 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({ @@ -73,8 +108,12 @@ class CalmBreathingView extends StatefulWidget { required this.active, this.result, this.error, + this.pattern, this.onStart, this.onStop, + this.onPhaseChange, + this.startedAt, + this.target, this.onBack, }); @@ -82,58 +121,156 @@ class CalmBreathingView extends StatefulWidget { State createState() => _CalmBreathingViewState(); } +/// 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]; + 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; + + /// 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; + int? _minutes = 2; + + 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(); - // 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 ?? _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. + _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); + // 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) { - _controller.forward(); + _begin(); } else if (!widget.active && oldWidget.active) { - _controller.stop(); - _controller.value = 0.0; - setState(() => _phaseText = "Inhale"); + _ticker.stop(); + _localStart = null; + setState(() {}); } } + void _begin() { + _lastPhase = null; + _lastCycle = -1; + _finished = false; + // 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(); + } + @override void dispose() { - _controller.dispose(); + _ticker + ..removeListener(_onTick) + ..dispose(); super.dispose(); } + 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. + /// + /// 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; + return t == null ? null : t - _elapsed; + } + + void _onTick() { + if (!widget.active || _finished) return; + // 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 + // to say that and run until you stopped it. + _finished = true; + _ticker.stop(); + 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(() {}); + } + 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 +281,167 @@ 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, + target: _target, + ) + : 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..cf9ab9b4 --- /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() { + // 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(); + 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) { + // 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; + } + 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..5ea69f5c --- /dev/null +++ b/test/auto_backup_test.dart @@ -0,0 +1,423 @@ +// 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: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', () { + 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, 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))), + ); + }); + }); + + 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-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-000000.db', + 'openstrap-20260201-000000.db', + 'openstrap-20260101-000000.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-000000.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-000000.db')) + .writeAsStringSync('x'); + } + await pruneBackups(tmp, keep: 2); + expect( + sortBackupsNewestFirst(tmp.listSync()).map((f) => p.basename(f.path)), + ['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-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-000000.db')) + .writeAsStringSync('x'); + await pruneBackups(tmp, keep: 5); + expect(sortBackupsNewestFirst(tmp.listSync()), hasLength(1)); + }); + }); + + 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('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 failed = await runBackup( + exportSnapshot: () async => throw const FileSystemException('nope'), + ); + 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}', + ); + }); + + 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'); + } + + 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')); + expect(f.readAsStringSync(), 'occupied'); + } + for (var i = 1; i < 100; i++) { + File(p.join(dir.path, i == 1 ? base : '$stem-$i.db')).deleteSync(); + } + }); + }); +} diff --git a/test/breath_phases_test.dart b/test/breath_phases_test.dart new file mode 100644 index 00000000..e41314da --- /dev/null +++ b/test/breath_phases_test.dart @@ -0,0 +1,199 @@ +// 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('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); + 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..bfe33ae5 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: () => started = true, + onStart: ({pattern, target}) => started = true, ))); await tester.tap(find.byType(FilledButton)); expect(started, isTrue); @@ -75,6 +76,91 @@ 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('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('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( 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..16cd201d --- /dev/null +++ b/test/health_profile_import_test.dart @@ -0,0 +1,305 @@ +// 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'; + +/// 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', + 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('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 a year, not a decade', () { + 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)); + // Wide enough to find a value someone records occasionally, narrow + // enough that an ancient reading cannot overwrite a current profile. + // Pinned exactly: a year-difference check passes for any date in 2025. + expect(windows.single.$1, DateTime(2025, 8, 9)); + }); + }); + }); + + 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..a4a913e9 --- /dev/null +++ b/test/nap_edits_test.dart @@ -0,0 +1,245 @@ +// 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('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. + 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); + }); + }); +}