What is sent is the barcode. It goes to
openfoodfacts.org, the free and open food database. Nothing about you,
@@ -164,8 +165,8 @@ Your controls
integration at any time in Settings if you'd previously turned them on. If
you explicitly installed a GitHub release and enabled health data
contribution, you can disable that feature at any time from the app's
- settings. Barcode lookup for the food log is off by default and can be
- turned off again at any time in Settings › Privacy ›
+ settings. Barcode lookup for the food log is on by default and can be
+ turned off at any time in Settings › Privacy ›
“Look barcodes up online”.
Uninstalling the App deletes all of your locally stored data immediately.
diff --git a/lib/data/off_lookup.dart b/lib/data/off_lookup.dart
index bd213d05..303afe3b 100644
--- a/lib/data/off_lookup.dart
+++ b/lib/data/off_lookup.dart
@@ -3,9 +3,9 @@
// THIS IS AN OUTBOUND NETWORK CALL, and this app's whole position is that it
// makes none it did not ask you about. So it is shaped like the only other one
// that touches your data (ui2/activity/tiles.dart, the map basemap):
-// · [offLookupAllowed] is off until the user turns it on, and every entry
-// point here refuses without it — there is no code path that fetches by
-// accident;
+// · [offLookupAllowed] gates every entry point here, so turning it off in
+// Settings stops the lookup dead — there is no code path that fetches
+// around it;
// · it is user-initiated, one product per scan, never a batch and never a
// background job;
// · what leaves is the barcode. Not the meal, not the day, not who you are;
@@ -60,16 +60,20 @@ const _userAgent =
// ══════════════════ CONSENT ══════════════════
-/// Whether the user has said openfoodfacts.org may be asked about a barcode.
+/// Whether openfoodfacts.org may be asked about a barcode.
///
-/// Default OFF and persisted, like every other outbound path in this app
-/// (crash reports, health contribution, update checks, map tiles). Revocable
-/// from Settings › Privacy, and the scanner is fully usable without it in the
-/// only sense that matters: typing the numbers off the pack was always the
-/// fallback and still is.
+/// Default ON — with the update check, and unlike every path that would send
+/// something ABOUT YOU (crash reports, health contribution), which stay off
+/// until asked. The line between them is what leaves: this sends a number
+/// printed on a packet by its manufacturer, and a scanner that refuses to scan
+/// until you have found a settings toggle is a scanner nobody uses.
+///
+/// Still persisted and still revocable from Settings › Privacy, and the food
+/// log is entirely usable with it off: typing the numbers off the pack was
+/// always the fallback and still is.
const kOffConsentKey = 'nutrition.barcode_lookup';
-bool get offLookupAllowed => Prefs.getBool(kOffConsentKey, false);
+bool get offLookupAllowed => Prefs.getBool(kOffConsentKey, true);
void setOffLookupAllowed(bool on) => Prefs.setBool(kOffConsentKey, on);
diff --git a/lib/ui2/profile/settings.dart b/lib/ui2/profile/settings.dart
index 751768db..03c73349 100644
--- a/lib/ui2/profile/settings.dart
+++ b/lib/ui2/profile/settings.dart
@@ -497,7 +497,7 @@ class MoreSettingsView extends StatelessWidget {
this.healthState = HealthLinkState.unknown,
this.healthStore = 'Apple Health',
this.telemetry = false,
- this.barcodeLookup = false,
+ this.barcodeLookup = true,
this.cycleTracking = false,
this.showHealthShare = false,
this.healthShare = false,
diff --git a/lib/ui2/screens/log_food.dart b/lib/ui2/screens/log_food.dart
index 20c6b9b5..7e476b8c 100644
--- a/lib/ui2/screens/log_food.dart
+++ b/lib/ui2/screens/log_food.dart
@@ -147,11 +147,13 @@ class _LogFoodSheetState extends State {
// ── the barcode path ──────────────────────────────────────────────────────
- /// Scan, then look the code up — but only after the user has agreed to the
- /// one outbound call this screen can make.
+ /// Scan, then look the code up.
///
- /// The consent is asked BEFORE the camera opens, not after: someone who
- /// would decline should not have pointed their phone at a packet first.
+ /// Lookup is on by default, so this normally goes straight to the camera.
+ /// The prompt below is for the person who turned it OFF and then tapped
+ /// Scan: refusing silently there reads as a broken scanner. It is asked
+ /// BEFORE the camera opens, not after — someone who would decline should not
+ /// have pointed their phone at a packet first.
Future _scan() async {
if (!offLookupAllowed) {
final agreed = await _askLookupConsent(context);
diff --git a/test/off_lookup_test.dart b/test/off_lookup_test.dart
index 6afc8783..7893fff9 100644
--- a/test/off_lookup_test.dart
+++ b/test/off_lookup_test.dart
@@ -17,6 +17,8 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:openstrap_edge/data/off_lookup.dart';
+import 'package:openstrap_edge/state/prefs.dart';
+import 'package:shared_preferences/shared_preferences.dart';
/// An api/v2 body, in the shape the endpoint actually returns.
Map body({
@@ -392,10 +394,20 @@ void main() {
});
});
- group('nothing leaves without consent', () {
- test('a lookup with the pref off refuses before any request', () async {
- // Prefs is unloaded in a headless test, so every read is its default —
- // and the default is off. This is the state a fresh install is in.
+ group('the consent gate', () {
+ // Order matters: Prefs caches its SharedPreferences instance on first load
+ // and never reloads, so the unloaded-defaults case has to be read before
+ // anything mocks a store in.
+ test('a fresh install may look up', () {
+ // Nothing has loaded Prefs, so this IS the default. It is ON: what
+ // leaves is the barcode, never anything about the person holding it.
+ expect(offLookupAllowed, isTrue);
+ });
+
+ test('a lookup refuses before any request once it is turned off', () async {
+ TestWidgetsFlutterBinding.ensureInitialized();
+ SharedPreferences.setMockInitialValues({kOffConsentKey: false});
+ await Prefs.ensureLoaded();
expect(offLookupAllowed, isFalse);
final r = await fetchOffProduct('8901719101090');
expect(r.outcome, OffOutcome.refused);
From 425fcc0f6539cd33033e934bcc11cd40cbc6cf67 Mon Sep 17 00:00:00 2001
From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com>
Date: Wed, 19 Aug 2026 23:09:33 +0530
Subject: [PATCH 02/64] podfile.lock: mobile_scanner in, video_player out
the lock in the repo didn't match the pods that built 0.9.27.
---
ios/Podfile.lock | 14 +++++++-------
1 file changed, 7 insertions(+), 7 deletions(-)
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index aeb7a31f..e1f53469 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -208,6 +208,9 @@ PODS:
- Flutter
- home_widget (0.0.1):
- Flutter
+ - mobile_scanner (7.0.0):
+ - Flutter
+ - FlutterMacOS
- nanopb (3.30910.0):
- nanopb/decode (= 3.30910.0)
- nanopb/encode (= 3.30910.0)
@@ -232,9 +235,6 @@ PODS:
- SwiftyGif (5.4.5)
- url_launcher_ios (0.0.1):
- Flutter
- - video_player_avfoundation (0.0.1):
- - Flutter
- - FlutterMacOS
- workmanager_apple (0.0.1):
- Flutter
@@ -255,12 +255,12 @@ DEPENDENCIES:
- geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`)
- health (from `.symlinks/plugins/health/ios`)
- home_widget (from `.symlinks/plugins/home_widget/ios`)
+ - mobile_scanner (from `.symlinks/plugins/mobile_scanner/darwin`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- - video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`)
- workmanager_apple (from `.symlinks/plugins/workmanager_apple/ios`)
SPEC REPOS:
@@ -323,6 +323,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/health/ios"
home_widget:
:path: ".symlinks/plugins/home_widget/ios"
+ mobile_scanner:
+ :path: ".symlinks/plugins/mobile_scanner/darwin"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
share_plus:
@@ -333,8 +335,6 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/sqflite_darwin/darwin"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
- video_player_avfoundation:
- :path: ".symlinks/plugins/video_player_avfoundation/darwin"
workmanager_apple:
:path: ".symlinks/plugins/workmanager_apple/ios"
@@ -374,6 +374,7 @@ SPEC CHECKSUMS:
GoogleUtilities: 766ace00c6b10d8148408f329d10c4f051931850
health: a4ddeac72091000e94776864d0028f6be31ec7a5
home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f
+ mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
PromisesObjC: 752c3227f599e3467650e47ea36f433eeb10c273
@@ -384,7 +385,6 @@ SPEC CHECKSUMS:
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
- video_player_avfoundation: 3453f792138786248960ca029747fcd9f318ef52
workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778
PODFILE CHECKSUM: b50997058227f33b81189532a9f3fc5007ec070b
From 9fef332e732ba2f00ef08560df4d58e8f386aa69 Mon Sep 17 00:00:00 2001
From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com>
Date: Wed, 19 Aug 2026 23:23:18 +0530
Subject: [PATCH 03/64] import: route by what the file holds, not what it's
called
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
the ui rebuild dispatches on the extension, and it gets it wrong both ways
round. noop's raw sensor export is a plain .csv, so it goes to the whoop
importer and the user gets told to re-download it in english (#160). a whoop
"my data" export is a .zip, which is what whoop actually hands you, so it goes
to the noop importer and gets refused for holding too many csvs. two good
files, two confident wrong answers.
sniff the content instead — import_container already had the machinery. a noop
raw csv starts with its unix_s, header; a .noopbak holds a sqlite db; a whoop
export is an archive of several named csvs and is neither.
also catch FormatException around the journal probe: vendor zips land in that
group now, and readAsString on a zip is exactly the "offset 10" from #199.
zip-of-one-csv is still called noop by member count, not content — a member is
deflated and inflating one to read its header would materialise a 300mb export
just to classify it. noted in the code.
---
lib/import/import_container.dart | 64 +++++++++++++
lib/ui2/onboarding/welcome.dart | 24 +++--
test/import_container_test.dart | 56 ++++++++++++
test/import_routing_test.dart | 152 +++++++++++++++++++++++++++++++
4 files changed, 288 insertions(+), 8 deletions(-)
create mode 100644 test/import_routing_test.dart
diff --git a/lib/import/import_container.dart b/lib/import/import_container.dart
index 3746f6c0..e5b50a5e 100644
--- a/lib/import/import_container.dart
+++ b/lib/import/import_container.dart
@@ -111,6 +111,70 @@ Future sniffFile(String path) async {
}
}
+/// The header row every NOOP raw-sensor CSV starts with. Same signature the
+/// reader itself matches on (noop_import.dart), so the router and the parser
+/// cannot disagree about what a NOOP CSV is.
+const String kNoopCsvHeader = 'unix_s,';
+
+/// True when [path] is a NOOP export — judged by CONTENT, not by name.
+///
+/// The onboarding router used to switch on the extension: `.noopbak`/`.zip`
+/// meant NOOP, anything else meant the vendor importer. Both halves were wrong
+/// in opposite directions (#160, #199). NOOP's Android "raw sensor CSV" export
+/// is a plain `.csv`, so it went to the vendor importer and the user was told
+/// to re-download it with WHOOP set to English. A WHOOP "My Data" export is a
+/// ZIP of CSVs — the shape WHOOP actually hands you — so it went to the NOOP
+/// importer and was refused for holding too many files. Two confident, wrong
+/// messages for two correct files.
+///
+/// The signatures: a raw-sensor CSV starts with [kNoopCsvHeader]; a `.noopbak`
+/// (or a backup someone unpacked by hand) is a SQLite database; a WHOOP export
+/// is an archive of several named CSVs and matches neither.
+Future isNoopExport(String path) async {
+ final List head;
+ final raf = await File(path).open();
+ try {
+ head = await raf.read(64);
+ } finally {
+ await raf.close();
+ }
+ switch (sniffImportContainer(head)) {
+ case ImportContainer.text:
+ return String.fromCharCodes(head).startsWith(kNoopCsvHeader);
+ case ImportContainer.sqlite:
+ return true;
+ case ImportContainer.zip:
+ return _zipHoldsNoopExport(path);
+ default:
+ // gzip, UTF-16, binary: not something the NOOP path claims. Whatever
+ // picks them up owns the message.
+ return false;
+ }
+}
+
+Future _zipHoldsNoopExport(String path) async {
+ final input = InputFileStream(path);
+ try {
+ final files =
+ ZipDecoder().decodeStream(input).files.where((f) => f.isFile);
+ // A `.noopbak` is a ZIP around NOOP's SQLite database.
+ if (files.any((f) => _isDbMember(f.name))) return true;
+ // ponytail: member COUNT, not member content. A ZIP member is deflated and
+ // this package can only inflate it whole, so reading one header line off a
+ // hundreds-of-megabyte raw export would materialise the entire thing just
+ // to classify it. A WHOOP export always ships several named CSVs; the only
+ // NOOP CSV-in-a-ZIP is one a user zipped by hand. If a single-file vendor
+ // export ever turns up, this needs a bounded member read instead.
+ return files.where((f) => _isCsvMember(f.name)).length == 1;
+ } catch (_) {
+ // Unreadable as an archive. Not a NOOP export as far as routing goes; the
+ // importer that takes it produces the message.
+ return false;
+ } finally {
+ await input.close();
+ }
+}
+
/// True for a ZIP member we can actually parse as an export.
bool _isCsvMember(String name) {
final base = p.basename(name).toLowerCase();
diff --git a/lib/ui2/onboarding/welcome.dart b/lib/ui2/onboarding/welcome.dart
index 9d2e6171..c81e68e1 100644
--- a/lib/ui2/onboarding/welcome.dart
+++ b/lib/ui2/onboarding/welcome.dart
@@ -19,6 +19,7 @@ import 'package:path_provider/path_provider.dart';
import 'package:provider/provider.dart';
import '../../import/backup_crypto.dart';
+import '../../import/import_container.dart';
import '../../import/journal_csv_import.dart';
import '../../state/app_state.dart';
import '../ui2.dart';
@@ -321,9 +322,16 @@ Future runImport(
// backup selected alongside a vendor CSV imported the backup and threw the
// CSV away without a word.
final db = [...plain.where(_isDbBackup), ...decrypted];
- final raw = plain.where(_isRawExport).toList();
- final csv =
- plain.where((p) => !_isDbBackup(p) && !_isRawExport(p)).toList();
+ // Raw-vs-vendor is decided by what the file HOLDS, not by what it is called.
+ // See [isNoopExport]: routing on the extension sent NOOP's raw-sensor `.csv`
+ // to the vendor importer and WHOOP's `.zip` to the NOOP one — both files
+ // fine, both refused, both with advice for the other file.
+ final raw = [];
+ final csv = [];
+ for (final p in plain) {
+ if (_isDbBackup(p)) continue;
+ (await isNoopExport(p) ? raw : csv).add(p);
+ }
if (decrypted.isNotEmpty) sources.add('Encrypted backup');
if (plain.any(_isDbBackup)) sources.add('OpenStrap backup');
@@ -370,6 +378,11 @@ Future runImport(
if (!sources.contains('Journal CSV')) sources.add('Journal CSV');
} on JournalCsvFormatException {
vendor.add(p);
+ } on FormatException {
+ // `readAsString` on an archive — #199's "Unexpected extension byte (at
+ // offset 10)". Vendor exports arrive as ZIPs now that routing is by
+ // content, and that path unwraps them properly.
+ vendor.add(p);
}
}
@@ -460,11 +473,6 @@ bool _isDbBackup(String path) {
p.contains('.db.unopenable-');
}
-bool _isRawExport(String path) {
- final p = path.toLowerCase();
- return p.endsWith('.noopbak') || p.endsWith('.zip');
-}
-
class WelcomeView extends StatelessWidget {
final bool busy;
final ImportOutcome? outcome;
diff --git a/test/import_container_test.dart b/test/import_container_test.dart
index eb3e78d5..a5d2afb7 100644
--- a/test/import_container_test.dart
+++ b/test/import_container_test.dart
@@ -490,4 +490,60 @@ void main() {
}
});
});
+
+ // The other half of #160/#199: the file was classified correctly here and
+ // then handed to the wrong importer anyway, because the router read the
+ // extension. What a file HOLDS decides now.
+ group('isNoopExport ignores the extension', () {
+ test('a NOOP raw-sensor CSV is claimed whatever it is called', () async {
+ final path = await write(
+ 'export (1).csv',
+ utf8.encode('unix_s,iso_utc,stream,hr_bpm\n1754000000,x,hr,61\n'),
+ );
+ expect(await isNoopExport(path), isTrue);
+ });
+
+ test('a WHOOP My Data ZIP is NOT a NOOP export', () async {
+ final path = await write(
+ 'my_whoop_data.zip',
+ _zipOf({
+ 'physiological_cycles.csv': 'Cycle start time,Recovery score %\n',
+ 'sleeps.csv': 'Cycle start time,Sleep performance %\n',
+ 'workouts.csv': 'Workout start time,Activity name\n',
+ }),
+ );
+ expect(await isNoopExport(path), isFalse);
+ });
+
+ test('a WHOOP CSV on its own is NOT a NOOP export', () async {
+ final path = await write('sleeps.csv',
+ utf8.encode('Cycle start time,Sleep performance %\n2026-08-01,88\n'));
+ expect(await isNoopExport(path), isFalse);
+ });
+
+ test('a .noopbak is claimed by its database member', () async {
+ final path = await write(
+ 'backup.noopbak',
+ _zipOf({'noop-backup.sqlite': 'SQLite format 3\x00 rows'}),
+ );
+ expect(await isNoopExport(path), isTrue);
+ });
+
+ test('a loose database is claimed by its magic', () async {
+ final path =
+ await write('unnamed', utf8.encode('SQLite format 3\x00 rows'));
+ expect(await isNoopExport(path), isTrue);
+ });
+
+ test('a single CSV zipped by hand is still a NOOP export', () async {
+ final path = await write('archive.zip',
+ _zipOf({'raw_sensor.csv': 'unix_s,iso_utc,stream\n1,x,hr\n'}));
+ expect(await isNoopExport(path), isTrue);
+ });
+
+ test('junk is claimed by nobody here', () async {
+ final path = await write('junk.bin', [0x00, 0x01, 0x02, 0x03]);
+ expect(await isNoopExport(path), isFalse);
+ });
+ });
}
diff --git a/test/import_routing_test.dart b/test/import_routing_test.dart
new file mode 100644
index 00000000..69a4ba73
--- /dev/null
+++ b/test/import_routing_test.dart
@@ -0,0 +1,152 @@
+// Issues #160 / #199: the onboarding router picked an importer by FILE
+// EXTENSION, and got it wrong in both directions at once.
+//
+// • NOOP's Android "raw sensor CSV" export is a plain `.csv`, so it went to
+// the vendor importer, which told the user to re-download it with WHOOP
+// set to English. (That is the exact file attached to #160.)
+// • A WHOOP "My Data" export is a `.zip` of CSVs — the shape WHOOP actually
+// gives you — so it went to the NOOP importer, which refused it for
+// holding too many CSVs.
+//
+// Both files were fine. Both were refused, each with advice meant for the
+// other one. These tests drive the real `runImport` and assert WHICH importer
+// each shape reaches, so neither direction can come back.
+
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:archive/archive.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:openstrap_edge/state/app_state.dart';
+import 'package:openstrap_edge/ui2/onboarding/welcome.dart';
+
+/// Records where `runImport` sent each path instead of importing it. Every
+/// override replaces work that needs a database and a derivation engine; the
+/// routing decision above them is what is under test.
+class _RoutingSpy extends AppState {
+ _RoutingSpy() : super.forTesting();
+
+ final noop = [];
+ final vendor = [];
+
+ @override
+ Future importNoopCsv(String path,
+ {void Function(int days)? onProgress}) async {
+ noop.add(path);
+ return 1;
+ }
+
+ @override
+ Future importWhoopCsvs(List paths,
+ {void Function(int days)? onProgress}) async {
+ vendor.addAll(paths);
+ return 1;
+ }
+}
+
+List _zipOf(Map members) {
+ final a = Archive();
+ members.forEach((name, body) {
+ final bytes = utf8.encode(body);
+ a.addFile(ArchiveFile(name, bytes.length, bytes));
+ });
+ return ZipEncoder().encode(a);
+}
+
+/// The header row a real NOOP raw-sensor export starts with (NOOP 9.1/9.2, as
+/// observed on the #160 attachment).
+const _noopCsv = 'unix_s,iso_utc,stream,hr_bpm,rr_ms,grav_x,grav_y,grav_z\n'
+ '1754000000,2026-08-01T00:00:00Z,hr,61,,,,\n';
+
+/// A WHOOP "My Data" export, which is several named CSVs in one archive.
+const _whoopZipMembers = {
+ 'physiological_cycles.csv': 'Cycle start time,Recovery score %\n',
+ 'sleeps.csv': 'Cycle start time,Sleep performance %\n',
+ 'workouts.csv': 'Workout start time,Activity name\n',
+ 'journal_entries.csv': 'Cycle start time,Question text\n',
+};
+
+void main() {
+ TestWidgetsFlutterBinding.ensureInitialized();
+
+ late Directory tmp;
+ setUp(() async {
+ tmp = await Directory.systemTemp.createTemp('import_routing_test_');
+ });
+ tearDown(() async {
+ if (tmp.existsSync()) await tmp.delete(recursive: true);
+ });
+
+ Future write(String name, List bytes) async {
+ final f = File('${tmp.path}/$name');
+ await f.writeAsBytes(bytes);
+ return f.path;
+ }
+
+ test('a NOOP raw-sensor CSV goes to the NOOP importer, not the vendor one',
+ () async {
+ final app = _RoutingSpy();
+ final path = await write('noop-export.csv', utf8.encode(_noopCsv));
+
+ final out = await runImport(app, [path]);
+
+ expect(app.noop, [path]);
+ expect(app.vendor, isEmpty,
+ reason: 'this is the #160 file — the vendor importer answers it with '
+ '"re-download it with WHOOP set to English"');
+ expect(out.source, contains('Raw sensor export'));
+ });
+
+ test('a WHOOP My Data ZIP goes to the vendor importer, not the NOOP one',
+ () async {
+ final app = _RoutingSpy();
+ final path = await write('my_whoop_data.zip', _zipOf(_whoopZipMembers));
+
+ final out = await runImport(app, [path]);
+
+ expect(app.vendor, [path]);
+ expect(app.noop, isEmpty,
+ reason: 'the NOOP importer refuses this for holding too many CSVs');
+ expect(out.source, contains('Vendor CSV export'));
+ });
+
+ test('a .noopbak still routes to NOOP once the name stops deciding',
+ () async {
+ final app = _RoutingSpy();
+ // The real shape: a ZIP whose member is NOOP's own SQLite database. The
+ // magic is what identifies it, so the bytes have to be real.
+ final path = await write(
+ 'backup.noopbak',
+ _zipOf({'noop-backup.sqlite': 'SQLite format 3\x00 and then some rows'}),
+ );
+
+ await runImport(app, [path]);
+
+ expect(app.noop, [path]);
+ expect(app.vendor, isEmpty);
+ });
+
+ test('a NOOP CSV keeps routing to NOOP when someone zips it first', () async {
+ final app = _RoutingSpy();
+ final path =
+ await write('noop.zip', _zipOf({'raw_sensor.csv': _noopCsv}));
+
+ await runImport(app, [path]);
+
+ expect(app.noop, [path]);
+ expect(app.vendor, isEmpty);
+ });
+
+ test('a mixed selection reaches both importers', () async {
+ final app = _RoutingSpy();
+ final noopPath = await write('noop-export.csv', utf8.encode(_noopCsv));
+ final whoopPath = await write('whoop.zip', _zipOf(_whoopZipMembers));
+
+ final out = await runImport(app, [noopPath, whoopPath]);
+
+ expect(app.noop, [noopPath]);
+ expect(app.vendor, [whoopPath]);
+ expect(out.source, contains('Raw sensor export'));
+ expect(out.source, contains('Vendor CSV export'));
+ });
+}
From ba52200958ac0bfacc35596183f2673e5a9e0d3a Mon Sep 17 00:00:00 2001
From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com>
Date: Wed, 19 Aug 2026 23:23:27 +0530
Subject: [PATCH 04/64] coach: serialize the keychain writes (#241)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
the -25299 report blames flutter_secure_storage for adding without checking.
that's not it — the plugin already does check → update → delete + add.
what's ours: load() doesn't only read, it writes the key back to upgrade an
item stored before we asked for first_unlock. load() itself is unawaited at
startup, so that write could overlap the user's save. either the upgrade lands
last and puts the old key back over the one they just pasted, or on ios a write
races a delete inside the plugin and comes out as errSecDuplicateItem. the
generation counter already handles the in-memory half; it can't order two calls
that are both inside the plugin.
writes only, on purpose. a keystore read can hang outright (the samsung knox
case this file is already shaped around) and a lock a hung read holds would
block save forever.
test hangs a write mid-upgrade and asserts the new key survives; fails without
the lock.
---
lib/coach/coach_config.dart | 84 +++++++++++++++++++++++----------
test/coach_config_key_test.dart | 54 +++++++++++++++++++++
2 files changed, 113 insertions(+), 25 deletions(-)
diff --git a/lib/coach/coach_config.dart b/lib/coach/coach_config.dart
index 3593fd8c..051e096b 100644
--- a/lib/coach/coach_config.dart
+++ b/lib/coach/coach_config.dart
@@ -72,6 +72,32 @@ class CoachConfig extends ChangeNotifier {
/// late `_key = null` would wipe the key they just saved out of the session.
int _generation = 0;
+ /// ONE keychain MUTATION at a time.
+ ///
+ /// [load] does not only read: it writes the value it just read back, to
+ /// upgrade an item stored before this class asked for `first_unlock`. That
+ /// write is awaited, but `load` itself is not — the startup call is
+ /// fire-and-forget — so nothing stopped it overlapping the user's Save. Two
+ /// ways that ends badly: the upgrade lands last and puts the OLD key back
+ /// over the one they just pasted, or, on iOS, a write races a delete inside
+ /// the plugin and comes out as `PlatformException(-25299)`
+ /// (errSecDuplicateItem). [_generation] already orders the in-memory half of
+ /// that race; it cannot order two calls that are both inside the plugin.
+ ///
+ /// WRITES ONLY, deliberately. The read is left outside, because a keystore
+ /// read can hang outright (the documented Samsung Knox case this file's
+ /// `load` is already shaped around) and a lock that a hung read holds would
+ /// block Save forever — trading a rare clobber for a wedged settings screen.
+ Future _keychainLock = Future.value();
+
+ Future _serialized(Future Function() op) {
+ final done = _keychainLock.then((_) => op());
+ // A failed operation must not wedge the queue — the next caller runs either
+ // way, and the error still reaches whoever awaited `done`.
+ _keychainLock = done.catchError((_) {});
+ return done;
+ }
+
String get baseUrl => _baseUrl;
String get model => _model;
String? get apiKey => _key;
@@ -150,13 +176,19 @@ class CoachConfig extends ChangeNotifier {
// Keystore (the documented Samsung Knox hang) on the startup path for
// no reason.
if (marker != true) {
- await _secure.write(
- key: _kKey,
- value: read,
- iOptions: _apple,
- mOptions: _macos,
- );
- await prefs.setBool(_kKeyPresent, true);
+ await _serialized(() async {
+ // Re-checked INSIDE the lock, not just before the read. A save can
+ // land while this upgrade is queued behind it, and writing `read`
+ // then would put the superseded key back.
+ if (generation != _generation) return;
+ await _secure.write(
+ key: _kKey,
+ value: read,
+ iOptions: _apple,
+ mOptions: _macos,
+ );
+ await prefs.setBool(_kKeyPresent, true);
+ });
}
} else if (trusted) {
// Foreground, so the keychain is readable and an empty answer is the
@@ -225,24 +257,26 @@ class CoachConfig extends ChangeNotifier {
// other order leaves memory holding a key that was never persisted (lost
// at the next launch, with no marker to even flag it as missing), or
// hiding one that is still stored.
- if (k.isEmpty) {
- await _secure.delete(key: _kKey, iOptions: _apple, mOptions: _macos);
- // The marker follows the keychain, and its own failure is not worth
- // failing the save: a stale `true` costs a retry, never a lost key.
- try {
- await prefs.setBool(_kKeyPresent, false);
- } catch (_) {/* re-established by the next load */}
- } else {
- await _secure.write(
- key: _kKey,
- value: k,
- iOptions: _apple,
- mOptions: _macos,
- );
- try {
- await prefs.setBool(_kKeyPresent, true);
- } catch (_) {/* re-established by the next load */}
- }
+ await _serialized(() async {
+ if (k.isEmpty) {
+ await _secure.delete(key: _kKey, iOptions: _apple, mOptions: _macos);
+ // The marker follows the keychain, and its own failure is not worth
+ // failing the save: a stale `true` costs a retry, never a lost key.
+ try {
+ await prefs.setBool(_kKeyPresent, false);
+ } catch (_) {/* re-established by the next load */}
+ } else {
+ await _secure.write(
+ key: _kKey,
+ value: k,
+ iOptions: _apple,
+ mOptions: _macos,
+ );
+ try {
+ await prefs.setBool(_kKeyPresent, true);
+ } catch (_) {/* re-established by the next load */}
+ }
+ });
_key = k.isEmpty ? null : k;
_keyUnreadable = false;
_keyUndetermined = false;
diff --git a/test/coach_config_key_test.dart b/test/coach_config_key_test.dart
index 11933ae8..e352cf53 100644
--- a/test/coach_config_key_test.dart
+++ b/test/coach_config_key_test.dart
@@ -24,6 +24,7 @@ class _FakeKeychain {
bool throwOnRead = false;
bool throwOnWrite = false;
bool hangReads = false;
+ bool hangWrites = false;
final List> _hung = [];
void releaseHung() {
@@ -49,6 +50,11 @@ class _FakeKeychain {
return items[args['key'] as String];
case 'write':
if (throwOnWrite) throw PlatformException(code: 'keychain');
+ if (hangWrites) {
+ final c = Completer();
+ _hung.add(c);
+ await c.future;
+ }
items[args['key'] as String] = args['value'] as String;
writeOptions.add((args['options'] as Map?) ?? const {});
return null;
@@ -259,6 +265,54 @@ void main() {
reason: 'a read that predates the save must not apply its result');
});
+ // #241 reported `PlatformException(-25299)` and blamed the plugin for adding
+ // without checking. It does check (check → update → delete + add). What was
+ // ours is this: `load` writes the key back to upgrade its accessibility, and
+ // an unawaited startup `load` could have that write in flight while the user
+ // saved a new one.
+ test('an in-flight upgrade write never puts the old key back', () async {
+ // A legacy item: a key in the keychain with no marker beside it, so the
+ // next load takes the accessibility-upgrade branch — the WRITE inside
+ // `load` that this is about.
+ keychain.items['coach_api_key'] = 'sk-old';
+ SharedPreferences.setMockInitialValues({'coach_model': 'gpt-4o'});
+
+ final cfg = CoachConfig();
+ // The read returns, the generation check passes, and the upgrade write is
+ // then in flight — which is the window the generation counter cannot close.
+ keychain.hangWrites = true;
+ unawaited(cfg.load());
+ await Future.delayed(const Duration(milliseconds: 10));
+
+ // The user pastes a new key right there.
+ keychain.hangWrites = false;
+ final saving = cfg.save(apiKey: 'sk-new', model: 'gpt-4o');
+ await Future.delayed(const Duration(milliseconds: 10));
+ keychain.releaseHung();
+ await saving;
+ await Future.delayed(const Duration(milliseconds: 20));
+
+ expect(keychain.items['coach_api_key'], 'sk-new',
+ reason: 'the upgrade write must not resurrect the superseded key');
+ expect(cfg.apiKey, 'sk-new');
+ });
+
+ test('a hung keychain read does not block a save', () async {
+ final cfg = CoachConfig();
+ keychain.hangReads = true;
+ unawaited(cfg.load());
+ await Future.delayed(const Duration(milliseconds: 10));
+
+ // A keystore read can hang outright. Save has to get through anyway — this
+ // is why only the writes are serialized and not the whole of `load`.
+ await cfg.save(apiKey: 'sk-new', model: 'gpt-4o').timeout(
+ const Duration(seconds: 2),
+ onTimeout: () => fail('save blocked behind a hung read'),
+ );
+ expect(cfg.apiKey, 'sk-new');
+ keychain.releaseHung();
+ });
+
test('a keychain that refuses the write does not report success', () async {
final cfg = CoachConfig();
keychain.throwOnWrite = true;
From ae23ac8c112570ad596a931fdf85a8f533a09112 Mon Sep 17 00:00:00 2001
From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com>
Date: Wed, 19 Aug 2026 23:23:38 +0530
Subject: [PATCH 05/64] ai briefing: the payload preview shows what was sent,
not a dash
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
"what was sent" is a preview of the prompt, so it has to match it. the prompt
writer prints $v for every entry, so a null goes to the model as the word null
— rendering an em dash there says "withheld" about a value that was in fact
sent, empty.
---
lib/ui2/screens/ai_briefing.dart | 9 +++++++--
1 file changed, 7 insertions(+), 2 deletions(-)
diff --git a/lib/ui2/screens/ai_briefing.dart b/lib/ui2/screens/ai_briefing.dart
index df6ce9cd..831a4459 100644
--- a/lib/ui2/screens/ai_briefing.dart
+++ b/lib/ui2/screens/ai_briefing.dart
@@ -190,8 +190,13 @@ class SentPayload extends StatelessWidget {
return s.isEmpty ? s : '${s[0].toUpperCase()}${s.substring(1)}';
}
- static String _value(dynamic v) =>
- v is List ? v.join(', ') : v?.toString() ?? '—';
+ /// Verbatim, because this is a preview of a payload and not a metric card.
+ /// [buildBriefingUserPrompt] writes `$v` for every entry, so a null reaches
+ /// the model as the word `null` and this has to say the same — an em dash
+ /// here would read as "withheld" for a value that was in fact sent, empty.
+ /// (`_put` drops absent metrics before they get this far, so this is the
+ /// belt and not the trousers.)
+ static String _value(dynamic v) => v is List ? v.join(', ') : '$v';
@override
Widget build(BuildContext c) {
From edada74af2339ae4586ab6c43bc97b507d7586b7 Mon Sep 17 00:00:00 2001
From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com>
Date: Wed, 19 Aug 2026 23:23:38 +0530
Subject: [PATCH 06/64] readme: whoop 5 and mg work, say so
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
the checklist still said "whoop 4.0 only, haven't touched a whoop 5, don't know
if it even shares a protocol", which contradicts the note further down and a
gen5 stack that's been shipped for a while. that line is probably why 5 owners
turn up with the wrong expectations.
the other line was stale the other way: "hasn't been validated against real 5.0
hardware" isn't true either — both bands pair, sync and decode against real
records. still experimental, still 4.0 that gets worn every day.
---
README.md | 10 ++++++----
1 file changed, 6 insertions(+), 4 deletions(-)
diff --git a/README.md b/README.md
index 5681a140..93d2c88c 100644
--- a/README.md
+++ b/README.md
@@ -79,7 +79,8 @@ drawer-bracelet problem can use it, or go dig through the code themselves.
## Checklist
-- **WHOOP 4.0 only.** Haven't touched a WHOOP 5, don't know if it even shares a protocol.
+- **WHOOP 4.0 is the one that's properly tested.** WHOOP 5 and MG work too, but they're
+ experimental — see the note further down.
- Not affiliated with WHOOP, doesn't talk to their servers.
- Not a clone of their algorithms — different math, published methods, cited in the
analytics repo. Don't expect identical numbers to what their app shows.
@@ -143,9 +144,10 @@ shortcuts, a smart alarm that buzzes the band.
against a lab, don't treat any of it as a diagnosis.
- Not on the App Store or Play Store yet. iOS is a public TestFlight beta, which is a
normal install but still a beta; Android is an APK straight off Releases.
-- WHOOP 5.0 / MG support is in progress and **experimental** — the band is detected and
- spoken to, but it hasn't been validated against real 5.0 hardware. WHOOP 4.0 is the
- only one that's actually tested.
+- WHOOP 5.0 / MG support is **experimental**. Both pair, sync and decode, and the work is
+ checked against real records off real bands — but 4.0 is the one I wear every day, so
+ it's the one that gets found out when it breaks. Expect rough edges on 5 and MG, and
+ open an issue when you hit one.
## Run it
From 0be0501ab352c72e2b44e90662b954884f17d230 Mon Sep 17 00:00:00 2001
From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com>
Date: Wed, 19 Aug 2026 23:23:54 +0530
Subject: [PATCH 07/64] pr agent: don't go green without reviewing anything
(#230)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
fork prs get no secrets, so the job ran with an empty key, reviewed nothing and
still passed. a check that says reviewed when it didn't is worse than no check
— skip cleanly instead. the guard has to hang off a job-level env var because
the secrets context isn't available in an if.
pinned the action too: it runs with contents: write and a token on every pr, so
@main is whatever landed upstream today.
and raised max_model_tokens. it defaults to 32000 and the effective input is
min(custom_model_max_tokens, max_model_tokens), so the 200k next to it bought
nothing and big diffs were being clipped to a third of the review they looked
like they got.
---
.github/workflows/pr-agent.yml | 12 +++++++++++-
.pr_agent.toml | 8 ++++++--
2 files changed, 17 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/pr-agent.yml b/.github/workflows/pr-agent.yml
index a1e2ce7e..09c45a6a 100644
--- a/.github/workflows/pr-agent.yml
+++ b/.github/workflows/pr-agent.yml
@@ -13,9 +13,19 @@ jobs:
issues: write
pull-requests: write
contents: write
+ # Job-level so the step `if` below can see it: the `secrets` context is not
+ # available in an `if` expression, but `env` is.
+ env:
+ PR_AGENT_API_KEY: ${{ secrets.PR_AGENT_API_KEY }}
steps:
- name: PR Agent action step
- uses: the-pr-agent/pr-agent@main
+ # A PR from a fork gets no secrets, so this ran with an empty key,
+ # reviewed nothing, and still went green - a check that says "reviewed"
+ # when it did not is worse than no check. Skip instead.
+ if: env.PR_AGENT_API_KEY != ''
+ # Pinned, not @main: this action runs with `contents: write` and a token
+ # on every PR, and a floating ref means whatever landed upstream today.
+ uses: the-pr-agent/pr-agent@f6af7d77554ff8d26adffded077e6461329e92fa # v0.42.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Credentials only. The model chain lives in .pr_agent.toml so it is
diff --git a/.pr_agent.toml b/.pr_agent.toml
index 7368da38..287cc67a 100644
--- a/.pr_agent.toml
+++ b/.pr_agent.toml
@@ -21,9 +21,13 @@ fallback_models = [
"openai/gpt-oss-120b-medium",
]
# Required: an `openai/`-prefixed name is not in PR-Agent's MAX_TOKENS map, and
-# get_max_tokens (algo/utils.py:1008) raises rather than defaulting. Effective
-# input is still min(this, max_model_tokens=32000).
+# get_max_tokens (algo/utils.py:1008) raises rather than defaulting.
custom_model_max_tokens = 200000
+# The effective input is min(custom_model_max_tokens, max_model_tokens), and
+# max_model_tokens defaults to 32000 - so the 200k above bought nothing and a
+# large diff was silently clipped to a third of the review it looked like it
+# got. Raise the ceiling to match.
+max_model_tokens = 200000
# Inject AGENTS.md as repository context into /review, /improve, /describe, /ask.
# NOTE: read from the DEFAULT BRANCH by default, so AGENTS.md only takes effect
From e38c218c1df6efa6dfa7af680534582d9d65f37c Mon Sep 17 00:00:00 2001
From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com>
Date: Wed, 19 Aug 2026 23:24:10 +0530
Subject: [PATCH 08/64] =?UTF-8?q?bump=20health=20to=2012.2.1=20=E2=80=94?=
=?UTF-8?q?=2011.1.1=20threw=20on=20every=20light-sleep=20write?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
_alignValue in 11.1.1 has SLEEP_ASLEEP twice and no SLEEP_LIGHT, so every
Core/light segment fell through to the throw. that's most of a night gone on
ios, and it also flipped the day's export to failed so we burned all six
retries and stalled the cursor. api surface is unchanged for us.
---
pubspec.lock | 4 ++--
pubspec.yaml | 5 ++++-
2 files changed, 6 insertions(+), 3 deletions(-)
diff --git a/pubspec.lock b/pubspec.lock
index f80f52ac..7e7b170c 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -652,10 +652,10 @@ packages:
dependency: "direct main"
description:
name: health
- sha256: "148ce984c2119f50224b4d187552d751b91aa47f4de8968daf05e6e596ddee50"
+ sha256: "0432c4e5c5348164adff57e78ca3191c88f0cdf7c2b0d72b6785a6af965177ac"
url: "https://pub.dev"
source: hosted
- version: "11.1.1"
+ version: "12.2.1"
home_widget:
dependency: "direct main"
description:
diff --git a/pubspec.yaml b/pubspec.yaml
index ee5192f7..01fd1aeb 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -280,7 +280,10 @@ dependencies:
# Apple Health (HealthKit, iOS) + Google Health Connect (Android) — export each
# day's derived metrics to the platform health store.
- health: ^11.1.1
+ # >=12.0.0 is not optional: 11.1.1's `_alignValue` lists SLEEP_ASLEEP twice
+ # and never lists SLEEP_LIGHT, so every light/Core stage write threw on iOS —
+ # ~70% of a night, every night, and it flipped the day's export to failed too.
+ health: ^12.2.1
# Open the Health Connect app/settings so the user can grant per-app access
# manually (the reliable path when its in-app request dialog is locked out).
android_intent_plus: ^5.1.0
From c797b526e79be71da24d70b549325cc5794983e4 Mon Sep 17 00:00:00 2001
From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com>
Date: Wed, 19 Aug 2026 23:28:06 +0530
Subject: [PATCH 09/64] double-tap can log water
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
in-app like mark-a-moment, so it works on ios too. step and ceiling
come off the journal field spec so a wrist tap and the + on nutrition
agree. one write at a time — postJournalMetrics replaces the day, so
two overlapping taps used to eat a glass.
---
lib/gestures/device_action.dart | 26 ++++++++---
lib/gestures/gesture_dispatcher.dart | 9 ++++
lib/state/app_state.dart | 66 +++++++++++++++++++++++++---
3 files changed, 89 insertions(+), 12 deletions(-)
diff --git a/lib/gestures/device_action.dart b/lib/gestures/device_action.dart
index f12320b4..943bc0b7 100644
--- a/lib/gestures/device_action.dart
+++ b/lib/gestures/device_action.dart
@@ -2,14 +2,18 @@
// (today: double-tap) can trigger. The enum is the single source of truth shared by
// the settings UI, the persisted mapping, and the native dispatch channel.
//
-// Adding a new action is one entry here + one `case` in the native handlers
-// (ActionHandler.kt / ActionBridge.swift). Whether a platform actually SUPPORTS an
-// action is reported at runtime by DeviceActions.capabilities() — the UI only offers
-// what the current OS can do, so e.g. volume control simply doesn't appear on iOS.
+// Adding a new NATIVE action is one entry here + one `case` in the native handlers:
+// NativeChannels.kt on Android, the ActionBridge enum in AppDelegate.swift on iOS.
+// An IN-APP action needs neither — one `case` in GestureDispatcher and a handler
+// wired from AppState, and it works on every platform.
+//
+// Whether a platform actually SUPPORTS a native action is reported at runtime by
+// DeviceActions.capabilities() — the UI only offers what the current OS can do, so
+// e.g. volume control simply doesn't appear on iOS.
//
// FUTURE (deliberately not wired yet — each needs more than a no-risk API or a
// product decision): answer/reject call (Android ANSWER_PHONE_CALLS; impossible on
-// iOS), "mark a moment" journal tag, workout lap/stop, torch (camera permission).
+// iOS), workout lap.
enum DeviceAction {
none,
@@ -24,6 +28,7 @@ enum DeviceAction {
// (iOS can't reach other apps, but it can always do these).
markMoment,
workoutToggle,
+ logWater,
// Native broadcast — sends an Android broadcast intent for Tasker to subscribe
// to (see NativeChannels.kt). Only offered on Android.
broadcastToTasker,
@@ -54,6 +59,8 @@ extension DeviceActionX on DeviceAction {
return 'mark_moment';
case DeviceAction.workoutToggle:
return 'workout_toggle';
+ case DeviceAction.logWater:
+ return 'log_water';
case DeviceAction.broadcastToTasker:
return 'broadcast_to_tasker';
}
@@ -82,6 +89,8 @@ extension DeviceActionX on DeviceAction {
return 'Mark a moment';
case DeviceAction.workoutToggle:
return 'Start / stop workout';
+ case DeviceAction.logWater:
+ return 'Log water';
case DeviceAction.broadcastToTasker:
return 'Broadcast to Tasker';
}
@@ -110,6 +119,9 @@ extension DeviceActionX on DeviceAction {
return 'Tag the current moment in your journal.';
case DeviceAction.workoutToggle:
return 'Begin or end a workout from your wrist.';
+ case DeviceAction.logWater:
+ return 'Add a glass to today\'s water, same step as the + on the '
+ 'nutrition screen.';
case DeviceAction.broadcastToTasker:
return 'Fire a broadcast intent so Tasker can trigger any automation.';
}
@@ -118,7 +130,9 @@ extension DeviceActionX on DeviceAction {
/// In-app actions act on our own app/backend (handled in Dart, no native call,
/// available on every platform). Everything else (except `none`) is native.
bool get isInApp =>
- this == DeviceAction.markMoment || this == DeviceAction.workoutToggle;
+ this == DeviceAction.markMoment ||
+ this == DeviceAction.workoutToggle ||
+ this == DeviceAction.logWater;
bool get isNative => this != DeviceAction.none && !isInApp;
diff --git a/lib/gestures/gesture_dispatcher.dart b/lib/gestures/gesture_dispatcher.dart
index f9125724..c6dc3974 100644
--- a/lib/gestures/gesture_dispatcher.dart
+++ b/lib/gestures/gesture_dispatcher.dart
@@ -21,12 +21,14 @@ class GestureDispatcher {
/// platform channel instead.
final Future Function()? onMarkMoment;
final Future Function()? onWorkoutToggle;
+ final Future Function()? onLogWater;
GestureDispatcher({
required this.settings,
this.log,
this.onMarkMoment,
this.onWorkoutToggle,
+ this.onLogWater,
});
static const int _doubleTapEventId = 14; // EventId.doubleTap
@@ -66,7 +68,14 @@ class GestureDispatcher {
case DeviceAction.workoutToggle:
onWorkoutToggle?.call();
break;
+ case DeviceAction.logWater:
+ onLogWater?.call();
+ break;
default:
+ // isInApp said yes and there is no case for it — an action that is
+ // offered in the picker and then does nothing, which is the exact
+ // failure the picker exists to end. Say so rather than return quietly.
+ log?.call('[gesture] ${action.id} is in-app with no handler');
break;
}
return;
diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart
index 64246c8f..b3e34377 100644
--- a/lib/state/app_state.dart
+++ b/lib/state/app_state.dart
@@ -45,6 +45,8 @@ import '../compute/manual_session.dart' show strainFromPerMinuteHr;
import '../compute/hr_max.dart';
import '../compute/profile.dart';
import '../data/day_label.dart';
+import '../data/journal_fields.dart'
+ show JournalMetricValue, kJournalFieldsByKey;
import '../data/auto_backup.dart'
show BackupCadence, BackupOutcome, runBackup;
import '../stress/breath_phases.dart';
@@ -454,7 +456,11 @@ class AppState extends ChangeNotifier {
}
// ── platform health export (Apple Health / Health Connect) ──────────────────
- final HealthExporter _healthExport = HealthExporter();
+ // The shared instance, not a private one: the coach and the log-workout
+ // sheet reach the exporter through `HealthExporter.exportWorkoutId` with no
+ // AppState in hand, and two exporters would mean two `Health()` handles and
+ // two Health-Connect availability probes doing the same work.
+ final HealthExporter _healthExport = HealthExporter.shared;
final HealthExportSingleFlight _healthExportSingleFlight =
HealthExportSingleFlight();
HealthLinkState healthState = HealthLinkState.unknown;
@@ -692,13 +698,19 @@ class AppState extends ChangeNotifier {
}
/// Session-triggered Health export for one just-finished workout (issue
- /// #130) — used by callers outside this class (e.g. confirming an
- /// auto-detected workout in workouts_screen.dart) that write a `sessions`
- /// row directly rather than going through [stopWorkout]. See
+ /// #130) — for callers outside this class that write a `sessions` row
+ /// directly rather than going through [stopWorkout]. See
/// [HealthExporter.exportWorkout] for why this can't just wait for the next
/// day export. Best-effort, never throws.
- Future exportWorkoutToHealth(Map session) =>
- _healthExport.exportWorkout(session);
+ ///
+ /// This used to take the row, and its only two call sites went out with the
+ /// old `lib/ui/workouts` — leaving it callerless while `logManualWorkout`
+ /// paths (the coach, the log-workout sheet) exported nothing at all. Those
+ /// callers hold the `workout_id` the repo hands back, not the row, and most
+ /// of them have no AppState to reach for either, so the seam that matters is
+ /// [HealthExporter.exportWorkoutId] and this just forwards to it.
+ Future exportWorkoutToHealth(String? sessionId) =>
+ HealthExporter.exportWorkoutId(sessionId);
// ── companion: anonymous telemetry + health-data contribution ────────────────
// All anchored to a stable anonymous install id (no account). Two SEPARATE
@@ -1138,6 +1150,7 @@ class AppState extends ChangeNotifier {
log: _log,
onMarkMoment: _markMomentFromGesture,
onWorkoutToggle: _toggleWorkoutFromGesture,
+ onLogWater: _logWaterFromGesture,
);
engine = BleEngine(
onRecord: _onRecord,
@@ -1225,6 +1238,7 @@ class AppState extends ChangeNotifier {
log: _log,
onMarkMoment: _markMomentFromGesture,
onWorkoutToggle: _toggleWorkoutFromGesture,
+ onLogWater: _logWaterFromGesture,
);
this.engine = engine ??
BleEngine(
@@ -1772,6 +1786,13 @@ class AppState extends ChangeNotifier {
if (nowMs - _lastStillnessScheduleMs < 10 * 60 * 1000) return;
_lastStillnessScheduleMs = nowMs;
try {
+ // Opt-in, off by default. Read here rather than cached because this runs
+ // at most once every ten minutes and SharedPreferences is already in
+ // memory — and because the switch has to bite on the next movement, not
+ // at the next launch. It is also what makes the slot allow-listed at all
+ // (NotificationService.schedulableIds): a nudge with no off switch was
+ // refused there, and had never once fired.
+ if (!(await NotificationPrefs.load()).movementEnabled) return;
await NotificationService.instance.cancel(NotificationService.idStillness);
final at =
DateTime.fromMillisecondsSinceEpoch(nowMs).add(const Duration(hours: 2));
@@ -5199,6 +5220,39 @@ class AppState extends ChangeNotifier {
}
}
+ /// One water write at a time. `_logWaterFromGesture` reads the day, awaits, then
+ /// writes the whole map back, and `postJournalMetrics` REPLACES the day — so two
+ /// taps overlapping that await both read the same total and the second write eats
+ /// the first glass. Same guard the nutrition screen's `+` already uses. This is not
+ /// a second debounce (the dispatcher owns that); it is the read-modify-write lock.
+ bool _writingWaterFromGesture = false;
+
+ /// Double-tap → add one glass to today's water. Step and ceiling come from the
+ /// journal field spec, so a wrist tap and the on-screen `+` always agree.
+ Future _logWaterFromGesture() async {
+ final r = repo;
+ if (r == null || _writingWaterFromGesture) return;
+ _writingWaterFromGesture = true;
+ try {
+ final spec = kJournalFieldsByKey['water_ml']!;
+ final date = todayLabel();
+ // Inside the try: the READ can throw too, and a guard set before it would
+ // stay set forever. Spread into a fresh map — postJournalMetrics rewrites
+ // the whole day from what it is handed.
+ final fields = {...await r.getJournalMetrics(date)};
+ final now = fields['water_ml']?.value ?? 0;
+ fields['water_ml'] =
+ JournalMetricValue((now + spec.step).clamp(0, spec.max).toDouble());
+ await r.postJournalMetrics(date, fields);
+ _log('[gesture] water logged (+${spec.step.round()} ${spec.unit})');
+ await HapticFeedback.mediumImpact();
+ } catch (e) {
+ _log('[gesture] log water failed: $e');
+ } finally {
+ _writingWaterFromGesture = false;
+ }
+ }
+
/// Double-tap → stamp a timestamped tag onto today's journal (read-modify-write so
/// existing tags/note survive). "Remember this" for a spike, a set, a feeling.
Future _markMomentFromGesture() async {
From f6ae0628b68e169db0d0bf92714eab107d668fae Mon Sep 17 00:00:00 2001
From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com>
Date: Wed, 19 Aug 2026 23:28:11 +0530
Subject: [PATCH 10/64] device_actions: name the files that exist
comment pointed at ActionHandler.kt and ActionBridge.swift. neither is
a file. it's NativeChannels.kt and the ActionBridge enum inside
AppDelegate.swift.
---
lib/platform/device_actions.dart | 11 +++++++++--
1 file changed, 9 insertions(+), 2 deletions(-)
diff --git a/lib/platform/device_actions.dart b/lib/platform/device_actions.dart
index 5ffa5789..dc938a67 100644
--- a/lib/platform/device_actions.dart
+++ b/lib/platform/device_actions.dart
@@ -2,9 +2,16 @@
// channel. Mirrors the edge_tracking / live_activity bridges: a thin wrapper that
// asks native what it can do (capabilities) and tells it to do one thing (perform).
//
-// Native handlers: android/.../ActionHandler.kt (via MainActivity), ios ActionBridge.
+// Native handlers, both registered at engine attach:
+// Android — NativeChannels.kt (`DEVICE_ACTIONS_CHANNEL` + its `perform`).
+// iOS — the `ActionBridge` enum in ios/Runner/AppDelegate.swift.
+// There is no ActionHandler.kt and no ActionBridge.swift; this comment used to name
+// both, which is two files' worth of grep that finds nothing.
+//
// All actions use no-risk OS APIs (media-key dispatch, system volume, a ringtone +
-// vibrate) — no special runtime permissions beyond VIBRATE (a normal permission).
+// vibrate, torch) — no special runtime permissions beyond VIBRATE (a normal
+// permission). In-app actions never reach this channel at all; the dispatcher
+// handles them in Dart.
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
From 2b98a8ca58126ce85fbd1e4c64745ec11ea5a583 Mon Sep 17 00:00:00 2001
From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com>
Date: Wed, 19 Aug 2026 23:28:24 +0530
Subject: [PATCH 11/64] the double-tap picker, which never got rebuilt
the engine has been running on every live event since 0.9.x with
nothing able to move the mapping off none. list is whatever
capabilities() reported, so ios never sees volume or tasker, and when
native answers with nothing the phone actions are absent and say why.
---
lib/ui2/profile/gestures.dart | 167 ++++++++++++++++++++++++++++
test/band_gestures_test.dart | 199 ++++++++++++++++++++++++++++++++++
test/ui2_tokens_test.dart | 6 +
3 files changed, 372 insertions(+)
create mode 100644 lib/ui2/profile/gestures.dart
create mode 100644 test/band_gestures_test.dart
diff --git a/lib/ui2/profile/gestures.dart b/lib/ui2/profile/gestures.dart
new file mode 100644
index 00000000..690dc41d
--- /dev/null
+++ b/lib/ui2/profile/gestures.dart
@@ -0,0 +1,167 @@
+// What a double-tap on the band does.
+//
+// The engine for this shipped a long time ago — the event decode, the recency
+// and debounce guards, the persisted mapping, the native channel — and then the
+// screen that sets it died with the old `lib/ui` tree. So the mapping sat on its
+// `none` default with nothing able to change it: a feature that ran on every
+// live event and could never do anything. This is the missing half.
+//
+// The list is not a fixed menu. It is whatever THIS phone said it can actually
+// do — `GestureSettings.supported`, seeded from `DeviceActions.capabilities()`.
+// An action drawn here and then silently doing nothing is worse than one that
+// was never offered: iOS cannot touch system volume or a third-party player, and
+// only Android has the Tasker broadcast, so on an iPhone those are simply not in
+// the list. When native answers with nothing at all, the phone actions are
+// absent AND SAY SO, rather than leaving a gap to guess at.
+
+import 'package:flutter/material.dart';
+import 'package:lucide_icons_flutter/lucide_icons.dart';
+import 'package:provider/provider.dart';
+
+import '../../gestures/device_action.dart';
+import '../../state/app_state.dart';
+import '../ui2.dart';
+import 'profile.dart';
+
+class BandGestures extends StatelessWidget {
+ const BandGestures({super.key});
+
+ @override
+ Widget build(BuildContext c) {
+ // `gestureSettings` is a ChangeNotifier the dispatcher reads live, so the
+ // screen listens to the same object rather than keeping its own copy —
+ // picking an action has to move the thing the band is about to consult.
+ final g = c.read().gestureSettings;
+ return ListenableBuilder(
+ listenable: g,
+ builder: (c, _) => BandGesturesView(
+ chosen: g.doubleTap,
+ supported: g.supported,
+ onPick: g.setDoubleTap,
+ ),
+ );
+ }
+}
+
+class BandGesturesView extends StatelessWidget {
+ final DeviceAction chosen;
+
+ /// What this phone can do. Always contains [DeviceAction.none].
+ final Set supported;
+
+ final ValueChanged? onPick;
+
+ const BandGesturesView({
+ super.key,
+ required this.chosen,
+ required this.supported,
+ this.onPick,
+ });
+
+ @override
+ Widget build(BuildContext c) {
+ final p = P.of(c);
+ // Enum order, filtered to this phone: nothing first (it is the default and
+ // the way back out), then the in-app actions, then whatever the OS offered.
+ final offered = [
+ DeviceAction.none,
+ ...DeviceAction.values.where((a) => a.isInApp && supported.contains(a)),
+ ...DeviceAction.values.where((a) => a.isNative && supported.contains(a)),
+ ];
+ final noPhoneActions = !offered.any((a) => a.isNative);
+
+ return Scaffold(
+ backgroundColor: p.bg,
+ body: SafeArea(
+ child: Column(children: [
+ const Padding(
+ padding: EdgeInsets.symmetric(horizontal: S.x4),
+ child: NavBar('Double-tap'),
+ ),
+ Expanded(
+ child: ListView(
+ padding: const EdgeInsets.fromLTRB(S.x4, 0, S.x4, S.x10),
+ children: [
+ Section(
+ 'Tap the band twice',
+ Surface(
+ child: Text(
+ 'Only while the app is connected and awake. A tap the '
+ 'band stored while your phone was away arrives later with '
+ 'an old timestamp, and is ignored rather than fired hours '
+ 'after you meant it.',
+ style: F.body.copyWith(color: p.ink2, height: 1.4),
+ ),
+ ),
+ ),
+ settingsGroup(c, 'It does', [
+ for (final a in offered)
+ _ActionRow(
+ action: a,
+ selected: a == chosen,
+ onTap: onPick == null ? null : () => onPick!(a),
+ ),
+ ]),
+ if (noPhoneActions) ...[
+ const SizedBox(height: S.x5),
+ Section(
+ 'Nothing on the phone?',
+ Surface(
+ child: Text(
+ 'Ringing your phone and the flashlight are missing '
+ 'because the app could not reach the system to ask what '
+ 'this device allows. Reopen the app and come back; the '
+ 'in-app actions above work either way.',
+ style: F.body.copyWith(color: p.ink2, height: 1.4),
+ ),
+ ),
+ ),
+ ],
+ ],
+ ),
+ ),
+ ]),
+ ),
+ );
+ }
+}
+
+/// One choice. Label, what it does, and a tick when it is the live mapping.
+class _ActionRow extends StatelessWidget {
+ final DeviceAction action;
+ final bool selected;
+ final VoidCallback? onTap;
+
+ const _ActionRow({required this.action, required this.selected, this.onTap});
+
+ @override
+ Widget build(BuildContext c) {
+ final p = P.of(c);
+ return Pressable(
+ onTap: onTap,
+ semanticLabel:
+ '${action.label}. ${action.blurb}${selected ? ' Selected.' : ''}',
+ child: Padding(
+ padding: const EdgeInsets.symmetric(vertical: S.x3),
+ child: Row(children: [
+ // THE ROW RULE (see SetRow): exactly one flexible child, so every
+ // tick in the list lands on the same right edge. Two would split the
+ // width by ratio instead.
+ Expanded(
+ child:
+ Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
+ Text(action.label,
+ style: F.body.copyWith(
+ color: selected ? p.on(C.indigo) : p.ink,
+ fontWeight: selected ? FontWeight.w600 : null)),
+ Text(action.blurb, style: F.over.copyWith(color: p.ink3)),
+ ]),
+ ),
+ const SizedBox(width: S.x2),
+ Icon(selected ? LucideIcons.check : LucideIcons.circle,
+ size: 17, color: selected ? p.on(C.indigo) : p.line),
+ ]),
+ ),
+ );
+ }
+}
diff --git a/test/band_gestures_test.dart b/test/band_gestures_test.dart
new file mode 100644
index 00000000..fe7999ad
--- /dev/null
+++ b/test/band_gestures_test.dart
@@ -0,0 +1,199 @@
+// THE DOUBLE-TAP PICKER — and the one action that made it worth building.
+//
+// The whole gesture engine shipped without this screen, so the mapping could
+// never leave `none`. Two things it may not get wrong:
+// * it offers ONLY what this phone reported it can do. An action drawn and
+// then silently doing nothing is worse than one never offered;
+// * when native answers with nothing, the phone actions are absent AND the
+// screen says why, rather than leaving a gap to guess at.
+//
+// Rendered, not read: this project has paid three times for layout faults that
+// inspecting a widget tree does not find.
+
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:openstrap_edge/gestures/device_action.dart';
+import 'package:openstrap_edge/gestures/gesture_dispatcher.dart';
+import 'package:openstrap_edge/gestures/gesture_settings.dart';
+import 'package:openstrap_edge/ui2/profile/gestures.dart';
+import 'package:openstrap_edge/ui2/ui2.dart';
+
+/// What `GestureSettings.bootstrap` builds on a phone whose native side
+/// answered: `none`, every in-app action, and the reported native ones.
+Set _supported(Set native) => {
+ DeviceAction.none,
+ ...DeviceAction.values.where((a) => a.isInApp),
+ ...native,
+ };
+
+Future _pump(
+ WidgetTester t, {
+ required Set supported,
+ DeviceAction chosen = DeviceAction.none,
+ ValueChanged? onPick,
+ double scale = 1,
+ Brightness brightness = Brightness.light,
+}) async {
+ t.view.physicalSize = Size(390 * 3, 2400 * 3 * scale);
+ t.view.devicePixelRatio = 3;
+ addTearDown(t.view.reset);
+ await t.pumpWidget(
+ MediaQuery(
+ data: MediaQueryData(textScaler: TextScaler.linear(scale)),
+ child: MaterialApp(
+ theme: buildTheme(brightness),
+ home: BandGesturesView(
+ chosen: chosen,
+ supported: supported,
+ onPick: onPick,
+ ),
+ ),
+ ),
+ );
+ await t.pumpAndSettle();
+}
+
+void main() {
+ group('the picker renders', () {
+ testWidgets('an iPhone is offered ring and torch, never volume or Tasker',
+ (t) async {
+ await _pump(t,
+ supported:
+ _supported({DeviceAction.ringPhone, DeviceAction.torch}));
+
+ expect(layoutFaults, isEmpty);
+ expect(find.text('Ring my phone'), findsOneWidget);
+ expect(find.text('Flashlight'), findsOneWidget);
+ expect(find.text('Log water'), findsOneWidget);
+ expect(find.text('Do nothing'), findsOneWidget);
+ // Not offerable on iOS, so not drawn.
+ expect(find.text('Volume up'), findsNothing);
+ expect(find.text('Broadcast to Tasker'), findsNothing);
+ expect(find.text('Play / pause music'), findsNothing);
+ });
+
+ testWidgets('an Android phone gets the full native list', (t) async {
+ await _pump(t,
+ supported: _supported({
+ DeviceAction.mediaPlayPause,
+ DeviceAction.mediaNext,
+ DeviceAction.mediaPrev,
+ DeviceAction.volumeUp,
+ DeviceAction.volumeDown,
+ DeviceAction.ringPhone,
+ DeviceAction.torch,
+ DeviceAction.broadcastToTasker,
+ }));
+
+ expect(layoutFaults, isEmpty);
+ for (final label in const [
+ 'Play / pause music',
+ 'Volume up',
+ 'Ring my phone',
+ 'Broadcast to Tasker',
+ 'Log water',
+ ]) {
+ expect(find.text(label), findsOneWidget, reason: label);
+ }
+ // No "why is this missing" note when nothing is missing.
+ expect(find.textContaining('could not reach the system'), findsNothing);
+ });
+
+ testWidgets('native unreachable: the in-app actions stand, and the '
+ 'missing ones state their reason', (t) async {
+ // capabilities() returned {} — the honest answer is not a bare gap.
+ await _pump(t, supported: _supported({}));
+
+ expect(layoutFaults, isEmpty);
+ expect(find.text('Ring my phone'), findsNothing);
+ expect(find.text('Flashlight'), findsNothing);
+ // In-app actions act on our own data, so they are unaffected.
+ expect(find.text('Log water'), findsOneWidget);
+ expect(find.text('Mark a moment'), findsOneWidget);
+ expect(find.textContaining('could not reach the system'), findsOneWidget);
+ // Absence explains itself; it is never a bare dash.
+ expect(find.text('—'), findsNothing);
+ });
+
+ testWidgets('a tap reports the action it is drawn next to', (t) async {
+ DeviceAction? picked;
+ await _pump(t,
+ supported: _supported({DeviceAction.ringPhone}),
+ onPick: (a) => picked = a);
+
+ await t.tap(find.text('Log water'));
+ await t.pumpAndSettle();
+ expect(picked, DeviceAction.logWater);
+
+ await t.tap(find.text('Ring my phone'));
+ await t.pumpAndSettle();
+ expect(picked, DeviceAction.ringPhone);
+ });
+
+ testWidgets('nothing overflows at 3.1x, in either theme', (t) async {
+ for (final b in Brightness.values) {
+ await _pump(t,
+ supported: _supported({DeviceAction.ringPhone, DeviceAction.torch}),
+ chosen: DeviceAction.logWater,
+ scale: 3.1,
+ brightness: b);
+ expect(layoutFaults, isEmpty, reason: '$b');
+ }
+ });
+ });
+
+ group('log water dispatches', () {
+ GestureDispatcher build(DeviceAction mapped, {required void Function() water,
+ void Function()? moment}) {
+ final s = GestureSettings()..doubleTap = mapped;
+ return GestureDispatcher(
+ settings: s,
+ onLogWater: () async => water(),
+ onMarkMoment: () async => moment?.call(),
+ );
+ }
+
+ int now() => DateTime.now().millisecondsSinceEpoch ~/ 1000;
+
+ test('a live double-tap mapped to water calls the water handler', () {
+ var n = 0;
+ build(DeviceAction.logWater, water: () => n++).onEvent(14, now(), '');
+ expect(n, 1);
+ });
+
+ test('the 2 s debounce still owns the second tap', () {
+ var n = 0;
+ final d = build(DeviceAction.logWater, water: () => n++);
+ d.onEvent(14, now(), '');
+ d.onEvent(14, now(), '');
+ expect(n, 1, reason: 'one physical tap can arrive twice from the band');
+ });
+
+ test('a tap drained from flash is too old to pour a glass', () {
+ var n = 0;
+ build(DeviceAction.logWater, water: () => n++)
+ .onEvent(14, now() - 3600, '');
+ expect(n, 0);
+ });
+
+ test('water is in-app, so it is offerable with no native at all', () {
+ expect(DeviceAction.logWater.isInApp, isTrue);
+ expect(DeviceAction.logWater.isNative, isFalse);
+ // Persisted. Changing it orphans everyone who already picked it.
+ expect(DeviceAction.logWater.id, 'log_water');
+ expect(DeviceActionX.fromId('log_water'), DeviceAction.logWater);
+ });
+ });
+}
+
+/// Layout faults are reported as caught exceptions, not failed matchers — a
+/// negative margin asserting on every build still leaves a findable tree.
+List get layoutFaults {
+ final out = [];
+ while (true) {
+ final e = TestWidgetsFlutterBinding.instance.takeException();
+ if (e == null) break;
+ out.add(e as Object);
+ }
+ return out;
+}
diff --git a/test/ui2_tokens_test.dart b/test/ui2_tokens_test.dart
index 465025ee..30544615 100644
--- a/test/ui2_tokens_test.dart
+++ b/test/ui2_tokens_test.dart
@@ -202,6 +202,12 @@ const _notComponents = {
// permission on tap — a gallery case would either mock all of that or
// trigger a real health-store prompt from a screenshot sweep.
'PhoneImport', 'AutomationSettings',
+ // The double-tap picker. A Scaffold route whose whole content is decided by
+ // what the OS answered to a method channel, so a gallery case would be a
+ // photograph of a fixture rather than of the screen. Rendered instead by
+ // band_gestures_test.dart, at a real phone width, in both the has-native and
+ // the native-unreachable state.
+ 'BandGestures', 'BandGesturesView',
// FULL-BLEED, so it is a screen element rather than a component: it takes
// the whole window width back off its parent's padding via OverflowBox. The
// gallery lays every case out in a ~179 logical-px cell, which is narrower
From f736463a931d9fefea9d9d4b8b1276a90f3ce950 Mon Sep 17 00:00:00 2001
From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com>
Date: Wed, 19 Aug 2026 23:28:26 +0530
Subject: [PATCH 12/64] import: don't read a zip as a string on the journal
probe
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
my own routing test caught it: readAsString on a zip throws
FileSystemException, not FormatException, so the catch i added went straight
past it. sniff first — only a text file can be a journal export, and vendor
zips now land in that group.
---
lib/ui2/onboarding/welcome.dart | 15 ++++++++++++---
1 file changed, 12 insertions(+), 3 deletions(-)
diff --git a/lib/ui2/onboarding/welcome.dart b/lib/ui2/onboarding/welcome.dart
index c81e68e1..b16d826f 100644
--- a/lib/ui2/onboarding/welcome.dart
+++ b/lib/ui2/onboarding/welcome.dart
@@ -371,6 +371,16 @@ Future runImport(
// through to the vendor importer below.
final vendor = [];
for (final p in csv) {
+ // Only a TEXT file can be a journal export, and `importJournalCsvFile`
+ // reads it as a string. Vendor exports arrive here as ZIPs now that routing
+ // is by content, and reading one as a string is #199 all over again — it
+ // comes back as `FileSystemException: Failed to decode data using encoding
+ // 'utf-8'`, which no catch below was going to turn into advice. The vendor
+ // path unwraps archives (and gzip) properly, so hand them straight over.
+ if (await sniffFile(p) != ImportContainer.text) {
+ vendor.add(p);
+ continue;
+ }
try {
final r = await importJournalCsvFile(p);
journalRows += r.imported;
@@ -379,9 +389,8 @@ Future runImport(
} on JournalCsvFormatException {
vendor.add(p);
} on FormatException {
- // `readAsString` on an archive — #199's "Unexpected extension byte (at
- // offset 10)". Vendor exports arrive as ZIPs now that routing is by
- // content, and that path unwraps them properly.
+ // Text, but not UTF-8 — a latin1/cp1252 CSV out of a spreadsheet. The
+ // sniff above cannot see that, and the vendor importer decodes leniently.
vendor.add(p);
}
}
From fcdf022f5d4979bdd9f830b1478f19877915cebe Mon Sep 17 00:00:00 2001
From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com>
Date: Wed, 19 Aug 2026 23:29:27 +0530
Subject: [PATCH 13/64] ios: delete the whole night, not the calendar day
stages go in at true epoch so a night that starts at 23:something sits in the
previous day. we were deleting [midnight, midnight) before rewriting, so the
pre-midnight half never got cleaned and every retry stacked another copy on
top of it. android already handles this in sleepCleanupRange; ios now widens
the sleep deletes the same way and takes its stages from the same
normalizeHealthSleepSession, so they're clipped to the window too.
---
lib/health/health_export.dart | 58 +++++++++++++++++++++++-------
test/health_sleep_export_test.dart | 25 +++++++++++++
2 files changed, 70 insertions(+), 13 deletions(-)
diff --git a/lib/health/health_export.dart b/lib/health/health_export.dart
index 5bd82fe6..57d60250 100644
--- a/lib/health/health_export.dart
+++ b/lib/health/health_export.dart
@@ -72,6 +72,24 @@ List healthDeleteTypes({required bool isApplePlatform}) {
.toList();
}
+/// The span a day's SLEEP-type delete has to cover.
+///
+/// The calendar day is not it. Stage samples are written at TRUE epoch, so a
+/// night that began at 23:10 sits in the PREVIOUS day — deleting only
+/// `[dayStart, dayEnd)` leaves that half behind and every re-export appends
+/// another copy of it. Widen to the union of the day and the night; with no
+/// night to write, the day window is already right.
+({DateTime start, DateTime end}) sleepCleanupWindow({
+ required DateTime dayStart,
+ required DateTime dayEnd,
+ HealthSleepSession? night,
+}) => (
+ start: (night != null && night.start.isBefore(dayStart))
+ ? night.start
+ : dayStart,
+ end: (night != null && night.end.isAfter(dayEnd)) ? night.end : dayEnd,
+);
+
bool shouldAttemptHealthExport({
required int attempts,
required int maxAttempts,
@@ -679,14 +697,30 @@ class HealthExporter {
// Outside the success accounting on purpose — see the method doc.
await _purgeLegacyStepsIfNeeded(date, dayStart, dayEnd);
+ // The night this day owns, normalized ONCE: stages clipped to the sleep
+ // window, sorted, de-overlapped. Shared by the delete window below and the
+ // Apple write further down so both cover exactly the same span. Android
+ // gets this from its native writer instead (see [_androidSleep] above).
+ final night = isApple ? normalizeHealthSleepSession(b) : null;
+
+ // Sleep deletes are night-scoped, everything else stays day-scoped —
+ // `HealthConnectSleepWriter.sleepCleanupRange` already does the equivalent
+ // on Android.
+ final sleepWindow = sleepCleanupWindow(
+ dayStart: dayStart,
+ dayEnd: dayEnd,
+ night: night,
+ );
+
// Idempotency: remove OUR previously-written samples for this day (HealthKit /
// Health Connect only let an app delete its own data), then re-write fresh.
for (final t in _rewriteTypes) {
+ final isSleep = _sleepHealthTypes.contains(t);
try {
final deleted = await _health.delete(
type: t,
- startTime: dayStart,
- endTime: dayEnd,
+ startTime: isSleep ? sleepWindow.start : dayStart,
+ endTime: isSleep ? sleepWindow.end : dayEnd,
);
if (!deleted) {
debugPrint('[health] delete ${t.name} returned false');
@@ -898,21 +932,19 @@ class HealthExporter {
// health 11.1.1 generic SLEEP_* writer instead creates one parent record
// per call, fragmenting a night. Android therefore uses our typed native
// replace API; Apple Health keeps its existing per-stage samples.
- if (isApple) {
- final segs = (_sub(b, 'series')?['hypnogram'] as List?) ?? const [];
- for (final s in segs) {
- if (s is! Map) continue;
- final st = (s['start'] as num?)?.toInt();
- final en = (s['end'] as num?)?.toInt();
- final stage = healthSleepStageOf(s['stage']?.toString());
- if (st == null || en == null || en <= st || stage == null) continue;
- final type = _sleepType(stage);
+ if (isApple && night != null) {
+ // Stages come from the SAME normalization Android uses, so they are
+ // clipped to the sleep window instead of spilling past either end of it
+ // — which is what let a pre-midnight segment survive the day-scoped
+ // delete and pile up a fresh copy on every retry.
+ for (final seg in night.stages) {
+ final type = _sleepType(seg.stage);
try {
final wrote = await _health.writeHealthData(
value: 0,
type: type,
- startTime: DateTime.fromMillisecondsSinceEpoch(st * 1000),
- endTime: DateTime.fromMillisecondsSinceEpoch(en * 1000),
+ startTime: seg.start,
+ endTime: seg.end,
);
if (!wrote) success = false;
} catch (e) {
diff --git a/test/health_sleep_export_test.dart b/test/health_sleep_export_test.dart
index 045f0125..21b50a65 100644
--- a/test/health_sleep_export_test.dart
+++ b/test/health_sleep_export_test.dart
@@ -248,6 +248,31 @@ void main() {
);
});
+ test('the sleep delete covers the pre-midnight half of the night', () {
+ final dayStart = DateTime(2026, 8, 5);
+ final dayEnd = DateTime(2026, 8, 6);
+ final night = normalizeHealthSleepSession(_overnightBundle())!;
+
+ // Onset is 2026-08-04 23:55 — OUTSIDE the day that owns this night. A
+ // day-scoped delete leaves it behind and every retry appends another
+ // copy, which is the truncation and the duplicate bars both.
+ expect(night.start.isBefore(dayStart), isTrue);
+
+ final window = sleepCleanupWindow(
+ dayStart: dayStart,
+ dayEnd: dayEnd,
+ night: night,
+ );
+ expect(window.start, night.start);
+ expect(window.end, dayEnd, reason: 'the night ends well inside the day');
+
+ // No night to write — nothing to widen for, and the day window still has
+ // to be swept so stale samples from an earlier export go.
+ final none = sleepCleanupWindow(dayStart: dayStart, dayEnd: dayEnd);
+ expect(none.start, dayStart);
+ expect(none.end, dayEnd);
+ });
+
test('Apple and Android share one hypnogram stage vocabulary', () {
expect(healthSleepStageOf('wake'), HealthSleepStage.awake);
expect(healthSleepStageOf('awake'), HealthSleepStage.awake);
From ac3896cd8e59e84b8d9bc7f38ae0d3e14d42783b Mon Sep 17 00:00:00 2001
From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com>
Date: Wed, 19 Aug 2026 23:29:58 +0530
Subject: [PATCH 14/64] detected workouts had nowhere to go, and manual logging
had no screen at all
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
the ui rebuild deleted lib/ui/workouts/ and ui2 never replaced three things
that lived in it.
the detector still writes workout_suggestions on every derive and nothing has
read it since. kRouteWorkoutSuggestion survived, the tab mapping survived, the
destination didn't — so "tap to log it" fell through screenForRoute's _ => null
and landed on the plain workouts tab. there's a screen again: the window it
spotted, the two answers, and adjust-the-times beside them, because the detector
reports the hard-effort core and an hour of mixed training lands as ~25 minutes.
they also show up on history now. the notification is emitted on the recovery
channel, which classOf drops, so it does not actually fire — a card on the tab
is the only surface these rows have ever had.
logManualWorkout and setWorkoutWindow had no ui caller anywhere. back-logging a
session, or fixing a clipped window, meant going through the byok coach. one
form does both: with a session id it retimes (same id, so the route stays
attached), without one it's a new entry. confirming a suggestion goes through
the same logManualWorkout, so it gets a strain and a calorie figure scored off
the substrate instead of the blanks the old confirm path wrote.
end time before start rolls to the next day — a run that finishes at 00:20 is an
ordinary session, not an invalid window.
---
lib/app.dart | 17 +-
lib/ui2/screens/log_workout.dart | 717 ++++++++++++++++++++++++++++
lib/ui2/screens/workout_screen.dart | 119 ++++-
test/log_workout_test.dart | 184 +++++++
test/ui2_tokens_test.dart | 11 +
5 files changed, 1037 insertions(+), 11 deletions(-)
create mode 100644 lib/ui2/screens/log_workout.dart
create mode 100644 test/log_workout_test.dart
diff --git a/lib/app.dart b/lib/app.dart
index f4e8fd97..d26a1ea5 100644
--- a/lib/app.dart
+++ b/lib/app.dart
@@ -27,6 +27,7 @@ import 'ui2/screens/what_changed.dart';
import 'ui2/screens/health_screen.dart';
import 'ui2/screens/home_screen.dart';
import 'ui2/screens/journal_compose.dart';
+import 'ui2/screens/log_workout.dart';
import 'ui2/screens/nutrition_screen.dart';
import 'ui2/screens/wellness_screen.dart';
import 'ui2/screens/workout_screen.dart';
@@ -337,13 +338,14 @@ ShellDomain domainForRoute(String route) => switch (route) {
/// The focused screen a deep link pushes on top of its domain, when one
/// exists. Null means the domain itself is the destination.
///
-/// One route still resolves to null and should not: `/workouts/suggestion`
-/// ("Tap to log it" has nothing to tap through to — nothing reads
-/// `workout_suggestions`). It is recorded in the sweep; the fix is to stop
-/// making the promise, not to route it somewhere plausible.
+/// `/workouts/suggestion` used to be in that list, and it was the one route
+/// where the fallback was a broken promise: "Tap to log it" landed on the
+/// plain Workouts tab, because the screen that could log it was deleted with
+/// `lib/ui/workouts/` and nothing read `workout_suggestions`. There is a
+/// destination again, and confirming on it writes a real session.
///
-/// `/ai/*` used to be in that list. It now lands on the briefing itself, which
-/// also carries the exact snapshot that was sent to produce it.
+/// `/ai/*` used to be in that list too. It now lands on the briefing itself,
+/// which also carries the exact snapshot that was sent to produce it.
Widget? screenForRoute(String route) => switch (route) {
kRouteAiMorning =>
const AiBriefingScreen(period: BriefingPeriod.morning),
@@ -357,6 +359,9 @@ Widget? screenForRoute(String route) => switch (route) {
// which is how the tile that everybody actually used stayed add-only for
// so long — the thing that could clear a value was behind a notification.
kRouteWater => const NutritionScreen(),
+ // The detected bout, with the three answers to it: log it, adjust the
+ // times first, or say it never happened.
+ kRouteWorkoutSuggestion => const WorkoutSuggestionScreen(),
// Battery, band and sources all live behind this one.
kRouteProfile => const ProfileHome(),
// The weekly recap used to land on the Health tab and push nothing,
diff --git a/lib/ui2/screens/log_workout.dart b/lib/ui2/screens/log_workout.dart
new file mode 100644
index 00000000..a430bdcc
--- /dev/null
+++ b/lib/ui2/screens/log_workout.dart
@@ -0,0 +1,717 @@
+// LOG A WORKOUT — the two places the athlete owns the times, and the review
+// screen the auto-detector has been writing to for months with nobody reading.
+//
+// WHY THIS FILE EXISTS AT ALL. `LocalRepository.logManualWorkout` and
+// `setWorkoutWindow` have been implemented, tested and reachable from the
+// coach's tool layer since the manual-session work landed, and reachable from
+// the app from nowhere: the UI rebuild deleted `lib/ui/workouts/` and lib/ui2
+// never replaced this part of it. Back-logging a session, or widening one the
+// detector clipped, meant asking a BYOK language model to do it for you.
+//
+// The same deletion orphaned `workout_suggestions`. The detector still fills
+// that table on every derive; `activeWorkoutSuggestions()` had exactly one
+// reader and it only ever DISMISSED. `kRouteWorkoutSuggestion` survived, the
+// tab mapping survived, and the destination did not — so the deep link fell
+// through `screenForRoute`'s `_ => null` and landed on the plain Workouts tab.
+//
+// ONE WRITE SEAM. Confirming a detected bout is not a special kind of write:
+// it is a manual session over the window the detector proposed, so it goes
+// through `logManualWorkout` like every other. That is what gets it a strain
+// and a calorie figure scored from the 1 Hz substrate — the old confirm path
+// hand-built a row with neither and every confirmed suggestion landed in the
+// log showing blanks. It also retires the suggestion on its own, inside the
+// repo, via `supersededSuggestionIds`.
+//
+// WHAT THE DETECTOR REPORTS. The hard-effort CORE, not wall clock — see the
+// header of `compute/manual_session.dart`. An hour of mixed training routinely
+// detects as ~25 minutes, which is correct for a prompt and wrong for a log
+// entry, and is exactly why "Adjust the times" sits beside "Log it" rather
+// than three screens away.
+
+import 'package:flutter/material.dart';
+import 'package:lucide_icons_flutter/lucide_icons.dart';
+import 'package:provider/provider.dart';
+
+import '../../compute/manual_session.dart';
+import '../../data/db.dart';
+import '../../data/journal_fields.dart' show formatMinuteOfDay;
+import '../../notify/notification_prefs.dart';
+import '../../state/app_state.dart';
+import '../activity/catalogue.dart';
+import '../profile/profile.dart' show SetRow, settingsGroup;
+import '../ui2.dart';
+import 'home_screen.dart' show repoOf;
+
+/// One detected bout, as this screen needs it. Built straight off a
+/// `workout_suggestions` row.
+class Suggestion {
+ const Suggestion({
+ required this.id,
+ required this.startTs,
+ required this.endTs,
+ this.sport,
+ this.peakBpm,
+ this.avgBpm,
+ });
+
+ final String id;
+ final int startTs, endTs;
+ final String? sport;
+ final int? peakBpm, avgBpm;
+
+ int get durationMin => ((endTs - startTs) / 60).round();
+
+ /// The catalogue entry behind `sport`, when this build knows it. Null is
+ /// carried rather than defaulted so the row can say what it was told.
+ Activity? get activity => activityByName(sport);
+
+ /// Null when the row is malformed — a suggestion with no window is not a
+ /// suggestion, and it must not reach a screen that offers to log it.
+ static Suggestion? from(Map r) {
+ final id = r['id'];
+ final s = (r['start_ts'] as num?)?.toInt();
+ final e = (r['end_ts'] as num?)?.toInt();
+ if (id is! String || s == null || e == null || e <= s) return null;
+ return Suggestion(
+ id: id,
+ startTs: s,
+ endTs: e,
+ sport: r['sport'] as String?,
+ peakBpm: (r['peak_bpm'] as num?)?.toInt(),
+ avgBpm: (r['avg_bpm'] as num?)?.toInt(),
+ );
+ }
+}
+
+// ══════════════════ THE REVIEW SCREEN ══════════════════
+
+/// Where "Did you work out?" lands. Every active bout, each with the two
+/// answers that are honest — it happened, or it didn't — and the third that
+/// matters more than either: the window is wrong.
+class WorkoutSuggestionScreen extends StatefulWidget {
+ const WorkoutSuggestionScreen({super.key, this.preloaded});
+
+ /// Injected in tests and goldens. Null means read the table.
+ final List? preloaded;
+
+ @override
+ State createState() =>
+ _WorkoutSuggestionScreenState();
+}
+
+class _WorkoutSuggestionScreenState extends State {
+ List? _items;
+
+ /// Tracked SEPARATELY from [_items]. A failed query rendered as "nothing to
+ /// review" tells the user a still-active suggestion was already handled,
+ /// which is the one thing this screen must never say by accident.
+ bool _failed = false;
+ bool _busy = false;
+
+ @override
+ void initState() {
+ super.initState();
+ if (widget.preloaded != null) {
+ _items = widget.preloaded;
+ } else {
+ _load();
+ }
+ }
+
+ Future _load() async {
+ setState(() => _failed = false);
+ try {
+ final rows = await LocalDb.activeWorkoutSuggestions();
+ if (!mounted) return;
+ setState(() => _items = [for (final r in rows) ?Suggestion.from(r)]);
+ } catch (_) {
+ if (mounted) setState(() => _failed = true);
+ }
+ }
+
+ /// Log it, over the window the detector proposed.
+ Future _confirm(Suggestion s) async {
+ final repo = repoOf(context);
+ if (repo == null || _busy) return;
+ setState(() => _busy = true);
+ var message = '';
+ try {
+ await repo.logManualWorkout(
+ startTs: s.startTs,
+ endTs: s.endTs,
+ type: s.activity?.typeKey ?? 'other',
+ );
+ // The repo retires every suggestion the saved window covers, this one
+ // included — nothing to dismiss here.
+ } on ManualWindowException catch (e) {
+ // A REFUSAL, not a failure to retry differently. The commonest is an
+ // overlap: those minutes are already in the log, so the bout is spent.
+ message = e.error.message;
+ try {
+ await LocalDb.dismissWorkoutSuggestion(s.id);
+ } catch (_) {/* the reason is already on screen */}
+ } catch (_) {
+ message = 'Could not log this one — try again.';
+ }
+ if (!mounted) return;
+ setState(() => _busy = false);
+ if (message.isNotEmpty) _say(message);
+ await _afterAction();
+ }
+
+ Future _dismiss(Suggestion s) async {
+ if (_busy) return;
+ setState(() => _busy = true);
+ try {
+ await LocalDb.dismissWorkoutSuggestion(s.id);
+ } catch (_) {
+ if (mounted) _say('Could not dismiss this one — try again.');
+ }
+ if (!mounted) return;
+ setState(() => _busy = false);
+ await _afterAction();
+ }
+
+ /// Open the form on the detected window so the athlete can widen it to the
+ /// session they actually did, then save that instead.
+ Future _adjust(Suggestion s) async {
+ final nav = Navigator.of(context);
+ final saved = await nav.push(MaterialPageRoute(
+ builder: (_) => LogWorkout(
+ start: DateTime.fromMillisecondsSinceEpoch(s.startTs * 1000),
+ end: DateTime.fromMillisecondsSinceEpoch(s.endTs * 1000),
+ activity: s.activity,
+ title: 'Adjust the times',
+ ),
+ ));
+ if (saved == true) await _afterAction();
+ }
+
+ void _say(String m) =>
+ ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(m)));
+
+ /// Re-read, then close once there is nothing left to review — the tab
+ /// underneath is where the now-logged session is.
+ Future _afterAction() async {
+ await _load();
+ if (!mounted) return;
+ bumpInsights(context);
+ if (!_failed && (_items?.isEmpty ?? false)) {
+ await Navigator.maybePop(context);
+ }
+ }
+
+ @override
+ Widget build(BuildContext c) {
+ final p = P.of(c);
+ final items = _items;
+ return Scaffold(
+ backgroundColor: p.bg,
+ body: SafeArea(
+ child: Column(children: [
+ const Padding(
+ padding: EdgeInsets.symmetric(horizontal: S.x4),
+ child: NavBar('Detected activity', sub: 'YOURS TO CONFIRM'),
+ ),
+ Expanded(
+ child: ListView(
+ padding: const EdgeInsets.fromLTRB(S.x4, 0, S.x4, S.x10),
+ children: [
+ if (_failed)
+ StatusCard(
+ 'Could not read your detected activity',
+ 'The store did not answer. Nothing has been logged or '
+ 'dismissed.',
+ fix: 'Try again',
+ icon: LucideIcons.refreshCw,
+ onFix: _load,
+ )
+ else if (items == null)
+ const NoData(message: 'Reading what the band spotted…')
+ else if (items.isEmpty)
+ const StatusCard(
+ 'Nothing to review',
+ 'This one may already have been logged or dismissed.',
+ icon: LucideIcons.circleCheck,
+ )
+ else
+ for (final s in items) ...[
+ _SuggestionCard(
+ s,
+ onConfirm: _busy ? null : () => _confirm(s),
+ onDismiss: _busy ? null : () => _dismiss(s),
+ onAdjust: _busy ? null : () => _adjust(s),
+ ),
+ const SizedBox(height: S.x3),
+ ],
+ const SizedBox(height: S.x3),
+ const StatusCard(
+ 'These are the hard minutes, not the whole session',
+ 'Detection reports the sustained effort it could see, so a '
+ 'warm-up and the rest between sets fall outside it. '
+ 'Adjust the times before logging if the window is short.',
+ icon: LucideIcons.scissors,
+ ),
+ ],
+ ),
+ ),
+ ]),
+ ),
+ );
+ }
+}
+
+/// One detected bout: what was seen, and the three answers to it.
+class _SuggestionCard extends StatelessWidget {
+ const _SuggestionCard(
+ this.s, {
+ this.onConfirm,
+ this.onDismiss,
+ this.onAdjust,
+ });
+
+ final Suggestion s;
+ final VoidCallback? onConfirm, onDismiss, onAdjust;
+
+ @override
+ Widget build(BuildContext c) {
+ final p = P.of(c);
+ final a = s.activity;
+ final colour = a?.color ?? C.purple;
+ return Surface(
+ child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
+ Row(children: [
+ Container(
+ width: 40,
+ height: 40,
+ decoration: BoxDecoration(color: p.wash(colour), borderRadius: R.rMd),
+ child: Icon(a?.icon ?? LucideIcons.activity,
+ size: 19, color: p.on(colour)),
+ ),
+ const SizedBox(width: S.x3),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text('${s.durationMin} min of effort',
+ style: F.body
+ .copyWith(color: p.ink, fontWeight: FontWeight.w600)),
+ Text(windowLabel(s.startTs, s.endTs),
+ style: F.over.copyWith(color: p.ink3)),
+ ]),
+ ),
+ ]),
+ const SizedBox(height: S.x3),
+ // What was actually measured. No strain and no calories: neither has
+ // been scored yet — the scoring happens on the write, over whatever
+ // window is finally saved, and printing one here would be a number
+ // this screen made up.
+ InlineMetrics([
+ if (s.avgBpm != null) ('Avg HR', '${s.avgBpm} bpm', p.on(C.red)),
+ if (s.peakBpm != null) ('Peak HR', '${s.peakBpm} bpm', p.on(C.orange)),
+ if (a != null) ('Looks like', a.name, p.on(colour)),
+ ]),
+ const SizedBox(height: S.x4),
+ BigButton('Log it', icon: LucideIcons.check, onTap: onConfirm),
+ const SizedBox(height: S.x2),
+ Row(children: [
+ Expanded(
+ child: BigButton('Adjust the times',
+ icon: LucideIcons.clock,
+ color: C.blue,
+ soft: true,
+ onTap: onAdjust),
+ ),
+ const SizedBox(width: S.x2),
+ Expanded(
+ child: BigButton('Not a workout',
+ icon: LucideIcons.x, color: C.red, soft: true, onTap: onDismiss),
+ ),
+ ]),
+ ]),
+ );
+ }
+}
+
+/// "Today · 6:30 PM – 7:31 PM". The WINDOW, never just the start — the whole
+/// reason someone opens this screen is to check whether the detector clipped
+/// it, and a start time alone cannot show that.
+String windowLabel(int startTs, int endTs) {
+ final s = DateTime.fromMillisecondsSinceEpoch(startTs * 1000);
+ final e = DateTime.fromMillisecondsSinceEpoch(endTs * 1000);
+ return '${dayLabel(s)} · ${formatMinuteOfDay(s.hour * 60 + s.minute)} – '
+ '${formatMinuteOfDay(e.hour * 60 + e.minute)}';
+}
+
+/// Today / Yesterday / "Mon 11 Aug", against the real calendar day rather than
+/// a 24-hour subtraction — the day after a spring-forward is 23 hours long.
+String dayLabel(DateTime at, {DateTime? now}) {
+ final n = now ?? DateTime.now();
+ final today = DateTime(n.year, n.month, n.day);
+ final d = DateTime(at.year, at.month, at.day);
+ final diff = today.difference(d).inDays;
+ if (diff == 0) return 'Today';
+ if (diff == 1) return 'Yesterday';
+ const wd = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
+ const mo = [
+ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
+ 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
+ ];
+ return '${wd[d.weekday - 1]} ${d.day} ${mo[d.month - 1]}';
+}
+
+// ══════════════════ THE FORM ══════════════════
+
+/// Log a past session, or fix the window on one already in the log.
+///
+/// [sessionId] is the whole difference between the two: with it the save is a
+/// RETIME (`setWorkoutWindow`, same id, so the row's GPS route and its rating
+/// stay attached), without it a new manual entry (`logManualWorkout`). The
+/// type is not editable on a retime — it belongs to the row already, and this
+/// screen is about the times.
+///
+/// Pops `true` when something was written, so the caller can re-read.
+class LogWorkout extends StatefulWidget {
+ const LogWorkout({
+ super.key,
+ this.sessionId,
+ this.start,
+ this.end,
+ this.activity,
+ this.title = 'Log a past workout',
+ this.spans,
+ this.now,
+ });
+
+ final String? sessionId;
+ final DateTime? start, end;
+ final Activity? activity;
+ final String title;
+
+ /// The windows already in the log, for the live overlap check. Injected in
+ /// tests; null means read them from the repo.
+ final List? spans;
+
+ /// Injected in tests so "that hasn't happened yet" is deterministic.
+ final DateTime? now;
+
+ @override
+ State createState() => _LogWorkoutState();
+}
+
+class _LogWorkoutState extends State {
+ late DateTime _start;
+ late DateTime _end;
+ late Activity _activity;
+ List _spans = const [];
+ bool _saving = false;
+ String? _wrote;
+
+ @override
+ void initState() {
+ super.initState();
+ final now = widget.now ?? DateTime.now();
+ // An hour, ending on the last whole hour. A form that opens on "now to
+ // now" is a form whose first state is invalid.
+ final defaultEnd = DateTime(now.year, now.month, now.day, now.hour);
+ _end = widget.end ?? defaultEnd;
+ _start = widget.start ?? _end.subtract(Motion.tick * 3600);
+ _activity = widget.activity ?? quickStart.first;
+ if (widget.spans != null) {
+ _spans = widget.spans!;
+ } else {
+ _loadSpans();
+ }
+ }
+
+ Future _loadSpans() async {
+ final repo = repoOf(context);
+ if (repo == null) return;
+ try {
+ final s = await repo.savedSessionSpans();
+ if (mounted) setState(() => _spans = s);
+ } catch (_) {/* the write seam re-checks anyway */}
+ }
+
+ int get _startSec => _start.millisecondsSinceEpoch ~/ 1000;
+ int get _endSec => _end.millisecondsSinceEpoch ~/ 1000;
+
+ /// The live verdict, from the SAME pure function the repo refuses on. Null
+ /// means the window is acceptable.
+ ManualWindowError? get _invalid => validateManualWindow(
+ startSec: _startSec,
+ endSec: _endSec,
+ nowSec:
+ (widget.now ?? DateTime.now()).millisecondsSinceEpoch ~/ 1000,
+ existing: _spans,
+ // A retime must not collide with itself; a new entry's id is derived
+ // from its start second, so re-logging the same window updates that
+ // row rather than colliding with it.
+ editingId: widget.sessionId ?? manualSessionId(_startSec),
+ );
+
+ Future _pickDate() async {
+ final now = widget.now ?? DateTime.now();
+ final picked = await showDatePicker(
+ context: context,
+ initialDate: _start,
+ firstDate: DateTime(now.year - 5),
+ lastDate: now,
+ );
+ if (picked == null) return;
+ final span = _end.difference(_start);
+ setState(() {
+ _start = DateTime(
+ picked.year, picked.month, picked.day, _start.hour, _start.minute);
+ _end = _start.add(span);
+ });
+ }
+
+ Future _pickTime({required bool isStart}) async {
+ final at = isStart ? _start : _end;
+ final picked = await showTimePicker(
+ context: context,
+ initialTime: TimeOfDay(hour: at.hour, minute: at.minute),
+ );
+ if (picked == null) return;
+ setState(() {
+ if (isStart) {
+ final span = _end.difference(_start);
+ _start = DateTime(_start.year, _start.month, _start.day, picked.hour,
+ picked.minute);
+ _end = _start.add(span);
+ } else {
+ var e = DateTime(
+ _start.year, _start.month, _start.day, picked.hour, picked.minute);
+ // Past midnight. A late run that finishes at 00:20 is an ordinary
+ // session, not an invalid window — the alternative is asking the user
+ // for a second date to express it.
+ if (!e.isAfter(_start)) e = e.add(Motion.tick * 86400);
+ _end = e;
+ }
+ });
+ }
+
+ Future _pickActivity() async {
+ final picked = await showModalBottomSheet(
+ context: context,
+ isScrollControlled: true,
+ sheetAnimationStyle: sheetMotion(context),
+ backgroundColor: P.of(context).card,
+ shape: const RoundedRectangleBorder(borderRadius: R.rXl),
+ builder: (_) => const _TypeSheet(),
+ );
+ if (picked != null) setState(() => _activity = picked);
+ }
+
+ Future _save() async {
+ final repo = repoOf(context);
+ if (repo == null || _saving || _invalid != null) return;
+ final nav = Navigator.of(context);
+ final app = appOf(context);
+ setState(() {
+ _saving = true;
+ _wrote = null;
+ });
+ try {
+ final r = widget.sessionId == null
+ ? await repo.logManualWorkout(
+ startTs: _startSec, endTs: _endSec, type: _activity.typeKey)
+ : await repo.setWorkoutWindow(widget.sessionId!,
+ startTs: _startSec, endTs: _endSec);
+ // Say what was actually banked. A window with no 1 Hz substrate left
+ // behind it — anything past the ~3-day retention, or a stretch the band
+ // was off — is saved UNSCORED, and a screen that pops silently would let
+ // the athlete believe a strain was computed for it.
+ app?.insightsRevision.value++;
+ if (r['unscored'] == true) {
+ if (!mounted) return;
+ setState(() {
+ _saving = false;
+ _wrote = 'Saved. No heart rate was recorded over that window, so it '
+ 'has no strain and no calorie figure — the times are all this '
+ 'one carries.';
+ });
+ return;
+ }
+ nav.pop(true);
+ } on ManualWindowException catch (e) {
+ if (mounted) setState(() { _saving = false; _wrote = e.error.message; });
+ } catch (_) {
+ if (mounted) {
+ setState(() {
+ _saving = false;
+ _wrote = 'Could not save that — try again.';
+ });
+ }
+ }
+ }
+
+ @override
+ Widget build(BuildContext c) {
+ final p = P.of(c);
+ final bad = _invalid;
+ final mins = _end.difference(_start).inMinutes;
+ final retime = widget.sessionId != null;
+ return Scaffold(
+ backgroundColor: p.bg,
+ body: SafeArea(
+ child: Column(children: [
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: S.x4),
+ child: NavBar(widget.title,
+ sub: retime ? 'THE WINDOW, RE-SCORED' : 'YOUR OWN TIMES'),
+ ),
+ Expanded(
+ child: ListView(
+ padding: const EdgeInsets.fromLTRB(S.x4, 0, S.x4, S.x10),
+ children: [
+ settingsGroup(c, 'When', [
+ if (!retime)
+ SetRow(_activity.icon, _activity.color, 'Activity',
+ value: _activity.name, onTap: _pickActivity),
+ SetRow(LucideIcons.calendar, C.blue, 'Date',
+ value: dayLabel(_start, now: widget.now),
+ onTap: _pickDate),
+ SetRow(LucideIcons.play, C.green, 'Started',
+ value:
+ formatMinuteOfDay(_start.hour * 60 + _start.minute),
+ onTap: () => _pickTime(isStart: true)),
+ SetRow(LucideIcons.square, C.orange, 'Ended',
+ value: formatMinuteOfDay(_end.hour * 60 + _end.minute),
+ sub: _end.day != _start.day ? 'the next morning' : '',
+ onTap: () => _pickTime(isStart: false)),
+ SetRow(LucideIcons.timer, C.purple, 'Length',
+ value: mins > 0 ? '$mins min' : '—',
+ chevron: false),
+ ]),
+ const SizedBox(height: S.x4),
+ if (bad != null)
+ StatusCard('That window will not save', bad.message,
+ icon: LucideIcons.triangleAlert)
+ else if (_wrote != null)
+ StatusCard(retime ? 'Times updated' : 'Workout logged',
+ _wrote!, icon: LucideIcons.circleCheck)
+ else
+ StatusCard(
+ 'Scored from what the band recorded',
+ 'Strain and calories come from the 1-second heart rate '
+ 'inside these times, through the same method the day '
+ 'uses. Nothing is estimated from the duration.',
+ icon: LucideIcons.heartPulse,
+ ),
+ const SizedBox(height: S.x4),
+ BigButton(
+ _saving
+ ? 'Saving…'
+ : retime
+ ? 'Save the new times'
+ : 'Log it',
+ icon: LucideIcons.check,
+ onTap: bad == null && !_saving ? _save : null,
+ ),
+ ],
+ ),
+ ),
+ ]),
+ ),
+ );
+ }
+}
+
+/// The activity list, searchable. The picker proper (`ActivityPicker`) starts a
+/// LIVE session; this one only names a window that has already happened.
+class _TypeSheet extends StatefulWidget {
+ const _TypeSheet();
+ @override
+ State<_TypeSheet> createState() => _TypeSheetState();
+}
+
+class _TypeSheetState extends State<_TypeSheet> {
+ String _q = '';
+
+ @override
+ Widget build(BuildContext c) {
+ final p = P.of(c);
+ final q = _q.trim().toLowerCase();
+ final items = q.isEmpty
+ ? allActivities
+ : [
+ for (final a in allActivities)
+ if (a.name.toLowerCase().contains(q)) a,
+ ];
+ return SafeArea(
+ child: Padding(
+ padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(c).bottom),
+ child: Column(mainAxisSize: MainAxisSize.min, children: [
+ Padding(
+ padding: const EdgeInsets.fromLTRB(S.x4, S.x4, S.x4, S.x2),
+ child: TextField(
+ autofocus: false,
+ style: F.body.copyWith(color: p.ink),
+ onChanged: (v) => setState(() => _q = v),
+ decoration: InputDecoration(
+ hintText: 'Search activities',
+ hintStyle: F.body.copyWith(color: p.ink3),
+ filled: true,
+ fillColor: p.card2,
+ contentPadding: const EdgeInsets.symmetric(
+ horizontal: S.x4, vertical: S.x3),
+ border: const OutlineInputBorder(
+ borderRadius: R.rPill, borderSide: BorderSide.none),
+ ),
+ ),
+ ),
+ Flexible(
+ child: items.isEmpty
+ ? const Padding(
+ padding: EdgeInsets.all(S.x6),
+ child: NoData(message: 'No activity by that name'),
+ )
+ : ListView.builder(
+ shrinkWrap: true,
+ padding: const EdgeInsets.fromLTRB(S.x4, 0, S.x4, S.x6),
+ itemCount: items.length,
+ itemBuilder: (_, i) {
+ final a = items[i];
+ return SetRow(a.icon, a.color, a.name,
+ chevron: false,
+ onTap: () => Navigator.of(c).pop(a));
+ },
+ ),
+ ),
+ ]),
+ ),
+ );
+ }
+}
+
+// ══════════════════ THE WORKOUTS-TAB ENTRY ══════════════════
+
+/// Tell the app a session was written, so the Workouts tab re-reads. Null-safe
+/// for a golden or a widget test, which have no AppState above them.
+void bumpInsights(BuildContext c) => appOf(c)?.insightsRevision.value++;
+
+/// The AppState, or null when there is none — same shape as [repoOf].
+AppState? appOf(BuildContext c) {
+ try {
+ return c.read();
+ } catch (_) {
+ return null;
+ }
+}
+
+/// Active suggestions for the History tab, or empty when the user has switched
+/// auto-detection off. Read here rather than in the screen so the switch is
+/// honoured at ONE place for both surfaces it has.
+Future> activeSuggestions() async {
+ try {
+ if (!(await NotificationPrefs.load()).autoDetectEnabled) return const [];
+ return [
+ for (final r in await LocalDb.activeWorkoutSuggestions())
+ ?Suggestion.from(r),
+ ];
+ } catch (_) {
+ return const [];
+ }
+}
diff --git a/lib/ui2/screens/workout_screen.dart b/lib/ui2/screens/workout_screen.dart
index 828d8cd6..a7c817c5 100644
--- a/lib/ui2/screens/workout_screen.dart
+++ b/lib/ui2/screens/workout_screen.dart
@@ -35,6 +35,7 @@ import '../charts.dart';
import '../profile/profile.dart' show openProfile;
import '../grammar.dart';
import '../theme.dart';
+import 'log_workout.dart';
import 'start_card.dart';
class WorkoutScreen extends StatefulWidget {
@@ -457,26 +458,81 @@ class _WorkoutScreenState extends State {
}
// ─────────────── HISTORY ───────────────
+
+ /// Open a screen that can write a session, then re-read. Every write path on
+ /// this tab goes through here: `AppState.insightsRevision` is what
+ /// [_onRevision] listens to, and a screen that saved while this one was
+ /// parked still has to leave the list correct on the way back.
+ Future _push(BuildContext c, Widget w) async {
+ await Navigator.of(c).push(MaterialPageRoute(builder: (_) => w));
+ if (mounted) _reload();
+ }
+
+ void _reload() {
+ final app = context.read();
+ setState(() {
+ _loadedAt = app.insightsRevision.value;
+ _load = _loadWorkoutData(app);
+ });
+ }
+
+ /// The detector's unreviewed bouts, at the top of History where the sessions
+ /// they might become are listed.
+ ///
+ /// This is the surface that was missing, not a second copy of one: the
+ /// notification is the only thing that has ever pointed at
+ /// `workout_suggestions`, and it is emitted on a channel `classOf` drops, so
+ /// it does not fire. Without this the rows accumulate forever, unseen.
+ List _suggestionCards(BuildContext c, _WorkoutData d) {
+ if (d.suggestions.isEmpty) return const [];
+ final n = d.suggestions.length;
+ return [
+ StatusCard(
+ n == 1
+ ? 'One effort we spotted but did not log'
+ : '$n efforts we spotted but did not log',
+ 'The band saw sustained work and nothing was started for it. Nothing '
+ 'is logged until you say so.',
+ fix: 'Review ${n == 1 ? 'it' : 'them'}',
+ icon: LucideIcons.radar,
+ onFix: () =>
+ _push(c, WorkoutSuggestionScreen(preloaded: d.suggestions)),
+ ),
+ const SizedBox(height: S.x5),
+ ];
+ }
+
+ /// Back-log a session the band never saw, or never saw the whole of.
+ Widget _logPastCard(BuildContext c) => StatusCard(
+ 'Did something the band missed?',
+ 'Enter the times yourself and it is scored from the heart rate '
+ 'recorded across them, like any other session.',
+ fix: 'Log a past workout',
+ icon: LucideIcons.calendarPlus,
+ onFix: () => _push(c, const LogWorkout()),
+ );
+
List _history(BuildContext c, _WorkoutData d) {
final p = P.of(c);
if (d.workouts.isEmpty) {
return [
+ ..._suggestionCards(c, d),
StatusCard(
'No sessions recorded yet',
- // Auto-detection writes `workout_suggestions` and nothing reads it
- // (lib/app.dart:339), so a detected effort never arrives here. The
- // string used to tell the user to wait for it.
'Sessions appear here once you start one.',
fix: 'Start a workout',
onFix: () => _openPicker(c, d),
icon: LucideIcons.dumbbell,
),
const SizedBox(height: S.x5),
+ _logPastCard(c),
+ const SizedBox(height: S.x5),
..._importCard(c, d),
];
}
final importedThisWeek = d.weekImported;
return [
+ ..._suggestionCards(c, d),
Row(children: [
Expanded(child: _sum(p, '${d.workoutsTracked ?? d.workouts.length}',
'Tracked')),
@@ -506,10 +562,29 @@ class _WorkoutScreenState extends State {
..._morningAfter(p, d),
const SizedBox(height: S.x5),
for (final w in d.workouts) ...[
- _HistoryRow(w, weightKg: d.weightKg),
+ _HistoryRow(w,
+ weightKg: d.weightKg,
+ // A retime is a re-score over the new window, so it is offered
+ // only where there is something of ours to re-score: an imported
+ // row's times belong to the app that recorded it, and this band
+ // measured nothing across them.
+ onRetime: w.importedFrom == null && w.id.isNotEmpty
+ ? () => _push(
+ c,
+ LogWorkout(
+ sessionId: w.id,
+ start: w.start,
+ end: w.start.add(w.duration),
+ activity: w.activity,
+ title: 'Fix the times',
+ ),
+ )
+ : null),
const SizedBox(height: S.x3),
],
const SizedBox(height: S.x3),
+ _logPastCard(c),
+ const SizedBox(height: S.x3),
..._importCard(c, d),
];
}
@@ -734,7 +809,12 @@ class _QuickTile extends StatelessWidget {
class _HistoryRow extends StatelessWidget {
final _PastWorkout w;
final double? weightKg;
- const _HistoryRow(this.w, {this.weightKg});
+
+ /// Widen or correct this session's window. Null for an imported row, and for
+ /// a session with no id to retime.
+ final VoidCallback? onRetime;
+
+ const _HistoryRow(this.w, {this.weightKg, this.onRetime});
Future _open(BuildContext c) async {
final nav = Navigator.of(c);
@@ -837,6 +917,23 @@ class _HistoryRow extends StatelessWidget {
accent: p.on(a.color),
),
],
+ // The way to correct a window the detector clipped, or one a session
+ // started late. Nested inside the card's own tap: the inner Pressable
+ // wins, so the row still opens the summary everywhere else.
+ if (onRetime != null) ...[
+ Divider(color: p.line, height: S.x5),
+ Pressable(
+ onTap: onRetime,
+ semanticLabel: 'Fix the times on this session',
+ child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [
+ Icon(LucideIcons.clock, size: 14, color: p.on(C.blue)),
+ const SizedBox(width: S.x2),
+ Text('Fix the times',
+ style: F.cap.copyWith(
+ color: p.on(C.blue), fontWeight: FontWeight.w600)),
+ ]),
+ ),
+ ],
]),
);
}
@@ -1521,6 +1618,16 @@ class _WorkoutData {
/// which takes months, and that is the honest state until then.
final List morningAfter;
+ /// The detector's active bouts — every "did you work out?" that has neither
+ /// been logged nor dismissed. Empty when auto-detection is switched off.
+ ///
+ /// These rows have been written on every derive since the detector shipped
+ /// and read by nothing, so a detected effort was invisible unless a
+ /// notification happened to catch you. The notification is not enough on its
+ /// own: it is emitted on the `recovery` channel, which `classOf` drops, so
+ /// in this build it never actually fires.
+ final List suggestions;
+
/// When the phone's health store last handed us a workout, or null for
/// never. It is the whole difference between an Import button and a Refresh
/// one — see health_import_state.dart for why the store cannot be asked.
@@ -1544,6 +1651,7 @@ class _WorkoutData {
this.setHistory = const {},
this.overreach,
this.morningAfter = const [],
+ this.suggestions = const [],
this.importedLast,
});
@@ -1778,6 +1886,7 @@ Future<_WorkoutData> _loadWorkoutData(AppState app) async {
setHistory: history,
overreach: overreach,
morningAfter: morningAfter,
+ suggestions: await activeSuggestions(),
importedLast: await lastImportAt(HealthImport.workouts),
);
}
diff --git a/test/log_workout_test.dart b/test/log_workout_test.dart
new file mode 100644
index 00000000..5fe75fc8
--- /dev/null
+++ b/test/log_workout_test.dart
@@ -0,0 +1,184 @@
+// THE TWO SCREENS THE UI REBUILD LEFT OUT, RENDERED.
+//
+// Reading a widget tree does not find layout bugs — this project has paid for
+// that three times over (a negative margin asserts, an OverflowBox blanks a
+// whole tab, Expanded and Flexible in one Row split it 50/50). Both of these
+// are pumped at a real phone width, and both are driven: the form's validation
+// is exercised through the controls a thumb would use, not by calling the pure
+// function underneath it.
+//
+// Neither screen gets an AppState here on purpose. `repoOf`/`appOf` return
+// null without one, which is exactly the golden case — a screen that cannot
+// reach the repository must still render its own absence rather than throw.
+
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+import 'package:openstrap_edge/compute/manual_session.dart';
+import 'package:openstrap_edge/ui2/activity/catalogue.dart';
+import 'package:openstrap_edge/ui2/screens/log_workout.dart';
+import 'package:openstrap_edge/ui2/ui2.dart';
+
+/// A real phone, and tall enough that nothing under test is below the fold —
+/// the default 800x600 harness hides the very controls these tests are about.
+Future _pump(WidgetTester t, Widget w) async {
+ t.view.physicalSize = const Size(390 * 3, 2400 * 3);
+ t.view.devicePixelRatio = 3;
+ addTearDown(t.view.reset);
+ await t.pumpWidget(MaterialApp(
+ theme: buildTheme(Brightness.light),
+ home: w,
+ ));
+ await t.pumpAndSettle();
+}
+
+/// 18:30–19:31 on a fixed day, as the detector would have reported it.
+final _now = DateTime(2026, 8, 19, 21);
+final _start = DateTime(2026, 8, 19, 18, 30);
+final _end = DateTime(2026, 8, 19, 19, 31);
+
+Suggestion _sug({String id = 'a', int? avg = 148, int? peak = 171}) =>
+ Suggestion(
+ id: id,
+ startTs: _start.millisecondsSinceEpoch ~/ 1000,
+ endTs: _end.millisecondsSinceEpoch ~/ 1000,
+ sport: 'running',
+ avgBpm: avg,
+ peakBpm: peak,
+ );
+
+void main() {
+ group('the detected-activity review', () {
+ testWidgets('draws the bout, its window and all three answers', (t) async {
+ await _pump(t, WorkoutSuggestionScreen(preloaded: [_sug()]));
+
+ expect(find.text('Detected activity'), findsOneWidget);
+ // The WINDOW, not just a start time — the whole reason to open this
+ // screen is to see whether the detector clipped it.
+ expect(find.textContaining('6:30 PM – 7:31 PM'), findsOneWidget);
+ expect(find.text('61 min of effort'), findsOneWidget);
+ // Every answer is reachable, including the one that matters most.
+ expect(find.text('Log it'), findsOneWidget);
+ expect(find.text('Adjust the times'), findsOneWidget);
+ expect(find.text('Not a workout'), findsOneWidget);
+ // and it never prints a strain or a calorie figure it has not scored
+ expect(find.textContaining('strain'), findsNothing);
+ });
+
+ testWidgets('an empty review says so, and never as a bare dash', (t) async {
+ await _pump(t, const WorkoutSuggestionScreen(preloaded: []));
+ expect(find.text('Nothing to review'), findsOneWidget);
+ expect(find.text('—'), findsNothing);
+ });
+
+ testWidgets('nothing overflows at 2x text', (t) async {
+ t.view.physicalSize = const Size(390 * 3, 3000 * 3);
+ t.view.devicePixelRatio = 3;
+ addTearDown(t.view.reset);
+ await t.pumpWidget(MediaQuery(
+ data: const MediaQueryData(textScaler: TextScaler.linear(2)),
+ child: MaterialApp(
+ theme: buildTheme(Brightness.dark),
+ home: WorkoutSuggestionScreen(preloaded: [_sug(), _sug(id: 'b')]),
+ ),
+ ));
+ await t.pumpAndSettle();
+ // An overflow paints its stripe and reports through the harness rather
+ // than failing the pump, so it has to be taken to be seen.
+ expect(t.takeException(), isNull);
+ });
+ });
+
+ group('the manual-entry form', () {
+ testWidgets('opens on a valid window and offers to save it', (t) async {
+ await _pump(t, LogWorkout(now: _now));
+ expect(find.text('Log a past workout'), findsOneWidget);
+ // Defaults to the last whole hour, which is a WINDOW — a form that opens
+ // on "now to now" opens invalid.
+ expect(find.text('60 min'), findsOneWidget);
+ expect(find.text('That window will not save'), findsNothing);
+ expect(find.text('Log it'), findsOneWidget);
+ });
+
+ testWidgets('a window that overlaps one already logged is refused', (
+ t,
+ ) async {
+ await _pump(
+ t,
+ LogWorkout(
+ now: _now,
+ start: _start,
+ end: _end,
+ spans: [
+ SessionSpan(
+ 'manual:1',
+ _start.millisecondsSinceEpoch ~/ 1000 + 600,
+ _end.millisecondsSinceEpoch ~/ 1000 + 600,
+ ),
+ ],
+ ),
+ );
+ expect(find.text('That window will not save'), findsOneWidget);
+ expect(
+ find.text('That overlaps a workout already in your log.'),
+ findsOneWidget,
+ );
+ });
+
+ testWidgets('a retime keeps the type and does not offer to change it', (
+ t,
+ ) async {
+ await _pump(
+ t,
+ LogWorkout(
+ sessionId: 'manual:123',
+ now: _now,
+ start: _start,
+ end: _end,
+ activity: activityByName('running'),
+ title: 'Fix the times',
+ ),
+ );
+ expect(find.text('Fix the times'), findsWidgets);
+ expect(find.text('Save the new times'), findsOneWidget);
+ // The row that would change the activity is absent: a retime is about
+ // the window, and the type belongs to the row already.
+ expect(find.text('Activity'), findsNothing);
+ });
+
+ testWidgets('picking an end time before the start rolls to the next day', (
+ t,
+ ) async {
+ // 23:40 → 00:20 is an ordinary late run, not an invalid window.
+ final late = DateTime(2026, 8, 19, 23, 40);
+ await _pump(
+ t,
+ LogWorkout(
+ now: DateTime(2026, 8, 20, 8),
+ start: late,
+ end: late.add(Motion.tick * 2400),
+ activity: activityByName('running'),
+ ),
+ );
+ expect(find.text('40 min'), findsOneWidget);
+ expect(find.text('the next morning'), findsOneWidget);
+ expect(find.text('That window will not save'), findsNothing);
+ });
+ });
+
+ group('the day label', () {
+ final now = DateTime(2026, 8, 19, 12);
+ test('names today and yesterday, then the date', () {
+ expect(dayLabel(DateTime(2026, 8, 19, 6), now: now), 'Today');
+ expect(dayLabel(DateTime(2026, 8, 18, 23), now: now), 'Yesterday');
+ expect(dayLabel(DateTime(2026, 8, 11, 9), now: now), 'Tue 11 Aug');
+ });
+
+ test('counts calendar days, not 24-hour blocks', () {
+ // 23:59 yesterday to 00:01 today is two minutes and one day. An
+ // `inDays` on the difference calls it "Today".
+ expect(dayLabel(DateTime(2026, 8, 18, 23, 59),
+ now: DateTime(2026, 8, 19, 0, 1)), 'Yesterday');
+ });
+ });
+}
diff --git a/test/ui2_tokens_test.dart b/test/ui2_tokens_test.dart
index 30544615..bc14ebfd 100644
--- a/test/ui2_tokens_test.dart
+++ b/test/ui2_tokens_test.dart
@@ -198,6 +198,11 @@ const _notComponents = {
'NotificationSettings', 'NotificationSettingsView', 'EditProfile',
'EditProfileView', 'DataScreen', 'AlarmScreen', 'AlarmScreenView',
'MyDevices', 'MyDevicesView', 'DeviceDetail', 'DeviceDetailView', 'RePair',
+ // The strap-buzz relay picker: a Scaffold route over a live
+ // NotificationRelay, whose list is whatever the OS notification stream has
+ // handed us this session. `BandNotificationsView` is the pure half and is
+ // what `band_notifications_test.dart` pumps.
+ 'BandNotifications', 'BandNotificationsView',
// Both are Scaffold routes that read the database and ask the OS for a
// permission on tap — a gallery case would either mock all of that or
// trigger a real health-store prompt from a screenshot sweep.
@@ -251,4 +256,10 @@ const _notComponents = {
'LiveFlow', 'LiveMatch', 'LiveInterval',
// the activity flow: pick → set up → do → summarise → share
'ActivityPicker', 'ActivitySetup', 'ActivitySummary', 'ShareSheet',
+ // The two write routes for a session the band did not capture as it
+ // happened. Both are Scaffolds that read `sessions` / `workout_suggestions`
+ // and write through the repo; the second also asks the OS for a date and a
+ // time picker on tap. Covered by `log_workout_test.dart`, which pumps each
+ // at a real phone width against injected rows.
+ 'WorkoutSuggestionScreen', 'LogWorkout',
};
From 9e90e10cbb6b74689075ea25c58ef44640785fcb Mon Sep 17 00:00:00 2001
From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com>
Date: Wed, 19 Aug 2026 23:30:04 +0530
Subject: [PATCH 15/64] ios: write the in-bed envelope around the stages
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
apple health was getting bare stage bars with nothing wrapping them, so
readers downstream stitch the night back together as a short sleep plus a
handful of naps. healthkit has no session record like health connect does, so
the wrapper is an inBed sleepAnalysis sample over the detected window — the
same span we already call in-bed time. no window, no envelope; we don't
invent a bedtime we didn't measure.
---
lib/health/health_export.dart | 33 ++++++++++++++++++++++++++++++++-
1 file changed, 32 insertions(+), 1 deletion(-)
diff --git a/lib/health/health_export.dart b/lib/health/health_export.dart
index 57d60250..6b689d1f 100644
--- a/lib/health/health_export.dart
+++ b/lib/health/health_export.dart
@@ -44,7 +44,12 @@ const _sleepHealthTypes = {
HealthDataType.SLEEP_REM,
HealthDataType.SLEEP_LIGHT,
HealthDataType.SLEEP_AWAKE,
+ // The night's envelope. Health Connect models it as a SleepSessionRecord
+ // parent; HealthKit has no session record, so the enclosing bar is an
+ // `inBed` sleepAnalysis sample. Only one of the two is ever asked for —
+ // see `_types` — but both belong to the sleep delete scope.
HealthDataType.SLEEP_SESSION,
+ HealthDataType.SLEEP_IN_BED,
};
List healthDeleteTypes({required bool isApplePlatform}) {
@@ -220,7 +225,10 @@ class HealthExporter {
HealthDataType.SLEEP_REM,
HealthDataType.SLEEP_LIGHT,
HealthDataType.SLEEP_AWAKE,
- HealthDataType.SLEEP_SESSION,
+ // The envelope, in whichever form the platform actually has. Asking
+ // for the other one sends a type name that store has never heard of
+ // (SLEEP_SESSION is Health-Connect-only, SLEEP_IN_BED HealthKit-only).
+ isApple ? HealthDataType.SLEEP_IN_BED : HealthDataType.SLEEP_SESSION,
HealthDataType.WORKOUT,
];
@@ -933,6 +941,29 @@ class HealthExporter {
// per call, fragmenting a night. Android therefore uses our typed native
// replace API; Apple Health keeps its existing per-stage samples.
if (isApple && night != null) {
+ // THE ENVELOPE FIRST. Bare stage bars with nothing enclosing them is why
+ // readers (Bevel and friends) reconstruct a night as a short sleep plus a
+ // scatter of naps — HealthKit has no session record, so the wrapper is an
+ // `inBed` sleepAnalysis sample spanning the night.
+ //
+ // The span is the DETECTED sleep window, which is the same wall-clock
+ // number the app already reports as in-bed time (`in_bed_sec` is
+ // offset - onset). Nothing is invented: no window, no envelope, and a
+ // bundle without one writes no stages either — which is also why an
+ // unstaged night (an import, a night staging refused) contributes no
+ // fragments here.
+ try {
+ final wrote = await _health.writeHealthData(
+ value: 0,
+ type: HealthDataType.SLEEP_IN_BED,
+ startTime: night.start,
+ endTime: night.end,
+ );
+ if (!wrote) success = false;
+ } catch (e) {
+ debugPrint('[health] write sleep envelope: $e');
+ success = false;
+ }
// Stages come from the SAME normalization Android uses, so they are
// clipped to the sleep window instead of spilling past either end of it
// — which is what let a pre-midnight segment survive the day-scoped
From a8b874e6e27b4d4acb36fec9a2a5fd5449a3108c Mon Sep 17 00:00:00 2001
From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com>
Date: Wed, 19 Aug 2026 23:30:10 +0530
Subject: [PATCH 16/64] auto-detection gets an off switch, and the movement
nudge gets one it needed
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
two switches, one of which turned out to be load-bearing.
auto-detect (#102, #149): asked for twice, never built. the rows were written,
the prompt emitted, and nothing anywhere could stop either. off silences the
notification and the review cards; it does not stop the detection, and the row
says so — the rows keep accumulating and come back if you turn it on again.
the movement nudge (#123) is the interesting one. the report was that
scheduleStandingReminders cancels idStillness on every foreground resume and
never re-arms, which is true. it is not why the nudge never fired: idStillness
was never in schedulableIds, so scheduleOnce dropped it at the gate before the
cancel ever mattered. deleting the cancel on its own would have fixed nothing.
so it earns its place on that list the way the list asks — a slot the user
asked for by name. off by default, and app_state bails before arming when it
is. the cancel here now only runs when the switch is off, which is the one case
it was ever right for.
---
lib/notify/notification_center.dart | 15 +++++++++-
lib/notify/notification_prefs.dart | 42 ++++++++++++++++++++++++++++
lib/notify/notification_service.dart | 20 +++++++++----
test/notification_center_test.dart | 35 +++++++++++++++++++++--
4 files changed, 104 insertions(+), 8 deletions(-)
diff --git a/lib/notify/notification_center.dart b/lib/notify/notification_center.dart
index 7b34e9b3..1ffead8d 100644
--- a/lib/notify/notification_center.dart
+++ b/lib/notify/notification_center.dart
@@ -204,7 +204,20 @@ class NotificationCenter {
final svc = NotificationService.instance;
await svc.cancel(NotificationService.idWindDown);
await svc.cancel(NotificationService.idWeeklyRecap);
- await svc.cancel(NotificationService.idStillness);
+ // idStillness is NOT a standing schedule and must not be cancelled with
+ // them. It is a one-shot armed by live movement
+ // (`AppState._rescheduleStillnessNudge`), nothing in this method re-arms
+ // it, and this method runs on EVERY foreground resume — so the fix for
+ // issue #123 was cancelling itself: open the app and the nudge was binned.
+ // The re-arm needs a connected band streaming foreground IMU AND is
+ // throttled to once per ten minutes, so it is not a gap that closes on its
+ // own; with the band off the wrist it never closes at all.
+ //
+ // The one cancel that IS correct here is the user's own switch: this is
+ // where a movement nudge that was just turned off actually goes away.
+ if (!prefs.movementEnabled) {
+ await svc.cancel(NotificationService.idStillness);
+ }
for (var i = 0; i < NotificationService.maxWaterSlots; i++) {
await svc.cancel(NotificationService.idWaterBase + i);
}
diff --git a/lib/notify/notification_prefs.dart b/lib/notify/notification_prefs.dart
index c74367e4..19f4f904 100644
--- a/lib/notify/notification_prefs.dart
+++ b/lib/notify/notification_prefs.dart
@@ -10,6 +10,7 @@
import 'package:shared_preferences/shared_preferences.dart';
import 'notification_event.dart';
+import 'tap_router.dart';
class NotificationPrefs {
/// The day's aggregated health exception (illness, unusual physiology,
@@ -50,6 +51,28 @@ class NotificationPrefs {
static const int waterIntervalMinAllowed = 30;
static const int waterIntervalMaxAllowed = 360;
+ /// Whether the auto-detected-workout surfaces are on: the "did you work out?"
+ /// notification and the review cards the detector feeds. Asked for twice
+ /// (issues #102, #149) and never built — the detector has never had an off
+ /// switch of any kind.
+ ///
+ /// WHAT IT DOES NOT DO: stop the detection itself. The bouts are computed
+ /// inside the day derivation and written to `workout_suggestions` there; this
+ /// switch silences every surface that shows them, which is the part the user
+ /// experiences. The rows stay, unread, and turning it back on shows them
+ /// again rather than losing a week of them.
+ final bool autoDetectEnabled;
+
+ /// The "time to move" nudge: a one-shot OS notification two hours after the
+ /// last movement the band's live IMU saw, re-armed on every movement so it
+ /// only ever fires on a genuinely uninterrupted still stretch.
+ ///
+ /// Opt-in, off by default, and it is what earns the nudge its place on
+ /// [NotificationService.schedulableIds] — the rule that list enforces is that
+ /// a scheduled slot must be one the user asked for by name. Without a switch
+ /// it was refused, which is why it has never fired for anyone (issue #123).
+ final bool movementEnabled;
+
const NotificationPrefs({
this.healthEnabled = true,
this.recoveryEnabled = true,
@@ -61,6 +84,8 @@ class NotificationPrefs {
this.criticalOverridesQuiet = true,
this.waterEnabled = false,
this.waterIntervalMin = 120, // every 2 hours
+ this.autoDetectEnabled = true,
+ this.movementEnabled = false,
});
static const _kHealth = 'notif_health';
@@ -73,6 +98,8 @@ class NotificationPrefs {
static const _kCriticalOverride = 'notif_critical_override';
static const _kWater = 'notif_water';
static const _kWaterInterval = 'notif_water_interval';
+ static const _kAutoDetect = 'notif_auto_detect';
+ static const _kMovement = 'notif_movement';
static Future load() async {
final p = await SharedPreferences.getInstance();
@@ -87,6 +114,8 @@ class NotificationPrefs {
criticalOverridesQuiet: p.getBool(_kCriticalOverride) ?? true,
waterEnabled: p.getBool(_kWater) ?? false,
waterIntervalMin: p.getInt(_kWaterInterval) ?? 120,
+ autoDetectEnabled: p.getBool(_kAutoDetect) ?? true,
+ movementEnabled: p.getBool(_kMovement) ?? false,
);
}
@@ -102,6 +131,8 @@ class NotificationPrefs {
await p.setBool(_kCriticalOverride, criticalOverridesQuiet);
await p.setBool(_kWater, waterEnabled);
await p.setInt(_kWaterInterval, waterIntervalMin);
+ await p.setBool(_kAutoDetect, autoDetectEnabled);
+ await p.setBool(_kMovement, movementEnabled);
}
NotificationPrefs copyWith({
@@ -115,6 +146,8 @@ class NotificationPrefs {
bool? criticalOverridesQuiet,
bool? waterEnabled,
int? waterIntervalMin,
+ bool? autoDetectEnabled,
+ bool? movementEnabled,
}) =>
NotificationPrefs(
healthEnabled: healthEnabled ?? this.healthEnabled,
@@ -128,6 +161,8 @@ class NotificationPrefs {
criticalOverridesQuiet ?? this.criticalOverridesQuiet,
waterEnabled: waterEnabled ?? this.waterEnabled,
waterIntervalMin: waterIntervalMin ?? this.waterIntervalMin,
+ autoDetectEnabled: autoDetectEnabled ?? this.autoDetectEnabled,
+ movementEnabled: movementEnabled ?? this.movementEnabled,
);
bool categoryEnabled(NotifCategory c) => switch (c) {
@@ -155,6 +190,13 @@ class NotificationPrefs {
/// a check at each of the emit sites, which is how twenty-two kinds accreted
/// in the first place.
bool shouldFireOs(NotifEvent event, int minuteOfDay) {
+ // The auto-detect off switch, applied before anything else: it is the one
+ // gate the user set for THIS notification, and route is what identifies it
+ // (the category it is emitted on is shared with everything else on the
+ // recovery channel).
+ if (!autoDetectEnabled && event.route == kRouteWorkoutSuggestion) {
+ return false;
+ }
final klass = classOf(event);
if (klass == null) return false; // not one of the three — never fires
// The alarm is the one thing quiet hours must not silence: the user armed
diff --git a/lib/notify/notification_service.dart b/lib/notify/notification_service.dart
index 97a77add..78af6fcd 100644
--- a/lib/notify/notification_service.dart
+++ b/lib/notify/notification_service.dart
@@ -155,11 +155,21 @@ class NotificationService {
/// reminder is switched on, at the interval the user picked.
/// • [idEveningBrief] — armed only when the nightly sweep found something
/// unusual for this user, and its body IS the finding.
- /// Wind-down, the morning briefing, the journal prompt and the "time to move"
- /// one-shot are none of those, and are still refused. Their callers keep
- /// CANCELLING, which is how an upgrade cleans out whatever an older build
- /// left standing.
- static const Set schedulableIds = {idWeeklyRecap, idEveningBrief};
+ /// • [idStillness] — armed only while `NotificationPrefs.movementEnabled`
+ /// is on (opt-in, off by default), and only by two hours of no movement
+ /// in the band's own live IMU. Its body IS that measurement. It was
+ /// refused here for as long as it had no switch, which is the real reason
+ /// issue #123 never fired: the cancel on every foreground resume was the
+ /// visible half, but `scheduleOnce` had been dropping it at this gate
+ /// before the cancel ever mattered.
+ /// Wind-down, the morning briefing and the journal prompt are none of those,
+ /// and are still refused. Their callers keep CANCELLING, which is how an
+ /// upgrade cleans out whatever an older build left standing.
+ static const Set schedulableIds = {
+ idWeeklyRecap,
+ idEveningBrief,
+ idStillness,
+ };
/// Whether [id] is one of the hydration slots. A band rather than a set
/// member, which is the only reason [maySchedule] exists as a function.
diff --git a/test/notification_center_test.dart b/test/notification_center_test.dart
index 6e0e110b..4c5a8fd9 100644
--- a/test/notification_center_test.dart
+++ b/test/notification_center_test.dart
@@ -10,6 +10,7 @@ import 'package:openstrap_edge/notify/notification_event.dart';
import 'package:openstrap_edge/notify/notification_ids.dart';
import 'package:openstrap_edge/notify/notification_prefs.dart';
import 'package:openstrap_edge/notify/notification_service.dart';
+import 'package:openstrap_edge/notify/tap_router.dart';
import 'package:openstrap_edge/ui2/profile/settings.dart';
NotificationEvent _ev(NotifCategory c, NotifPriority p) => NotificationEvent(
@@ -66,9 +67,15 @@ void main() {
// The OS fires a zonedSchedule with no Dart running, so shouldFireOs never
// sees one. What may be SCHEDULED is a separate, narrower list: a slot the
// user asked for by name, at a time or interval they picked.
- test('allows the lookback, the hydration band and the nightly sweep', () {
+ test('allows the lookback, the hydration band, the sweep and the nudge', () {
expect(NotificationService.maySchedule(NotificationService.idWeeklyRecap),
isTrue);
+ // The movement nudge earned its place by growing an off switch
+ // (NotificationPrefs.movementEnabled). Refused here for as long as it had
+ // none, which is why issue #123 never fired for anyone — the cancel on
+ // every foreground resume was the visible half of it.
+ expect(NotificationService.maySchedule(NotificationService.idStillness),
+ isTrue);
expect(NotificationService.maySchedule(NotificationService.idEveningBrief),
isTrue);
for (var i = 0; i < NotificationService.maxWaterSlots; i++) {
@@ -85,7 +92,6 @@ void main() {
NotificationService.idWindDown,
NotificationService.idJournalLog,
NotificationService.idMorningBrief,
- NotificationService.idStillness,
NotificationService.idLowBattery,
NotificationService.idWaterBase - 1,
NotificationService.idWaterBase + NotificationService.maxWaterSlots,
@@ -119,6 +125,31 @@ void main() {
isFalse);
}
});
+ // The auto-detect off switch (issues #102, #149). The detector has never
+ // had one — the row is written, the notification is emitted, and nothing
+ // anywhere could stop either.
+ test('the detected-workout prompt is silenced by its own switch', () {
+ const on = NotificationPrefs();
+ const off = NotificationPrefs(autoDetectEnabled: false);
+ const e = NotificationEvent(
+ dedupeKey: '2026-06-27:auto_workout:1',
+ // health, so the three-class rule is not what is being measured here:
+ // the point is that the switch outranks a category that WOULD fire.
+ category: NotifCategory.health,
+ priority: NotifPriority.normal,
+ title: 'Did you work out?',
+ body: 'b',
+ date: '2026-06-27',
+ route: kRouteWorkoutSuggestion,
+ );
+ expect(on.shouldFireOs(e, 12 * 60), isTrue);
+ expect(off.shouldFireOs(e, 12 * 60), isFalse);
+ // and it silences nothing else
+ expect(
+ off.shouldFireOs(_ev(NotifCategory.health, NotifPriority.normal),
+ 12 * 60),
+ isTrue);
+ });
test('critical overrides quiet hours when allowed', () {
expect(p.shouldFireOs(_ev(NotifCategory.health, NotifPriority.critical),
2 * 60), isTrue);
From e250fb830ab15a7aaabc6433f239b53e5626840c Mon Sep 17 00:00:00 2001
From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com>
Date: Wed, 19 Aug 2026 23:30:24 +0530
Subject: [PATCH 17/64] =?UTF-8?q?the=20notification=E2=86=92strap=20relay?=
=?UTF-8?q?=20has=20a=20screen=20again=20(#92)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
the relay itself never stopped working — app_state still bootstraps it and the
manifest still declares BIND_NOTIFICATION_LISTENER_SERVICE for it. what got
deleted was every control, so we've been shipping a notification-listener
permission with no way to reach the feature it's there for. that's the part
that matters: a reviewer reading the manifest sees an unexplained permission.
the app list is apps that have actually notified you while the listener was
running, not the installed set. enumerating installed apps needs
QUERY_ALL_PACKAGES, which the sweep pulled out of the manifest with
tools:node=remove and called the most policy-expensive permission there is —
that stands. it's also the better list: the dozen apps that interrupt you
instead of two hundred to scroll. cost is it starts empty and fills over the
first few minutes, which the empty state says out loud.
names come off the package (the real label is behind the permission we're not
asking for); the icon comes off the notification itself and is the thing you
actually recognise.
no telephony call-buzz here — pr #95 never merged, there's no READ_PHONE_STATE
and nothing in history.
---
lib/notify/notification_relay.dart | 84 +++++++-
lib/ui2/profile/band_notifications.dart | 268 ++++++++++++++++++++++++
test/band_notifications_test.dart | 136 ++++++++++++
3 files changed, 487 insertions(+), 1 deletion(-)
create mode 100644 lib/ui2/profile/band_notifications.dart
create mode 100644 test/band_notifications_test.dart
diff --git a/lib/notify/notification_relay.dart b/lib/notify/notification_relay.dart
index 5de52fea..9e42e90a 100644
--- a/lib/notify/notification_relay.dart
+++ b/lib/notify/notification_relay.dart
@@ -10,6 +10,7 @@
import 'dart:async';
import 'dart:io' show Platform;
+import 'dart:typed_data';
import 'package:flutter/services.dart' show MethodChannel;
import 'package:flutter/widgets.dart';
@@ -36,6 +37,12 @@ class NotificationRelay extends ChangeNotifier with WidgetsBindingObserver {
static const _kEnabled = 'notif_relay_enabled';
static const _kPackages = 'notif_relay_packages';
+ static const _kSeen = 'notif_relay_seen';
+
+ /// How many apps the "seen" list remembers. A phone posts from a long tail
+ /// of packages over a week; past this the list stops being a list you can
+ /// read.
+ static const int maxSeen = 60;
/// Only Android can observe other apps' notifications. Everything below is a
/// no-op when this is false, and the UI hides the feature entirely.
@@ -47,6 +54,24 @@ class NotificationRelay extends ChangeNotifier with WidgetsBindingObserver {
bool _granted = false;
bool get permissionGranted => _granted;
+ /// Packages that have actually posted a notification while the listener was
+ /// running, most recent first. This is what the picker offers.
+ ///
+ /// The alternative — enumerating installed apps — needs QUERY_ALL_PACKAGES,
+ /// which was deliberately removed from the manifest with `tools:node=remove`
+ /// as the most policy-expensive permission there is. It is also the worse
+ /// list: two hundred packages to scroll, against the dozen that actually
+ /// interrupt you.
+ final List _seen = [];
+
+ /// Per-package icon, straight off the notification the OS handed us. RAM
+ /// only, deliberately: the packages persist, the bitmaps do not, and a
+ /// freshly-launched app simply shows names until each one posts again.
+ final Map _icons = {};
+
+ List get seenPackages => List.unmodifiable(_seen);
+ Uint8List? iconFor(String pkg) => _icons[pkg];
+
final Set _packages = {};
Set get packages => _packages;
bool isAppEnabled(String pkg) => _packages.contains(pkg);
@@ -73,6 +98,15 @@ class NotificationRelay extends ChangeNotifier with WidgetsBindingObserver {
_packages
..clear()
..addAll(prefs.getStringList(_kPackages) ?? const []);
+ _seen
+ ..clear()
+ ..addAll(prefs.getStringList(_kSeen) ?? const []);
+ // An app already on the allow-list belongs in the picker whether or not it
+ // has posted since launch — otherwise turning the feature on and reopening
+ // the screen shows an empty list with your choices invisibly still active.
+ for (final p in _packages) {
+ if (!_seen.contains(p)) _seen.add(p);
+ }
WidgetsBinding.instance.addObserver(this);
await refreshPermission();
_resync();
@@ -189,12 +223,34 @@ class NotificationRelay extends ChangeNotifier with WidgetsBindingObserver {
} catch (_) {/* handler absent on this plugin build — ignore */}
}
+ /// Remember that [pkg] notifies, so the picker has something to offer.
+ ///
+ /// Persisted only when the package is NEW: the in-memory order changes on
+ /// every ping and a SharedPreferences write per notification would be a
+ /// disk write per notification.
+ void _noteSeen(String pkg, Uint8List? icon) {
+ if (icon != null && icon.isNotEmpty) _icons[pkg] = icon;
+ final known = _seen.remove(pkg);
+ _seen.insert(0, pkg);
+ if (_seen.length > maxSeen) _seen.removeRange(maxSeen, _seen.length);
+ if (!known) {
+ SharedPreferences.getInstance()
+ .then((p) => p.setStringList(_kSeen, _seen))
+ .catchError((_) => false);
+ }
+ notifyListeners();
+ }
+
void _onNotification(ServiceNotificationEvent e) {
// Only fresh, user-facing posts: skip removals and persistent/ongoing ones
// (media players, foreground-service notifications) — those aren't "a ping".
if (e.hasRemoved || e.onGoing) return;
final pkg = e.packageName;
- if (pkg.isEmpty || !_packages.contains(pkg)) return;
+ if (pkg.isEmpty) return;
+ // BEFORE the allow-list check: an app you have not chosen yet is exactly
+ // the one the picker needs to be able to offer you.
+ _noteSeen(pkg, e.appIcon);
+ if (!_packages.contains(pkg)) return;
if (!isConnected()) return;
final now = DateTime.now().millisecondsSinceEpoch;
@@ -215,3 +271,29 @@ class NotificationRelay extends ChangeNotifier with WidgetsBindingObserver {
super.dispose();
}
}
+
+/// A readable name for [pkg], from the package name alone.
+///
+/// An app's own label lives behind `getApplicationLabel`, which needs the
+/// package-visibility permission this feature deliberately does not have — so
+/// the ICON beside it (taken off the notification itself) is the identifier a
+/// human actually reads, and this is the caption under it.
+///
+/// The last meaningful segment, capitalised: `com.whatsapp` → "Whatsapp",
+/// `org.telegram.messenger` → "Messenger", `com.foo.android` → "Foo". Segments
+/// that name a platform or a build rather than a product are stepped over,
+/// because "Android" under every second icon is not a name.
+String appLabel(String pkg) {
+ const generic = {
+ 'android', 'app', 'apps', 'client', 'mobile', 'main', 'ui',
+ 'free', 'pro', 'lite', 'beta', 'release',
+ };
+ final parts = [for (final p in pkg.split('.')) if (p.isNotEmpty) p];
+ if (parts.isEmpty) return pkg;
+ var i = parts.length - 1;
+ while (i > 0 && generic.contains(parts[i].toLowerCase())) {
+ i--;
+ }
+ final w = parts[i];
+ return w[0].toUpperCase() + w.substring(1);
+}
diff --git a/lib/ui2/profile/band_notifications.dart b/lib/ui2/profile/band_notifications.dart
new file mode 100644
index 00000000..9dfa3567
--- /dev/null
+++ b/lib/ui2/profile/band_notifications.dart
@@ -0,0 +1,268 @@
+// BAND NOTIFICATIONS — buzz the strap when a phone app notifies you.
+// ANDROID ONLY, and silently absent everywhere else: iOS has no API to observe
+// another app's notifications, so there is no "unavailable on this device"
+// copy to write.
+//
+// WHY THIS FILE HAD TO COME BACK. The relay itself never stopped working:
+// `AppState` still bootstraps it, and the manifest still declares
+// BIND_NOTIFICATION_LISTENER_SERVICE for it. What the UI rebuild deleted was
+// every control — so the app shipped a notification-listener permission with
+// no way to reach the feature it exists for. A permission a reviewer can read
+// in the manifest and a user cannot find in the app is the problem, more than
+// the missing feature is.
+//
+// WHERE THE APP LIST COMES FROM. Apps that have actually posted a notification
+// while the listener was running, not the installed set. Enumerating installed
+// packages needs QUERY_ALL_PACKAGES, which the sweep removed from the manifest
+// with `tools:node="remove"` and called the most policy-expensive permission
+// there is — that decision stands. It also happens to be the better list: the
+// dozen apps that interrupt you, rather than two hundred to scroll past. The
+// cost is that the list starts empty and fills over the first minutes, which
+// the empty state says in as many words rather than looking broken.
+
+import 'dart:typed_data';
+
+import 'package:flutter/material.dart';
+import 'package:lucide_icons_flutter/lucide_icons.dart';
+import 'package:provider/provider.dart';
+
+import '../../notify/notification_relay.dart';
+import '../../state/app_state.dart';
+import '../ui2.dart';
+import 'profile.dart' show SetRow, settingsGroup;
+
+/// One row's worth of the picker.
+class RelayApp {
+ const RelayApp(this.package, {this.icon, this.on = false});
+ final String package;
+ final Uint8List? icon;
+ final bool on;
+}
+
+/// The route. Reads the live [NotificationRelay] off [AppState] and hands
+/// [BandNotificationsView] plain values — the view is what the tests pump, and
+/// it never asks the platform anything.
+class BandNotifications extends StatefulWidget {
+ const BandNotifications({super.key});
+
+ @override
+ State createState() => _BandNotificationsState();
+}
+
+class _BandNotificationsState extends State
+ with WidgetsBindingObserver {
+ NotificationRelay get _relay => context.read().notificationRelay;
+
+ @override
+ void initState() {
+ super.initState();
+ WidgetsBinding.instance.addObserver(this);
+ }
+
+ @override
+ void dispose() {
+ WidgetsBinding.instance.removeObserver(this);
+ super.dispose();
+ }
+
+ @override
+ void didChangeAppLifecycleState(AppLifecycleState state) {
+ // Back from the system Notification-access page: re-read the real grant
+ // rather than trusting what the user said they did.
+ if (state == AppLifecycleState.resumed && mounted) {
+ _relay.refreshPermission();
+ }
+ }
+
+ @override
+ Widget build(BuildContext c) {
+ final relay = _relay;
+ return AnimatedBuilder(
+ animation: relay,
+ builder: (c, _) => BandNotificationsView(
+ supported: relay.supported,
+ enabled: relay.enabled,
+ granted: relay.permissionGranted,
+ apps: [
+ for (final p in relay.seenPackages)
+ RelayApp(p, icon: relay.iconFor(p), on: relay.isAppEnabled(p)),
+ ],
+ onEnabled: relay.setEnabled,
+ onGrant: relay.requestPermission,
+ onApp: relay.setAppEnabled,
+ ),
+ );
+ }
+}
+
+/// The screen, as a pure function of its inputs.
+class BandNotificationsView extends StatelessWidget {
+ const BandNotificationsView({
+ super.key,
+ this.supported = true,
+ this.enabled = false,
+ this.granted = false,
+ this.apps = const [],
+ this.onEnabled,
+ this.onGrant,
+ this.onApp,
+ });
+
+ final bool supported, enabled, granted;
+ final List apps;
+ final ValueChanged? onEnabled;
+ final VoidCallback? onGrant;
+ final void Function(String pkg, bool on)? onApp;
+
+ /// How many apps are actually armed — the one number that says whether the
+ /// feature will do anything at all.
+ int get _armed => apps.where((a) => a.on).length;
+
+ @override
+ Widget build(BuildContext c) {
+ final p = P.of(c);
+ return Scaffold(
+ backgroundColor: p.bg,
+ body: SafeArea(
+ child: Column(children: [
+ const Padding(
+ padding: EdgeInsets.symmetric(horizontal: S.x4),
+ child: NavBar('Band notifications', sub: 'WHAT MAKES THE STRAP BUZZ'),
+ ),
+ Expanded(
+ child: ListView(
+ padding: const EdgeInsets.fromLTRB(S.x4, 0, S.x4, S.x10),
+ children: [
+ if (!supported)
+ const StatusCard(
+ 'This phone cannot do it',
+ 'Reading which app posted a notification is an Android '
+ 'capability. iOS gives no app that access, including '
+ 'this one.',
+ icon: LucideIcons.smartphone,
+ )
+ else ...[
+ settingsGroup(c, 'Relay', [
+ SetRow(LucideIcons.bellRing, C.purple, 'Buzz on app notifications',
+ sub: 'The strap buzzes when one of the apps below '
+ 'notifies you. Nothing is read, stored or sent — '
+ 'only which app posted',
+ value: enabled ? 'On' : 'Off',
+ chevron: false,
+ onTap: () => onEnabled?.call(!enabled)),
+ if (enabled && granted)
+ SetRow(LucideIcons.listChecks, C.teal, 'Apps armed',
+ value: '$_armed', chevron: false),
+ ]),
+ if (enabled && !granted) ...[
+ const SizedBox(height: S.x4),
+ StatusCard(
+ 'Android needs to let us see notifications',
+ 'The permission says which app posted, and that is all '
+ 'this uses it for. Nothing leaves your phone.',
+ fix: 'Grant notification access',
+ icon: LucideIcons.shieldCheck,
+ onFix: onGrant,
+ ),
+ ],
+ if (enabled && granted) ...[
+ if (apps.isEmpty)
+ Padding(
+ padding: const EdgeInsets.only(top: S.x4),
+ child: StatusCard(
+ 'No app has notified you yet',
+ // Absence with its reason, not an empty list: this
+ // is the cost of not asking for the permission that
+ // enumerates every installed app, and it resolves
+ // itself within minutes of ordinary use.
+ 'Apps appear here the first time each one notifies '
+ 'you while the relay is on. Nothing is missed in '
+ 'the meantime — the first ping is what puts an '
+ 'app on this list, and the second can buzz.',
+ icon: LucideIcons.hourglass,
+ ),
+ )
+ else
+ settingsGroup(c, 'Apps that notify you', [
+ for (final a in apps)
+ _AppRow(a, onChanged: onApp),
+ ]),
+ ],
+ const SizedBox(height: S.x4),
+ const StatusCard(
+ 'One buzz, not a stream',
+ 'Repeat posts from the same app are ignored for four '
+ 'seconds, ongoing notifications (media players, '
+ 'downloads) never buzz, and nothing buzzes at all '
+ 'while the band is disconnected.',
+ icon: LucideIcons.waves,
+ ),
+ ],
+ ],
+ ),
+ ),
+ ]),
+ ),
+ );
+ }
+}
+
+/// One app. The icon is the identifier a human reads — [appLabel] is only the
+/// caption under it, derived from the package name because the app's real
+/// label is behind a permission this feature does not ask for.
+class _AppRow extends StatelessWidget {
+ const _AppRow(this.app, {this.onChanged});
+ final RelayApp app;
+ final void Function(String pkg, bool on)? onChanged;
+
+ @override
+ Widget build(BuildContext c) {
+ final p = P.of(c);
+ final icon = app.icon;
+ return Pressable(
+ onTap: () => onChanged?.call(app.package, !app.on),
+ semanticLabel:
+ '${appLabel(app.package)}, ${app.on ? 'buzzes' : 'does not buzz'}',
+ child: Padding(
+ padding: const EdgeInsets.symmetric(vertical: S.x3),
+ child: Row(children: [
+ ClipRRect(
+ borderRadius: R.rSm,
+ child: icon != null && icon.isNotEmpty
+ ? Image.memory(icon,
+ width: 32, height: 32, gaplessPlayback: true)
+ : Container(
+ width: 32,
+ height: 32,
+ alignment: Alignment.center,
+ decoration: BoxDecoration(
+ color: p.wash(C.purple), borderRadius: R.rSm),
+ child: Icon(LucideIcons.appWindow,
+ size: 16, color: p.on(C.purple)),
+ ),
+ ),
+ const SizedBox(width: S.x3),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(appLabel(app.package),
+ style: F.body.copyWith(color: p.ink),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis),
+ Text(app.package,
+ style: F.over.copyWith(color: p.ink3),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis),
+ ]),
+ ),
+ const SizedBox(width: S.x2),
+ Text(app.on ? 'Buzzes' : 'Off',
+ style: F.cap.copyWith(
+ color: app.on ? p.on(C.green) : p.ink3,
+ fontWeight: FontWeight.w600)),
+ ]),
+ ),
+ );
+ }
+}
diff --git a/test/band_notifications_test.dart b/test/band_notifications_test.dart
new file mode 100644
index 00000000..06aa7089
--- /dev/null
+++ b/test/band_notifications_test.dart
@@ -0,0 +1,136 @@
+// THE STRAP-BUZZ RELAY, RENDERED — and the label it has to derive.
+//
+// The point of this screen is that the app ships a notification-listener
+// permission (AndroidManifest.xml declares BIND_NOTIFICATION_LISTENER_SERVICE)
+// with no way to reach the feature it exists for. So the assertions are about
+// reachability and honesty rather than pixels: every state has a control or a
+// reason, the permission is explained where it is asked for, and the empty app
+// list says why it is empty instead of looking broken.
+
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+import 'package:openstrap_edge/notify/notification_relay.dart';
+import 'package:openstrap_edge/ui2/profile/band_notifications.dart';
+import 'package:openstrap_edge/ui2/ui2.dart';
+
+Future _pump(WidgetTester t, Widget w, {double scale = 1}) async {
+ t.view.physicalSize = Size(390 * 3, 2400 * 3 * scale);
+ t.view.devicePixelRatio = 3;
+ addTearDown(t.view.reset);
+ await t.pumpWidget(MediaQuery(
+ data: MediaQueryData(textScaler: TextScaler.linear(scale)),
+ child: MaterialApp(theme: buildTheme(Brightness.light), home: w),
+ ));
+ await t.pumpAndSettle();
+}
+
+void main() {
+ group('the relay screen', () {
+ testWidgets('off is one tap from on, and says what it will do', (t) async {
+ var toggled;
+ await _pump(
+ t,
+ BandNotificationsView(onEnabled: (v) => toggled = v),
+ );
+ expect(find.text('Buzz on app notifications'), findsOneWidget);
+ expect(find.text('Off'), findsOneWidget);
+ await t.tap(find.text('Buzz on app notifications'));
+ expect(toggled, isTrue);
+ });
+
+ testWidgets('on-but-ungranted asks for the permission and says why', (
+ t,
+ ) async {
+ var asked = false;
+ await _pump(
+ t,
+ BandNotificationsView(enabled: true, onGrant: () => asked = true),
+ );
+ expect(find.text('Grant notification access'), findsOneWidget);
+ // The claim that has to be on the same card as the request.
+ expect(find.textContaining('Nothing leaves your phone'), findsOneWidget);
+ await t.tap(find.text('Grant notification access'));
+ expect(asked, isTrue);
+ });
+
+ testWidgets('an empty app list states its reason, not a bare emptiness', (
+ t,
+ ) async {
+ await _pump(t, const BandNotificationsView(enabled: true, granted: true));
+ expect(find.text('No app has notified you yet'), findsOneWidget);
+ expect(find.textContaining('the first time each one notifies'),
+ findsOneWidget);
+ expect(find.text('—'), findsNothing);
+ });
+
+ testWidgets('each seen app is a row you can arm, with its package under it',
+ (t) async {
+ final calls = <(String, bool)>[];
+ await _pump(
+ t,
+ BandNotificationsView(
+ enabled: true,
+ granted: true,
+ apps: const [
+ RelayApp('com.whatsapp', on: true),
+ RelayApp('org.telegram.messenger'),
+ ],
+ onApp: (p, v) => calls.add((p, v)),
+ ),
+ );
+ expect(find.text('Whatsapp'), findsOneWidget);
+ expect(find.text('com.whatsapp'), findsOneWidget);
+ expect(find.text('Buzzes'), findsOneWidget);
+ // The count is the one number that says whether this does anything.
+ expect(find.text('Apps armed'), findsOneWidget);
+ expect(find.text('1'), findsOneWidget);
+
+ await t.tap(find.text('Messenger'));
+ expect(calls, [('org.telegram.messenger', true)]);
+ });
+
+ testWidgets('iOS gets a reason, not a dead switch', (t) async {
+ await _pump(t, const BandNotificationsView(supported: false));
+ expect(find.text('This phone cannot do it'), findsOneWidget);
+ expect(find.text('Buzz on app notifications'), findsNothing);
+ });
+
+ testWidgets('nothing overflows at 2x text', (t) async {
+ await _pump(
+ t,
+ const BandNotificationsView(
+ enabled: true,
+ granted: true,
+ apps: [
+ RelayApp('com.google.android.apps.messaging', on: true),
+ RelayApp('com.whatsapp'),
+ ],
+ ),
+ scale: 2,
+ );
+ expect(t.takeException(), isNull);
+ });
+ });
+
+ group('appLabel', () {
+ test('takes the last meaningful segment, capitalised', () {
+ expect(appLabel('com.whatsapp'), 'Whatsapp');
+ expect(appLabel('org.telegram.messenger'), 'Messenger');
+ expect(appLabel('com.slack'), 'Slack');
+ });
+
+ test('steps over a platform or build segment', () {
+ // "Android" under every second icon is not a name.
+ expect(appLabel('com.foo.android'), 'Foo');
+ expect(appLabel('com.foo.mobile.lite'), 'Foo');
+ });
+
+ test('never returns empty, whatever the package looks like', () {
+ expect(appLabel('android'), 'Android');
+ expect(appLabel('a'), 'A');
+ expect(appLabel('com..bar.'), 'Bar');
+ expect(appLabel(''), '');
+ });
+ });
+}
From 9c49ac6ce82c65b0551439802dacc39ff644df1f Mon Sep 17 00:00:00 2001
From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com>
Date: Wed, 19 Aug 2026 23:30:24 +0530
Subject: [PATCH 18/64] notifications settings: the three rows behind all of
that
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
detected workouts and the movement nudge as switches, and the way into the
strap relay. the relay row is android-only and absent rather than disabled on
ios — there's nothing to explain when the platform gives no app that access.
---
lib/ui2/profile/settings.dart | 50 +++++++++++++++++++++++++++++++++++
1 file changed, 50 insertions(+)
diff --git a/lib/ui2/profile/settings.dart b/lib/ui2/profile/settings.dart
index 03c73349..1adfdb3f 100644
--- a/lib/ui2/profile/settings.dart
+++ b/lib/ui2/profile/settings.dart
@@ -35,6 +35,7 @@ import '../../telemetry/health_uploader.dart';
import '../../theme/theme_controller.dart';
import '../ui2.dart';
import 'alarm.dart';
+import 'band_notifications.dart';
import 'data.dart';
import 'gallery.dart';
import 'profile.dart';
@@ -727,6 +728,12 @@ class _NotificationSettingsState extends State {
});
}
+ /// Whether the strap-buzz relay exists on this platform. Android only —
+ /// iOS gives no app access to another app's notifications — and the row is
+ /// absent rather than disabled there, so there is nothing to explain.
+ bool get _relaySupported =>
+ defaultTargetPlatform == TargetPlatform.android;
+
Future _apply(NotificationPrefs next) async {
setState(() => _prefs = next);
await next.save();
@@ -753,6 +760,7 @@ class _NotificationSettingsState extends State {
prefs: p ?? const NotificationPrefs(),
loaded: p != null,
granted: _granted,
+ relaySupported: _relaySupported,
onChanged: _apply,
onRequestPermission: _requestPermission,
);
@@ -762,6 +770,11 @@ class _NotificationSettingsState extends State {
class NotificationSettingsView extends StatelessWidget {
final NotificationPrefs prefs;
final bool loaded, granted;
+
+ /// Android only. False hides the strap-buzz relay row entirely rather than
+ /// showing a control that cannot work.
+ final bool relaySupported;
+
final Future Function(NotificationPrefs next)? onChanged;
final VoidCallback? onRequestPermission;
@@ -770,6 +783,7 @@ class NotificationSettingsView extends StatelessWidget {
this.prefs = const NotificationPrefs(),
this.loaded = true,
this.granted = true,
+ this.relaySupported = false,
this.onChanged,
this.onRequestPermission,
});
@@ -828,6 +842,31 @@ class NotificationSettingsView extends StatelessWidget {
chevron: false,
onTap: () => set(prefs.copyWith(
remindersEnabled: !prefs.remindersEnabled))),
+ // The auto-detector's off switch, asked for twice (#102,
+ // #149) and never built: the bouts were written, the
+ // prompt was emitted, and nothing anywhere could stop
+ // either. The sub-line says exactly what it stops,
+ // because it does NOT stop the detection itself.
+ SetRow(LucideIcons.radar, C.green, 'Detected workouts',
+ sub: 'Ask about efforts the band spotted that you did '
+ 'not start. Off hides the prompt and the review '
+ 'cards; the band goes on measuring either way',
+ value: prefs.autoDetectEnabled ? 'On' : 'Off',
+ chevron: false,
+ onTap: () => set(prefs.copyWith(
+ autoDetectEnabled: !prefs.autoDetectEnabled))),
+ // Off by default, and it is the switch that lets the nudge
+ // be scheduled at all — see
+ // NotificationService.schedulableIds. It had none, so it
+ // was refused there and had never once fired.
+ SetRow(LucideIcons.footprints, C.orange, 'Movement nudge',
+ sub: 'One notification after two hours with no '
+ 'movement at all, and only while the band is on '
+ 'and connected. Never inside 21:00–09:00',
+ value: prefs.movementEnabled ? 'On' : 'Off',
+ chevron: false,
+ onTap: () => set(prefs.copyWith(
+ movementEnabled: !prefs.movementEnabled))),
// A prompt to log, not a reading. The app measures no
// hydration and this row may never imply it does.
SetRow(LucideIcons.glassWater, C.teal, 'Water reminder',
@@ -848,6 +887,17 @@ class NotificationSettingsView extends StatelessWidget {
waterIntervalMin:
_nextEvery(prefs.waterIntervalMin)))),
]),
+ if (relaySupported)
+ settingsGroup(c, 'The strap', [
+ // The other direction: not what this app sends you, but
+ // what your phone's apps make the band do. The permission
+ // for it has been in the manifest all along with nothing
+ // in the app that could reach it.
+ SetRow(LucideIcons.bellRing, C.purple,
+ 'Buzz on app notifications',
+ sub: 'Pick which phone apps make the strap buzz',
+ onTap: () => goto(c, const BandNotifications())),
+ ]),
settingsGroup(c, 'Quiet hours', [
SetRow(LucideIcons.moon, C.indigo, 'Quiet hours',
sub: 'Nothing buzzes inside this window',
From b580d7a6290b2fdd433e8295a4bbc9c2cd0ce4a3 Mon Sep 17 00:00:00 2001
From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com>
Date: Wed, 19 Aug 2026 23:30:24 +0530
Subject: [PATCH 19/64] note the missing health export on the coach's workout
write (#130)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
addCompletedWorkout is the one write path that doesn't export. leaving a marker
rather than guessing — the export seam is being reworked in the same pass.
---
lib/coach/coach_actions.dart | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/lib/coach/coach_actions.dart b/lib/coach/coach_actions.dart
index 7596c2fa..40bad3b9 100644
--- a/lib/coach/coach_actions.dart
+++ b/lib/coach/coach_actions.dart
@@ -264,6 +264,12 @@ class CoachActions {
endTs: startTs + mins * 60,
type: type,
);
+ // TODO(#130): export this session to the phone's health store, the way
+ // AppState.stopWorkout does. Every other write path exports; a workout
+ // logged through the coach reaches the health store only if the next
+ // day-result pass happens to sweep it up. The export seam is being
+ // reworked in the same audit — the one-line call goes here once its
+ // signature lands, and it must be a no-op when health sync is off.
return jsonEncode({'saved': true, 'date': d, 'type': type, ...r});
} catch (e) {
// The repo rejects overlaps, futures and absurd durations. Hand the
From 799e8e02d26dcf530380aa912be50dfedfaeeff8 Mon Sep 17 00:00:00 2001
From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com>
Date: Wed, 19 Aug 2026 23:33:43 +0530
Subject: [PATCH 20/64] health export seam takes a workout id (#130)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
exportWorkoutToHealth took the row, and both its callers went out with the old
lib/ui/workouts, so it's had zero callers for a while. the paths that actually
need it — the coach's add_completed_workout, the log-workout sheet — hold the
workout_id logManualWorkout hands back, not the row, and most have no AppState
either. so: HealthExporter.exportWorkoutId(id) looks the row up itself, off a
shared exporter instance. gated on the health_sync pref, since these callers
can't check healthSyncEnabled the way stopWorkout does.
---
lib/health/health_export.dart | 39 +++++++++++++++++++++++++++++++++++
lib/state/app_state.dart | 4 +++-
2 files changed, 42 insertions(+), 1 deletion(-)
diff --git a/lib/health/health_export.dart b/lib/health/health_export.dart
index 6b689d1f..3339cccc 100644
--- a/lib/health/health_export.dart
+++ b/lib/health/health_export.dart
@@ -23,6 +23,7 @@ import 'dart:io' show Platform;
import 'package:android_intent_plus/android_intent.dart';
import 'package:flutter/foundation.dart';
import 'package:health/health.dart';
+import 'package:shared_preferences/shared_preferences.dart';
import '../data/db.dart';
import '../data/series_codec.dart';
@@ -39,6 +40,12 @@ enum HealthLinkState {
unsupported, // no health store on this device (iPad / simulator)
}
+/// The user's "sync to Apple Health / Health Connect" switch. `AppState` owns
+/// the toggle; the key lives here so an export seam reached without an
+/// AppState can honour the same answer instead of keeping a second copy of the
+/// string.
+const String kHealthSyncPref = 'health_sync';
+
const _sleepHealthTypes = {
HealthDataType.SLEEP_DEEP,
HealthDataType.SLEEP_REM,
@@ -191,6 +198,38 @@ class HealthExporter {
: _androidHeartRate =
androidHeartRate ?? MethodChannelHealthConnectHeartRateWriter();
+ /// The process-wide exporter. `AppState` holds this one, and so does every
+ /// seam that lands a session without a widget tree to read AppState from —
+ /// the coach's `add_completed_workout` tool has only a [LocalRepository].
+ /// Lazily built, so importing this file starts no platform channels.
+ static final HealthExporter shared = HealthExporter();
+
+ /// [exportWorkout] for a caller that holds the ID it just wrote rather than
+ /// the row: `logManualWorkout` returns `workout_id`, not the session. This
+ /// is the seam issue #130 is actually about — a workout logged from the
+ /// coach (or any non-UI path) otherwise reaches the health store only if a
+ /// full-day export happens to run afterwards, which needs a `day_result`
+ /// row AND a derive pass, so a hand-logged session can sit unexported for
+ /// hours.
+ ///
+ /// GATED ON [kHealthSyncPref], because unlike `AppState.stopWorkout` these
+ /// callers have no `healthSyncEnabled` to check first — and writing to the
+ /// platform store with the switch off is exactly the thing the switch is
+ /// for. Best-effort: never throws, false when nothing was written.
+ static Future exportWorkoutId(String? id) async {
+ if (id == null || id.isEmpty) return false;
+ try {
+ final prefs = await SharedPreferences.getInstance();
+ if (prefs.getBool(kHealthSyncPref) != true) return false;
+ final row = await LocalDb.session(id);
+ if (row == null) return false;
+ return await shared.exportWorkout(row);
+ } catch (e) {
+ debugPrint('[health] exportWorkoutId $id: $e');
+ return false;
+ }
+ }
+
/// True on iOS/macOS (Apple Health); false on Android (Health Connect).
static bool get isApple => Platform.isIOS || Platform.isMacOS;
diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart
index b3e34377..eb3c1a8e 100644
--- a/lib/state/app_state.dart
+++ b/lib/state/app_state.dart
@@ -465,7 +465,9 @@ class AppState extends ChangeNotifier {
HealthExportSingleFlight();
HealthLinkState healthState = HealthLinkState.unknown;
bool healthSyncEnabled = false;
- static const String _kHealthSync = 'health_sync';
+ // Shared with `HealthExporter.exportWorkoutId`, which has to honour this
+ // switch from callers that never see this class.
+ static const String _kHealthSync = kHealthSyncPref;
/// "Apple Health" (iOS) or "Health Connect" (Android).
String get healthStoreName => HealthExporter.storeName;
From 1b90add5b5527b2f1c03ab69b3f07a9a726d0087 Mon Sep 17 00:00:00 2001
From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com>
Date: Wed, 19 Aug 2026 23:52:32 +0530
Subject: [PATCH 21/64] import: one unix_s header constant, not two
the router and the reader were each matching their own copy. same string,
nothing to keep them that way.
---
lib/import/noop_import.dart | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/lib/import/noop_import.dart b/lib/import/noop_import.dart
index 6cfe992f..7aa1d54c 100644
--- a/lib/import/noop_import.dart
+++ b/lib/import/noop_import.dart
@@ -182,7 +182,7 @@ class NoopImporter {
await for (final line in lines) {
if (line.isEmpty || line.startsWith('#')) continue;
firstLine ??= line;
- if (line.startsWith('unix_s,')) {
+ if (line.startsWith(kNoopCsvHeader)) {
sawHeader = true;
// Header → (re)build the name→index map and skip.
final h = line.split(',');
From e8e1bb0c1aed47f40c23294c20157272ec12c5cd Mon Sep 17 00:00:00 2001
From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com>
Date: Wed, 19 Aug 2026 23:57:11 +0530
Subject: [PATCH 22/64] readiness bands: 50 is the middle of the scale, not a
warning (#250)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
the composite is 100/(1+exp(-z̄)) with no scale param, so a night at your own
median scores 50 by construction — and we labelled that "take it easy". the
cut-offs are now the score's own quantiles at σ(z̄)≈0.65 (the weighted mean of
3-4 robust z's, allowing for how correlated hrv/rhr/rr actually are):
score = 100/(1+exp(-0.65·Φ⁻¹(p))), p=.05 → 26, p=.20 → 37, p=.75 → 61
nights per band, before → after:
rest today 27% → 5%
take it easy 47% → 15%
steady 25% → 55%
good to go 2% → 25%
"good to go" used to need every input ~1.4 SD above median at once, which is
why nobody ever saw it. RR's 56 lands on "steady" now instead of a warning.
shipped number: no score changes, but the label and the published tier do —
widget, watch and siri all read `readiness_tier`.
---
lib/ui2/screens/home_screen.dart | 34 ++++++++++++++++++++++---
lib/ui2/screens/readiness_detail.dart | 5 ++--
test/widget_service_sentinels_test.dart | 21 ++++++++++-----
3 files changed, 48 insertions(+), 12 deletions(-)
diff --git a/lib/ui2/screens/home_screen.dart b/lib/ui2/screens/home_screen.dart
index b76c7a3e..6edd373f 100644
--- a/lib/ui2/screens/home_screen.dart
+++ b/lib/ui2/screens/home_screen.dart
@@ -479,11 +479,39 @@ String prettyDay(String? dayId) {
/// palettes instead of each keeping a private copy of the cut-offs. They did,
/// and a 65 rendered green on the phone, orange on the widget and yellow on
/// the wrist. -1 = not scored.
+///
+/// THE CUT-OFFS ARE THE SCORE'S OWN QUANTILES, NOT ROUND NUMBERS (issue #250).
+/// `readinessComposite` is `100 / (1 + exp(-z̄))` with no scale parameter, and
+/// z̄ is a weight-renormalised mean of per-input robust z's — each ~N(0,1)
+/// against that person's OWN baseline. So the score is a percentile of self
+/// whose CENTRE IS 50 BY CONSTRUCTION: a night exactly at personal median
+/// scores 50, and the old 40/60/80 bands filed that median night under "Take it
+/// easy". Roughly a quarter of all nights fell under "Rest today" and 1.7 %
+/// could ever reach "Good to go" — it needed every input ~1.4 SD above median
+/// at once. A warning that fires on the typical night is not a warning.
+///
+/// z̄'s own SD is NOT 1: averaging the disclosed weights (.40/.30/.20/.10,
+/// renormalised over present inputs) gives σ ≈ 0.55-0.60 if the inputs were
+/// independent, ~0.70 at the positive correlation HRV/RHR/RR actually have.
+/// σ ≈ 0.65 is the middle of that, and the cut-offs below are its quantiles:
+///
+/// score = 100 / (1 + exp(-0.65 · Φ⁻¹(p)))
+/// p=.05 → 26 p=.20 → 37 p=.75 → 61
+///
+/// which lands 5 % of nights on "Rest today", 15 % on "Take it easy", 55 % on
+/// "Steady" and 25 % on "Good to go". The median night is now the neutral band,
+/// which is the whole point. Under the old cut-offs the same distribution read
+/// 27 / 47 / 25 / 2.
+///
+/// σ is the one soft number here — it is a property of how correlated a given
+/// person's four inputs are, and it moves with how many of them are present.
+/// Re-derive it from a real `metric_series` readiness distribution when there
+/// is one long enough to measure; do not nudge the cut-offs by feel.
({String label, Color color, int tier}) readinessBand(num? v) {
if (v == null) return (label: 'Not scored', color: C.n400, tier: -1);
- if (v >= 80) return (label: 'Good to go', color: C.green, tier: 3);
- if (v >= 60) return (label: 'Steady', color: C.green, tier: 2);
- if (v >= 40) return (label: 'Take it easy', color: C.orange, tier: 1);
+ if (v >= 61) return (label: 'Good to go', color: C.green, tier: 3);
+ if (v >= 37) return (label: 'Steady', color: C.green, tier: 2);
+ if (v >= 26) return (label: 'Take it easy', color: C.orange, tier: 1);
return (label: 'Rest today', color: C.red, tier: 0);
}
diff --git a/lib/ui2/screens/readiness_detail.dart b/lib/ui2/screens/readiness_detail.dart
index e4a1e523..37cb88cf 100644
--- a/lib/ui2/screens/readiness_detail.dart
+++ b/lib/ui2/screens/readiness_detail.dart
@@ -86,8 +86,9 @@ class ReadinessData {
readiness: readiness,
// `narrative` and the glass-box `score` are DELIBERATELY not read. Both
// belong to the deprecated percentile score, which bands at 70/40 while
- // the headline composite bands at 80/60/40 — printing its verdict under
- // the ring put "You're ready" directly beneath "45 · Take it easy". The
+ // the headline composite bands at 61/37/26 (see `readinessBand`) —
+ // printing its verdict under the ring put "You're ready" directly
+ // beneath "45 · Take it easy". The
// breakdown below IS worth keeping; it is a parallel ranking of the same
// four inputs, and the footer now says so.
breakdown: [
diff --git a/test/widget_service_sentinels_test.dart b/test/widget_service_sentinels_test.dart
index f94cbfb7..344c3b9b 100644
--- a/test/widget_service_sentinels_test.dart
+++ b/test/widget_service_sentinels_test.dart
@@ -147,16 +147,23 @@ void main() {
group('readiness banding', () {
test('tiers at the boundaries', () {
expect(readinessBand(100).tier, 3);
- expect(readinessBand(80).tier, 3);
- expect(readinessBand(79.9).tier, 2);
- expect(readinessBand(65).tier, 2);
- expect(readinessBand(60).tier, 2);
- expect(readinessBand(59.9).tier, 1);
- expect(readinessBand(40).tier, 1);
- expect(readinessBand(38).tier, 0);
+ expect(readinessBand(61).tier, 3);
+ expect(readinessBand(60.9).tier, 2);
+ expect(readinessBand(50).tier, 2);
+ expect(readinessBand(37).tier, 2);
+ expect(readinessBand(36.9).tier, 1);
+ expect(readinessBand(26).tier, 1);
+ expect(readinessBand(25.9).tier, 0);
expect(readinessBand(0).tier, 0);
});
+ // The bug the cut-offs above exist to fix (#250): the score's centre is 50
+ // by construction, so whatever band contains 50 is the one a typical night
+ // gets. It must not be a warning.
+ test('a night at personal median is the neutral band, not a warning', () {
+ expect(readinessBand(50).label, 'Steady');
+ });
+
test('an unscored day is tier -1, which every native reader paints grey',
() {
expect(readinessBand(null).tier, -1);
From d43353dc7eb79eb49487288be5373c11bbf4fe96 Mon Sep 17 00:00:00 2001
From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com>
Date: Wed, 19 Aug 2026 23:58:10 +0530
Subject: [PATCH 23/64] calories: pass the resting HR the new active gate needs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
analytics af9d6f3 made `dailyEnergy`'s active gate a %HRR flex point, so
restingHr is required now. wakeDayEnergy takes it too and abstains without one
— no resting HR means no gate, and no gate means every wake minute bills as
active, which is worse than an absent figure. the day pipeline uses the same
anchor its TRIMP is scored against.
not from the audit list — the analytics change landed mid-branch and this is
the edge side of it. no number moves for anyone who has a resting HR.
---
lib/compute/derivation_engine.dart | 9 +++++++++
lib/compute/onehz_pipeline.dart | 9 ++++++++-
test/workout_calorie_anchors_test.dart | 21 +++++++++++++++++++++
3 files changed, 38 insertions(+), 1 deletion(-)
diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart
index 337026ad..079602e2 100644
--- a/lib/compute/derivation_engine.dart
+++ b/lib/compute/derivation_engine.dart
@@ -4609,10 +4609,15 @@ class DerivationEngine {
static ({double active, double basal, double total})? wakeDayEnergy(
List wakeHrPerMin, {
required Profile profile,
+ required double? restingHr,
int? dayMinutes,
String? deviceFamily,
}) {
if (!profile.hasCalorieAnchors) return null;
+ // The active gate is a %HRR flex point, so it needs BOTH ends of the
+ // reserve. No resting HR, no gate — and no gate means every wake minute
+ // bills as active. Abstain, same as an absent ceiling below.
+ if (restingHr == null) return null;
// `dailyEnergy`'s flex gate is a fraction of HRmax, so an absent ceiling is
// an absent gate — the whole triple abstains rather than bill a day against
// some other strap's number. See hr_max.dart.
@@ -4636,6 +4641,7 @@ class DerivationEngine {
sex: _workoutSex(profile.sex),
),
hrmax: hrmax,
+ restingHr: restingHr,
dayMinutes: dayMinutes ?? 1440,
);
return (active: e.active, basal: e.basal, total: e.total);
@@ -5331,6 +5337,9 @@ class DerivationEngine {
final energy = wakeDayEnergy(
perMin,
profile: profile,
+ // The same anchor the TRIMP above is scored against — a nocturnal RHR
+ // or the one the user entered, never a daytime fallback.
+ restingHr: rhrForTrimp,
dayMinutes: motion.length,
deviceFamily: daySub.deviceFamily,
);
diff --git a/lib/compute/onehz_pipeline.dart b/lib/compute/onehz_pipeline.dart
index 29b8e5a6..792fcdbd 100644
--- a/lib/compute/onehz_pipeline.dart
+++ b/lib/compute/onehz_pipeline.dart
@@ -684,10 +684,16 @@ Map deriveDayBundle(Map inputJson) {
// active figure this line publishes (about 117 kcal/day across a
// 150-195 cm profile). `wakeDayEnergy` abstains for that reason; so does
// this, or Today shows an imputed number the derived day then withdraws.
+ //
+ // The RESTING HR is required for the same class of reason: `dailyEnergy`'s
+ // active gate is a %HRR flex point, so without the lower reserve anchor
+ // there is no gate and every wake minute bills as active. `wakeDayEnergy`
+ // abstains without it; so does this, or the two drift again.
if (age != null &&
sex != null &&
weightKg != null &&
- heightCm != null) {
+ heightCm != null &&
+ rhrForTrimp != null) {
caloriesKcal = Calories.dailyEnergy(
perMin,
profile: WorkoutUserProfile(
@@ -697,6 +703,7 @@ Map deriveDayBundle(Map inputJson) {
sex: workoutSex(sex),
),
hrmax: hrMax,
+ restingHr: rhrForTrimp,
).active; // active-energy component (Keytel surplus over basal)
}
}
diff --git a/test/workout_calorie_anchors_test.dart b/test/workout_calorie_anchors_test.dart
index 2afb1cbf..ba26c29a 100644
--- a/test/workout_calorie_anchors_test.dart
+++ b/test/workout_calorie_anchors_test.dart
@@ -73,6 +73,7 @@ void main() {
DerivationEngine.wakeDayEnergy(
[for (var i = 0; i < 60; i++) 140.0],
profile: _anchored,
+ restingHr: 55,
deviceFamily: 'gen4',
),
isNull,
@@ -87,6 +88,7 @@ void main() {
heightCm: 178,
sex: 'm',
),
+ restingHr: 55,
deviceFamily: 'gen4',
),
isNotNull,
@@ -105,10 +107,29 @@ void main() {
heightCm: 178,
sex: 'm',
),
+ restingHr: 55,
),
isNotNull,
reason: 'Tanaka is an age formula, not a calibration constant',
);
+
+ // The active gate is a %HRR flex point, so it needs the LOWER reserve
+ // anchor too. Without one there is no gate and every wake minute bills as
+ // active, which is a bigger lie than an absent figure.
+ expect(
+ DerivationEngine.wakeDayEnergy(
+ [for (var i = 0; i < 60; i++) 140.0],
+ profile: const Profile(
+ ageYears: 34,
+ weightKg: 72,
+ heightCm: 178,
+ sex: 'm',
+ ),
+ restingHr: null,
+ ),
+ isNull,
+ reason: 'no resting HR, no active gate',
+ );
});
});
From effb90d9fa17fc81f21d5536eafc44a38d90ddc4 Mon Sep 17 00:00:00 2001
From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com>
Date: Wed, 19 Aug 2026 23:58:28 +0530
Subject: [PATCH 24/64] strain: state which quiet-waking level we mean
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
analytics 0a30315 stopped defaulting it, so every caller has to say. passing
`quietWakingHrr` — the constant the anchor table was generated at — keeps
today's strain exactly where it is.
the real fix is edge#226: `dailyQuietWakingHrr` through a rolling personal
median, and the bout scorers need the same one the day uses or a workout
subtracts its own effort away. that needs a series key and baseline plumbing,
so it is not this commit. all five call sites carry the note.
---
lib/compute/derivation_engine.dart | 3 +++
lib/compute/manual_session.dart | 3 +++
lib/compute/onehz_pipeline.dart | 20 +++++++++++++++++++-
lib/compute/strain_backfill.dart | 9 ++++++++-
4 files changed, 33 insertions(+), 2 deletions(-)
diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart
index 079602e2..367f068d 100644
--- a/lib/compute/derivation_engine.dart
+++ b/lib/compute/derivation_engine.dart
@@ -5284,6 +5284,9 @@ class DerivationEngine {
final score = ana.strainScoreMetric(
trimp.value,
wakeMinutes: perMin.length.toDouble(),
+ // Reference level, not this user's — see onehz_pipeline's
+ // `strainMetric` for why, and edge#226 for the fix.
+ quietHrr: ana.quietWakingHrr,
female: _workoutSex(sex) == 'female',
);
if (score.present) strain = score.value;
diff --git a/lib/compute/manual_session.dart b/lib/compute/manual_session.dart
index 35330dd1..9ab33efb 100644
--- a/lib/compute/manual_session.dart
+++ b/lib/compute/manual_session.dart
@@ -270,6 +270,9 @@ double? strainFromPerMinuteHr(
final score = ana.strainScoreMetric(
trimp.value,
wakeMinutes: perMinuteHr.length.toDouble(),
+ // Reference level, not this user's — see onehz_pipeline's
+ // `strainMetric` for why, and edge#226 for the fix.
+ quietHrr: ana.quietWakingHrr,
female: workoutSex(sex) == 'female',
);
return score.present ? score.value : null;
diff --git a/lib/compute/onehz_pipeline.dart b/lib/compute/onehz_pipeline.dart
index 792fcdbd..e6608dfc 100644
--- a/lib/compute/onehz_pipeline.dart
+++ b/lib/compute/onehz_pipeline.dart
@@ -715,6 +715,17 @@ Map deriveDayBundle(Map inputJson) {
final strainMetric = strainScoreMetric(
rawTrimp,
wakeMinutes: perMin.isEmpty ? null : perMin.length.toDouble(),
+ // THE REFERENCE LEVEL, NOT THIS USER'S (edge#226 is still open). analytics
+ // stopped defaulting the quiet-waking level so every caller has to state
+ // which one it means; `quietWakingHrr` is the constant the anchor table was
+ // generated at, so passing it reproduces the strain this app ships today
+ // and nobody's number moves on this commit. The real level is
+ // `dailyQuietWakingHrr` fed through a rolling personal median — a trait,
+ // not a day, and the workout scorers need the same one the day uses or a
+ // bout subtracts its own effort away. That plumbing is edge#226.
+ // ponytail: population constant, swap for the rolling personal median when
+ // edge#226 lands — see the same comment at the other four call sites.
+ quietHrr: quietWakingHrr,
female: workoutSex(sex) == 'female',
);
@@ -1526,7 +1537,14 @@ List> _strainCurve(
out.add({
't': p.tsSec,
'v': _round(
- strainScore(trimp, wakeMinutes: wakeMin, female: female),
+ strainScore(
+ trimp,
+ wakeMinutes: wakeMin,
+ // Reference level, not this user's — see onehz_pipeline's
+ // `strainMetric` for why, and edge#226 for the fix.
+ quietHrr: quietWakingHrr,
+ female: female,
+ ),
2,
),
});
diff --git a/lib/compute/strain_backfill.dart b/lib/compute/strain_backfill.dart
index 5a670caa..46762b80 100644
--- a/lib/compute/strain_backfill.dart
+++ b/lib/compute/strain_backfill.dart
@@ -71,7 +71,14 @@ double? rescaledStrain({
required bool female,
}) {
if (trimp == null || wakeMinutes == null || wakeMinutes <= 0) return null;
- return ana.strainScore(trimp, wakeMinutes: wakeMinutes, female: female);
+ return ana.strainScore(
+ trimp,
+ wakeMinutes: wakeMinutes,
+ // Reference level, not this user's — see onehz_pipeline's
+ // `strainMetric` for why, and edge#226 for the fix.
+ quietHrr: ana.quietWakingHrr,
+ female: female,
+ );
}
/// Rescale every stored day that can no longer be re-derived from raw.
From 88267d9ebd2d4f5b5ce9cc388c69a865d7250a18 Mon Sep 17 00:00:00 2001
From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com>
Date: Wed, 19 Aug 2026 23:58:50 +0530
Subject: [PATCH 25/64] readiness: pass the settled fraction, so skin temp can
actually be a driver (#250)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`tempInput` refuses the temp driver outright when settledFraction is null, and
nothing in edge ever passed it — so the documented fourth driver has never once
contributed on any night, hrv/rhr/rr renormalised over 0.90, and "skin
temperature" could never appear in a breakdown. with minInputs=2 that also left
users one thin baseline from a blank score.
`nightlySkinTemp` measures it. called with minSettledFraction 0 on purpose:
measure here, gate in `tempInput`, or an unsettled night lands on the "nobody
measured it" refusal instead of "the strap was cold for two hours". it still
goes absent where the fraction genuinely cannot be measured — a family with no
settle band (gen5 has none) or a night under sixty samples — and those nights
say so by name.
the mean stays raw: value and baseline have to be the same quantity and the
stored history is raw nightly means.
shipped number: yes. readiness moves on any gen4 night whose strap was settled
— temp now carries its 0.10 and the other three renormalise over 1.0 instead of
0.90. also emits skin_temp_settled_frac.
---
lib/compute/onehz_pipeline.dart | 48 ++++++++++++++++++++++++++++++++-
1 file changed, 47 insertions(+), 1 deletion(-)
diff --git a/lib/compute/onehz_pipeline.dart b/lib/compute/onehz_pipeline.dart
index e6608dfc..8bea8fca 100644
--- a/lib/compute/onehz_pipeline.dart
+++ b/lib/compute/onehz_pipeline.dart
@@ -480,6 +480,33 @@ Map deriveDayBundle(Map inputJson) {
final double? skinTempCoverage = (inBedSec == null || inBedSec <= 0)
? null
: (tempValid.length / inBedSec).clamp(0.0, 1.0);
+ // HOW MUCH OF THE NIGHT THE STRAP SPENT AT SKIN TEMPERATURE (#250).
+ //
+ // `tempInput` refuses readiness's temp driver outright when this is null, and
+ // nothing in this app has ever passed it — so the documented FOURTH DRIVER
+ // has never contributed on any night, the other three renormalised over 0.90,
+ // and "Skin temperature" could not appear in a breakdown. This is the number
+ // it wants: the share of the night's valid samples sitting within the
+ // family's settle band of the night's OWN median (warm-up and off-body read
+ // low; a fever reads high and passes through).
+ //
+ // MEASURED HERE, GATED IN `tempInput` — hence `minSettledFraction: 0`.
+ // `nightlySkinTemp` would otherwise go absent on an unsettled night and the
+ // fraction would be lost, which lands on the "nobody measured it" refusal
+ // instead of the true "the strap was cold for two hours" one. It still goes
+ // absent for a family whose settle band nobody has measured (gen5 has none)
+ // and for a night under sixty samples, and those genuinely ARE "no fraction
+ // measured".
+ //
+ // Ts is not read by `nightlySkinTemp` (it is a median + a mean over the
+ // night's samples), and `tempValid` has no parallel timestamp series, so 0
+ // is passed rather than a fabricated clock.
+ final settledTemp = nightlySkinTemp(
+ [for (final v in tempValid) AdcSample(0, v)],
+ deviceFamily: d.deviceFamily,
+ minSettledFraction: 0.0,
+ );
+ final double? skinTempSettledFrac = settledTemp.value?.settledFraction;
// STEP 2 — z-score today's RAW mean against the RAW-ADC baseline history (NOT
// the previously-computed z-scores; that unit mismatch was the bug). Gated on
// ≥3 prior raw means.
@@ -509,7 +536,16 @@ Map deriveDayBundle(Map inputJson) {
// Feed the RAW ADC mean + the RAW-ADC baseline so the composite computes its
// own oriented robust-z internally (consistent with the other inputs, which
// pass raw values + their raw baselines).
- tempInput(skinTempAdc, d.skinTempAdcHistory),
+ //
+ // The mean stays RAW — value and baseline have to be the same quantity, and
+ // the stored history is a series of raw nightly means. The settled fraction
+ // is the GATE on using it at all: below 0.80 the driver is refused for this
+ // night, by name, and readiness renormalises over the three that are left.
+ tempInput(
+ skinTempAdc,
+ d.skinTempAdcHistory,
+ settledFraction: skinTempSettledFrac,
+ ),
]);
// Diagnostic only — populated when readiness comes back absent, so the main
// isolate can log WHY to Crashlytics instead of a bare null (this runs
@@ -545,6 +581,9 @@ Map deriveDayBundle(Map inputJson) {
'value': skinTempAdc != null,
'baseline_n': d.skinTempAdcHistory.length,
'baseline_sd': _stddev(d.skinTempAdcHistory),
+ // The gate, not the value: a temp driver can be refused with a perfectly
+ // good mean and a full baseline. Null = the fraction was unmeasurable.
+ 'settled_frac': skinTempSettledFrac,
},
'note': composite.note,
};
@@ -1254,6 +1293,13 @@ Map deriveDayBundle(Map inputJson) {
'skin_temp_coverage_frac': skinTempCoverage == null
? null
: _round(skinTempCoverage, 4),
+ // RD-15 — the settled fraction readiness's temp driver is gated on, so a
+ // night whose driver was refused can be told apart from one where the
+ // gate never ran. NULL means the fraction itself is unmeasurable (no
+ // settle band for this band's family, or under sixty samples).
+ 'skin_temp_settled_frac': skinTempSettledFrac == null
+ ? null
+ : _round(skinTempSettledFrac, 4),
'sdnn': hrvT.present ? hrvT.value!.sdnn : null,
// CV-03 — deceleration capacity (ms). Personal trend only: PRSA anchors on
// decelerations and pulse-arrival jitter attenuates DC by an amount that
From 2a1f6acbaadf66ccbe0bc2ca09c1bd4cb94e983f Mon Sep 17 00:00:00 2001
From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com>
Date: Wed, 19 Aug 2026 23:59:13 +0530
Subject: [PATCH 26/64] peak hr: the day peak and the manual save go through
the same smoothing (#127)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
#127 didn't get fixed, it moved. the three workout producers smooth through
hr_max.dart now, but the day peak was still a bare reduce(max) over raw 1 Hz —
so the same PPG transient that gave RR 160-vs-143 was still on the strain card
while the timeline showed the per-minute-mean peak. both copies of it (pipeline
and derivation engine) route through smoothedMaxHr now, and the min with them:
a 1 s dropout must not define the day's low either.
same family, two more:
- computeManualSessionStats banked a raw peak, and one caller re-smoothed it
afterwards. smoothed at the source instead, so the manual save, the re-score
and the workout list are one definition rather than three that agree by
convention.
- reconcileSessionScore took max(stored, substrate) for max_hr below 90%
coverage. strain and calories accumulate — over a subset of the window each
is a floor and the bigger floor is the better estimate. a maximum moves the
other way: an artefact only ever makes it bigger, so max() is a ratchet a
spike wins forever. it did, on any session the band never fully offloaded.
the substrate's peak wins whenever it has one, which is also what
_sessionTrace already displays.
shipped number: yes. day peak/min hr, manually logged and retimed session
max_hr, and any session whose stored max_hr was spiked.
---
lib/compute/derivation_engine.dart | 15 +++++++--
lib/compute/manual_session.dart | 31 ++++++++++++++++--
lib/compute/onehz_pipeline.dart | 19 +++++++++--
lib/data/local_repository_impl.dart | 19 +++--------
test/session_score_reconcile_test.dart | 44 ++++++++++++++++++++++++--
5 files changed, 104 insertions(+), 24 deletions(-)
diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart
index 367f068d..448bf694 100644
--- a/lib/compute/derivation_engine.dart
+++ b/lib/compute/derivation_engine.dart
@@ -44,7 +44,8 @@ import '../notify/tap_router.dart' show kRouteWorkoutSuggestion;
import '../telemetry/telemetry_service.dart';
import 'crossday_pipeline.dart';
import 'derive_pacing.dart';
-import 'hr_max.dart' show estimatedMaxHr, kHrFloorBpm;
+import 'hr_max.dart'
+ show estimatedMaxHr, kHrFloorBpm, smoothedMaxHr, smoothedMinHr;
import 'movement_floor_policy.dart' as mfp;
import 'sleep_profile_policy.dart';
import 'derive_prepare.dart';
@@ -5352,11 +5353,19 @@ class DerivationEngine {
caloriesBasal = energy.basal;
}
}
+ // Same peak, same smoothing as the pipeline's copy and as every workout
+ // producer — see `hr_max.dart` and the note beside the pipeline's `hrStats`.
+ // A bare max over raw 1 Hz let one PPG transient be the day's "Peak HR"
+ // (#127).
+ final dayHrInt = [for (final h in dayHrValid) h.round()];
+ final age = profile.ageYears?.round();
final hrStats = dayHrValid.isEmpty
? null
: {
- 'max': dayHrValid.reduce(math.max).round(),
- 'min': dayHrValid.reduce(math.min).round(),
+ 'max': smoothedMaxHr(dayHrInt, age: age) ??
+ dayHrValid.reduce(math.max).round(),
+ 'min': smoothedMinHr(dayHrInt, age: age) ??
+ dayHrValid.reduce(math.min).round(),
'avg': _meanWake(dayHrValid)?.round(),
};
return {
diff --git a/lib/compute/manual_session.dart b/lib/compute/manual_session.dart
index 9ab33efb..1a2af375 100644
--- a/lib/compute/manual_session.dart
+++ b/lib/compute/manual_session.dart
@@ -31,6 +31,7 @@ import 'dart:convert';
import 'package:openstrap_analytics/onehz.dart' as ana;
+import 'hr_max.dart' show smoothedMaxHr;
import 'profile.dart';
/// Shortest window we accept. Below a minute the 1 Hz substrate cannot say
@@ -325,10 +326,17 @@ ManualSessionStats computeManualSessionStats({
if (worn.isEmpty) return const ManualSessionStats();
final avg = worn.reduce((a, b) => a + b) / worn.length;
- final peak = worn.reduce((a, b) => a > b ? a : b);
final perMin = hrPerMinute(wornTs, worn);
final age = profile.ageYears?.toDouble();
+ // THE peak, spike-suppressed, at the point every save goes through (#127).
+ // This was a raw `reduce(max)` and one caller re-smoothed it afterwards, so a
+ // manually logged or retimed session banked the transient — and once the raw
+ // window is pruned there is nothing left to correct it from. Smoothing here
+ // means the stored value is the same quantity the re-score and the Heart page
+ // report, rather than three producers agreeing by convention.
+ final peak = smoothedMaxHr(worn, age: age?.round()) ??
+ worn.reduce((a, b) => a > b ? a : b);
final weightKg = profile.weightKg;
final sex = profile.sex?.toLowerCase();
@@ -565,7 +573,26 @@ ReconciledSessionScore reconcileSessionScore({
final strain = better(liveStrain, substrate.strain);
final calories = better(liveCalories, substrate.calories);
- final maxHr = better(liveMaxHr, substrate.maxHr);
+ // MAX HR IS NOT A LOWER BOUND, so `better` is the wrong rule for it (#127).
+ // Strain and calories accumulate: over a subset of the window each is a floor,
+ // and the larger of two floors is the better estimate. A maximum moves the
+ // other way — an artefact only ever makes it BIGGER, so `max(live, substrate)`
+ // is a ratchet that a single PPG transient wins forever. It did: a session
+ // saved before the peak was smoothed carries a spike in `max_hr`, the
+ // substrate re-scores it to the real figure, and the ratchet put the spike
+ // straight back on every pass under 90 % coverage.
+ //
+ // The substrate is the same band's record of the same window with artefact
+ // rejection applied, and it is what the Heart page and the day's Peak HR are
+ // read from — so when it has a peak, that is the peak, and every surface says
+ // the same number. The live value survives only where the substrate has none.
+ //
+ // THE COST, accepted: a window the band never fully hands over can report a
+ // peak lower than the live tally saw. That is not a new understatement — it
+ // is the same one the session's HR trace and the day's Peak HR already show
+ // for those minutes, and #127 is a complaint about two screens disagreeing,
+ // not about the peak being low.
+ final maxHr = substrate.maxHr ?? liveMaxHr;
// Zone minutes are a vector of the same lower-bound quantity, so take the
// side with more total measured minutes rather than mixing two partial
diff --git a/lib/compute/onehz_pipeline.dart b/lib/compute/onehz_pipeline.dart
index 8bea8fca..e7473433 100644
--- a/lib/compute/onehz_pipeline.dart
+++ b/lib/compute/onehz_pipeline.dart
@@ -29,7 +29,8 @@ import 'package:openstrap_analytics/onehz.dart';
// does not compromise this file's isolate safety. It is here so the sex
// normalisation has ONE definition across the pipeline and the coordinator
// instead of two that can drift.
-import 'hr_max.dart' show estimatedMaxHr, trainingZones;
+import 'hr_max.dart'
+ show estimatedMaxHr, smoothedMaxHr, smoothedMinHr, trainingZones;
import 'profile.dart' show workoutSex;
// Same argument: a pure `DateTime` lookup, no DB / IO / Flutter binding. It is
// the ONE definition of "the UTC offset in effect at this instant" in the tree,
@@ -1065,11 +1066,23 @@ Map deriveDayBundle(Map inputJson) {
}
// ── HR stats over the day's valid HR (for the strain detail hr {max,avg,min}).
+ //
+ // THE DAY PEAK GOES THROUGH THE SAME SMOOTHING AS EVERY WORKOUT PEAK (#127).
+ // This used to be a bare `reduce(math.max)` over raw 1 Hz, so one PPG motion
+ // transient WAS the day's "Peak HR" on the strain card while the Heart page —
+ // reading per-minute means — showed the real peak: the 160-vs-143 pair the
+ // issue reported, moved to a different screen rather than fixed. `hr_max.dart`
+ // is the one definition (physiological reject + 5 s rolling median, which
+ // steps over a 1-2 s spike but keeps a genuine brief effort peak). Min is the
+ // symmetric case: a 1 s dropout must not define the day's low either.
+ final dayHrInt = [for (final h in dayHrValid) h.round()];
final hrStats = dayHrValid.isEmpty
? null
: {
- 'max': dayHrValid.reduce(math.max).round(),
- 'min': dayHrValid.reduce(math.min).round(),
+ 'max': smoothedMaxHr(dayHrInt, age: age?.round()) ??
+ dayHrValid.reduce(math.max).round(),
+ 'min': smoothedMinHr(dayHrInt, age: age?.round()) ??
+ dayHrValid.reduce(math.min).round(),
'avg': _mean(dayHrValid)!.round(),
};
diff --git a/lib/data/local_repository_impl.dart b/lib/data/local_repository_impl.dart
index eda57b68..f6c24e4f 100644
--- a/lib/data/local_repository_impl.dart
+++ b/lib/data/local_repository_impl.dart
@@ -2672,7 +2672,7 @@ class LocalRepositoryImpl extends LocalRepository {
final profile = Profile.fromMap(getProfileMap());
final hrBpm = [for (final e in hrRows) (e['hr'] as num).toInt()];
- final raw = computeManualSessionStats(
+ final stats = computeManualSessionStats(
hrTs: [for (final e in hrRows) (e['rec_ts'] as num).toInt()],
hrBpm: hrBpm,
profile: profile,
@@ -2682,19 +2682,10 @@ class LocalRepositoryImpl extends LocalRepository {
zoneSet: _zoneSetFor(
row['device_family'] as String?, await _zoneAnchors()),
);
- // `computeManualSessionStats` reports the raw 1 Hz peak. Persisting that
- // writes a PPG spike into the column `getWorkout` deliberately refuses to
- // floor against (issue #127) — and once raw ages out past retention the
- // list has no smoothed value left to prefer, so the artefact would become
- // permanent. Store the spike-suppressed peak instead.
- final stats = ManualSessionStats(
- avgHr: raw.avgHr,
- maxHr: smoothedMaxHr(hrBpm, age: _profileAge()) ?? raw.maxHr,
- strain: raw.strain,
- calories: raw.calories,
- zoneMinutes: raw.zoneMinutes,
- hrSampleCount: raw.hrSampleCount,
- );
+ // The peak is smoothed inside `computeManualSessionStats` now — one
+ // definition for the manual save, this re-score and the workout list
+ // (#127), instead of the raw peak being re-smoothed here and banked raw
+ // everywhere else. Nothing to re-wrap.
// "Complete" = the band has handed over essentially the whole window.
// 1 Hz means one sample per second, so sample count vs window seconds is
diff --git a/test/session_score_reconcile_test.dart b/test/session_score_reconcile_test.dart
index e8398542..c8b55479 100644
--- a/test/session_score_reconcile_test.dart
+++ b/test/session_score_reconcile_test.dart
@@ -81,9 +81,49 @@ void main() {
);
expect(r.strain, 9.0);
expect(r.calories, 400);
- expect(r.maxHr, 171);
expect(r.zoneMinutes, const [1, 5, 10, 4, 0]);
- expect(r.changed, isFalse);
+ // ... EXCEPT the peak, which is not that kind of quantity — see below.
+ expect(r.maxHr, 140);
+ expect(r.changed, isTrue);
+ });
+
+ // #127. Strain and calories accumulate, so over a subset of the window each
+ // is a floor and the larger of two floors is the better estimate. A MAXIMUM
+ // moves the other way: an artefact only ever makes it bigger, so max() is a
+ // ratchet a single PPG transient wins forever. It did — a session saved
+ // before the peak was smoothed carries the spike, the substrate re-scores it
+ // to the real figure, and the ratchet put the spike straight back on every
+ // pass under 90 % coverage. Meanwhile `_sessionTrace` recomputes the peak
+ // from the same substrate and deliberately does NOT floor against the stored
+ // column, so the list and the detail screen printed different peaks for one
+ // session.
+ test('a spiked stored peak loses to the substrate, at any coverage', () {
+ final r = reconcileSessionScore(
+ liveStrain: 5.0,
+ liveCalories: 200,
+ liveMaxHr: 160, // the PPG transient RR reported
+ liveZoneMinutes: const [],
+ substrate: _substrate(
+ strain: 4.0,
+ calories: 180,
+ maxHr: 143, // the real peak, spike-suppressed
+ samples: 600,
+ ),
+ );
+ expect(r.maxHr, 143);
+ expect(r.strain, 5.0, reason: 'the additive rule is unchanged');
+ });
+
+ test('an absent substrate peak still keeps the live one', () {
+ final r = reconcileSessionScore(
+ liveStrain: 5.0,
+ liveCalories: 200,
+ liveMaxHr: 160,
+ liveZoneMinutes: const [],
+ substrate: _substrate(strain: 4.0, calories: 180, samples: 600),
+ );
+ expect(r.maxHr, 160,
+ reason: 'no worn samples survived is not "the answer is nothing"');
});
test('absent stays absent — an unscored session never becomes 0.0', () {
From af8744a0449bedfeea4b004d6224e6f1fa817c85 Mon Sep 17 00:00:00 2001
From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com>
Date: Wed, 19 Aug 2026 23:59:27 +0530
Subject: [PATCH 27/64] sleep: a night never re-stages shorter than the one
already banked (#242)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
not the bridging — a 40 min mid-night wake bridges and sums correctly, the
60 min constant covers it. it is the write path. a day re-stages on every pass
for its first 48 h and the candidate is replaced unconditionally, but the
substrate underneath does not only grow: pruning runs once the covering day is
derived, so a later pass sees the same night through less data, produces a
shorter one, and the day rebuilds from it. that is "it got fixed, then a few
syncs later it went back".
the guard compares tst_sec on every pass now, and sits on the CANDIDATE rather
than the day result — the candidate is upstream of the sleep block, the
hypnogram and every sleep scalar, so keeping the richer one keeps the whole day
consistent. carrying a richer sleep block into a thinner day's bundle would
pair last pass's night with this pass's stage minutes.
keyed at the algo version, so a bump still re-stages from scratch. an override
never reaches this branch, so shortening your own night still works.
shipped number: no new maths, but a day that was regressing will now hold its
better night.
---
lib/compute/derivation_engine.dart | 62 +++++++++++++++++++++++++
test/derive_result_protection_test.dart | 35 ++++++++++++++
2 files changed, 97 insertions(+)
diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart
index 448bf694..c1f075a5 100644
--- a/lib/compute/derivation_engine.dart
+++ b/lib/compute/derivation_engine.dart
@@ -2265,6 +2265,41 @@ class DerivationEngine {
final candidate = SleepSessionCandidate.fromJson(
(jsonDecode(candidateJson) as Map).cast());
if (override == null) {
+ // NEVER RE-STAGE A NIGHT SHORTER THAN THE ONE ALREADY BANKED (#242).
+ //
+ // A day re-stages on every pass for its first 48 h, and the substrate it
+ // stages over does not only grow: `pruneDecodedBeforeRecTs` runs once the
+ // covering day is derived, so a later pass can look at the same night
+ // through less data and produce a shorter one — which then REPLACED the
+ // good candidate, and the day rebuilt from it. That is the reported "it
+ // got fixed, then a few syncs later it went back", and it is a write-path
+ // defect, not a staging one (a mid-night wake bridges and sums correctly).
+ //
+ // The guard belongs HERE rather than on the day result: the candidate is
+ // upstream of the sleep block, the hypnogram AND every sleep scalar, so
+ // keeping the richer one keeps the whole day internally consistent.
+ // Swapping a richer sleep block into a thinner day's bundle would pair
+ // last pass's night with this pass's stage minutes.
+ //
+ // Keyed at this algo version, so a bump still re-stages from scratch —
+ // that is what a bump is for. An override never reaches this branch, so a
+ // user shortening their own night is untouched.
+ final stored = await LocalDb.sleepSessionCandidate(dayId, kAlgoVersion);
+ final storedJson = stored?['payload_json'];
+ if (storedJson is String && storedJson.isNotEmpty) {
+ try {
+ final prev = SleepSessionCandidate.fromJson(
+ (jsonDecode(storedJson) as Map).cast());
+ if (isRicherSleep(prev, candidate)) {
+ _log('derive $dayId: kept the banked night '
+ '(${_tstSec(prev)} s) over this pass\'s '
+ '${_tstSec(candidate)} s — less substrate, not a shorter night');
+ return prev;
+ }
+ } catch (_) {
+ // Undecodable stored candidate — the fresh one is strictly better.
+ }
+ }
await LocalDb.putSleepSessionCandidate(
dayId: dayId,
algoVersion: kAlgoVersion,
@@ -3751,6 +3786,33 @@ class DerivationEngine {
return carried;
}
+ /// The night's measured total sleep, seconds. Null when this candidate has no
+ /// night in it at all.
+ static num? _tstSec(SleepSessionCandidate c) =>
+ c.sleepJson['tst_sec'] as num?;
+
+ /// Whether the already-banked [prev] night is RICHER than the freshly staged
+ /// [next] one, measured by total sleep time (#242).
+ ///
+ /// TST, not confidence and not the window: it is the quantity the user sees
+ /// change, and the failure mode this guards is a re-stage over a pruned
+ /// substrate seeing less of the same night. A night that grows is a night the
+ /// band handed over more of, and it wins.
+ ///
+ /// A candidate with no night at all is never richer than one that has one, and
+ /// EQUAL is not richer — a pass that reproduces the same night writes, so an
+ /// otherwise-identical candidate still refreshes.
+ @visibleForTesting
+ static bool isRicherSleep(
+ SleepSessionCandidate prev,
+ SleepSessionCandidate next,
+ ) {
+ final p = _tstSec(prev);
+ if (p == null) return false;
+ final n = _tstSec(next);
+ return n == null || p > n;
+ }
+
/// How a day should be filed after its second half failed and the previous
/// result's detail was carried forward.
///
diff --git a/test/derive_result_protection_test.dart b/test/derive_result_protection_test.dart
index 33fed1ff..de32bdd8 100644
--- a/test/derive_result_protection_test.dart
+++ b/test/derive_result_protection_test.dart
@@ -346,4 +346,39 @@ void main() {
expect(outcome.partial, isTrue);
expect(outcome.finalized, isFalse);
});
+
+ // 3. A re-stage over LESS substrate than the last one had (#242). A day
+ // re-stages on every pass for its first 48 h, and pruning can take the
+ // substrate away between passes — so the same night comes back shorter and
+ // replaced the good one. "It got fixed, then a few syncs later it went
+ // back."
+ group('a night never re-stages shorter', () {
+ SleepSessionCandidate night(num? tstSec) => SleepSessionCandidate(
+ dayId: '2026-08-19',
+ confidence: 0.8,
+ flags: const [],
+ sleepJson: {'tst_sec': ?tstSec},
+ hypnoStages: const [],
+ sleepOnsetSec: 1000,
+ sleepOffsetSec: 2000,
+ );
+
+ test('a shorter re-stage loses to the banked night', () {
+ expect(DerivationEngine.isRicherSleep(night(27000), night(9000)), isTrue);
+ });
+
+ test('a longer re-stage wins — the band handed over more of it', () {
+ expect(DerivationEngine.isRicherSleep(night(9000), night(27000)), isFalse);
+ });
+
+ test('an identical re-stage writes, so equal is not richer', () {
+ expect(DerivationEngine.isRicherSleep(night(27000), night(27000)), isFalse);
+ });
+
+ test('a night beats no night, and no night never beats one', () {
+ expect(DerivationEngine.isRicherSleep(night(27000), night(null)), isTrue);
+ expect(DerivationEngine.isRicherSleep(night(null), night(27000)), isFalse);
+ expect(DerivationEngine.isRicherSleep(night(null), night(null)), isFalse);
+ });
+ });
}
From 74850a093544fa70edde268174c588e1e631f207 Mon Sep 17 00:00:00 2001
From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com>
Date: Wed, 19 Aug 2026 23:59:38 +0530
Subject: [PATCH 28/64] decode: absent accel stays absent, not 0
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
the raw-hex seam coalesced an empty accelG to 0 on all three axes, which is a
reading — a perfectly still wrist — and the same fabricated stillness the
nullable columns and the v25 refusal above it exist to prevent. protocol 60676cf
now returns an empty accelG for v25 (those offsets were refuted on real data),
so this is one guard-deletion away from shipping wrong numbers rather than
theoretical. null, same as the gen5 gravityG path right above it.
unreachable today — the v25 skip-guard drops the record first, and both
skip-guards are left alone.
---
lib/data/db.dart | 12 +++++++++---
1 file changed, 9 insertions(+), 3 deletions(-)
diff --git a/lib/data/db.dart b/lib/data/db.dart
index f989acac..5fa7b18e 100644
--- a/lib/data/db.dart
+++ b/lib/data/db.dart
@@ -3829,9 +3829,15 @@ class LocalDb {
counter: r.counter,
hr: r.hr,
rrIntervalsMs: List.from(r.rrIntervalsMs),
- ax: r.accelG.isNotEmpty ? r.accelG[0] : 0,
- ay: r.accelG.length > 1 ? r.accelG[1] : 0,
- az: r.accelG.length > 2 ? r.accelG[2] : 0,
+ // ABSENT ACCEL STAYS ABSENT. These used to coalesce to 0, which is
+ // a reading — a perfectly still wrist — and it is the same
+ // fabricated stillness the nullable columns and the v25 refusal
+ // above exist to prevent. protocol now returns an empty `accelG`
+ // for a record whose accelerometer it will not vouch for, so the
+ // fallback is null, exactly as the gen5 `gravityG` path above does.
+ ax: r.accelG.isNotEmpty ? r.accelG[0] : null,
+ ay: r.accelG.length > 1 ? r.accelG[1] : null,
+ az: r.accelG.length > 2 ? r.accelG[2] : null,
spo2RedRaw: r.spo2RedRaw,
spo2IrRaw: r.spo2IrRaw,
// raw column passthrough, same as the ble path. not read as a temp.
From 7bfe44024655ed93f8ef864a3513f1c4f6eb1b44 Mon Sep 17 00:00:00 2001
From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com>
Date: Thu, 20 Aug 2026 00:16:48 +0530
Subject: [PATCH 29/64] tests: the energy fixtures need a resting HR now
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
same reason as the gate itself — the active term is %HRR, so a fixture with no
resting HR abstains. the pipeline case has no sleep, so resting_hr on the
profile is the only anchor there is.
---
test/daily_energy_consistency_test.dart | 38 ++++++++++++++++---------
1 file changed, 25 insertions(+), 13 deletions(-)
diff --git a/test/daily_energy_consistency_test.dart b/test/daily_energy_consistency_test.dart
index ab748931..8f95bc4f 100644
--- a/test/daily_energy_consistency_test.dart
+++ b/test/daily_energy_consistency_test.dart
@@ -51,7 +51,7 @@ final _dayHr = [
void main() {
group('DerivationEngine.wakeDayEnergy', () {
test('active calories net out the basal minute, not double-count it', () {
- final e = DerivationEngine.wakeDayEnergy(_dayHr, profile: _profile, deviceFamily: 'gen4');
+ final e = DerivationEngine.wakeDayEnergy(_dayHr, profile: _profile, restingHr: 55, deviceFamily: 'gen4');
expect(e, isNotNull);
@@ -72,7 +72,7 @@ void main() {
});
test('total is the full-day basal floor plus the active surplus', () {
- final e = DerivationEngine.wakeDayEnergy(_dayHr, profile: _profile, deviceFamily: 'gen4')!;
+ final e = DerivationEngine.wakeDayEnergy(_dayHr, profile: _profile, restingHr: 55, deviceFamily: 'gen4')!;
expect(e.basal, closeTo(_bmrDay, 0.5));
expect(e.total, closeTo(e.basal + e.active, 0.001));
@@ -82,7 +82,7 @@ void main() {
// health_export writes BASAL_ENERGY_BURNED as calories_total - calories.
// When the two came from different implementations that subtraction
// silently produced a basal figure that was too low.
- final e = DerivationEngine.wakeDayEnergy(_dayHr, profile: _profile, deviceFamily: 'gen4')!;
+ final e = DerivationEngine.wakeDayEnergy(_dayHr, profile: _profile, restingHr: 55, deviceFamily: 'gen4')!;
expect(e.total - e.active, closeTo(e.basal, 0.001));
});
@@ -90,7 +90,7 @@ void main() {
test('a day spent entirely below the flex point reads as pure basal', () {
final quiet = [for (var i = 0; i < 1440; i++) 55.0];
- final e = DerivationEngine.wakeDayEnergy(quiet, profile: _profile, deviceFamily: 'gen4')!;
+ final e = DerivationEngine.wakeDayEnergy(quiet, profile: _profile, restingHr: 55, deviceFamily: 'gen4')!;
expect(e.active, 0.0);
expect(e.total, closeTo(_bmrDay, 0.5));
@@ -103,7 +103,7 @@ void main() {
DerivationEngine.wakeDayEnergy(
_dayHr,
profile: const Profile(weightKg: 72, sex: 'm'),
- deviceFamily: 'gen4',
+ restingHr: 55, deviceFamily: 'gen4',
),
isNull,
reason: 'age is a Keytel term',
@@ -112,7 +112,7 @@ void main() {
DerivationEngine.wakeDayEnergy(
_dayHr,
profile: const Profile(ageYears: 34, sex: 'm'),
- deviceFamily: 'gen4',
+ restingHr: 55, deviceFamily: 'gen4',
),
isNull,
reason: 'body mass is a Keytel term',
@@ -121,7 +121,7 @@ void main() {
DerivationEngine.wakeDayEnergy(
_dayHr,
profile: const Profile(ageYears: 34, weightKg: 72),
- deviceFamily: 'gen4',
+ restingHr: 55, deviceFamily: 'gen4',
),
isNull,
reason: 'the formula has a different constant per sex',
@@ -136,7 +136,7 @@ void main() {
// in moves a scalar that is persisted to `day_result` and exported to
// Apple Health.
const noHeight = Profile(ageYears: 34, weightKg: 72, sex: 'm');
- expect(DerivationEngine.wakeDayEnergy(_dayHr, profile: noHeight, deviceFamily: 'gen4'), isNull);
+ expect(DerivationEngine.wakeDayEnergy(_dayHr, profile: noHeight, restingHr: 55, deviceFamily: 'gen4'), isNull);
});
test('a stand-in height would move ACTIVE, not just the basal floor', () {
@@ -147,9 +147,9 @@ void main() {
final hr = [for (var i = 0; i < 600; i++) 130.0];
final s =
- DerivationEngine.wakeDayEnergy(hr, profile: short, deviceFamily: 'gen4')!;
+ DerivationEngine.wakeDayEnergy(hr, profile: short, restingHr: 55, deviceFamily: 'gen4')!;
final t =
- DerivationEngine.wakeDayEnergy(hr, profile: tall, deviceFamily: 'gen4')!;
+ DerivationEngine.wakeDayEnergy(hr, profile: tall, restingHr: 55, deviceFamily: 'gen4')!;
expect((s.active - t.active).abs(), greaterThan(100.0));
expect((s.total - t.total).abs(), greaterThan(100.0));
@@ -160,7 +160,7 @@ void main() {
// the same claim as "this day burned exactly your BMR".
expect(
DerivationEngine.wakeDayEnergy(const [],
- profile: _profile, deviceFamily: 'gen4'),
+ profile: _profile, restingHr: 55, deviceFamily: 'gen4'),
isNull,
);
});
@@ -177,7 +177,11 @@ void main() {
// A 70-year-old is the sharpest case for the wake-vs-whole-day question:
// `dailyEnergy`'s flex gate is 0.50 x Tanaka HRmax = 104 - 0.35*age, so at
// 70 it sits at 79.5 bpm — under a perfectly ordinary sleeping heart rate.
- const older = Profile(ageYears: 70, weightKg: 80, heightCm: 175, sex: 'm');
+ const older = Profile(
+ ageYears: 70, weightKg: 80, heightCm: 175, sex: 'm',
+ // The active gate is a %HRR flex point now, so the lower reserve anchor
+ // is a term in it — no resting HR, no gate, no figure.
+ restingHrManual: 55);
// Mifflin (male): 10*80 + 6.25*175 - 5*70 + 5 = 1548.75 kcal/day
const olderBasalPerMin = 1548.75 / 1440.0;
@@ -309,7 +313,11 @@ void main() {
bundle: bundle,
scalars: scalars,
daySub: daySub,
- profile: const Profile(ageYears: 70, weightKg: 80, heightCm: 175),
+ profile: const Profile(
+ ageYears: 70,
+ weightKg: 80,
+ heightCm: 175,
+ restingHrManual: 55),
sleepOnsetSec: sleepOnset,
sleepOffsetSec: sleepOffset,
dayStartSec: daySub.tsSec.first,
@@ -374,6 +382,10 @@ void main() {
'sex': 'm',
'weight_kg': 72,
'height_cm': 178,
+ // The active gate is a %HRR flex point now, so the lower reserve
+ // anchor is a term in it. This day has no sleep, so the manual one
+ // is the only resting HR there is.
+ 'resting_hr': 55,
}),
isNotNull,
);
From d4f7229ee635b951594bd3a16151034622648fdd Mon Sep 17 00:00:00 2001
From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com>
Date: Thu, 20 Aug 2026 00:16:59 +0530
Subject: [PATCH 30/64] tier sentinel: 65 is "good to go" now, publish a
mid-band score instead
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
follow-on from the band change — 65 crossed the new top cut-off, so the test
that pins "the tier and its label reach the App Group" was asserting the old
band. 50 is the median night and the neutral band, which is the thing worth
pinning anyway.
---
test/widget_service_sentinels_test.dart | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/test/widget_service_sentinels_test.dart b/test/widget_service_sentinels_test.dart
index 344c3b9b..99b26d26 100644
--- a/test/widget_service_sentinels_test.dart
+++ b/test/widget_service_sentinels_test.dart
@@ -178,9 +178,9 @@ void main() {
test('the tier and its label are published for the native surfaces',
() async {
await WidgetService.push(TodayData.fromJson({
- 'daily': {'readiness': 65},
+ 'daily': {'readiness': 50},
}));
- expect(written['readiness'], 65);
+ expect(written['readiness'], 50);
expect(written['readiness_tier'], 2);
expect(written['readiness_band'], 'Steady');
});
From 40cb3a16403da29fc6b8096b40f7156f2b194964 Mon Sep 17 00:00:00 2001
From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com>
Date: Thu, 20 Aug 2026 00:16:59 +0530
Subject: [PATCH 31/64] v25: protocol hands us no vector at all now
it used to hand over a gravity vector from inner[69/71/73] and we dropped the
record anyway; 60676cf refuted those offsets and returns an empty accelG, so
the assertion flips to "absent, never a still wrist". the refusal itself is
unchanged and both skip-guards are untouched.
---
test/v25_refusal_test.dart | 17 ++++++++++-------
1 file changed, 10 insertions(+), 7 deletions(-)
diff --git a/test/v25_refusal_test.dart b/test/v25_refusal_test.dart
index 19ac30e9..80cd23da 100644
--- a/test/v25_refusal_test.dart
+++ b/test/v25_refusal_test.dart
@@ -44,18 +44,21 @@ void main() {
await LocalDb.close();
});
- test('protocol still hands us the vector — this is the thing we refuse', () {
- // Not a change request against protocol (SEALED): asserted so that if the
- // decoder ever DOES change, this test tells whoever changed it that edge
- // is deliberately dropping the record.
+ test('protocol hands us no vector at all now — and we still drop the record',
+ () {
+ // This used to assert the opposite: protocol handed over a "gravity"
+ // vector from inner[69/71/73] and edge dropped the record anyway. Those
+ // offsets were refuted on real data and protocol 60676cf stopped emitting
+ // them, so `accelG` is empty — absent, not (0,0,0), the same idiom gen5's
+ // `gravityG` uses. Asserted so that if the decoder changes again, whoever
+ // changes it learns edge is deliberately dropping the record either way.
final r = proto.FirmwareAwareR24Decoder().decode(proto.hexToBytes(_v25a));
expect(r, isNotNull);
expect(r!.histVersion, 25);
expect(r.hr, 0, reason: 'v25 carries no heart rate');
- // The tell: the same "y" value on both records, and a "z" of zero.
+ expect(r.accelG, isEmpty, reason: 'absent, never a still wrist');
final s = proto.FirmwareAwareR24Decoder().decode(proto.hexToBytes(_v25b))!;
- expect(r.accelG[1], s.accelG[1], reason: 'a wrist axis that never moves');
- expect(r.accelG[2], 0.0);
+ expect(s.accelG, isEmpty);
});
test('decodeSubstrate drops v25 rather than banking a still wrist', () {
From 468c1f17bd217e2ed0055b674c8f264832350e7b Mon Sep 17 00:00:00 2001
From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com>
Date: Thu, 20 Aug 2026 00:20:27 +0530
Subject: [PATCH 32/64] every workout write path exports, including the coach's
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
logManualWorkout had three callers and none of them exported. the seam takes
an id now, so all three can use it — coach, the new log screen, and a retime
(a moved window changes what health should hold for it too).
---
lib/coach/coach_actions.dart | 12 ++++++------
lib/ui2/screens/log_workout.dart | 10 +++++++++-
2 files changed, 15 insertions(+), 7 deletions(-)
diff --git a/lib/coach/coach_actions.dart b/lib/coach/coach_actions.dart
index 40bad3b9..93667844 100644
--- a/lib/coach/coach_actions.dart
+++ b/lib/coach/coach_actions.dart
@@ -30,6 +30,7 @@ import '../data/journal_fields.dart';
import '../data/local_repository.dart';
import '../data/med_store.dart';
import '../data/nutrition_store.dart';
+import '../health/health_export.dart';
/// Raised when the model's arguments cannot be honoured. The message goes back
/// into the transcript so the model can correct itself rather than retrying the
@@ -264,12 +265,11 @@ class CoachActions {
endTs: startTs + mins * 60,
type: type,
);
- // TODO(#130): export this session to the phone's health store, the way
- // AppState.stopWorkout does. Every other write path exports; a workout
- // logged through the coach reaches the health store only if the next
- // day-result pass happens to sweep it up. The export seam is being
- // reworked in the same audit — the one-line call goes here once its
- // signature lands, and it must be a no-op when health sync is off.
+ // Every other write path exports; without this a workout logged through
+ // the coach reached the health store only if the next day-result pass
+ // happened to sweep it up (#130). The seam checks `healthSyncEnabled`
+ // itself, so this is a no-op with the switch off, and it never throws.
+ await HealthExporter.exportWorkoutId(r['workout_id'] as String?);
return jsonEncode({'saved': true, 'date': d, 'type': type, ...r});
} catch (e) {
// The repo rejects overlaps, futures and absurd durations. Hand the
diff --git a/lib/ui2/screens/log_workout.dart b/lib/ui2/screens/log_workout.dart
index a430bdcc..18c22152 100644
--- a/lib/ui2/screens/log_workout.dart
+++ b/lib/ui2/screens/log_workout.dart
@@ -35,6 +35,7 @@ import 'package:provider/provider.dart';
import '../../compute/manual_session.dart';
import '../../data/db.dart';
import '../../data/journal_fields.dart' show formatMinuteOfDay;
+import '../../health/health_export.dart';
import '../../notify/notification_prefs.dart';
import '../../state/app_state.dart';
import '../activity/catalogue.dart';
@@ -136,11 +137,14 @@ class _WorkoutSuggestionScreenState extends State {
setState(() => _busy = true);
var message = '';
try {
- await repo.logManualWorkout(
+ final r = await repo.logManualWorkout(
startTs: s.startTs,
endTs: s.endTs,
type: s.activity?.typeKey ?? 'other',
);
+ // Every write path exports, or the health store quietly disagrees with
+ // the log (#130). No-op with health sync off; never throws.
+ await HealthExporter.exportWorkoutId(r['workout_id'] as String?);
// The repo retires every suggestion the saved window covers, this one
// included — nothing to dismiss here.
} on ManualWindowException catch (e) {
@@ -519,6 +523,10 @@ class _LogWorkoutState extends State {
startTs: _startSec, endTs: _endSec, type: _activity.typeKey)
: await repo.setWorkoutWindow(widget.sessionId!,
startTs: _startSec, endTs: _endSec);
+ // Both branches: a new session and a RETIMED one both change what the
+ // health store should hold for that window (#130).
+ await HealthExporter.exportWorkoutId(
+ (r['workout_id'] ?? widget.sessionId) as String?);
// Say what was actually banked. A window with no 1 Hz substrate left
// behind it — anything past the ~3-day retention, or a stretch the band
// was off — is saved UNSCORED, and a screen that pops silently would let
From 7cd399c88bc1e0060115f5cb14c20b5bb285eae9 Mon Sep 17 00:00:00 2001
From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com>
Date: Thu, 20 Aug 2026 00:20:27 +0530
Subject: [PATCH 33/64] the double-tap picker has a door again
next to tasker: both are the band setting something else off.
---
lib/ui2/profile/settings.dart | 9 +++++++++
1 file changed, 9 insertions(+)
diff --git a/lib/ui2/profile/settings.dart b/lib/ui2/profile/settings.dart
index 1adfdb3f..74dc3632 100644
--- a/lib/ui2/profile/settings.dart
+++ b/lib/ui2/profile/settings.dart
@@ -38,6 +38,7 @@ import 'alarm.dart';
import 'band_notifications.dart';
import 'data.dart';
import 'gallery.dart';
+import 'gestures.dart';
import 'profile.dart';
/// Unwind the profile stack back to the gate.
@@ -601,6 +602,14 @@ class MoreSettingsView extends StatelessWidget {
onTap: onToggleHealthSync),
]),
settingsGroup(c, 'Automation', [
+ // The picker died with the old ui tree and the engine kept
+ // running against a mapping nothing could set — the whole
+ // feature was live code pinned at "do nothing".
+ Builder(
+ builder: (c) => SetRow(
+ LucideIcons.hand, C.orange, 'Double-tap',
+ sub: 'What a double-tap on the band does',
+ onTap: () => goto(c, const BandGestures()))),
SetRow(LucideIcons.workflow, C.indigo, 'Tasker and Shortcuts',
// The row states the asymmetry rather than leaving it to
// the screen: someone on an iPhone should learn what they
From e8213a786917d3640e119e43f45ecc96b611645f Mon Sep 17 00:00:00 2001
From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com>
Date: Thu, 20 Aug 2026 00:23:44 +0530
Subject: [PATCH 34/64] the sawtooth test straddles the gate it asks for
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
it hardcoded 94/93 against a %hrmax gate. the gate is %hrr now and moved 13
bpm, so the sawtooth sat entirely under it and the whole thing read as rest —
the test was pinning arithmetic, not the behaviour it names.
---
test/live_rescore_calorie_parity_test.dart | 32 ++++++++++++++--------
1 file changed, 20 insertions(+), 12 deletions(-)
diff --git a/test/live_rescore_calorie_parity_test.dart b/test/live_rescore_calorie_parity_test.dart
index 0dd760a5..82ab8f31 100644
--- a/test/live_rescore_calorie_parity_test.dart
+++ b/test/live_rescore_calorie_parity_test.dart
@@ -31,6 +31,7 @@
// The last four cases in this file are each one of those.
import 'package:flutter_test/flutter_test.dart';
+import 'package:openstrap_analytics/onehz.dart' as ana;
import 'package:openstrap_edge/compute/manual_session.dart';
import 'package:openstrap_edge/compute/profile.dart';
import 'package:openstrap_edge/state/app_state.dart';
@@ -213,28 +214,35 @@ void main() {
test('a sawtooth hovering at the gate does not collapse to one side', () {
// The worst case for minute-mean gating, and an entirely ordinary heart
- // rate: 30 s at 94 and 30 s at 93 against a 93.76 gate. Every minute mean
- // is 93.5, just under, so the whole session reads as rest.
+ // rate: half a minute a beat above the gate, half a minute a beat below.
+ // Every minute mean lands under it, so the whole session reads as rest.
+ //
+ // The gate is ASKED FOR, not written down. It used to be a fraction of
+ // HRmax and is now a fraction of heart-rate reserve, and this test failed
+ // the day that changed — it was still straddling a boundary that had moved
+ // 13 bpm away, which is a test pinning arithmetic instead of behaviour.
+ final gate = ana.Calories.activeGateHr(_hrMax, _restingHr);
+ final above = gate.ceil() + 1;
+ final below = gate.floor() - 1;
final sawtooth = [
for (var block = 0; block < 10; block++) ...[
- for (var i = 0; i < 30; i++) 94,
- for (var i = 0; i < 30; i++) 93,
+ for (var i = 0; i < 30; i++) above,
+ for (var i = 0; i < 30; i++) below,
],
];
final live = _run(sawtooth);
expect(live.calories, closeTo(_rescore(sawtooth), 0.25));
- // 300 s * activeKcalPerS(94) + 300 s * restingRate
- // = 300 * 0.1010958 + 300 * 0.0198397 = 36.28 kcal
- expect(live.calories, closeTo(36.28, 0.25));
- // Minute-mean gating bills all 600 s at the resting rate: 11.90 kcal, i.e.
- // 1.19 kcal/min where the re-score says 3.63 — about 146 kcal adrift over a
- // zone-2 hour, off a stream that never looks unusual.
+
+ // What minute-mean gating would have billed: all 600 s at the resting rate,
+ // because no minute's mean ever clears the gate. Derived from the same
+ // estimator rather than stated, so it tracks the gate too.
+ final allRest = _rescore([for (var i = 0; i < 600; i++) below]);
expect(
live.calories,
- greaterThan(20.0),
- reason: 'billing a 94 bpm half-minute as rest is the bug this pins',
+ greaterThan(allRest * 1.5),
+ reason: 'billing an above-gate half-minute as rest is the bug this pins',
);
});
From 5f1136cb9a2954a7836385534f0fdb65aea395e0 Mon Sep 17 00:00:00 2001
From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com>
Date: Thu, 20 Aug 2026 00:25:08 +0530
Subject: [PATCH 35/64] algo 75, and repin both siblings
both move numbers this time, which is the point of the bump: the active-energy
gate is on heart-rate reserve now, strain measures quiet waking instead of
assuming it, readiness finally carries its fourth driver, and the day peak hr
stops disagreeing with the timeline about the same beats.
---
lib/compute/derivation_engine.dart | 45 ++++++++++++++++++++++++------
pubspec.lock | 8 +++---
pubspec.yaml | 4 +--
3 files changed, 42 insertions(+), 15 deletions(-)
diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart
index c1f075a5..e89697cf 100644
--- a/lib/compute/derivation_engine.dart
+++ b/lib/compute/derivation_engine.dart
@@ -1234,7 +1234,33 @@ import 'substrate.dart';
// deliberately so — it is gen5/MG-only, so gating on it makes the same
// night answer differently on two straps. The refusal's construct argument
// is untouched and is the one that carries it.
-const int kAlgoVersion = 74;
+// v75 — THE ISSUE AUDIT. Every issue and discussion ever filed was re-checked
+// against the shipped tree; these are the ones that were still true. Four
+// numbers move, and each moved because it was wrong, not because it was tuned:
+// 1. READINESS carries its fourth driver. `tempInput` refused on every night
+// ever shipped, because `settledFraction` was never passed from this side
+// — the driver was documented, weighted 0.10, and unreachable. The other
+// three renormalised over 0.90 and quietly absorbed it. Nights the strap
+// cannot vouch for (device_family NULL, pre-schema-41, imports, gen5) are
+// refused BY NAME now instead of silently.
+// 2. READINESS BANDS are the score's own quantiles. The composite is a
+// logistic with no scale parameter, so its centre is 50 — and 50 was
+// labelled "Take it easy". Half of every user's nights read as a warning
+// by construction, and "Good to go" needed every input ~1.4 SD above
+// personal median at once. The score did not change; the verdict did.
+// 3. PEAK HR stopped contradicting itself. The workout producers smoothed
+// through hr_max.dart, the day peak still did reduce(math.max) over raw
+// 1 Hz — so the strain card and the timeline printed different numbers off
+// the same beats (#127, closed once already). Manual saves and the
+// below-coverage reconcile fed it unsmoothed too.
+// 4. CALORIES and STRAIN follow the analytics gates above, and both abstain
+// rather than guess: a day with no resting HR now has no calorie figure
+// instead of billing every waking minute as active.
+// Also here, changing nothing derived: a night never re-stages shorter than the
+// one already banked (#242 — the guard only fired on a FAILED pass and never
+// compared tst_sec, which is why a fixed night came back wrong a few syncs
+// later), and absent accel stays absent instead of coalescing to zero.
+const int kAlgoVersion = 75;
/// The sibling SHAs this version was derived against, asserted against
/// pubspec.yaml in test/db_serve_version_and_reads_test.dart.
@@ -1245,14 +1271,15 @@ const int kAlgoVersion = 74;
/// so it is not repairable after the fact. That is exactly what happened
/// between v67 and v68. Repinning without touching this block fails the suite,
/// one line above the constant you then have to bump.
-// Both siblings are on MAIN now (protocol #29, analytics #46, merged
-// 2026-08-19). kAlgoVersion is deliberately NOT bumped with this repin: the
-// analytics hop is two comment lines in tests and touches no lib/ file at all,
-// and the protocol hop only adds `rr_ms` to decodeFrame's R10 branch, which
-// nothing in edge reads. No derived number moves, so forcing every install to
-// recompute would be churn with nothing on the other side of it.
-const String kAnalyticsPin = 'bfea5e56e74f336c3e3d83743123e58da225617d';
-const String kProtocolPin = 'fe3b681a3e9ca76f8a0865339035f949f36f6000';
+// Both siblings move with this bump, and both move NUMBERS this time — which
+// is the whole reason the version goes up. analytics: one active-energy gate
+// on heart-rate reserve instead of %HRmax (the day and the bout used to
+// disagree by 8-35 bpm depending on age and rest), and a measured quiet-waking
+// level under strain instead of a population constant that scored a day with
+// no activity at all somewhere between 6.9 and 12.1 out of 21. protocol: v25
+// stops emitting a gravity vector from offsets that were refuted on real data.
+const String kAnalyticsPin = '0a303151e0d22ceeb3a1cf92f820baea0a73098d';
+const String kProtocolPin = '60676cfb37fe7650e949d53b7f2faef2bed74f09';
// Fold idempotency, the minimum-nights warm-up, and legacy-payload handling
// all live in SleepProfilePolicy (pure, unit-tested) — see
diff --git a/pubspec.lock b/pubspec.lock
index 7e7b170c..6cb861fd 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -924,8 +924,8 @@ packages:
dependency: "direct main"
description:
path: "."
- ref: bfea5e56e74f336c3e3d83743123e58da225617d
- resolved-ref: bfea5e56e74f336c3e3d83743123e58da225617d
+ ref: "0a303151e0d22ceeb3a1cf92f820baea0a73098d"
+ resolved-ref: "0a303151e0d22ceeb3a1cf92f820baea0a73098d"
url: "https://github.com/OpenStrap/analytics.git"
source: git
version: "1.0.0"
@@ -933,8 +933,8 @@ packages:
dependency: "direct main"
description:
path: "."
- ref: fe3b681a3e9ca76f8a0865339035f949f36f6000
- resolved-ref: fe3b681a3e9ca76f8a0865339035f949f36f6000
+ ref: "60676cfb37fe7650e949d53b7f2faef2bed74f09"
+ resolved-ref: "60676cfb37fe7650e949d53b7f2faef2bed74f09"
url: "https://github.com/OpenStrap/protocol.git"
source: git
version: "1.0.0"
diff --git a/pubspec.yaml b/pubspec.yaml
index 01fd1aeb..e4689a1e 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -70,7 +70,7 @@ dependencies:
# emits R10 beat intervals) — deliberately NOT repinned: edge never reads
# `rr_ms` off decodeFrame, so the hop changes no number here and a repin
# would drag kAlgoVersion with it for nothing.
- ref: fe3b681a3e9ca76f8a0865339035f949f36f6000
+ ref: 60676cfb37fe7650e949d53b7f2faef2bed74f09
openstrap_analytics:
git:
url: https://github.com/OpenStrap/analytics.git
@@ -187,7 +187,7 @@ dependencies:
# moved 21664f8 -> bfea5e5 to track it. Diff between the two is two
# test-only deprecation-ignore annotations (analytics `lib/` untouched),
# so kAlgoVersion needs no bump for this move.
- ref: bfea5e56e74f336c3e3d83743123e58da225617d
+ ref: 0a303151e0d22ceeb3a1cf92f820baea0a73098d
# BLE — flutter_blue_plus is the maintained cross-platform GATT client.
flutter_blue_plus: ^1.36.8
From 66b0070efe5e4b984080ccaa0ce3cafe4b305aee Mon Sep 17 00:00:00 2001
From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com>
Date: Thu, 20 Aug 2026 01:28:51 +0530
Subject: [PATCH 36/64] the ios delete asks for a type healthkit has never
heard of
SLEEP_SESSION is health connect only. on ios the plugin resolves an
unknown key to bodyMass, queries a type we never asked permission for,
and the error path never calls result() back - so delete() just doesn't
return and the day's export sits behind it. that's the same stall the
write side of this pr is about, coming in the other door.
---
lib/health/health_export.dart | 18 ++++++++++++++++--
test/health_sleep_export_test.dart | 20 ++++++++++++++++++++
2 files changed, 36 insertions(+), 2 deletions(-)
diff --git a/lib/health/health_export.dart b/lib/health/health_export.dart
index 3339cccc..f4681ade 100644
--- a/lib/health/health_export.dart
+++ b/lib/health/health_export.dart
@@ -54,11 +54,24 @@ const _sleepHealthTypes = {
// The night's envelope. Health Connect models it as a SleepSessionRecord
// parent; HealthKit has no session record, so the enclosing bar is an
// `inBed` sleepAnalysis sample. Only one of the two is ever asked for —
- // see `_types` — but both belong to the sleep delete scope.
+ // see `_types` and `_sleepEnvelopeFor` — but both belong to the sleep
+ // delete SCOPE, which is what this set answers.
HealthDataType.SLEEP_SESSION,
HealthDataType.SLEEP_IN_BED,
};
+/// The envelope type the OTHER store uses, which this one must never be sent.
+///
+/// SLEEP_SESSION is Health-Connect-only. Handing it to HealthKit is not a
+/// harmless no-op: the plugin resolves an unknown key to bodyMass and runs a
+/// sample query for a type we never asked permission for, which errors — and
+/// the error path never calls back, so `delete()` never completes. That hangs
+/// the day's export, which is the stall (#239/#225) this whole seam exists to
+/// stop, re-entered through the delete side.
+HealthDataType _foreignSleepEnvelope(bool isApplePlatform) => isApplePlatform
+ ? HealthDataType.SLEEP_SESSION
+ : HealthDataType.SLEEP_IN_BED;
+
List healthDeleteTypes({required bool isApplePlatform}) {
final types = [
HealthDataType.RESTING_HEART_RATE,
@@ -70,7 +83,8 @@ List healthDeleteTypes({required bool isApplePlatform}) {
HealthDataType.ACTIVE_ENERGY_BURNED,
HealthDataType.BASAL_ENERGY_BURNED,
HealthDataType.STEPS,
- ..._sleepHealthTypes,
+ for (final t in _sleepHealthTypes)
+ if (t != _foreignSleepEnvelope(isApplePlatform)) t,
HealthDataType.WORKOUT,
];
return isApplePlatform
diff --git a/test/health_sleep_export_test.dart b/test/health_sleep_export_test.dart
index 21b50a65..56e8a184 100644
--- a/test/health_sleep_export_test.dart
+++ b/test/health_sleep_export_test.dart
@@ -248,6 +248,26 @@ void main() {
);
});
+ test('Apple delete scope never names the Health Connect envelope', () {
+ final types = healthDeleteTypes(isApplePlatform: true);
+
+ // SLEEP_SESSION is Health-Connect-only. On iOS the plugin resolves an
+ // unknown key to bodyMass, queries a type we never asked for, and its
+ // error path never calls back — `delete()` hangs and the day's export
+ // stalls behind it. Same failure #239/#225 fixed on the write side.
+ expect(types, isNot(contains(HealthDataType.SLEEP_SESSION)));
+ expect(types, contains(HealthDataType.SLEEP_IN_BED));
+ expect(
+ types,
+ containsAll([
+ HealthDataType.SLEEP_DEEP,
+ HealthDataType.SLEEP_REM,
+ HealthDataType.SLEEP_LIGHT,
+ HealthDataType.SLEEP_AWAKE,
+ ]),
+ );
+ });
+
test('the sleep delete covers the pre-midnight half of the night', () {
final dayStart = DateTime(2026, 8, 5);
final dayEnd = DateTime(2026, 8, 6);
From b4e0ae65ebe03be4ac14c819efad4a301d86e538 Mon Sep 17 00:00:00 2001
From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com>
Date: Thu, 20 Aug 2026 01:29:02 +0530
Subject: [PATCH 37/64] the briefing kept its own readiness cuts and the ring
moved
#250 put the ring on the score's own quantiles (26/37/61) and this was
still on 40/66 with a comment insisting the two must match. so a 61 was
"good to go" on home and "moderate" in the morning briefing, same
number, same day. take the tier off the ring and fold it to three words.
---
lib/ai/briefing_engine.dart | 28 ++++++++++++++++------------
test/ai_briefing_test.dart | 26 +++++++++++++++++---------
2 files changed, 33 insertions(+), 21 deletions(-)
diff --git a/lib/ai/briefing_engine.dart b/lib/ai/briefing_engine.dart
index 823edb87..47cc37b1 100644
--- a/lib/ai/briefing_engine.dart
+++ b/lib/ai/briefing_engine.dart
@@ -16,6 +16,7 @@ import '../coach/coach_config.dart';
import '../coach/coach_engine.dart';
import '../data/day_label.dart';
import '../data/local_repository.dart';
+import '../ui2/screens/home_screen.dart' as ring show readinessBand;
import 'briefing.dart';
import 'nightly_sweep.dart';
@@ -224,18 +225,21 @@ String partOfDay(DateTime now) {
/// and can contradict the score itself (a 16/100 read as "strong overnight
/// recovery"). The band is declared authoritative in the system prompt.
///
-/// THE single source of truth for readiness-score banding — also used by
-/// the Today ring's status word (`TodayVitals._orbitHero` in
-/// today_screen.dart maps good/moderate/low → Push/Focus/Recover).
-/// These cuts (40/66) MUST match the ring's own thresholds: a briefing band
-/// computed from different cuts than the ring's word is exactly the
-/// tone-vs-score contradiction this function exists to prevent, just moved
-/// from "sub-metrics vs score" to "briefing vs ring".
-String readinessBand(num v) {
- if (v < 40) return 'low';
- if (v < 66) return 'moderate';
- return 'good';
-}
+/// DERIVED FROM THE RING, never re-declared. It used to carry its own 40/66
+/// cuts with a comment insisting they match the ring's — and then #250 moved
+/// the ring to the score's own quantiles (26/37/61) and left these behind. A
+/// 61 was "Good to go" on Home and "moderate" in the briefing on the same
+/// morning: the tone-vs-score contradiction this function exists to prevent,
+/// arrived from the one direction the comment could not police.
+///
+/// So there is one classifier ([readinessBand] in home_screen.dart) and this
+/// is a PRESENTATION of it: four tiers folded to the three words the prompt
+/// speaks, with both warning tiers reading "low".
+String readinessBand(num v) => switch (ring.readinessBand(v).tier) {
+ 3 => 'good',
+ 2 => 'moderate',
+ _ => 'low',
+ };
/// The nightly sweep's rules.
///
diff --git a/test/ai_briefing_test.dart b/test/ai_briefing_test.dart
index b2f2e34c..b2fcfb14 100644
--- a/test/ai_briefing_test.dart
+++ b/test/ai_briefing_test.dart
@@ -224,16 +224,24 @@ void main() {
});
test(
- 'readinessBand cuts at 40/66 — MUST match the Today ring\'s own '
- 'word-thresholds (score>=66 Push, >=40 Focus, else Recover) or '
- 'the briefing and the ring can disagree again', () {
- // Just below/at each ring boundary.
- expect(readinessBand(39), 'low'); // ring: "Recover"
- expect(readinessBand(40), 'moderate'); // ring: "Focus"
- expect(readinessBand(65), 'moderate'); // ring: "Focus"
- expect(readinessBand(66), 'good'); // ring: "Push"
+ 'readinessBand is the ring\'s own band, folded to three words — a '
+ 'second set of cuts here is how the briefing and Home came to '
+ 'disagree about the same number', () {
+ // Every ring boundary (26/37/61), from below and at.
+ expect(readinessBand(0), 'low'); // ring: "Rest today"
+ expect(readinessBand(25.9), 'low'); // ring: "Rest today"
+ expect(readinessBand(26), 'low'); // ring: "Take it easy"
+ expect(readinessBand(36.9), 'low'); // ring: "Take it easy"
+ expect(readinessBand(37), 'moderate'); // ring: "Steady"
+ expect(readinessBand(60.9), 'moderate'); // ring: "Steady"
+ expect(readinessBand(61), 'good'); // ring: "Good to go"
expect(readinessBand(100), 'good');
- expect(readinessBand(0), 'low');
+ });
+
+ test('every ring tier has a briefing word', () {
+ for (var v = 0; v <= 100; v++) {
+ expect(readinessBand(v), isIn(const ['low', 'moderate', 'good']));
+ }
});
});
From 0823d1ffc60c93870834552afb19d59406c76008 Mon Sep 17 00:00:00 2001
From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com>
Date: Thu, 20 Aug 2026 01:29:02 +0530
Subject: [PATCH 38/64] auto-detect off, and the screen the notification opens
still loaded them
activeSuggestions() honoured the switch; the review screen read the
table directly, and that's the path the notification tap takes. one
gate now, and it fails closed - unreadable prefs are not permission.
while in here: the past-midnight end time added an absolute 24h, which
is an hour off on the two transition nights. next calendar day at the
picked wall time, same as health_export's dayEnd.
---
lib/ui2/screens/log_workout.dart | 38 ++++++++++++++++++++++++++++----
1 file changed, 34 insertions(+), 4 deletions(-)
diff --git a/lib/ui2/screens/log_workout.dart b/lib/ui2/screens/log_workout.dart
index 18c22152..af7958de 100644
--- a/lib/ui2/screens/log_workout.dart
+++ b/lib/ui2/screens/log_workout.dart
@@ -121,6 +121,15 @@ class _WorkoutSuggestionScreenState extends State {
Future _load() async {
setState(() => _failed = false);
+ // The switch, before the table. This screen is reachable by tapping the
+ // notification (`kRouteWorkoutSuggestion`), which does not come through
+ // the Workouts tab's already-gated read — so "auto-detect off" has to be
+ // answered here too or the one surface the user actually taps is the one
+ // the switch never reached.
+ if (!await autoDetectOn()) {
+ if (mounted) setState(() => _items = const []);
+ return;
+ }
try {
final rows = await LocalDb.activeWorkoutSuggestions();
if (!mounted) return;
@@ -490,7 +499,16 @@ class _LogWorkoutState extends State {
// Past midnight. A late run that finishes at 00:20 is an ordinary
// session, not an invalid window — the alternative is asking the user
// for a second date to express it.
- if (!e.isAfter(_start)) e = e.add(Motion.tick * 86400);
+ //
+ // The NEXT CALENDAR DAY at the picked wall time, built from date
+ // fields — not +24h of absolute Duration, which lands at 23:20 or
+ // 01:20 on the two transition nights a year and saves a window an
+ // hour off the one the user picked. Same trap as `_exportDay`'s
+ // `dayEnd` in health_export.dart.
+ if (!e.isAfter(_start)) {
+ e = DateTime(_start.year, _start.month, _start.day + 1, picked.hour,
+ picked.minute);
+ }
_end = e;
}
});
@@ -709,12 +727,24 @@ AppState? appOf(BuildContext c) {
}
}
+/// The auto-detect switch, read once for every surface that shows a bout.
+///
+/// FAILS CLOSED. Unreadable prefs are not permission to render cards the user
+/// may have switched off — and hiding them costs nothing, since the rows stay
+/// in `workout_suggestions` and reappear the moment the switch can be read.
+Future autoDetectOn() async {
+ try {
+ return (await NotificationPrefs.load()).autoDetectEnabled;
+ } catch (_) {
+ return false;
+ }
+}
+
/// Active suggestions for the History tab, or empty when the user has switched
-/// auto-detection off. Read here rather than in the screen so the switch is
-/// honoured at ONE place for both surfaces it has.
+/// auto-detection off.
Future> activeSuggestions() async {
+ if (!await autoDetectOn()) return const [];
try {
- if (!(await NotificationPrefs.load()).autoDetectEnabled) return const [];
return [
for (final r in await LocalDb.activeWorkoutSuggestions())
?Suggestion.from(r),
From 7a3025272ebe91c9240017e6ed28758b729d0c9b Mon Sep 17 00:00:00 2001
From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com>
Date: Thu, 20 Aug 2026 01:29:12 +0530
Subject: [PATCH 39/64] barcode lookup defaults on, but not when we can't read
the switch
Prefs.getBool hands back the fallback for "key never written" and for
"prefs never loaded", and i made this one default to on in this pr. so
someone who explicitly turned it off could still have their barcode go
out if storage wasn't up. loaded-and-absent stays on, unreadable refuses.
---
lib/data/off_lookup.dart | 11 ++++++++++-
lib/state/prefs.dart | 9 +++++++++
test/off_lookup_test.dart | 32 ++++++++++++++++++++++++--------
3 files changed, 43 insertions(+), 9 deletions(-)
diff --git a/lib/data/off_lookup.dart b/lib/data/off_lookup.dart
index 303afe3b..993b4681 100644
--- a/lib/data/off_lookup.dart
+++ b/lib/data/off_lookup.dart
@@ -71,9 +71,18 @@ const _userAgent =
/// Still persisted and still revocable from Settings › Privacy, and the food
/// log is entirely usable with it off: typing the numbers off the pack was
/// always the fallback and still is.
+///
+/// Default-on and fail-closed are not in tension, because they answer two
+/// different questions. `Prefs.getBool`'s fallback covers BOTH "loaded, no key
+/// yet" (a fresh install — default on, deliberately) and "prefs never loaded"
+/// (we cannot see the answer). Reading the second as the first sends the
+/// barcode of someone who explicitly opted out, which is the one thing a
+/// revocable consent must never do. So storage has to be there before the
+/// default counts.
const kOffConsentKey = 'nutrition.barcode_lookup';
-bool get offLookupAllowed => Prefs.getBool(kOffConsentKey, true);
+bool get offLookupAllowed =>
+ Prefs.loaded && Prefs.getBool(kOffConsentKey, true);
void setOffLookupAllowed(bool on) => Prefs.setBool(kOffConsentKey, on);
diff --git a/lib/state/prefs.dart b/lib/state/prefs.dart
index 465db398..287b89a9 100644
--- a/lib/state/prefs.dart
+++ b/lib/state/prefs.dart
@@ -24,6 +24,15 @@ class Prefs {
} catch (_) {/* reads fall back to defaults */}
}
+ /// Whether storage is actually available, i.e. whether a `getX` default is
+ /// "the key is unset" or "we cannot see what you chose".
+ ///
+ /// For a tab index those are the same answer. For a CONSENT they are not:
+ /// an on-by-default switch read through unavailable storage would send on
+ /// behalf of somebody who turned it off. Anything gating an outbound call
+ /// checks this first — see `offLookupAllowed`.
+ static bool get loaded => _sp != null;
+
// ── synchronous read (fall back to default until loaded) ────────────────────
static int getInt(String key, int fallback) => _sp?.getInt(key) ?? fallback;
static String getString(String key, String fallback) =>
diff --git a/test/off_lookup_test.dart b/test/off_lookup_test.dart
index 7893fff9..37f5df28 100644
--- a/test/off_lookup_test.dart
+++ b/test/off_lookup_test.dart
@@ -396,18 +396,34 @@ void main() {
group('the consent gate', () {
// Order matters: Prefs caches its SharedPreferences instance on first load
- // and never reloads, so the unloaded-defaults case has to be read before
- // anything mocks a store in.
- test('a fresh install may look up', () {
- // Nothing has loaded Prefs, so this IS the default. It is ON: what
- // leaves is the barcode, never anything about the person holding it.
- expect(offLookupAllowed, isTrue);
+ // and never reloads, so the unloaded case has to be read before anything
+ // mocks a store in.
+ test('storage we cannot read is a refusal, not the default', () async {
+ // Nothing has loaded Prefs. The default is ON, but an unreadable store
+ // is not evidence of a fresh install — it is equally the phone of
+ // somebody who turned this OFF, and their barcode must not go out on a
+ // guess.
+ expect(Prefs.loaded, isFalse);
+ expect(offLookupAllowed, isFalse);
+ final r = await fetchOffProduct('8901719101090');
+ expect(r.outcome, OffOutcome.refused);
});
- test('a lookup refuses before any request once it is turned off', () async {
+ test('a fresh install may look up', () async {
TestWidgetsFlutterBinding.ensureInitialized();
- SharedPreferences.setMockInitialValues({kOffConsentKey: false});
+ SharedPreferences.setMockInitialValues(const {});
await Prefs.ensureLoaded();
+ // Loaded, and the key has never been written: THIS is the fresh install,
+ // and it is on. What leaves is the barcode, never anything about the
+ // person holding it.
+ expect(Prefs.loaded, isTrue);
+ expect(offLookupAllowed, isTrue);
+ });
+
+ test('a lookup refuses before any request once it is turned off', () async {
+ // Written through the instance the test above loaded — Prefs caches it
+ // for the process, so a second `setMockInitialValues` would not be seen.
+ setOffLookupAllowed(false);
expect(offLookupAllowed, isFalse);
final r = await fetchOffProduct('8901719101090');
expect(r.outcome, OffOutcome.refused);
From 405db73f6d22fd3f194c9cc64e2cf9515f100864 Mon Sep 17 00:00:00 2001
From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com>
Date: Thu, 20 Aug 2026 01:29:12 +0530
Subject: [PATCH 40/64] the relay screen said nothing is stored, and it stores
package names
_noteSeen keeps up to 60 of them in shared prefs - that's how the picker
has anything to offer without asking for the permission that enumerates
every installed app. fine, but say it. content is still never read or
sent, which is the part that matters and is actually true.
also evict the icons with the names. _seen was bounded, _icons wasn't.
---
lib/notify/notification_relay.dart | 10 +++++++++-
lib/ui2/profile/band_notifications.dart | 15 ++++++++++++---
test/band_notifications_test.dart | 7 +++++--
3 files changed, 26 insertions(+), 6 deletions(-)
diff --git a/lib/notify/notification_relay.dart b/lib/notify/notification_relay.dart
index 9e42e90a..0a908092 100644
--- a/lib/notify/notification_relay.dart
+++ b/lib/notify/notification_relay.dart
@@ -232,7 +232,15 @@ class NotificationRelay extends ChangeNotifier with WidgetsBindingObserver {
if (icon != null && icon.isNotEmpty) _icons[pkg] = icon;
final known = _seen.remove(pkg);
_seen.insert(0, pkg);
- if (_seen.length > maxSeen) _seen.removeRange(maxSeen, _seen.length);
+ if (_seen.length > maxSeen) {
+ _seen.removeRange(maxSeen, _seen.length);
+ // The icons go with them. `_seen` is bounded, `_icons` was not — an
+ // evicted package left its bitmap resident for the life of the process,
+ // and on a phone with a lot of chatty apps that is the picker's whole
+ // icon set held for a list it is no longer on.
+ // ponytail: O(n) scan over 60 entries, only on eviction.
+ _icons.removeWhere((k, _) => !_seen.contains(k));
+ }
if (!known) {
SharedPreferences.getInstance()
.then((p) => p.setStringList(_kSeen, _seen))
diff --git a/lib/ui2/profile/band_notifications.dart b/lib/ui2/profile/band_notifications.dart
index 9dfa3567..6b7832a5 100644
--- a/lib/ui2/profile/band_notifications.dart
+++ b/lib/ui2/profile/band_notifications.dart
@@ -144,9 +144,17 @@ class BandNotificationsView extends StatelessWidget {
else ...[
settingsGroup(c, 'Relay', [
SetRow(LucideIcons.bellRing, C.purple, 'Buzz on app notifications',
+ // What is actually true, and no more. The relay reads
+ // no content and sends nothing anywhere — but it DOES
+ // keep the package names on this phone, because that
+ // list is the only way the picker below can offer you
+ // an app without asking for the permission that
+ // enumerates every app you have installed. "Nothing is
+ // stored" was the wrong claim to make about it.
sub: 'The strap buzzes when one of the apps below '
- 'notifies you. Nothing is read, stored or sent — '
- 'only which app posted',
+ 'notifies you. What a notification says is never '
+ 'read or sent — only which app posted, kept on '
+ 'this phone to build the list',
value: enabled ? 'On' : 'Off',
chevron: false,
onTap: () => onEnabled?.call(!enabled)),
@@ -159,7 +167,8 @@ class BandNotificationsView extends StatelessWidget {
StatusCard(
'Android needs to let us see notifications',
'The permission says which app posted, and that is all '
- 'this uses it for. Nothing leaves your phone.',
+ 'this uses it for. The names stay on this phone and '
+ 'nothing leaves it.',
fix: 'Grant notification access',
icon: LucideIcons.shieldCheck,
onFix: onGrant,
diff --git a/test/band_notifications_test.dart b/test/band_notifications_test.dart
index 06aa7089..82c60dc3 100644
--- a/test/band_notifications_test.dart
+++ b/test/band_notifications_test.dart
@@ -48,8 +48,11 @@ void main() {
BandNotificationsView(enabled: true, onGrant: () => asked = true),
);
expect(find.text('Grant notification access'), findsOneWidget);
- // The claim that has to be on the same card as the request.
- expect(find.textContaining('Nothing leaves your phone'), findsOneWidget);
+ // The claim that has to be on the same card as the request — and it has
+ // to be the TRUE one. The relay keeps the package names locally, so
+ // "nothing is stored" was a promise the code did not keep.
+ expect(find.textContaining('stay on this phone'), findsOneWidget);
+ expect(find.textContaining('nothing leaves it'), findsOneWidget);
await t.tap(find.text('Grant notification access'));
expect(asked, isTrue);
});
From 393af7451d85dd10a9a049710bc9e159912833c6 Mon Sep 17 00:00:00 2001
From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com>
Date: Thu, 20 Aug 2026 01:29:24 +0530
Subject: [PATCH 41/64] a load that starts during a save straddles it and wins
one generation bump only caught the load that started BEFORE the save.
start one during, and it captures the already-incremented value, so its
check passes - and its read, taken while the write is still inside the
plugin, comes back empty. trusted, that empty clears the key and writes
the marker false over the true the save just set. after that a stored
key reads as absent rather than unreadable and refreshKeyOnResume stops
retrying. bump on the way out too.
read stays outside the lock, on purpose - a hung keystore read holding
it would block save forever.
---
lib/coach/coach_config.dart | 27 ++++++++++++---
test/coach_config_key_test.dart | 60 ++++++++++++++++++++++++++++-----
2 files changed, 75 insertions(+), 12 deletions(-)
diff --git a/lib/coach/coach_config.dart b/lib/coach/coach_config.dart
index 051e096b..c4211c92 100644
--- a/lib/coach/coach_config.dart
+++ b/lib/coach/coach_config.dart
@@ -66,10 +66,23 @@ class CoachConfig extends ChangeNotifier {
bool _keyUndetermined = false;
bool get keyUndetermined => _keyUndetermined;
- /// Bumped by every [save]. A [load] that started before a save must not apply
- /// its stale result afterwards: the startup load is unawaited and a slow
- /// keystore read can still be in flight when the user pastes a key, and its
- /// late `_key = null` would wipe the key they just saved out of the session.
+ /// Bumped TWICE by every [save] — once on the way in, once on the way out.
+ ///
+ /// A [load] that started before a save must not apply its stale result
+ /// afterwards: the startup load is unawaited and a slow keystore read can
+ /// still be in flight when the user pastes a key, and its late `_key = null`
+ /// would wipe the key they just saved out of the session.
+ ///
+ /// One bump only caught the load that started BEFORE the save. A load that
+ /// starts DURING one captured the already-incremented value, so its check
+ /// passed, and its read — taken while the write was still inside the plugin —
+ /// came back empty. Trusted, that empty read is treated as proof there is no
+ /// key: it cleared `_key` and wrote the `_kKeyPresent` marker to false over
+ /// the true the save had just set. A later background read then reports the
+ /// stored key as ABSENT rather than unreadable, which also puts
+ /// [refreshKeyOnResume] to sleep — the retry that would have recovered it.
+ /// Bumping again on the way out invalidates any read that straddled the
+ /// write, which is the only kind that can be wrong about it.
int _generation = 0;
/// ONE keychain MUTATION at a time.
@@ -277,6 +290,12 @@ class CoachConfig extends ChangeNotifier {
} catch (_) {/* re-established by the next load */}
}
});
+ // The second bump: see [_generation]. Anything that read the keychain
+ // while that write was in flight now fails its check and drops its
+ // answer, instead of clearing the key and filing the marker false.
+ // Skipped when the write threw, on purpose — nothing landed, so a
+ // straddling read's "no key" is the truth.
+ _generation++;
_key = k.isEmpty ? null : k;
_keyUnreadable = false;
_keyUndetermined = false;
diff --git a/test/coach_config_key_test.dart b/test/coach_config_key_test.dart
index e352cf53..708de47f 100644
--- a/test/coach_config_key_test.dart
+++ b/test/coach_config_key_test.dart
@@ -25,13 +25,19 @@ class _FakeKeychain {
bool throwOnWrite = false;
bool hangReads = false;
bool hangWrites = false;
- final List> _hung = [];
-
- void releaseHung() {
- for (final c in _hung) {
- if (!c.isCompleted) c.complete();
+ final List> _hungReads = [];
+ final List> _hungWrites = [];
+
+ /// Reads and writes release SEPARATELY, so a test can land a save while a
+ /// read that started before it is still parked inside the plugin — the one
+ /// ordering the generation counter has to survive.
+ void releaseHung({bool reads = true, bool writes = true}) {
+ for (final l in [if (reads) _hungReads, if (writes) _hungWrites]) {
+ for (final c in l) {
+ if (!c.isCompleted) c.complete();
+ }
+ l.clear();
}
- _hung.clear();
}
Future handle(MethodCall call) async {
@@ -41,7 +47,7 @@ class _FakeKeychain {
if (throwOnRead) throw PlatformException(code: 'keychain');
if (hangReads) {
final c = Completer();
- _hung.add(c);
+ _hungReads.add(c);
await c.future;
}
// A locked keychain does not error — it simply returns nothing, which
@@ -52,7 +58,7 @@ class _FakeKeychain {
if (throwOnWrite) throw PlatformException(code: 'keychain');
if (hangWrites) {
final c = Completer();
- _hung.add(c);
+ _hungWrites.add(c);
await c.future;
}
items[args['key'] as String] = args['value'] as String;
@@ -297,6 +303,44 @@ void main() {
expect(cfg.apiKey, 'sk-new');
});
+ // The other half of the same race, and the one the single generation bump
+ // could not see: a load that starts DURING a save captures the already-
+ // incremented generation, so its check passes — and its read, taken while
+ // the write was still inside the plugin, comes back empty. Trusted, that
+ // empty is treated as proof there is no key.
+ test('a trusted load straddling a save does not erase the key', () async {
+ final cfg = CoachConfig();
+
+ // The save's write parks inside the plugin.
+ keychain.hangWrites = true;
+ final saving = cfg.save(apiKey: 'sk-new', model: 'gpt-4o');
+ await Future.delayed(const Duration(milliseconds: 10));
+
+ // Resume brings the app forward and re-reads the key. It starts here —
+ // after the save began — and its read parks too. `locked` so it comes back
+ // empty rather than seeing the key the save is about to land.
+ keychain.hangReads = true;
+ keychain.locked = true;
+ final loading = cfg.load(trusted: true);
+ await Future.delayed(const Duration(milliseconds: 10));
+
+ // The save lands FIRST, completely: key in the keychain, marker true.
+ keychain.releaseHung(reads: false);
+ await saving;
+ expect(cfg.apiKey, 'sk-new');
+
+ // Now the straddling read finally answers, and it answers "nothing".
+ keychain.releaseHung();
+ await loading;
+
+ expect(cfg.apiKey, 'sk-new',
+ reason: 'a read taken before the write landed proves nothing about it');
+ final prefs = await SharedPreferences.getInstance();
+ expect(prefs.getBool('coach_api_key_present'), isTrue,
+ reason: 'a false marker files a stored key as ABSENT, not unreadable, '
+ 'and puts the resume retry to sleep with it');
+ });
+
test('a hung keychain read does not block a save', () async {
final cfg = CoachConfig();
keychain.hangReads = true;
From 93c16a31816766467b2e14ea82f339d9666fc4f4 Mon Sep 17 00:00:00 2001
From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com>
Date: Thu, 20 Aug 2026 01:29:24 +0530
Subject: [PATCH 42/64] the router judged byte zero, the reader doesn't
noop_import skips blank and # lines first, and falls back to the
documented positional layout when there's no header at all. so an export
with a preamble, or a legacy headerless one, went to the vendor importer
and got told to re-download it in english. same misroute as #160/#199.
bounded first-record rule in both now.
---
lib/import/import_container.dart | 44 +++++++++++++++++++--
test/import_container_test.dart | 68 ++++++++++++++++++++++++++++++++
2 files changed, 108 insertions(+), 4 deletions(-)
diff --git a/lib/import/import_container.dart b/lib/import/import_container.dart
index e5b50a5e..32773067 100644
--- a/lib/import/import_container.dart
+++ b/lib/import/import_container.dart
@@ -116,6 +116,37 @@ Future sniffFile(String path) async {
/// cannot disagree about what a NOOP CSV is.
const String kNoopCsvHeader = 'unix_s,';
+/// How much of a text file the router reads to find its first record.
+const int _headBytes = 4096;
+
+/// True when the first RECORD of [text] is one the NOOP reader accepts.
+///
+/// The router used to ask whether byte zero began the header. The reader does
+/// not: it skips blank and `#` lines first, and it falls back to the
+/// documented positional layout when a file carries no header at all. So a
+/// NOOP export with a preamble, or a legacy headerless one, was handed to the
+/// vendor importer and refused with a confident wrong message — the same class
+/// of misroute (#160, #199) this function exists to end.
+///
+/// [truncated] says the buffer stopped mid-file, in which case the trailing
+/// fragment is not a whole line and is not judged.
+bool noopCsvFirstRecordMatches(String text, {bool truncated = false}) {
+ final lines = text.split('\n');
+ if (truncated && !text.endsWith('\n')) lines.removeLast();
+ for (var line in lines) {
+ line = line.trimRight(); // a CRLF export's \r
+ if (line.isEmpty || line.startsWith('#')) continue;
+ if (line.startsWith(kNoopCsvHeader)) return true;
+ // Headerless, i.e. [NoopImporter._defaultCols]: unix seconds in column 0
+ // and the full documented column count behind it. Deliberately structural
+ // — a vendor CSV's first field is a formatted date, never an epoch.
+ final f = line.split(',');
+ final ts = f.isEmpty ? null : int.tryParse(f.first.trim());
+ return f.length >= 15 && ts != null && ts > 1000000000 && ts < 4100000000;
+ }
+ return false;
+}
+
/// True when [path] is a NOOP export — judged by CONTENT, not by name.
///
/// The onboarding router used to switch on the extension: `.noopbak`/`.zip`
@@ -127,20 +158,25 @@ const String kNoopCsvHeader = 'unix_s,';
/// importer and was refused for holding too many files. Two confident, wrong
/// messages for two correct files.
///
-/// The signatures: a raw-sensor CSV starts with [kNoopCsvHeader]; a `.noopbak`
+/// The signatures: a raw-sensor CSV's FIRST RECORD is [kNoopCsvHeader] or the
+/// documented positional layout ([noopCsvFirstRecordMatches]); a `.noopbak`
/// (or a backup someone unpacked by hand) is a SQLite database; a WHOOP export
/// is an archive of several named CSVs and matches neither.
Future isNoopExport(String path) async {
final List head;
final raf = await File(path).open();
try {
- head = await raf.read(64);
+ // Enough to reach the first RECORD, not just the first byte — see
+ // [noopCsvFirstRecordMatches]. The container sniff still only reads the
+ // magic at the front.
+ head = await raf.read(_headBytes);
} finally {
await raf.close();
}
- switch (sniffImportContainer(head)) {
+ switch (sniffImportContainer(head.take(64).toList())) {
case ImportContainer.text:
- return String.fromCharCodes(head).startsWith(kNoopCsvHeader);
+ return noopCsvFirstRecordMatches(String.fromCharCodes(head),
+ truncated: head.length == _headBytes);
case ImportContainer.sqlite:
return true;
case ImportContainer.zip:
diff --git a/test/import_container_test.dart b/test/import_container_test.dart
index a5d2afb7..f8915ed9 100644
--- a/test/import_container_test.dart
+++ b/test/import_container_test.dart
@@ -546,4 +546,72 @@ void main() {
expect(await isNoopExport(path), isFalse);
});
});
+
+ // The router judged byte ZERO; the reader skips blank and `#` lines first and
+ // falls back to the documented positional layout when there is no header at
+ // all. Anything the reader would take, the router has to route — otherwise a
+ // valid export goes to the vendor importer and is refused with a confident
+ // wrong message, which is #160/#199 all over again.
+ group('the router uses the reader\'s own first-record rule', () {
+ test('a leading comment does not lose the file', () async {
+ final path = await write(
+ 'export.csv',
+ utf8.encode('# noop raw sensor export\n# v3\n'
+ 'unix_s,iso_utc,stream,hr_bpm\n1754000000,x,hr,61\n'),
+ );
+ expect(await isNoopExport(path), isTrue);
+ });
+
+ test('leading blank lines do not lose the file', () async {
+ final path = await write(
+ 'export.csv',
+ utf8.encode('\n\r\n\nunix_s,iso_utc,stream,hr_bpm\n1754000000,x,hr,61\n'),
+ );
+ expect(await isNoopExport(path), isTrue);
+ });
+
+ test('a headerless export is claimed, as the reader claims it', () async {
+ // The positional layout in `NoopImporter._defaultCols`, no header row.
+ final path = await write(
+ 'raw.csv',
+ utf8.encode('1754000000,2026-08-01T00:00:00Z,hr,61,,,,,,,,,,,,,\n'
+ '1754000001,2026-08-01T00:00:01Z,hr,62,,,,,,,,,,,,,\n'),
+ );
+ expect(await isNoopExport(path), isTrue);
+ });
+
+ test('a vendor CSV behind a comment is still not ours', () async {
+ final path = await write(
+ 'sleeps.csv',
+ utf8.encode('# exported 2026-08-01\n'
+ 'Cycle start time,Sleep performance %\n2026-08-01,88\n'),
+ );
+ expect(await isNoopExport(path), isFalse);
+ });
+
+ test('a comma-heavy row that is not an epoch is not ours', () async {
+ // The headerless signature is structural on purpose: 17 columns is not
+ // enough, column zero has to be unix seconds.
+ final path = await write(
+ 'other.csv',
+ utf8.encode('${List.filled(17, 'x').join(',')}\n'),
+ );
+ expect(await isNoopExport(path), isFalse);
+ });
+
+ test('an all-comment file claims nothing', () async {
+ final path = await write('notes.csv', utf8.encode('# nothing\n# here\n'));
+ expect(await isNoopExport(path), isFalse);
+ });
+
+ test('a header past the read ceiling is not guessed at', () async {
+ // 4 KB of comments, then the header. Bounded read means bounded answer:
+ // it declines rather than materialising the file to be sure.
+ final path = await write(
+ 'export.csv',
+ utf8.encode('${'# pad\n' * 1200}unix_s,iso_utc,stream\n1754000000,x,hr\n'),
+ );
+ expect(await isNoopExport(path), isFalse);
+ });
+ });
}
From b8cd547f3d26ff66f6ed6dceefd638c4227e6279 Mon Sep 17 00:00:00 2001
From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com>
Date: Thu, 20 Aug 2026 01:29:24 +0530
Subject: [PATCH 43/64] skipping the only step still leaves the job green
which is the thing i was trying to stop - a required check that reads as
a pass when nothing was reviewed. say so in the summary and as an
annotation instead of leaving it silent.
---
.github/workflows/pr-agent.yml | 24 +++++++++++++++++++++---
1 file changed, 21 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/pr-agent.yml b/.github/workflows/pr-agent.yml
index 09c45a6a..1da003ef 100644
--- a/.github/workflows/pr-agent.yml
+++ b/.github/workflows/pr-agent.yml
@@ -18,10 +18,28 @@ jobs:
env:
PR_AGENT_API_KEY: ${{ secrets.PR_AGENT_API_KEY }}
steps:
+ # A PR from a fork gets no secrets, so the step below ran with an empty
+ # key, reviewed nothing, and still went green - a check that says
+ # "reviewed" when it did not is worse than no check.
+ #
+ # Skipping the step alone did not fix that: the only step is skipped, the
+ # JOB still reports success, and a green required check still reads as a
+ # pass. So SAY SO, in the one place a reader of the PR looks - the check's
+ # summary - and make the log line an annotation on the PR itself.
+ - name: Not applicable - no review key on this PR
+ if: env.PR_AGENT_API_KEY == ''
+ run: |
+ echo "::notice title=PR Agent did not run::No review key is available \
+ on this pull request (forks get no secrets), so NOTHING was reviewed. \
+ A green check here means the job finished, not that the diff passed."
+ {
+ echo "## PR Agent: not applicable"
+ echo
+ echo "No \`PR_AGENT_API_KEY\` on this run - a fork PR gets no"
+ echo "secrets. **No review was performed.** Treat this check as"
+ echo "absent, not as a pass."
+ } >> "$GITHUB_STEP_SUMMARY"
- name: PR Agent action step
- # A PR from a fork gets no secrets, so this ran with an empty key,
- # reviewed nothing, and still went green - a check that says "reviewed"
- # when it did not is worse than no check. Skip instead.
if: env.PR_AGENT_API_KEY != ''
# Pinned, not @main: this action runs with `contents: write` and a token
# on every PR, and a floating ref means whatever landed upstream today.
From 14089ee1a717f73d7bd485c433cd1dfb74eba295 Mon Sep 17 00:00:00 2001
From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com>
Date: Thu, 20 Aug 2026 01:29:34 +0530
Subject: [PATCH 44/64] repin both siblings, and the third copy of the gate
analytics 3174a49, protocol c761f29. no kAlgoVersion bump: both fixes
only reject NaN/inf, so for anyone whose data is valid the numbers are
byte-identical and a bump would recompute every day to the same answer.
dailyEnergy is nullable now - it abstains instead of billing every
waking minute as active - so two call sites take a ?. and a null check.
and app_state had the gate arithmetic inlined a third time for the live
gauge, with none of the validation. a NaN resting hr makes the gate NaN,
every hr < gate is false, every sample bills active. through
Calories.activeGateHr now, abstaining when it can't define one.
---
lib/compute/derivation_engine.dart | 13 +++++++++++--
lib/compute/onehz_pipeline.dart | 5 ++++-
lib/state/app_state.dart | 15 ++++++++++++++-
pubspec.lock | 8 ++++----
pubspec.yaml | 15 +++++++++++++--
test/live_rescore_calorie_parity_test.dart | 4 +++-
6 files changed, 49 insertions(+), 11 deletions(-)
diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart
index e89697cf..b37f810b 100644
--- a/lib/compute/derivation_engine.dart
+++ b/lib/compute/derivation_engine.dart
@@ -1278,8 +1278,13 @@ const int kAlgoVersion = 75;
// level under strain instead of a population constant that scored a day with
// no activity at all somewhere between 6.9 and 12.1 out of 21. protocol: v25
// stops emitting a gravity vector from offsets that were refuted on real data.
-const String kAnalyticsPin = '0a303151e0d22ceeb3a1cf92f820baea0a73098d';
-const String kProtocolPin = '60676cfb37fe7650e949d53b7f2faef2bed74f09';
+// Both siblings moved again after their own review passes, and kAlgoVersion
+// deliberately did NOT: those fixes reject NaN and ±inf, which no sensor ever
+// produced and no baseline ever held. For a user whose data is valid, every
+// number out of both packages is byte-identical, so a bump would invalidate
+// every stored day to recompute the same answers.
+const String kAnalyticsPin = '3174a493472a5e6280b11a0ab11fec82483507e1';
+const String kProtocolPin = 'c761f29bcbed73886b1b059dcd9e92e4333574f5';
// Fold idempotency, the minimum-nights warm-up, and legacy-payload handling
// all live in SleepProfilePolicy (pure, unit-tested) — see
@@ -4734,6 +4739,10 @@ class DerivationEngine {
restingHr: restingHr,
dayMinutes: dayMinutes ?? 1440,
);
+ // Anchors that cannot define an active gate are an ABSENT day's energy,
+ // not a day billed entirely as active. `dailyEnergy` abstains; so does the
+ // day, which is what every other caller of this method already expects.
+ if (e == null) return null;
return (active: e.active, basal: e.basal, total: e.total);
}
diff --git a/lib/compute/onehz_pipeline.dart b/lib/compute/onehz_pipeline.dart
index e7473433..6df7c356 100644
--- a/lib/compute/onehz_pipeline.dart
+++ b/lib/compute/onehz_pipeline.dart
@@ -744,7 +744,10 @@ Map deriveDayBundle(Map inputJson) {
),
hrmax: hrMax,
restingHr: rhrForTrimp,
- ).active; // active-energy component (Keytel surplus over basal)
+ // `?.` — `dailyEnergy` abstains outright when the anchors cannot
+ // define a gate, rather than billing every waking minute as active.
+ // Absent stays absent here, same as every other input on this seam.
+ )?.active; // active-energy component (Keytel surplus over basal)
}
}
diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart
index eb3c1a8e..77994b99 100644
--- a/lib/state/app_state.dart
+++ b/lib/state/app_state.dart
@@ -5421,6 +5421,20 @@ class LiveWorkoutState {
calories = 0.0;
return;
}
+ // THE gate, from the one place that defines it. This used to be the
+ // arithmetic inlined below, which is the third copy of it — and
+ // `Calories`' own docstring says a second copy is how the day and the bout
+ // came to disagree in the first place. It also got none of the anchor
+ // validation: a non-finite resting HR makes the gate NaN, every
+ // `bpm < gate` is then false, and EVERY sample bills at the active rate.
+ // Null means the anchors cannot define a gate, and the live gauge abstains
+ // exactly as the re-score does.
+ final gate = ana.Calories.activeGateHr(maxHr, rhr);
+ if (gate == null) {
+ _caloriesScored = false;
+ calories = 0.0;
+ return;
+ }
if (_secondsByBpm.isEmpty && _lastSampleHr == null) {
_caloriesScored = false;
calories = 0.0;
@@ -5434,7 +5448,6 @@ class LiveWorkoutState {
// floor. Defaulted to match `computeManualSessionStats`, so the two paths
// cannot disagree for a profile that carries no height.
final heightCm = profile.heightCm ?? 170.0;
- final gate = rhr + ana.Calories.activeHRRFraction * (maxHr - rhr);
final restingRate =
ana.Calories.restingKcalPerS(coeffs, weightKg, heightCm, age);
diff --git a/pubspec.lock b/pubspec.lock
index 6cb861fd..9262bb70 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -924,8 +924,8 @@ packages:
dependency: "direct main"
description:
path: "."
- ref: "0a303151e0d22ceeb3a1cf92f820baea0a73098d"
- resolved-ref: "0a303151e0d22ceeb3a1cf92f820baea0a73098d"
+ ref: "3174a493472a5e6280b11a0ab11fec82483507e1"
+ resolved-ref: "3174a493472a5e6280b11a0ab11fec82483507e1"
url: "https://github.com/OpenStrap/analytics.git"
source: git
version: "1.0.0"
@@ -933,8 +933,8 @@ packages:
dependency: "direct main"
description:
path: "."
- ref: "60676cfb37fe7650e949d53b7f2faef2bed74f09"
- resolved-ref: "60676cfb37fe7650e949d53b7f2faef2bed74f09"
+ ref: c761f29bcbed73886b1b059dcd9e92e4333574f5
+ resolved-ref: c761f29bcbed73886b1b059dcd9e92e4333574f5
url: "https://github.com/OpenStrap/protocol.git"
source: git
version: "1.0.0"
diff --git a/pubspec.yaml b/pubspec.yaml
index e4689a1e..7d76c67b 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -70,7 +70,11 @@ dependencies:
# emits R10 beat intervals) — deliberately NOT repinned: edge never reads
# `rr_ms` off decodeFrame, so the hop changes no number here and a repin
# would drag kAlgoVersion with it for nothing.
- ref: 60676cfb37fe7650e949d53b7f2faef2bed74f09
+ #
+ # Repinned to the #27 head after its own review pass. NO kAlgoVersion
+ # bump: the fixes only reject NaN/±inf, which was never a measurement, so
+ # for any user whose data is valid the output is byte-identical.
+ ref: c761f29bcbed73886b1b059dcd9e92e4333574f5
openstrap_analytics:
git:
url: https://github.com/OpenStrap/analytics.git
@@ -187,7 +191,14 @@ dependencies:
# moved 21664f8 -> bfea5e5 to track it. Diff between the two is two
# test-only deprecation-ignore annotations (analytics `lib/` untouched),
# so kAlgoVersion needs no bump for this move.
- ref: 0a303151e0d22ceeb3a1cf92f820baea0a73098d
+ #
+ # Repinned again after that branch's own review pass. Still no bump, same
+ # reason: the change rejects NaN/±inf inputs and nothing else, and a NaN
+ # was never a reading. `dailyEnergy` returns a nullable record now — it
+ # abstains rather than billing every waking minute as active when the
+ # anchors are unusable — which is source-breaking here, not
+ # number-changing (see `onehz_pipeline` and `_dailyEnergy`).
+ ref: 3174a493472a5e6280b11a0ab11fec82483507e1
# BLE — flutter_blue_plus is the maintained cross-platform GATT client.
flutter_blue_plus: ^1.36.8
diff --git a/test/live_rescore_calorie_parity_test.dart b/test/live_rescore_calorie_parity_test.dart
index 82ab8f31..2e4b1beb 100644
--- a/test/live_rescore_calorie_parity_test.dart
+++ b/test/live_rescore_calorie_parity_test.dart
@@ -221,7 +221,9 @@ void main() {
// HRmax and is now a fraction of heart-rate reserve, and this test failed
// the day that changed — it was still straddling a boundary that had moved
// 13 bpm away, which is a test pinning arithmetic instead of behaviour.
- final gate = ana.Calories.activeGateHr(_hrMax, _restingHr);
+ // `!` — the fixture's anchors are a real pair, so a null here would mean
+ // the gate stopped being definable for ordinary numbers.
+ final gate = ana.Calories.activeGateHr(_hrMax, _restingHr)!;
final above = gate.ceil() + 1;
final below = gate.floor() - 1;
final sawtooth = [
From bcea409c74f8ba7dd754f67de3d63c220e2d0fc3 Mon Sep 17 00:00:00 2001
From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com>
Date: Thu, 20 Aug 2026 00:53:07 +0530
Subject: [PATCH 45/64] forget the band and there was no way back to pairing
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
the pair button lived inside `sources.isEmpty` and the phone is a source, so
one steps row was enough to hide it. gate it on whether there's a BAND, and
put it at the top where the missing band would sit — a phone pedometer is not
a substitute for one and the screen now says so. same dead end for anyone who
only ever had phone steps and never paired.
while in there: forgetting now drops what the old band said about itself.
the engine keeps one DeviceState for the life of the process and the strap
name is in prefs, so a re-pair with a DIFFERENT band inherited its name,
serial, generation and bond verdicts — including an autoReconnectPaused that
would have quietly paused the loop for the new one. generation is the one
that matters: the device page states it as a calibration fact.
driven through the real screen over a real AppState, because both halves look
fine on their own.
---
lib/data/models.dart | 36 ++++++++++++
lib/state/app_state.dart | 6 ++
lib/ui2/profile/devices.dart | 36 +++++++++---
test/ui2_router_test.dart | 105 +++++++++++++++++++++++++++++++++++
4 files changed, 174 insertions(+), 9 deletions(-)
diff --git a/lib/data/models.dart b/lib/data/models.dart
index 3a3be661..02eede79 100644
--- a/lib/data/models.dart
+++ b/lib/data/models.dart
@@ -382,4 +382,40 @@ class DeviceState {
String? generation;
DeviceState({this.connection = 'disconnected'});
+
+ /// Back to "no band has ever connected this process".
+ ///
+ /// The engine holds ONE of these for its whole life, so without this a
+ /// forget leaves the old strap's serial, name, generation and battery in
+ /// place — and a re-pair with a different band then shows them until the new
+ /// link happens to overwrite each one. `generation` is the one that is not
+ /// cosmetic: it is the key every sensor-dependent metric looks its constants
+ /// up under, and the device page states it as a calibration fact.
+ ///
+ /// The bond/radio verdicts go too. They are findings about the band that was
+ /// forgotten — an `autoReconnectPaused` left standing would silently pause
+ /// the reconnect loop for the NEXT band as well.
+ void reset() {
+ address = null;
+ serial = null;
+ batteryPct = null;
+ charging = null;
+ chargingTs = null;
+ wristOn = null;
+ liveHr = null;
+ liveHrAt = null;
+ alarmEpoch = null;
+ strapName = null;
+ generation = null;
+ connection = 'disconnected';
+ standardHrFallback = false;
+ needsRepairGuide = false;
+ bondRefusals = 0;
+ autoReconnectPaused = false;
+ syncClockLost = false;
+ strapNeedsReboot = false;
+ syncChunkQuarantined = false;
+ dataRangeOldest = null;
+ dataRangeNewest = null;
+ }
}
diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart
index 77994b99..d85b2e86 100644
--- a/lib/state/app_state.dart
+++ b/lib/state/app_state.dart
@@ -3519,6 +3519,12 @@ class AppState extends ChangeNotifier {
await engine.disconnect();
_releaseForegroundLease();
await PairedDevice.clear();
+ // Everything the old band told us about itself. The engine's DeviceState
+ // lives as long as the process and the persisted strap name outlives even
+ // that, so without both of these a re-pair — with a DIFFERENT band —
+ // inherits the forgotten one's name, serial, generation and bond verdicts.
+ device.reset();
+ Prefs.setString(_kStrapName, '');
paired = null;
notifyListeners();
}
diff --git a/lib/ui2/profile/devices.dart b/lib/ui2/profile/devices.dart
index c9cab86c..d24d6ee5 100644
--- a/lib/ui2/profile/devices.dart
+++ b/lib/ui2/profile/devices.dart
@@ -253,6 +253,11 @@ class MyDevicesView extends StatelessWidget {
Widget build(BuildContext c) {
final p = P.of(c);
final fault = status?.isFault == true ? status : null;
+ // A BAND, not "a source". The phone is a source and it is not a substitute
+ // for one: gating this on `sources.isEmpty` meant a phone counting steps
+ // hid the only route back to pairing, and forgetting a band left the user
+ // stranded with a steps row and no way to add another.
+ final hasBand = sources.any((s) => s.isBand);
return Scaffold(
backgroundColor: p.bg,
body: SafeArea(
@@ -265,6 +270,28 @@ class MyDevicesView extends StatelessWidget {
child: ListView(
padding: const EdgeInsets.fromLTRB(S.x4, 0, S.x4, S.x10),
children: [
+ // FIRST, in the tier-2 slot the missing band would occupy —
+ // not appended under the phone. Someone who has just forgotten
+ // a band is here to add one, and the row they can see is not
+ // the one they came for.
+ if (!hasBand) ...[
+ StatusCard(
+ sources.isEmpty
+ ? 'Nothing is measuring yet'
+ : 'No band is paired',
+ sources.isEmpty
+ ? 'No band is paired and phone steps are off, so every '
+ 'metric in the app will abstain rather than '
+ 'estimate.'
+ : 'The phone counts steps and nothing else. Heart '
+ 'rate, sleep, recovery and temperature all abstain '
+ 'until a band is paired.',
+ fix: 'Pair a band',
+ icon: LucideIcons.watch,
+ onFix: onPair,
+ ),
+ if (sources.isNotEmpty) const SizedBox(height: S.x3),
+ ],
for (final s in sources) ...[
SourceRow(s, onTap: () => goto(c, DeviceDetail(s))),
if (s.isBand && fault != null) ...[
@@ -275,15 +302,6 @@ class MyDevicesView extends StatelessWidget {
],
const SizedBox(height: S.x3),
],
- if (sources.isEmpty)
- StatusCard(
- 'Nothing is measuring yet',
- 'No band is paired and phone steps are off, so every '
- 'metric in the app will abstain rather than estimate.',
- fix: 'Pair a band',
- icon: LucideIcons.watch,
- onFix: onPair,
- ),
const SizedBox(height: S.x5),
Text('THE QUALITY LADDER',
style: F.over.copyWith(color: p.ink3)),
diff --git a/test/ui2_router_test.dart b/test/ui2_router_test.dart
index 01759882..5fa0f03b 100644
--- a/test/ui2_router_test.dart
+++ b/test/ui2_router_test.dart
@@ -17,11 +17,14 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
+import 'package:provider/provider.dart';
import 'package:openstrap_edge/app.dart';
import 'package:openstrap_edge/ble/ble_state.dart';
+import 'package:openstrap_edge/data/models.dart' show DeviceState;
import 'package:openstrap_edge/notify/tap_router.dart';
import 'package:openstrap_edge/state/app_state.dart';
+import 'package:openstrap_edge/sync/paired_device.dart' show PairedDevice;
import 'package:openstrap_edge/import/backup_crypto.dart';
import 'package:openstrap_edge/ui2/onboarding/pairing.dart';
import 'package:openstrap_edge/ui2/onboarding/profile_setup.dart';
@@ -442,6 +445,108 @@ void main() {
});
});
+ // The dead end this group exists for: forget the band and the only route
+ // back to pairing disappeared. The pair affordance lived inside
+ // `sources.isEmpty`, and a phone reporting steps is a source — so one
+ // steps-only row was enough to hide it, and the app became unusable as a
+ // band app with no way to say so.
+ //
+ // Driven through the real screen over a real AppState, because reading the
+ // widget tree is exactly what missed it: both halves look right on their
+ // own.
+ group('the way back to pairing survives a forget', () {
+ testWidgets('the band goes, the phone stays, the pair affordance appears',
+ (tester) async {
+ _tallView(tester);
+ final app = AppState()
+ ..paired = PairedDevice('AA:BB:CC:DD:EE:FF', 'SER1')
+ ..phoneStepsEnabled = true
+ ..phoneStepsLastSyncedDays = 1
+ ..phoneStepsLastTotal = 4200;
+ addTearDown(app.dispose);
+
+ await tester.pumpWidget(ChangeNotifierProvider.value(
+ value: app,
+ child: MaterialApp(
+ theme: buildTheme(Brightness.light), home: const MyDevices()),
+ ));
+ expect(find.text('WHOOP band'), findsOneWidget);
+ expect(find.text('Pair a band'), findsNothing);
+
+ // Forget. `unpair()` itself is platform-bound (ASK, the engine, the
+ // foreground service); what it leaves behind for this screen is this.
+ app.paired = null;
+ app.notifyListeners();
+ await tester.pump();
+
+ expect(find.text('WHOOP band'), findsNothing);
+ expect(find.text('This phone'), findsOneWidget,
+ reason: 'the phone row is what used to swallow the empty state');
+ expect(find.text('Pair a band'), findsOneWidget);
+ });
+
+ testWidgets('and for someone who only ever had the phone', (tester) async {
+ _tallView(tester);
+ var pairs = 0;
+ await tester.pumpWidget(MaterialApp(
+ theme: buildTheme(Brightness.light),
+ home: MyDevicesView(
+ sources: [
+ const HealthSource(
+ name: 'This phone',
+ kind: 'Motion coprocessor',
+ tier: SourceTier.phone,
+ icon: LucideIcons.smartphone,
+ connected: true),
+ ],
+ onPair: () => pairs++,
+ ),
+ ));
+ await tester.tap(find.text('Pair a band'));
+ expect(pairs, 1);
+ });
+
+ // The other half of the same dead end: getting back to pairing is no good
+ // if the band you pair next inherits the forgotten one's identity. The
+ // engine holds one DeviceState for the life of the process.
+ test('forgetting drops what the old band said about itself', () {
+ final d = DeviceState(connection: 'connected')
+ ..serial = 'SER1'
+ ..strapName = 'Old band'
+ ..generation = 'gen4'
+ ..batteryPct = 71
+ ..autoReconnectPaused = true
+ ..bondRefusals = 5;
+ d.reset();
+ expect(d.serial, isNull);
+ expect(d.strapName, isNull);
+ expect(d.generation, isNull,
+ reason: 'a gen5 band must not be calibrated as the gen4 it replaced');
+ expect(d.batteryPct, isNull);
+ expect(d.connection, 'disconnected');
+ expect(d.autoReconnectPaused, isFalse,
+ reason: 'the next band starts with a clean reconnect loop');
+ expect(d.bondRefusals, 0);
+ });
+
+ testWidgets('a paired band is not asked to pair again', (tester) async {
+ _tallView(tester);
+ await tester.pumpWidget(MaterialApp(
+ theme: buildTheme(Brightness.light),
+ home: MyDevicesView(sources: [
+ const HealthSource(
+ name: 'WHOOP 4.0',
+ kind: '',
+ tier: SourceTier.wristOptical,
+ icon: LucideIcons.watch,
+ connected: true,
+ isBand: true),
+ ], onPair: () {}),
+ ));
+ expect(find.text('Pair a band'), findsNothing);
+ });
+ });
+
test('byte sizes read like sizes', () {
expect(formatBytes(512), '512 B');
expect(formatBytes(1536), '1.5 KB');
From 98dee9c8015594649c0a38cba487452bebd1cfff Mon Sep 17 00:00:00 2001
From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com>
Date: Thu, 20 Aug 2026 01:08:25 +0530
Subject: [PATCH 46/64] notify: two prompts that actually ask you to log
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
nothing in the app ever asked — you had to remember to open it.
meds come off the schedule you already typed in, one notification per
dose still due (a dose marked taken or skipped is never armed), and the
notification names no drug: that lands on a lock screen.
check-in is one prompt for the whole self-report, an hour before the
bedtime the coach learned, and it's skipped once the day has a rating in
it. both off by default, both on schedulableIds so they can actually
fire, both land on a screen that exists.
---
lib/app.dart | 11 +
lib/notify/notification_center.dart | 237 ++++++++++++++++++-
lib/notify/notification_prefs.dart | 32 +++
lib/notify/notification_service.dart | 32 ++-
lib/notify/tap_router.dart | 15 ++
lib/state/app_state.dart | 39 ++++
test/log_prompts_test.dart | 328 +++++++++++++++++++++++++++
7 files changed, 689 insertions(+), 5 deletions(-)
create mode 100644 test/log_prompts_test.dart
diff --git a/lib/app.dart b/lib/app.dart
index d26a1ea5..89d1794b 100644
--- a/lib/app.dart
+++ b/lib/app.dart
@@ -316,6 +316,9 @@ ShellDomain domainForRoute(String route) => switch (route) {
// Water is a journal field that lives on Nutrition — that is the tab
// behind the log screen, and where a "back" from it should land.
kRouteWater => ShellDomain.nutrition,
+ // The medication reminder. Wellness owns the Medication tab and its
+ // checklist, which is where a dose is actually recorded.
+ kRouteMeds => ShellDomain.wellness,
kRouteWorkoutSuggestion => ShellDomain.workout,
// Emitted by the battery forecast (`app_state.dart`) and the weekly
// recap (`notification_center.dart`), and declared in `tap_router`
@@ -361,6 +364,14 @@ Widget? screenForRoute(String route) => switch (route) {
kRouteWater => const NutritionScreen(),
// The detected bout, with the three answers to it: log it, adjust the
// times first, or say it never happened.
+ // The medication reminder pushes nothing: the checklist it is about is a
+ // SUB-TAB of Wellness, and `WellnessScreen` keeps that index in private
+ // state with no constructor argument, so there is nothing to hand it.
+ // `domainForRoute` still lands the tap on Wellness, one tap from the
+ // Medication tab — deliberately null rather than pushing a second copy
+ // of a shell tab over the shell. Give `WellnessScreen` an `initialTab`
+ // and this becomes `WellnessScreen(initialTab: 3)`.
+ kRouteMeds => null,
kRouteWorkoutSuggestion => const WorkoutSuggestionScreen(),
// Battery, band and sources all live behind this one.
kRouteProfile => const ProfileHome(),
diff --git a/lib/notify/notification_center.dart b/lib/notify/notification_center.dart
index 1ffead8d..ef9605c5 100644
--- a/lib/notify/notification_center.dart
+++ b/lib/notify/notification_center.dart
@@ -31,6 +31,8 @@ import 'package:shared_preferences/shared_preferences.dart';
import '../ai/ai_prefs.dart';
import '../ai/reminder_plan.dart';
import '../data/day_label.dart';
+import '../data/journal_fields.dart';
+import '../data/med_store.dart';
import 'fired_keys.dart';
import 'notification_event.dart';
import 'notification_prefs.dart';
@@ -196,14 +198,52 @@ class NotificationCenter {
///
/// [bedtimeMinOfDay] is no longer read: it timed the wind-down nudge. Kept so
/// the existing caller compiles unchanged; drop both together.
+ /// [checkInDoneToday] — whether the day's self-report is already written
+ /// (see [checkInDone]). The prompt is not armed for a day that is already
+ /// answered, which is the whole reason the caller reads it. NULL means the
+ /// caller could not tell, and the check-in is then left exactly as it is.
+ ///
+ /// [medDefs] / [medDosesToday] come straight from `MedDb` and are only read
+ /// when `prefs.medsEnabled` is on. They stay parameters rather than a query
+ /// in here for the same reason [weeklyFinding] does: this method is the
+ /// policy, and a policy that opens the database cannot be tested without
+ /// one.
Future scheduleStandingReminders(
NotificationPrefs prefs, {
double? bedtimeMinOfDay,
String? weeklyFinding,
+ bool? checkInDoneToday,
+ List medDefs = const [],
+ Map>> medDosesToday = const {},
}) async {
final svc = NotificationService.instance;
await svc.cancel(NotificationService.idWindDown);
await svc.cancel(NotificationService.idWeeklyRecap);
+ // The check-in and the medication band follow the same rule as the
+ // movement nudge below: cancel what the user just switched off, and
+ // otherwise only what THIS call can put back.
+ //
+ // `checkInDoneToday` null means the caller does not know whether today is
+ // already written — the notifications screen re-asserting after an
+ // unrelated toggle. Cancelling then would drop tonight's prompt, and
+ // re-arming would risk asking for a day already answered, so neither
+ // happens and the next foreground pass (which does know) decides.
+ if (!prefs.checkInEnabled || checkInDoneToday != null) {
+ await svc.cancel(NotificationService.idCheckIn);
+ }
+ // The medication band is cancelled when the switch is OFF — that is where
+ // a reminder the user just turned off actually goes away — or when we were
+ // handed the schedule and can therefore re-arm from it a few lines down.
+ //
+ // NOT unconditionally. This method also runs from the notifications screen
+ // after any unrelated toggle, with no schedule passed, and an unconditional
+ // cancel there would bin every armed dose for a user who came in to change
+ // their quiet hours. Cancel only what this call can put back.
+ if (!prefs.medsEnabled || medDefs.isNotEmpty) {
+ for (var i = 0; i < NotificationService.maxMedSlots; i++) {
+ await svc.cancel(NotificationService.idMedsBase + i);
+ }
+ }
// idStillness is NOT a standing schedule and must not be cancelled with
// them. It is a one-shot armed by live movement
// (`AppState._rescheduleStillnessNudge`), nothing in this method re-arms
@@ -223,13 +263,75 @@ class NotificationCenter {
}
final water = waterSlotMinutes(prefs);
final wantWeekly = prefs.remindersEnabled && weeklyFinding != null;
- if (water.isEmpty && !wantWeekly) return;
+ final now = DateTime.now();
+ final checkIn = checkInDoneToday == null
+ ? null
+ : checkInSlot(prefs, bedtimeMinOfDay,
+ doneToday: checkInDoneToday, nowMin: now.hour * 60 + now.minute);
+ final meds = medPromptSlots(prefs, medDefs, medDosesToday, now: now);
+ if (water.isEmpty && !wantWeekly && checkIn == null && meds.isEmpty) return;
// Re-resolve the zone first: this runs on every foreground resume, and the
// instants below are wall-clock. A phone that flew somewhere would otherwise
// keep arming Sunday 18:00 in the zone the app first launched in.
await svc.ensureTimezone();
await _armWaterSlots(svc, water);
if (wantWeekly) await _armWeeklyLookback(svc, weeklyFinding);
+ if (checkIn != null) await _armCheckIn(svc, checkIn);
+ await _armMedSlots(svc, meds);
+ }
+
+ /// One notification per dose still due — never one per day, never a summary.
+ ///
+ /// ONE-SHOT per slot, at the minute the user entered. A daily repeat cannot
+ /// know whether today's dose was already taken, and a reminder for a pill
+ /// already swallowed is exactly the notification people turn everything off
+ /// over. The cost of the one-shot is that cover only reaches as far as
+ /// [medPromptSlots]' horizon from the last foreground pass; the reminder
+ /// re-arms on every resume, which for anyone who opens the app daily is
+ /// always ahead of the doses.
+ ///
+ /// Quiet hours are deliberately NOT applied: this is the user's own entered
+ /// time, the same reasoning that exempts the alarm. Someone who takes a pill
+ /// at 23:00 typed 23:00.
+ Future _armMedSlots(NotificationService svc, List slots) async {
+ for (var i = 0; i < slots.length; i++) {
+ final s = slots[i];
+ final at = medSlotInstant(s);
+ if (at == null) continue;
+ await svc.scheduleOnce(
+ id: NotificationService.idMedsBase + i,
+ category: NotifCategory.reminders,
+ // NO MEDICATION NAME, deliberately. This lands on a lock screen, in
+ // front of whoever is in the room, and "which drug" is the most
+ // sensitive fact in the app. The checklist behind the tap says which —
+ // one unlock away, which is where that belongs. It is also why the
+ // body is not a dose or a count.
+ title: 'Medication',
+ // Not an adherence score, not a streak, and nothing about a dose that
+ // was missed: this is the reminder, not the report.
+ body: 'A dose is due.',
+ at: at,
+ route: kRouteMeds,
+ );
+ }
+ }
+
+ /// The daily check-in, as a ONE-SHOT at the next [minuteOfDay].
+ ///
+ /// One-shot for the same reason the meds slots are: whether the day is
+ /// already written changes daily, and a repeat would go on asking after the
+ /// journal was filled in. Re-armed on every foreground pass, and the caller
+ /// suppresses it outright once the day has any rating in it.
+ Future _armCheckIn(NotificationService svc, int minuteOfDay) async {
+ await svc.scheduleOnce(
+ id: NotificationService.idCheckIn,
+ category: NotifCategory.reminders,
+ title: 'How was today?',
+ // No guilt, no count, no reference to a day that was missed.
+ body: 'Mood, energy, stress — a minute of it.',
+ at: svc.nextDailyInstant(minuteOfDay ~/ 60, minuteOfDay % 60),
+ route: kRouteJournalCompose,
+ );
}
/// One daily-repeating notification per hydration slot.
@@ -325,6 +427,139 @@ class NotificationCenter {
return slots;
}
+ // ── the daily check-in ──────────────────────────────────────────────────
+ //
+ // ONE prompt for the whole self-report, not one per field. Mood, energy,
+ // stress, soreness and sleep quality are all written on the same screen, so
+ // five prompts would be five interruptions for one minute of typing.
+
+ /// Fixed fallback time when nothing has learned a bedtime yet: 20:30. Late
+ /// enough that the day is over, early enough to be well clear of the default
+ /// quiet window.
+ static const int checkInFallbackMin = 20 * 60 + 30;
+
+ /// How long before the recommended bedtime the check-in lands.
+ static const int checkInBeforeBedMin = 60;
+
+ /// Never before this — a "how was today?" at teatime is asking about a day
+ /// that has not happened.
+ static const int checkInEarliestMin = 17 * 60;
+
+ /// Whether the day's self-report is already written, from
+ /// `journal_metric` for that day.
+ ///
+ /// RATINGS only. Water and caffeine are logged as they happen and say
+ /// nothing about whether the day has been reflected on; mood, energy, stress,
+ /// soreness and sleep quality are the answer the prompt is asking for. A
+ /// single one of them is enough — the screen is one screen, and someone who
+ /// filled in mood and stopped has been asked.
+ static bool checkInDone(Map todayMetrics) {
+ for (final f in kJournalFields) {
+ if (f.isRating && todayMetrics.containsKey(f.key)) return true;
+ }
+ return false;
+ }
+
+ /// The check-in's wall-clock minute, or null when it must not be armed.
+ ///
+ /// TIMED OFF THE PERSON where the data supports it: an hour before the
+ /// bedtime the Sleep Coach learned from their own nights, so a late
+ /// chronotype is not asked about their day at what is, for them, mid-evening.
+ /// [bedtimeMinOfDay] null (no recommendation yet) falls back to a fixed
+ /// [checkInFallbackMin], stated rather than pretended.
+ ///
+ /// The window is then bounded on both sides. Quiet hours do not gate an OS
+ /// schedule — the OS fires it with no Dart running — so the ceiling is
+ /// applied HERE instead: half an hour before the quiet window opens, and
+ /// never after it. A 01:00 bedtime must not produce a midnight prompt.
+ static int? checkInMinute(NotificationPrefs prefs, double? bedtimeMinOfDay) {
+ if (!prefs.checkInEnabled) return null;
+ var t = bedtimeMinOfDay == null
+ ? checkInFallbackMin
+ : bedtimeMinOfDay.round() - checkInBeforeBedMin;
+ if (prefs.quietEnabled && prefs.quietStartMin > prefs.quietEndMin) {
+ final cap = prefs.quietStartMin - 30;
+ if (t > cap) t = cap;
+ }
+ if (t < checkInEarliestMin) t = checkInEarliestMin;
+ // A degenerate quiet window (one that swallows the whole evening) leaves
+ // nowhere honest to put this. Nothing is armed rather than something at
+ // a time the user has already said not to interrupt.
+ if (prefs.inQuietHours(t) || t >= 24 * 60) return null;
+ return t;
+ }
+
+ /// [checkInMinute], with the "already answered" rule applied.
+ ///
+ /// Suppressed only when the slot would land TODAY and today is already
+ /// written. A day that is done at 21:00 still arms tomorrow's — the prompt
+ /// is re-armed on every foreground pass, but a user who does not open the
+ /// app tomorrow would otherwise never be asked again.
+ static int? checkInSlot(
+ NotificationPrefs prefs,
+ double? bedtimeMinOfDay, {
+ required bool doneToday,
+ required int nowMin,
+ }) {
+ final t = checkInMinute(prefs, bedtimeMinOfDay);
+ if (t == null) return null;
+ if (doneToday && t > nowMin) return null; // would land today, already asked
+ return t;
+ }
+
+ // ── medication ──────────────────────────────────────────────────────────
+
+ /// How far ahead doses are armed. Three days rather than one because these
+ /// are one-shots: nothing re-arms them while the app is closed, and a
+ /// weekend without opening the app should not silently drop a prescription.
+ /// Not more, because a slot armed days out cannot know it was taken early.
+ static const int medHorizonDays = 3;
+
+ /// The doses to arm: every slot still UPCOMING across [medHorizonDays],
+ /// soonest first, capped at [NotificationService.maxMedSlots].
+ ///
+ /// `DoseState.upcoming` is the whole rule-4 answer and it is already
+ /// computed by [slotsForDay]: a dose marked taken, a dose deliberately
+ /// skipped, and a slot that has already passed are all something other than
+ /// upcoming, and none of them is armed. [dosesToday] only covers today
+ /// because that is the only day a dose can already have been recorded for.
+ static List medPromptSlots(
+ NotificationPrefs prefs,
+ List defs,
+ Map>> dosesToday, {
+ DateTime? now,
+ }) {
+ if (!prefs.medsEnabled || defs.isEmpty) return const [];
+ final at = now ?? DateTime.now();
+ final out = [];
+ for (var d = 0; d < medHorizonDays; d++) {
+ final day = dayLabelOf(DateTime(at.year, at.month, at.day + d));
+ for (final s in slotsForDay(defs, day, d == 0 ? dosesToday : const {},
+ now: at)) {
+ if (s.state != DoseState.upcoming) continue;
+ // Two pills at 08:00 are ONE interruption. The list is in time order,
+ // so an instant equal to the last kept one is the same moment — and
+ // the notification names nothing anyway, so a second copy of it would
+ // carry no extra information and burn an id from the band.
+ if (out.isNotEmpty &&
+ out.last.date == s.date &&
+ out.last.slotMin == s.slotMin) {
+ continue;
+ }
+ out.add(s);
+ if (out.length >= NotificationService.maxMedSlots) return out;
+ }
+ }
+ return out;
+ }
+
+ /// The absolute instant [s] is due, or null when its day cannot be resolved.
+ static DateTime? medSlotInstant(MedSlot s) {
+ final start = localDayStartSec(s.date);
+ if (start == null) return null;
+ return DateTime.fromMillisecondsSinceEpoch((start + s.slotMin * 60) * 1000);
+ }
+
/// Re-assert the three AI slots (morning briefing, nightly sweep, pre-sleep
/// journal prompt).
///
diff --git a/lib/notify/notification_prefs.dart b/lib/notify/notification_prefs.dart
index 19f4f904..93e7e637 100644
--- a/lib/notify/notification_prefs.dart
+++ b/lib/notify/notification_prefs.dart
@@ -73,6 +73,26 @@ class NotificationPrefs {
/// it was refused, which is why it has never fired for anyone (issue #123).
final bool movementEnabled;
+ /// The medication reminder: one notification per scheduled dose the user
+ /// entered themselves, and ONLY for a dose that is still upcoming — a slot
+ /// already marked taken or deliberately skipped is not armed at all.
+ ///
+ /// This is the one prompt in the app whose time is not a guess: it is the
+ /// schedule in `med_def.schedule_json`, which the user typed. Opt-in and off
+ /// by default like every other outbound path, because someone who wants a
+ /// water reminder has not thereby asked to be told about their pills.
+ final bool medsEnabled;
+
+ /// The daily check-in: one prompt, once, to write the day's self-report
+ /// (mood, energy, stress, soreness, sleep quality — the whole journal, not
+ /// one field at a time).
+ ///
+ /// Suppressed for the day the moment any rating is written, so it can never
+ /// ask for something already answered. It is NOT armed for a day that was
+ /// missed — there is no catching up on a self-report, and a prompt that
+ /// fires because yesterday is blank is a streak wearing a different hat.
+ final bool checkInEnabled;
+
const NotificationPrefs({
this.healthEnabled = true,
this.recoveryEnabled = true,
@@ -86,6 +106,8 @@ class NotificationPrefs {
this.waterIntervalMin = 120, // every 2 hours
this.autoDetectEnabled = true,
this.movementEnabled = false,
+ this.medsEnabled = false,
+ this.checkInEnabled = false,
});
static const _kHealth = 'notif_health';
@@ -100,6 +122,8 @@ class NotificationPrefs {
static const _kWaterInterval = 'notif_water_interval';
static const _kAutoDetect = 'notif_auto_detect';
static const _kMovement = 'notif_movement';
+ static const _kMeds = 'notif_meds';
+ static const _kCheckIn = 'notif_checkin';
static Future load() async {
final p = await SharedPreferences.getInstance();
@@ -116,6 +140,8 @@ class NotificationPrefs {
waterIntervalMin: p.getInt(_kWaterInterval) ?? 120,
autoDetectEnabled: p.getBool(_kAutoDetect) ?? true,
movementEnabled: p.getBool(_kMovement) ?? false,
+ medsEnabled: p.getBool(_kMeds) ?? false,
+ checkInEnabled: p.getBool(_kCheckIn) ?? false,
);
}
@@ -133,6 +159,8 @@ class NotificationPrefs {
await p.setInt(_kWaterInterval, waterIntervalMin);
await p.setBool(_kAutoDetect, autoDetectEnabled);
await p.setBool(_kMovement, movementEnabled);
+ await p.setBool(_kMeds, medsEnabled);
+ await p.setBool(_kCheckIn, checkInEnabled);
}
NotificationPrefs copyWith({
@@ -148,6 +176,8 @@ class NotificationPrefs {
int? waterIntervalMin,
bool? autoDetectEnabled,
bool? movementEnabled,
+ bool? medsEnabled,
+ bool? checkInEnabled,
}) =>
NotificationPrefs(
healthEnabled: healthEnabled ?? this.healthEnabled,
@@ -163,6 +193,8 @@ class NotificationPrefs {
waterIntervalMin: waterIntervalMin ?? this.waterIntervalMin,
autoDetectEnabled: autoDetectEnabled ?? this.autoDetectEnabled,
movementEnabled: movementEnabled ?? this.movementEnabled,
+ medsEnabled: medsEnabled ?? this.medsEnabled,
+ checkInEnabled: checkInEnabled ?? this.checkInEnabled,
);
bool categoryEnabled(NotifCategory c) => switch (c) {
diff --git a/lib/notify/notification_service.dart b/lib/notify/notification_service.dart
index 78af6fcd..e9e47cc5 100644
--- a/lib/notify/notification_service.dart
+++ b/lib/notify/notification_service.dart
@@ -126,6 +126,17 @@ class NotificationService {
static const int idMorningBrief = 2005; // scheduled daily (AI morning briefing)
static const int idEveningBrief = 2006; // scheduled daily (AI evening recap)
static const int idStillness = 2200; // provisional one-shot ("time to move", issue #123)
+ static const int idCheckIn = 2201; // daily ("how was today?" → the journal)
+
+ /// Slot band [idMedsBase .. idMedsBase + maxMedSlots) — one ONE-SHOT per
+ /// scheduled dose that is still upcoming, armed by
+ /// [NotificationCenter.scheduleStandingReminders] from the user's own
+ /// `med_def` schedule. One-shot rather than a daily repeat because whether a
+ /// dose is still due changes every day and a repeat cannot know: it would go
+ /// on asking for a dose already taken, which is the fastest way to get every
+ /// notification in the app turned off. Re-armed on each foreground pass.
+ static const int idMedsBase = 2300;
+ static const int maxMedSlots = 12;
/// Slot band [idWaterBase .. idWaterBase + maxWaterSlots) — one daily-repeating
/// OS notification per hydration slot, armed by
@@ -162,13 +173,21 @@ class NotificationService {
/// issue #123 never fired: the cancel on every foreground resume was the
/// visible half, but `scheduleOnce` had been dropping it at this gate
/// before the cancel ever mattered.
- /// Wind-down, the morning briefing and the journal prompt are none of those,
- /// and are still refused. Their callers keep CANCELLING, which is how an
- /// upgrade cleans out whatever an older build left standing.
+ /// • [idCheckIn] — armed only while `NotificationPrefs.checkInEnabled` is
+ /// on (opt-in, off by default), at a time derived from the user's own
+ /// bedtime, and NOT armed for a day whose self-report is already
+ /// written.
+ /// • the medication band ([isMedSlot]) — armed only while
+ /// `NotificationPrefs.medsEnabled` is on, at the times in the user's own
+ /// `med_def` schedule, and only for a dose still upcoming.
+ /// Wind-down, the morning briefing and the AI journal prompt are none of
+ /// those, and are still refused. Their callers keep CANCELLING, which is how
+ /// an upgrade cleans out whatever an older build left standing.
static const Set schedulableIds = {
idWeeklyRecap,
idEveningBrief,
idStillness,
+ idCheckIn,
};
/// Whether [id] is one of the hydration slots. A band rather than a set
@@ -176,11 +195,16 @@ class NotificationService {
static bool isWaterSlot(int id) =>
id >= idWaterBase && id < idWaterBase + maxWaterSlots;
+ /// Whether [id] is one of the medication slots — same band reasoning as
+ /// [isWaterSlot].
+ static bool isMedSlot(int id) =>
+ id >= idMedsBase && id < idMedsBase + maxMedSlots;
+
/// The gate itself — see [schedulableIds]. Public because
/// [NotificationCenter.scheduleAiReminders] filters its plan through it
/// rather than arming a slot and having it refused one line later.
static bool maySchedule(int id) =>
- schedulableIds.contains(id) || isWaterSlot(id);
+ schedulableIds.contains(id) || isWaterSlot(id) || isMedSlot(id);
AndroidNotificationChannel _channelFor(NotifCategory c) => switch (c) {
NotifCategory.health => _healthChannel,
diff --git a/lib/notify/tap_router.dart b/lib/notify/tap_router.dart
index 55ff8b99..39d86e5b 100644
--- a/lib/notify/tap_router.dart
+++ b/lib/notify/tap_router.dart
@@ -22,6 +22,16 @@ const String kRouteWater = '/water';
/// history list (issue #113).
const String kRouteWorkoutSuggestion = '/workouts/suggestion';
+/// The medication reminder. Lands on Wellness, where the Medication tab's
+/// checklist is the thing that records the dose.
+///
+/// CEILING, and it is a real one: `WellnessScreen` holds its sub-tab in
+/// private state with no constructor argument, so this lands on Wellness with
+/// Medication one tap away in the sub-tab row rather than on the checklist
+/// itself. Adding `initialTab` to that screen is the whole fix — see the note
+/// on `screenForRoute` in app.dart.
+const String kRouteMeds = '/meds';
+
/// Emitted by the battery forecast and the device alerts. Profile is reached
/// from the Home avatar rather than a tab of its own, so the base is Home and
/// `screenForRoute` pushes the profile on top of it.
@@ -71,6 +81,11 @@ const Map _screenRoutes = {
kRouteJournalCompose: 0,
kRouteBreathing: 0,
kRouteWater: 0,
+ // Wellness has no index in the old five-tab vocabulary, so the base is Today
+ // and `domainForRoute` is what actually decides where it lands. The entry
+ // still has to exist: a route absent from this table produces no screen
+ // request at all, and the shell then falls back to the tab index.
+ kRouteMeds: 0,
kRouteWorkoutSuggestion: 4,
kRouteProfile: 0,
kRouteRecap: 1, // 1|2|3 all fold into Health — see domainForTab
diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart
index d85b2e86..f2479d3b 100644
--- a/lib/state/app_state.dart
+++ b/lib/state/app_state.dart
@@ -47,6 +47,7 @@ import '../compute/profile.dart';
import '../data/day_label.dart';
import '../data/journal_fields.dart'
show JournalMetricValue, kJournalFieldsByKey;
+import '../data/med_store.dart' show MedDb, MedDef;
import '../data/auto_backup.dart'
show BackupCadence, BackupOutcome, runBackup;
import '../stress/breath_phases.dart';
@@ -2220,9 +2221,13 @@ class AppState extends ChangeNotifier {
try {
final prefs = await NotificationPrefs.load();
final bedtimeMin = await _recommendedBedtimeMin();
+ final meds = await _medScheduleToday(prefs);
await NotificationCenter.instance.scheduleStandingReminders(
prefs,
bedtimeMinOfDay: bedtimeMin,
+ checkInDoneToday: await _checkInDoneToday(),
+ medDefs: meds.defs,
+ medDosesToday: meds.doses,
);
// AI slots. The nightly sweep is armed only when today actually produced
// a finding — see [_sweepHeadlineNow], which is also where the body of
@@ -2260,6 +2265,40 @@ class AppState extends ChangeNotifier {
}
}
+ /// Whether today's self-report is already written — the check-in prompt's
+ /// "do not ask for something already logged" gate.
+ ///
+ /// NOT `BriefingStore.journalDoneToday()`, which reads a flag that
+ /// `markJournalDone` would set and nothing anywhere calls: it is false for
+ /// every user on every day. The journal rows are the truth.
+ Future _checkInDoneToday() async {
+ try {
+ return NotificationCenter.checkInDone(
+ await LocalDb.journalMetricsForDay(todayLabel()));
+ } catch (_) {
+ // Unknown → treat the day as unanswered. Nothing is armed anyway unless
+ // the user switched the check-in on.
+ return false;
+ }
+ }
+
+ /// The medication schedule + today's recorded doses, or empty when the
+ /// reminder is off. Two indexed reads, only on the path that will use them.
+ Future<({List defs, Map>> doses})>
+ _medScheduleToday(NotificationPrefs prefs) async {
+ const empty = >>{};
+ if (!prefs.medsEnabled) return (defs: const [], doses: empty);
+ try {
+ final db = await LocalDb.instance;
+ return (
+ defs: await MedDb.defs(db),
+ doses: await MedDb.dosesForDay(db, todayLabel()),
+ );
+ } catch (_) {
+ return (defs: const [], doses: empty);
+ }
+ }
+
String? _sweepHeadline;
String _sweepDay = '';
int _lastSweepScanMs = 0;
diff --git a/test/log_prompts_test.dart b/test/log_prompts_test.dart
new file mode 100644
index 00000000..1785158f
--- /dev/null
+++ b/test/log_prompts_test.dart
@@ -0,0 +1,328 @@
+// The two prompts that ASK you to log something — medication and the daily
+// check-in — as pure policy. No plugins: nothing here schedules, it only
+// decides what would be scheduled and when.
+//
+// Three properties are pinned, because each one is a bug this app has already
+// shipped:
+//
+// · every new id is on NotificationService.schedulableIds. A slot absent
+// from that list is dropped silently at the gate and never fires once —
+// which is what happened to the movement nudge for its whole life.
+// · a prompt does not fire for something already logged. A reminder to take
+// a pill already taken is how people turn every notification off.
+// · the tap route resolves to a real destination. The audit found one
+// notification saying "tap to log it" that landed on a screen which did
+// not exist, and another whose route mapped to null.
+
+import 'package:flutter_test/flutter_test.dart';
+
+import 'package:openstrap_edge/app.dart';
+import 'package:openstrap_edge/ui2/app_shell.dart' show ShellDomain;
+import 'package:openstrap_edge/data/day_label.dart';
+import 'package:openstrap_edge/data/journal_fields.dart';
+import 'package:openstrap_edge/data/med_store.dart';
+import 'package:openstrap_edge/notify/notification_center.dart';
+import 'package:openstrap_edge/notify/notification_prefs.dart';
+import 'package:openstrap_edge/notify/notification_service.dart';
+import 'package:openstrap_edge/notify/tap_router.dart';
+
+/// A definition that existed long before any day under test, so `slotsForDay`'s
+/// created-at bound never trims a slot out from under a case.
+MedDef _def(String key, List schedule) => MedDef(
+ key: key,
+ label: key,
+ doseValue: 1000,
+ doseUnit: 'IU',
+ schedule: schedule,
+ createdAt: DateTime(2020, 1, 1).millisecondsSinceEpoch,
+ );
+
+/// One `med_dose` row in the shape `MedDb.dosesForDay` returns.
+Map>> _doses(
+ String medKey,
+ int slotMin, {
+ bool taken = false,
+ bool skipped = false,
+}) =>
+ {
+ medKey: {
+ slotMin: {
+ 'taken_ts': taken ? 1 : null,
+ 'skipped': skipped ? 1 : 0,
+ }
+ }
+ };
+
+void main() {
+ group('the scheduler allow-list', () {
+ test('takes the check-in and the whole medication band', () {
+ expect(NotificationService.maySchedule(NotificationService.idCheckIn),
+ isTrue);
+ for (var i = 0; i < NotificationService.maxMedSlots; i++) {
+ expect(
+ NotificationService.maySchedule(NotificationService.idMedsBase + i),
+ isTrue,
+ reason: 'med slot $i');
+ }
+ });
+
+ test('and still refuses the ids either side of the band', () {
+ expect(NotificationService.maySchedule(NotificationService.idMedsBase - 1),
+ isFalse);
+ expect(
+ NotificationService.maySchedule(
+ NotificationService.idMedsBase + NotificationService.maxMedSlots),
+ isFalse);
+ });
+
+ test('the bands are disjoint from the hydration one', () {
+ for (var i = 0; i < NotificationService.maxMedSlots; i++) {
+ expect(NotificationService.isWaterSlot(NotificationService.idMedsBase + i),
+ isFalse);
+ }
+ expect(NotificationService.isMedSlot(NotificationService.idCheckIn), isFalse);
+ expect(NotificationService.isMedSlot(NotificationService.idStillness), isFalse);
+ });
+ });
+
+ group('the check-in knows when it has already been answered', () {
+ test('any rating counts, and one is enough', () {
+ for (final f in kJournalFields.where((f) => f.isRating)) {
+ expect(
+ NotificationCenter.checkInDone({f.key: const JournalMetricValue(3)}),
+ isTrue,
+ reason: f.key);
+ }
+ });
+
+ test('a dose logged as it happened is not a self-report', () {
+ // Water at lunchtime says nothing about whether the day has been
+ // reflected on — this is the case that would otherwise silence the
+ // prompt for anyone who uses the water reminder.
+ expect(
+ NotificationCenter.checkInDone(
+ const {'water_ml': JournalMetricValue(500)}),
+ isFalse);
+ expect(
+ NotificationCenter.checkInDone(
+ const {'caffeine_mg': JournalMetricValue(200)}),
+ isFalse);
+ expect(NotificationCenter.checkInDone(const {}), isFalse);
+ });
+ });
+
+ group('the check-in follows the person', () {
+ const off = NotificationPrefs();
+ const on = NotificationPrefs(checkInEnabled: true);
+
+ test('off by default — nothing is armed for anyone who did not ask', () {
+ expect(NotificationCenter.checkInMinute(off, 23 * 60), isNull);
+ });
+
+ test('an hour before the bedtime the coach learned', () {
+ expect(NotificationCenter.checkInMinute(on, 21 * 60), 20 * 60);
+ // A late chronotype is asked later, not at everyone else's 20:30.
+ expect(NotificationCenter.checkInMinute(on, 22 * 60 + 30), 21 * 60 + 30);
+ });
+
+ test('no bedtime yet → the stated fixed fallback', () {
+ expect(NotificationCenter.checkInMinute(on, null),
+ NotificationCenter.checkInFallbackMin);
+ });
+
+ test('never inside the quiet window, whatever the bedtime says', () {
+ // 01:00 bedtime. Minus an hour is midnight, which is the middle of the
+ // window the user asked not to be interrupted in.
+ final t = NotificationCenter.checkInMinute(on, 25 * 60)!;
+ expect(t, 21 * 60 + 30); // quietStart 22:00, minus the half-hour margin
+ expect(on.inQuietHours(t), isFalse);
+ });
+
+ test('never before the day has happened', () {
+ // A 17:00 bedtime would put the prompt at 16:00.
+ expect(NotificationCenter.checkInMinute(on, 17 * 60),
+ NotificationCenter.checkInEarliestMin);
+ });
+
+ test('a quiet window that swallows the evening arms nothing', () {
+ const all = NotificationPrefs(
+ checkInEnabled: true, quietStartMin: 12 * 60, quietEndMin: 11 * 60);
+ expect(NotificationCenter.checkInMinute(all, null), isNull);
+ });
+ });
+
+ group('the check-in does not ask twice', () {
+ const on = NotificationPrefs(checkInEnabled: true);
+
+ test('a day already written is not asked about again', () {
+ expect(
+ NotificationCenter.checkInSlot(on, null,
+ doneToday: true, nowMin: 12 * 60),
+ isNull);
+ });
+
+ test('but tomorrow is still armed once tonight has passed', () {
+ // 21:00, journal written, slot was 20:30 — that instance is behind us, so
+ // the one being armed is tomorrow's and the day it asks about is not
+ // written yet.
+ expect(
+ NotificationCenter.checkInSlot(on, null,
+ doneToday: true, nowMin: 21 * 60),
+ NotificationCenter.checkInFallbackMin);
+ });
+
+ test('an unwritten day arms normally', () {
+ expect(
+ NotificationCenter.checkInSlot(on, null,
+ doneToday: false, nowMin: 12 * 60),
+ NotificationCenter.checkInFallbackMin);
+ });
+ });
+
+ group('medication prompts come off the schedule the user typed', () {
+ // A Thursday, mid-morning: the 08:00 dose is behind us, the 20:00 one is not.
+ final now = DateTime(2026, 8, 20, 10, 0);
+ final defs = [
+ _def('d3', const [MedSchedule(8 * 60, []), MedSchedule(20 * 60, [])]),
+ ];
+ const on = NotificationPrefs(medsEnabled: true);
+
+ test('off by default', () {
+ expect(
+ NotificationCenter.medPromptSlots(
+ const NotificationPrefs(), defs, const {},
+ now: now),
+ isEmpty);
+ });
+
+ test('every dose still due across the horizon, soonest first', () {
+ final s =
+ NotificationCenter.medPromptSlots(on, defs, const {}, now: now);
+ // today 20:00, then both slots on each of the next two days.
+ expect(s.length, 5);
+ expect(s.first.date, todayLabel(now));
+ expect(s.first.slotMin, 20 * 60);
+ for (var i = 1; i < s.length; i++) {
+ expect(NotificationCenter.medSlotInstant(s[i])!
+ .isAfter(NotificationCenter.medSlotInstant(s[i - 1])!), isTrue);
+ }
+ });
+
+ test('a dose already taken is never asked for', () {
+ final s = NotificationCenter.medPromptSlots(
+ on, defs, _doses('d3', 20 * 60, taken: true),
+ now: now);
+ expect(s.length, 4);
+ expect(s.where((x) => x.date == todayLabel(now)), isEmpty);
+ });
+
+ test('a dose deliberately skipped is not asked for either', () {
+ final s = NotificationCenter.medPromptSlots(
+ on, defs, _doses('d3', 20 * 60, skipped: true),
+ now: now);
+ expect(s.where((x) => x.date == todayLabel(now)), isEmpty);
+ });
+
+ test('a dose that already came due today is not chased', () {
+ // The 08:00 slot is a miss, not an upcoming dose. Arming it would be a
+ // notification about a thing that is over — the same "yesterday's news"
+ // rule emitOncePerDay carries.
+ final s =
+ NotificationCenter.medPromptSlots(on, defs, const {}, now: now);
+ expect(
+ s.where((x) => x.date == todayLabel(now) && x.slotMin == 8 * 60),
+ isEmpty);
+ });
+
+ test('a weekday-restricted course only fires on its days', () {
+ final mondays = [
+ _def('m', const [MedSchedule(9 * 60, [DateTime.monday])])
+ ];
+ // Thu 20 Aug + Fri + Sat — no Monday in the horizon.
+ expect(NotificationCenter.medPromptSlots(on, mondays, const {}, now: now),
+ isEmpty);
+ // From the Sunday, Monday is in it.
+ final s = NotificationCenter.medPromptSlots(on, mondays, const {},
+ now: DateTime(2026, 8, 23, 10, 0));
+ expect(s.length, 1);
+ expect(
+ DateTime.parse(s.first.date).weekday, DateTime.monday);
+ });
+
+ test('two pills at the same minute are one interruption', () {
+ final pair = [
+ _def('a', const [MedSchedule(20 * 60, [])]),
+ _def('b', const [MedSchedule(20 * 60, [])]),
+ ];
+ final s = NotificationCenter.medPromptSlots(on, pair, const {}, now: now);
+ // One per day across the horizon, not two.
+ expect(s.length, 3);
+ expect(s.map((x) => x.date).toSet().length, 3);
+ });
+
+ test('an inactive definition is not armed', () {
+ final stopped = [
+ MedDef(
+ key: 'x',
+ label: 'x',
+ active: false,
+ schedule: const [MedSchedule(20 * 60, [])],
+ createdAt: DateTime(2020).millisecondsSinceEpoch,
+ )
+ ];
+ expect(NotificationCenter.medPromptSlots(on, stopped, const {}, now: now),
+ isEmpty);
+ });
+
+ test('never more slots than the id band has room for', () {
+ final many = [
+ for (var i = 0; i < 8; i++)
+ _def('m$i', [MedSchedule(11 * 60 + i, const [])]),
+ ];
+ final s = NotificationCenter.medPromptSlots(on, many, const {}, now: now);
+ expect(s.length, NotificationService.maxMedSlots);
+ // And every one of them lands on an id inside the band.
+ for (var i = 0; i < s.length; i++) {
+ expect(
+ NotificationService.maySchedule(NotificationService.idMedsBase + i),
+ isTrue);
+ }
+ });
+
+ test('the instant is the day plus the minute the user entered', () {
+ final s =
+ NotificationCenter.medPromptSlots(on, defs, const {}, now: now).first;
+ final at = NotificationCenter.medSlotInstant(s)!;
+ expect(at.hour, 20);
+ expect(at.minute, 0);
+ expect(todayLabel(at), todayLabel(now));
+ expect(at.isAfter(now), isTrue);
+ });
+ });
+
+ group('both prompts have somewhere to land', () {
+ test('the check-in opens the journal it is asking you to write', () {
+ final t = resolveTapRoute(kRouteJournalCompose);
+ expect(t.screen, kRouteJournalCompose);
+ expect(screenForRoute(kRouteJournalCompose), isNotNull);
+ });
+
+ test('the medication reminder lands on Wellness, which owns the checklist',
+ () {
+ final t = resolveTapRoute(kRouteMeds);
+ // Not the Home fallback an unknown payload gets — the route is KNOWN,
+ // which is the half `/profile` and `/recap` were missing.
+ expect(t.screen, kRouteMeds);
+ expect(domainForRoute(kRouteMeds), ShellDomain.wellness);
+ // Deliberately pushes nothing: the Medication tab is a sub-tab of a shell
+ // tab with no initial-tab argument. Flip this to isNotNull on the day
+ // WellnessScreen grows one.
+ expect(screenForRoute(kRouteMeds), isNull);
+ });
+
+ test('an unknown route still falls back to Home rather than crashing', () {
+ expect(resolveTapRoute('/nope').tab, 0);
+ expect(resolveTapRoute('/nope').screen, isNull);
+ });
+ });
+}
From c324c131f59d6041b5c63ee0e54b4a547839873c Mon Sep 17 00:00:00 2001
From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com>
Date: Thu, 20 Aug 2026 01:10:50 +0530
Subject: [PATCH 47/64] the two log prompts get their switches
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
and the notification screen reschedules through appstate — calling the centre
directly cancels what you turned off and arms nothing back, so meds stayed
silent until the next foreground pass.
---
lib/ui2/profile/settings.dart | 34 ++++++++++++++++++++++++++++++++--
1 file changed, 32 insertions(+), 2 deletions(-)
diff --git a/lib/ui2/profile/settings.dart b/lib/ui2/profile/settings.dart
index 74dc3632..ce8c9e09 100644
--- a/lib/ui2/profile/settings.dart
+++ b/lib/ui2/profile/settings.dart
@@ -23,7 +23,6 @@ import '../../data/off_lookup.dart';
import '../../health/health_export.dart' show HealthLinkState;
import '../../health/health_import_state.dart';
import '../../health/health_profile_import.dart';
-import '../../notify/notification_center.dart';
import '../../platform/tasker_bridge.dart';
import '../../notify/notification_prefs.dart';
import '../../notify/notification_service.dart';
@@ -749,7 +748,13 @@ class _NotificationSettingsState extends State {
// Re-run the scheduler so a switch that was just turned off actually
// cancels what it was standing for, rather than taking effect at some
// later resume.
- await NotificationCenter.instance.scheduleStandingReminders(next);
+ //
+ // Through AppState, not straight at the NotificationCenter: the medication
+ // slots need the med schedule and the check-in needs today's journal, and
+ // only AppState can read either. Calling the centre directly cancels what
+ // the switch turned off and arms nothing back, so meds stayed silent until
+ // the next foreground pass.
+ if (mounted) await context.read().refreshAiReminders();
// the water buzz is an in-memory timer, not an OS slot — re-arm it here or
// the switch only takes effect at the next launch.
if (mounted) await context.read().armWaterReminder(next);
@@ -876,6 +881,31 @@ class NotificationSettingsView extends StatelessWidget {
chevron: false,
onTap: () => set(prefs.copyWith(
movementEnabled: !prefs.movementEnabled))),
+ // The one prompt whose time is not a guess: it is the
+ // schedule already typed into the Medication tab. Only a
+ // dose still due is armed, and the notification names no
+ // drug — it lands on a lock screen in front of whoever is
+ // in the room.
+ SetRow(LucideIcons.pill, C.blue, 'Medication reminders',
+ sub: 'One notification per scheduled dose, at the '
+ 'times you entered. Nothing is sent for a dose '
+ 'already marked taken or skipped',
+ value: prefs.medsEnabled ? 'On' : 'Off',
+ chevron: false,
+ onTap: () =>
+ set(prefs.copyWith(medsEnabled: !prefs.medsEnabled))),
+ // ONE prompt for the whole journal, not one per field —
+ // mood, energy, stress and the rest are all the same
+ // screen, so five rows would be five interruptions for one
+ // minute of typing.
+ SetRow(LucideIcons.notebookPen, C.purple, 'Daily check-in',
+ sub: 'One prompt in the evening to write the day — '
+ 'mood, energy, stress. Skipped once the day '
+ 'already has a rating in it',
+ value: prefs.checkInEnabled ? 'On' : 'Off',
+ chevron: false,
+ onTap: () => set(prefs.copyWith(
+ checkInEnabled: !prefs.checkInEnabled))),
// A prompt to log, not a reading. The app measures no
// hydration and this row may never imply it does.
SetRow(LucideIcons.glassWater, C.teal, 'Water reminder',
From 64ee5e706eb179790031274711a164f423be2703 Mon Sep 17 00:00:00 2001
From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com>
Date: Thu, 20 Aug 2026 01:15:47 +0530
Subject: [PATCH 48/64] imported days were setting the baseline they're
supposed to stay out of
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
isMeasuredDay only ever guarded the write path. metric_series reads had
no filter, so a whoop/cloud import's rhr, rmssd, readiness and resp_rate
rows went straight into the readiness + illness window.
can't filter on metric_series_version.source alone — it only exists from
v43 and is never retro-filled, so every older day reads NULL and dropping
those would delete the real early history instead. NULL is decidable
though: the bundle behind the day still carries "imported":true, which is
the same flag isMeasuredDay tests. so the mask is both signals unioned,
in LocalDb.importedDates.
trailingSeriesValues defaults to measured-only now (it exists to build a
baseline), which also fixes the live rhr anchor without touching
app_state. journal insights and the weekday permutation test filter too —
a chart may splice two algorithms, a statistic may not.
---
lib/compute/derivation_engine.dart | 15 +++
lib/data/db.dart | 77 ++++++++++-
lib/data/local_repository_impl.dart | 12 +-
lib/health/health_rhr_seed.dart | 5 +-
test/baseline_imported_exclusion_test.dart | 143 +++++++++++++++++++++
5 files changed, 247 insertions(+), 5 deletions(-)
create mode 100644 test/baseline_imported_exclusion_test.dart
diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart
index b37f810b..f9144d23 100644
--- a/lib/compute/derivation_engine.dart
+++ b/lib/compute/derivation_engine.dart
@@ -1463,7 +1463,21 @@ class _BaselineHistoryCache {
/// window possible at all. It must NOT be given a `limit` (that is `date ASC
/// LIMIT n`, i.e. the OLDEST n — the opposite of a trailing window); the
/// trailing window is taken here, in Dart, per target day.
+ ///
+ /// IMPORTED DAYS ARE EXCLUDED. `LocalDb.isMeasuredDay` kept another vendor's
+ /// export from OVERWRITING a measured day, but nothing kept it out of the
+ /// window on the way back in: a WHOOP or cloud import writes real
+ /// `metric_series` rows for `rhr`, `rmssd`, `readiness` and `resp_rate`, and
+ /// this load read them like any other day. Their scores are a different
+ /// algorithm's output over a different (or no) substrate, so blending them in
+ /// moves the median every personal z-score is taken against — silently, for
+ /// as long as the window is, and worst exactly when someone imports their
+ /// history on day one and has nothing else in the window at all.
+ ///
+ /// The mask is taken ONCE per load and applied to every key, because the
+ /// query behind it scans day bundles (see [LocalDb.importedDates]).
static Future<_BaselineHistoryCache> load() async {
+ final imported = await LocalDb.importedDates();
Future> hist(String key) async {
final rows = await LocalDb.metricSeries(key);
final out = <_DatedValue>[];
@@ -1471,6 +1485,7 @@ class _BaselineHistoryCache {
final date = row['date'];
final value = row['value'];
if (date is! String || date.isEmpty || value is! num) continue;
+ if (imported.contains(date)) continue;
out.add((date: date, value: value.toDouble()));
}
return out;
diff --git a/lib/data/db.dart b/lib/data/db.dart
index 5fa7b18e..d74334ad 100644
--- a/lib/data/db.dart
+++ b/lib/data/db.dart
@@ -6092,6 +6092,61 @@ class LocalDb {
return true;
}
+ /// SQL selecting the `date` of every day whose scalars were IMPORTED.
+ /// A fragment so the mask and the filters that apply it cannot drift apart.
+ ///
+ /// TWO SIGNALS, because the exact one is younger than the data:
+ ///
+ /// * `metric_series_version.source` — precise, but the column only exists
+ /// from schema v43 and is deliberately NEVER retro-filled (a guessed
+ /// provenance is worse than none). Every day written before v43 reads
+ /// NULL, which is most of any real user's history.
+ /// * `day_result.payload_json`'s `"imported": true` — the marker BOTH
+ /// importers have written since they existed, and the same flag
+ /// [isMeasuredDayRow] tests on the write path.
+ ///
+ /// The second is what makes a NULL `source` DECIDABLE rather than ambiguous:
+ /// NULL means "the column did not exist yet", not "unknown vendor", and the
+ /// bundle behind that day still says who wrote it. So NULL is resolved
+ /// against the payload rather than treated as suspect — dropping every
+ /// NULL-source day would delete the user's genuine pre-v43 history from
+ /// their own baselines, i.e. fabricate a baseline out of a short recent
+ /// window, which is the worse fault of the two.
+ ///
+ /// A substring match, not `json_extract`: `jsonEncode` emits no spaces and
+ /// this app is the only writer of the flag, so the literal is exact, and it
+ /// does not assume a JSON1-enabled sqlite on every platform we ship to.
+ ///
+ /// The `IS NOT NULL` guards are not decoration. SQLite does not enforce NOT
+ /// NULL on a declared PRIMARY KEY column of a legacy rowid table, and a
+ /// single NULL inside a `NOT IN (…)` list makes the whole predicate NULL for
+ /// EVERY row — one stray row would silently empty every baseline in the app
+ /// rather than filter one day out of it.
+ static const String _importedDatesSql =
+ 'SELECT date FROM metric_series_version '
+ "WHERE date IS NOT NULL AND source IS NOT NULL AND source <> 'band' "
+ 'UNION '
+ 'SELECT day_id FROM day_result '
+ "WHERE day_id IS NOT NULL AND payload_json LIKE '%\"imported\":true%'";
+
+ /// Day labels whose stored scalars are ANOTHER vendor's derived numbers.
+ ///
+ /// THE MASK for every baseline read, and the inverse of [isMeasuredDay]:
+ /// that one guards the WRITE path (an import must not clobber a measured
+ /// day), and nothing guarded the read path — so imported days were feeding
+ /// the readiness and illness baselines the user's own scores are measured
+ /// against. A window that mixes them is not a baseline of this person.
+ ///
+ /// Returned as a set rather than applied inside each query on purpose: the
+ /// scan behind it is over `day_result.payload_json` (whole day bundles), so
+ /// a caller reading several series takes it ONCE and filters in Dart.
+ static Future> importedDates() async {
+ final db = await instance;
+ return {
+ for (final r in await db.rawQuery(_importedDatesSql)) r['date'] as String,
+ };
+ }
+
/// Import another device's exported OpenStrap DB ([path], from [exportCopy] +
/// share) by MERGING its rows into this one (INSERT-OR-REPLACE). Covers derived
/// results, the metric series, user data, and the raw ledger so the receiving
@@ -6860,14 +6915,23 @@ class LocalDb {
}
/// A long-format metric series (oldest first) for trends/sparklines.
+ ///
+ /// [measuredOnly] drops days another vendor's export wrote (see
+ /// [importedDates]). OFF by default: a trend line is a picture of the user's
+ /// history and imported days belong in it. Turn it ON for anything that
+ /// COMPUTES against the series — a baseline, a personal percentile, a
+ /// seed-versus-band comparison — where a foreign algorithm's output is not
+ /// the same measurement.
static Future>> metricSeries(
String key, {
int? limit,
+ bool measuredOnly = false,
}) async {
final db = await instance;
return db.query(
'metric_series',
- where: 'key = ? AND value IS NOT NULL',
+ where: 'key = ? AND value IS NOT NULL'
+ '${measuredOnly ? ' AND date NOT IN ($_importedDatesSql)' : ''}',
whereArgs: [key],
orderBy: 'date ASC',
limit: limit,
@@ -6879,11 +6943,20 @@ class LocalDb {
/// OLDEST n days), this is the right window for a rolling baseline. Because
/// metric_series is keyed `(date, key)` with REPLACE, there is exactly one row
/// per day, so the result is inherently de-duplicated.
- static Future> trailingSeriesValues(String key, int n) async {
+ ///
+ /// [measuredOnly] defaults ON here, unlike [metricSeries]: this helper exists
+ /// to build a rolling baseline, and a baseline blended with another vendor's
+ /// derived numbers is not a baseline of this person (see [importedDates]).
+ static Future> trailingSeriesValues(
+ String key,
+ int n, {
+ bool measuredOnly = true,
+ }) async {
final db = await instance;
final rows = await db.rawQuery(
'SELECT value FROM metric_series '
'WHERE key = ? AND value IS NOT NULL '
+ '${measuredOnly ? 'AND date NOT IN ($_importedDatesSql) ' : ''}'
'ORDER BY date DESC LIMIT ?',
[key, n],
);
diff --git a/lib/data/local_repository_impl.dart b/lib/data/local_repository_impl.dart
index f6c24e4f..090658be 100644
--- a/lib/data/local_repository_impl.dart
+++ b/lib/data/local_repository_impl.dart
@@ -3165,7 +3165,12 @@ class LocalRepositoryImpl extends LocalRepository {
for (final od in outcomeDefs) {
final key = od['key'] as String;
final m = {};
- for (final r in await LocalDb.metricSeries(key)) {
+ // MEASURED DAYS ONLY. This is a comparison of the user against
+ // themselves; an imported day is another vendor's derived score on a
+ // different scale, and it lands in the window mean every tagged day is
+ // priced against (see LocalDb.importedDates). The chart underneath still
+ // shows those days — a picture may be spliced, a statistic may not.
+ for (final r in await LocalDb.metricSeries(key, measuredOnly: true)) {
final v = (r['value'] as num?)?.toDouble();
if (v != null) m[r['date'] as String] = v;
}
@@ -3380,7 +3385,10 @@ class LocalRepositoryImpl extends LocalRepository {
Future> getWeekdayEffect({
String key = 'readiness',
}) async {
- final rows = await LocalDb.metricSeries(key);
+ // MEASURED DAYS ONLY — a permutation test over a series spliced from two
+ // different algorithms reports the splice, not the weekday (same reasoning
+ // as the journal outcomes above).
+ final rows = await LocalDb.metricSeries(key, measuredOnly: true);
if (rows.isEmpty) return const {};
final dates = [];
final values = [];
diff --git a/lib/health/health_rhr_seed.dart b/lib/health/health_rhr_seed.dart
index 93d58690..f0a265ad 100644
--- a/lib/health/health_rhr_seed.dart
+++ b/lib/health/health_rhr_seed.dart
@@ -272,7 +272,10 @@ class RhrSeedImporter {
static Future compareAgainstBand() async {
final seed = await storedSeedBaseline();
if (seed == null) return null;
- final rows = await LocalDb.metricSeries('rhr');
+ // THE BAND'S OWN nightly values — an imported day is another vendor's
+ // resting HR, and comparing a phone seed against that answers a different
+ // question than the one this gate asks (see LocalDb.importedDates).
+ final rows = await LocalDb.metricSeries('rhr', measuredOnly: true);
final band = [
for (final r in rows) (r['value'] as num?)?.toDouble(),
];
diff --git a/test/baseline_imported_exclusion_test.dart b/test/baseline_imported_exclusion_test.dart
new file mode 100644
index 00000000..c8631008
--- /dev/null
+++ b/test/baseline_imported_exclusion_test.dart
@@ -0,0 +1,143 @@
+// A baseline is a picture of THIS person as measured by THIS device. Another
+// vendor's export is a different algorithm's output over a different (or no)
+// substrate, so a day that came from one must never set the median a personal
+// z-score is taken against.
+//
+// The law was enforced on the WRITE path only (`LocalDb.isMeasuredDay` stops an
+// import overwriting a measured day) and not on the READ path, so imported days
+// were feeding the readiness/illness baselines in shipped code.
+//
+// The trap this test pins down is the OTHER direction. `metric_series_version.
+// source` only exists from schema v43 and is never retro-filled, so every day
+// written before it reads NULL — a naive `source = 'band'` filter would delete
+// the user's whole genuine early history from their own baselines. NULL means
+// "the column did not exist yet", and the day bundle behind it still says who
+// wrote it, so it is decidable rather than ambiguous.
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:openstrap_edge/compute/derivation_engine.dart';
+import 'package:openstrap_edge/data/db.dart';
+import 'package:path/path.dart' as p;
+import 'package:sqflite_common_ffi/sqflite_ffi.dart';
+
+/// A day this device derived from 1 Hz records.
+Future _measured(String date, double rhr, {String? source = 'band'}) =>
+ LocalDb.putDayResult(
+ dayId: date,
+ algoVersion: 1,
+ payloadJson: '{"date":"$date"}',
+ windowJson: '{}',
+ finalized: true,
+ source: source,
+ rhr: rhr,
+ series: {'rhr': rhr},
+ );
+
+/// A day an importer wrote — the `imported` flag is the marker both importers
+/// have always put in the bundle.
+Future _imported(
+ String date,
+ double rhr, {
+ String? source = 'whoop_export',
+}) =>
+ LocalDb.putDayResult(
+ dayId: date,
+ algoVersion: 1,
+ payloadJson: '{"date":"$date","imported":true,"source":"whoop_export"}',
+ windowJson: '{}',
+ finalized: true,
+ source: source,
+ rhr: rhr,
+ series: {'rhr': rhr},
+ );
+
+/// Age the stamps back to before the `source` column existed.
+Future _forgetSources() async {
+ final db = await LocalDb.instance;
+ await db.rawUpdate('UPDATE metric_series_version SET source = NULL');
+}
+
+Future _clear() async {
+ final db = await LocalDb.instance;
+ await db.delete('day_result');
+ await db.delete('metric_series');
+ await db.delete('metric_series_version');
+}
+
+void main() {
+ TestWidgetsFlutterBinding.ensureInitialized();
+
+ setUpAll(() async {
+ sqfliteFfiInit();
+ databaseFactory = databaseFactoryFfi;
+ LocalDb.dbName = 'openstrap_baseline_imported_test.db';
+ final dir = await databaseFactory.getDatabasesPath();
+ await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName));
+ });
+
+ tearDownAll(() async {
+ await LocalDb.close();
+ final dir = await databaseFactory.getDatabasesPath();
+ await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName));
+ });
+
+ setUp(_clear);
+
+ test('a mixed history baselines on the measured days only', () async {
+ await _measured('2026-01-01', 50);
+ await _imported('2026-01-02', 70);
+ await _measured('2026-01-03', 51);
+ await _imported('2026-01-04', 71);
+
+ expect(await debugBaselineWindow('rhr'), [50, 51]);
+ });
+
+ test('pre-v43 days have NULL source and must NOT be dropped', () async {
+ await _measured('2026-02-01', 50);
+ await _measured('2026-02-02', 52);
+ await _forgetSources();
+
+ final window = await debugBaselineWindow('rhr');
+ expect(window, isNotEmpty,
+ reason: 'a NULL source is "written before the column existed", not '
+ '"foreign" — filtering on source alone deletes real history');
+ expect(window, [50, 52]);
+ });
+
+ test('a pre-v43 IMPORTED day is still excluded — the bundle says so',
+ () async {
+ await _measured('2026-03-01', 50);
+ await _imported('2026-03-02', 70);
+ await _forgetSources();
+
+ expect(await debugBaselineWindow('rhr'), [50]);
+ });
+
+ test('importedDates names both eras and nothing else', () async {
+ await _measured('2026-04-01', 50); // source = 'band'
+ await _imported('2026-04-02', 70); // source = 'whoop_export'
+ await _imported('2026-04-03', 71, source: null); // pre-v43 import
+ await _measured('2026-04-04', 51, source: null); // pre-v43 band day
+
+ expect(await LocalDb.importedDates(), {'2026-04-02', '2026-04-03'});
+ });
+
+ test('trailingSeriesValues excludes imported days by default', () async {
+ await _measured('2026-05-01', 50);
+ await _imported('2026-05-02', 70);
+ await _measured('2026-05-03', 51);
+
+ expect(await LocalDb.trailingSeriesValues('rhr', 28), [50, 51]);
+ expect(await LocalDb.trailingSeriesValues('rhr', 28, measuredOnly: false),
+ [50, 70, 51]);
+ });
+
+ test('metricSeries keeps imported days for trends, drops them when asked',
+ () async {
+ await _measured('2026-06-01', 50);
+ await _imported('2026-06-02', 70);
+
+ expect((await LocalDb.metricSeries('rhr')).length, 2);
+ expect((await LocalDb.metricSeries('rhr', measuredOnly: true)).length, 1);
+ });
+}
From 9c3b23ca5ebc8dad067879266b3546a96cb689a4 Mon Sep 17 00:00:00 2001
From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com>
Date: Thu, 20 Aug 2026 01:21:19 +0530
Subject: [PATCH 49/64] widgets: same three rings as home, and two new faces
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
the widgets were left behind by the ui2 rebuild. the home face was a
readiness headline over strain · sleep · hrv, which is the OLD home
screen — home has three rings now (recovery · strain · sleep), the icon
inside the dial and the number under it. so does the widget.
the bigger thing: an absence used to be a dimmed empty circle. "four
more nights and this fills in" and "the band recorded nothing all
night" were the same picture, forever, because the calibration counts
and the pipeline's reason live in a metric's note and the note never
crossed the app group. push() resolves all four ring states now
(measured / calibrating / unscaled / absent) exactly like RingTrio, and
swift + kotlin just draw them. deletes the ring maths from both natives
rather than adding to them.
also: home refuses an overnight block that belongs to an earlier night
instead of printing it in the today slot. the widget didn't, so every
morning before the first sync the home screen showed the night before
last's recovery as today's. it refuses it now and publishes the reason
in its place.
new widgets, both small + all three lock screen families:
last night — the sleep ring at full size plus efficiency. the number
people look for before they open anything, at the moment the lock
screen is already up.
overnight — hrv against your own baseline, and resting hr. hrv left
the home rings in the rebuild; this is where it went, and it's a
better home for it than a third of a card.
rejected: steps and day strain. both accrue all day and only move on a
derive, and widgetkit's reload budget throttles us well under that — a
step count reading low is a wrong number, not a missing one. battery
already has a widget.
arcs now spend P.on(accent) like the app's do, not raw pigment, and the
numerals are sf pro text tabular rather than rounded. android gets the
same three dials with the icon drawn into the bitmap.
---
android/app/src/main/AndroidManifest.xml | 22 +
.../openstrap_edge/OpenStrapWidgetProvider.kt | 152 +++--
.../openstrap_edge/OvernightWidgetProvider.kt | 84 +++
.../openstrap_edge/SleepWidgetProvider.kt | 71 +++
.../openstrap/openstrap_edge/StrapWidgets.kt | 152 +++--
.../src/main/res/drawable/ic_widget_hrv.xml | 14 +
.../main/res/drawable/ic_widget_recovery.xml | 26 +
.../src/main/res/drawable/ic_widget_sleep.xml | 14 +
.../main/res/drawable/ic_widget_strain.xml | 14 +
.../src/main/res/layout/widget_openstrap.xml | 213 ++++---
.../res/layout/widget_openstrap_small.xml | 231 +++-----
.../src/main/res/layout/widget_overnight.xml | 105 ++++
.../app/src/main/res/layout/widget_sleep.xml | 63 ++
.../src/main/res/values/widget_strings.xml | 9 +-
.../main/res/xml/widget_overnight_info.xml | 15 +
.../src/main/res/xml/widget_sleep_info.xml | 16 +
.../OpenStrapOvernightWidget.swift | 186 ++++++
.../OpenStrapSleepWidget.swift | 138 +++++
ios/OpenStrapWidget/OpenStrapWidget.swift | 552 +++++-------------
.../OpenStrapWidgetBundle.swift | 2 +
ios/OpenStrapWidget/StrapWidgetKit.swift | 342 +++++++++++
lib/widget/widget_service.dart | 195 ++++++-
test/widget_service_sentinels_test.dart | 119 ++++
23 files changed, 1947 insertions(+), 788 deletions(-)
create mode 100644 android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/OvernightWidgetProvider.kt
create mode 100644 android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/SleepWidgetProvider.kt
create mode 100644 android/app/src/main/res/drawable/ic_widget_hrv.xml
create mode 100644 android/app/src/main/res/drawable/ic_widget_recovery.xml
create mode 100644 android/app/src/main/res/drawable/ic_widget_sleep.xml
create mode 100644 android/app/src/main/res/drawable/ic_widget_strain.xml
create mode 100644 android/app/src/main/res/layout/widget_overnight.xml
create mode 100644 android/app/src/main/res/layout/widget_sleep.xml
create mode 100644 android/app/src/main/res/xml/widget_overnight_info.xml
create mode 100644 android/app/src/main/res/xml/widget_sleep_info.xml
create mode 100644 ios/OpenStrapWidget/OpenStrapOvernightWidget.swift
create mode 100644 ios/OpenStrapWidget/OpenStrapSleepWidget.swift
create mode 100644 ios/OpenStrapWidget/StrapWidgetKit.swift
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 27fd5b98..d98b57da 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -283,6 +283,28 @@
android:name="android.appwidget.provider"
android:resource="@xml/widget_openstrap_info" />
+
+
+
+
+
+
+
+
+
+
+
+
0, so an unknown need leaves it empty
- // instead of filling against a fabricated 8h denominator.
- val needMin = w.readInt(prefs, "sleep_need_min", -1)
- val hrv = w.readInt(prefs, "hrv", -1)
- val hrvBaseline = w.readInt(prefs, "hrv_baseline", -1)
-
- // Ring fractions — negative means "nothing measured", so ringBitmap
- // draws the track alone rather than an arc pinned at empty (which reads
- // as a real value of zero).
- val readinessT = if (readiness >= 0) readiness / 100.0 else -1.0
- val tier = w.readInt(prefs, "readiness_tier", -1)
- val readinessArc = w.readinessArc(tier)
- val readinessColor = w.readinessColor(tier, pal)
- // 0-21 is the headline scale strainScore maps TRIMP onto
- // (analytics/lib/src/onehz/clinical/load_trimp.dart:104-122).
- val strainT = if (strain >= 0) (strain / 21.0).coerceAtMost(1.0) else -1.0
- val sleepT = if (sleepMin >= 0 && needMin > 0) {
- (sleepMin.toDouble() / needMin).coerceAtMost(1.0)
- } else {
- -1.0
- }
- // HRV against YOUR OWN baseline: a full ring is at or above it. There
- // is no population scale for RMSSD, so with no baseline there is no
- // denominator and the arc is not drawn. This used to divide by a
- // hard-coded 100 (and by 1.5 x baseline), neither of which exists
- // anywhere in the pipeline.
- val hrvT = if (hrv >= 0 && hrvBaseline > 0) {
- (hrv.toDouble() / hrvBaseline).coerceAtMost(1.0)
- } else {
- -1.0
- }
- // HRV carries its domain accent and no colour judgement: a "0.8 x
- // baseline is amber" cut-off was invented here and appears in no
- // analytics output.
- val hrvColor = if (hrv >= 0) w.GREEN else w.N400
-
- // "" = no measurement. A bare dash is the one rendering the phone's
- // grammar forbids outright.
- val strainText = if (strain >= 0) String.format("%.1f", strain) else ""
- val readinessText = if (readiness >= 0) "$readiness" else ""
- val hrvText = if (hrv >= 0) "$hrv" else ""
-
val layout = if (small) R.layout.widget_openstrap_small else R.layout.widget_openstrap
- val ringDp = if (small) 40 else 56
- val strokeDp = if (small) 5f else 7f
-
+ val dialDp = if (small) 30 else 44
+ val strokeDp = if (small) 4.5f else 6f
val views = RemoteViews(context.packageName, layout)
views.setInt(R.id.widget_root, "setBackgroundResource", pal.bgRes)
views.setOnClickPendingIntent(R.id.widget_root, w.openAppIntent(context))
- // Readiness leads the row; its VALUE carries the readiness colour (the
- // iOS headline treatment, compressed into a cell).
- views.setImageViewBitmap(
- R.id.ring_readiness,
- w.ringBitmap(context, ringDp, strokeDp, pal.track, readinessArc, readinessT),
- )
- views.setTextViewText(R.id.val_readiness, readinessText)
- views.setTextColor(R.id.val_readiness, readinessColor)
- views.setTextColor(R.id.cap_readiness, pal.inkMuted)
+ // Recovery wears its band's colour (from the published tier — the
+ // cut-offs are never re-derived here), the other two their domain accent.
+ val tier = w.readInt(prefs, "readiness_tier", -1)
+ var gap: Pair? = null
- fun metric(ring: Int, value: Int, cap: Int, bmpColor: Int, t: Double, text: String) {
+ for (slot in slots) {
+ val r = w.ring(prefs, slot.key)
+ val accent = when (slot.key) {
+ "recovery" -> w.tierColor(tier, pal)
+ "strain" -> pal.move
+ else -> pal.sleep
+ }
+ val tint = r.color(accent, pal)
views.setImageViewBitmap(
- ring,
- w.ringBitmap(context, ringDp, strokeDp, pal.track, bmpColor, t),
+ slot.dial,
+ w.dialBitmap(context, dialDp, strokeDp, pal.track, tint, r.frac, slot.iconRes),
)
- views.setTextViewText(value, text)
- views.setTextColor(value, pal.ink)
- views.setTextColor(cap, pal.inkMuted)
+ views.setTextViewText(slot.cap, slot.label)
+ views.setTextColor(slot.cap, pal.inkMuted)
+ // The absence takes the SENTENCE colour rather than the numeral
+ // one, because it is a sentence: "No sleep" in full-weight ink
+ // would read as a score.
+ views.setTextViewText(slot.value, r.value)
+ views.setTextColor(slot.value, if (r.measured) pal.ink else pal.ink2)
+ if (!small) {
+ views.setTextViewText(slot.sub, r.sub)
+ views.setTextColor(slot.sub, pal.inkMuted)
+ }
+ if (gap == null && r.why.isNotEmpty()) gap = slot.label to r.why
+ }
+
+ // The first ring that is missing and said why. One line is what a
+ // widget can afford; the rest is one tap away in the app.
+ if (!small) {
+ val g = gap
+ if (g == null) {
+ views.setViewVisibility(R.id.gap_row, View.GONE)
+ } else {
+ views.setViewVisibility(R.id.gap_row, View.VISIBLE)
+ views.setTextViewText(R.id.gap_row, "${g.first} · ${g.second}")
+ views.setTextColor(R.id.gap_row, pal.inkMuted)
+ }
}
- metric(R.id.ring_strain, R.id.val_strain, R.id.cap_strain, w.PURPLE, strainT, strainText)
- metric(R.id.ring_sleep, R.id.val_sleep, R.id.cap_sleep, w.BLUE, sleepT, w.hm(sleepMin))
- metric(R.id.ring_hrv, R.id.val_hrv, R.id.cap_hrv, hrvColor, hrvT, hrvText)
return views
}
diff --git a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/OvernightWidgetProvider.kt b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/OvernightWidgetProvider.kt
new file mode 100644
index 00000000..5388ed32
--- /dev/null
+++ b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/OvernightWidgetProvider.kt
@@ -0,0 +1,84 @@
+package wtf.openstrap.openstrap_edge
+
+import android.appwidget.AppWidgetManager
+import android.content.Context
+import android.content.SharedPreferences
+import android.view.View
+import android.widget.RemoteViews
+import es.antonborri.home_widget.HomeWidgetProvider
+
+/**
+ * The two things the band actually MEASURED while you slept — the Android
+ * sibling of OpenStrapOvernightWidget.swift.
+ *
+ * It exists because the rebuilt home screen has three rings and HRV is not one
+ * of them, so OpenStrapWidgetProvider dropped the HRV ring it used to carry.
+ * This is where that number went, and it is a better home for it: HRV means
+ * nothing against a population and everything against your own baseline.
+ *
+ * HRV IS DRAWN AGAINST YOUR OWN BASELINE AND NOTHING ELSE. Full ring at or
+ * above it; with no baseline there is no denominator, so there is no arc. It
+ * carries the Health domain accent and no colour judgement — a "0.8 x baseline
+ * is amber" cut-off was invented on this surface once and appears in no
+ * analytics output.
+ */
+class OvernightWidgetProvider : HomeWidgetProvider() {
+
+ override fun onUpdate(
+ context: Context,
+ appWidgetManager: AppWidgetManager,
+ appWidgetIds: IntArray,
+ widgetData: SharedPreferences,
+ ) {
+ val w = StrapWidgets
+ val pal = w.pal(widgetData)
+
+ val views = if (!w.fresh(widgetData)) {
+ RemoteViews(context.packageName, R.layout.widget_openstrap_nodata).apply {
+ setTextColor(R.id.nodata_title, pal.ink)
+ setTextColor(R.id.nodata_body, pal.inkMuted)
+ }
+ } else {
+ val hrv = w.readInt(widgetData, "hrv", -1)
+ val base = w.readInt(widgetData, "hrv_baseline", -1)
+ val rhr = w.readInt(widgetData, "rhr", -1)
+ val frac = if (hrv >= 0 && base > 0) {
+ (hrv.toDouble() / base).coerceAtMost(1.0)
+ } else {
+ -1.0
+ }
+ RemoteViews(context.packageName, R.layout.widget_overnight).apply {
+ setImageViewBitmap(
+ R.id.dial_hrv,
+ w.dialBitmap(
+ context, 38, 5.5f, pal.track,
+ if (hrv >= 0) pal.good else pal.inkMuted, frac,
+ R.drawable.ic_widget_hrv,
+ ),
+ )
+ setTextColor(R.id.cap_hrv, pal.inkMuted)
+ setTextColor(R.id.cap_rhr, pal.inkMuted)
+ // The absence is a WORD, in the sentence colour. Never a dash,
+ // and never a zero — a zero RMSSD is a claim about a heart.
+ setTextViewText(R.id.val_hrv, if (hrv >= 0) "$hrv ms" else "Not measured")
+ setTextColor(R.id.val_hrv, if (hrv >= 0) pal.good else pal.ink2)
+ setTextViewText(R.id.sub_hrv, if (base > 0) "base $base ms" else "")
+ setTextColor(R.id.sub_hrv, pal.inkMuted)
+ setTextViewText(R.id.val_rhr, if (rhr >= 0) "$rhr bpm" else "Not measured")
+ setTextColor(R.id.val_rhr, if (rhr >= 0) pal.ink else pal.ink2)
+ // Why, when there is a why — the held-over night's reason
+ // first, then the night's own, and nothing when neither said.
+ // A reason is never written here.
+ val why = (widgetData.getString("overnight_why", "") ?: "")
+ .ifEmpty { w.ring(widgetData, "sleep").why }
+ val foot = if (hrv < 0 && rhr < 0) why else ""
+ setViewVisibility(R.id.foot, if (foot.isEmpty()) View.GONE else View.VISIBLE)
+ setTextViewText(R.id.foot, foot)
+ setTextColor(R.id.foot, pal.inkMuted)
+ }
+ }
+ views.setInt(R.id.widget_root, "setBackgroundResource", pal.bgRes)
+ views.setOnClickPendingIntent(R.id.widget_root, w.openAppIntent(context))
+ for (id in appWidgetIds) appWidgetManager.updateAppWidget(id, views)
+ }
+}
diff --git a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/SleepWidgetProvider.kt b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/SleepWidgetProvider.kt
new file mode 100644
index 00000000..2f59a95b
--- /dev/null
+++ b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/SleepWidgetProvider.kt
@@ -0,0 +1,71 @@
+package wtf.openstrap.openstrap_edge
+
+import android.appwidget.AppWidgetManager
+import android.content.Context
+import android.content.SharedPreferences
+import android.view.View
+import android.widget.RemoteViews
+import es.antonborri.home_widget.HomeWidgetProvider
+
+/**
+ * Last night, on its own — the Android sibling of OpenStrapSleepWidget.swift.
+ *
+ * The one number people look for before they open anything. It is the trio's
+ * sleep ring at full size plus the figure that does not fit in a third of a
+ * card: efficiency.
+ *
+ * Everything it renders is resolved by WidgetService.push, including whether
+ * there is a need to measure the night against at all — a night with no LEARNED
+ * need draws an open track and says so rather than filling against a hardcoded
+ * 8 h that is not this user's.
+ */
+class SleepWidgetProvider : HomeWidgetProvider() {
+
+ override fun onUpdate(
+ context: Context,
+ appWidgetManager: AppWidgetManager,
+ appWidgetIds: IntArray,
+ widgetData: SharedPreferences,
+ ) {
+ val w = StrapWidgets
+ val pal = w.pal(widgetData)
+ val fresh = w.fresh(widgetData)
+
+ val views = if (!fresh) {
+ RemoteViews(context.packageName, R.layout.widget_openstrap_nodata).apply {
+ setTextColor(R.id.nodata_title, pal.ink)
+ setTextColor(R.id.nodata_body, pal.inkMuted)
+ }
+ } else {
+ val r = w.ring(widgetData, "sleep")
+ val eff = w.readInt(widgetData, "sleep_efficiency", -1)
+ RemoteViews(context.packageName, R.layout.widget_sleep).apply {
+ setImageViewBitmap(
+ R.id.dial_sleep,
+ w.dialBitmap(
+ context, 52, 7f, pal.track, r.color(pal.sleep, pal), r.frac,
+ R.drawable.ic_widget_sleep,
+ ),
+ )
+ setTextColor(R.id.cap_sleep, pal.inkMuted)
+ setTextViewText(R.id.val_sleep, r.value)
+ setTextColor(R.id.val_sleep, if (r.measured) pal.ink else pal.ink2)
+ setTextViewText(R.id.sub_sleep, r.sub)
+ setTextColor(R.id.sub_sleep, pal.inkMuted)
+ // Efficiency when the night has one, the reason when it does
+ // not, and nothing at all when there is neither — never a dash.
+ val foot = when {
+ r.measured && eff >= 0 -> "$eff% efficient"
+ !r.measured -> r.why
+ else -> ""
+ }
+ setViewVisibility(R.id.foot, if (foot.isEmpty()) View.GONE else View.VISIBLE)
+ setTextViewText(R.id.foot, foot)
+ setTextColor(R.id.foot, pal.inkMuted)
+ }
+ }
+ views.setInt(R.id.widget_root, "setBackgroundResource", pal.bgRes)
+ views.setOnClickPendingIntent(R.id.widget_root, w.openAppIntent(context))
+ for (id in appWidgetIds) appWidgetManager.updateAppWidget(id, views)
+ }
+}
diff --git a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/StrapWidgets.kt b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/StrapWidgets.kt
index 1ab0cd0c..a2626ba9 100644
--- a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/StrapWidgets.kt
+++ b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/StrapWidgets.kt
@@ -8,46 +8,56 @@ import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.RectF
+import androidx.core.content.ContextCompat
/**
- * Shared bits for the home-screen widgets (see OpenStrapWidgetProvider /
- * OpenStrapBatteryWidgetProvider) — the palette, readers for the home_widget
- * snapshot, the freshness rule, and the arc-ring renderer.
+ * Shared bits for the home-screen widgets — the palette, readers for the
+ * home_widget snapshot, the freshness rule, and the dial renderer. The Kotlin
+ * half of ios/OpenStrapWidget/StrapWidgetKit.swift, so the two platforms read
+ * as the same product.
*
- * The palette and all value/colour rules mirror the Swift widgets under
- * ios/OpenStrapWidget exactly, so the two platforms read as the same product.
- * Both now spend lib/ui2/theme.dart's tokens rather than the retired
- * lib/theme/tokens.dart ones. Rings are pre-rendered as bitmaps because
- * RemoteViews can't draw arcs.
+ * WHAT THIS SIDE DECIDES: layout, and nothing else. Whether a ring is a
+ * reading, calibration progress or an absence — and what any of them SAY —
+ * arrives already resolved from `WidgetService.push`, which mirrors `RingTrio`
+ * on Home. The rule used to live in Dart AND Swift AND here, and the three
+ * copies disagreed about the same day.
+ *
+ * Dials are pre-rendered as bitmaps because RemoteViews cannot draw arcs.
*/
internal object StrapWidgets {
- // ── lib/ui2/theme.dart (mirrors Pal in OpenStrapWidget.swift) ───────────
- // A widget is a card, so surfaces are `P.card` over a `P.track` ring track
- // and `P.ink3` captions. `onGood`/`onWarn`/`onBad`/`onNone` are the tier
- // accents as TEXT — `P.on()`'s output, which nudges an accent toward the
- // page ink until it clears WCAG AA on the worst surface it can land on.
- // Arcs are non-text UI and spend the raw `C.*` pigment below.
+ // ── lib/ui2/theme.dart (mirrors SW.Pal in StrapWidgetKit.swift) ────────
+ // A widget is a card, so surfaces are `P.card` over a `P.track` ring track,
+ // `P.ink` numerals and `P.ink3` captions.
class Pal(
val bgRes: Int,
val ink: Int,
+ val ink2: Int,
val inkMuted: Int,
val track: Int,
- val onGood: Int,
- val onWarn: Int,
- val onBad: Int,
- val onNone: Int,
+ val good: Int,
+ val warn: Int,
+ val bad: Int,
+ val sleep: Int,
+ val move: Int,
)
+ // `P.on(accent)` per brightness — ui2 nudges an accent toward the page ink
+ // until it clears WCAG AA 4.5:1 on the worst surface it can land on, and a
+ // ring spends that solved value for BOTH its arc and its number (see
+ // `_RingState.arc` / `.ink` in home_screen.dart). Recomputing these means
+ // running P.on's binary search, not eyeballing a hex.
private val LIGHT = Pal(
- R.drawable.widget_bg_paper, 0xFF0F172A.toInt(),
- 0xFF627188.toInt(), 0xFFE2E8F0.toInt(),
- 0xFF1A7948.toInt(), 0xFFA5521D.toInt(), 0xFFB9393E.toInt(), 0xFF606B80.toInt(),
+ R.drawable.widget_bg_paper,
+ 0xFF0F172A.toInt(), 0xFF475569.toInt(), 0xFF627188.toInt(), 0xFFE2E8F0.toInt(),
+ 0xFF1A7A48.toInt(), 0xFFA5521D.toInt(), 0xFFB9393E.toInt(),
+ 0xFF2F66C0.toInt(), 0xFF734FCF.toInt(),
)
private val DARK = Pal(
- R.drawable.widget_bg_char, 0xFFF1F5F9.toInt(),
- 0xFF7F8DA0.toInt(), 0xFF232D3B.toInt(),
- 0xFF22C55E.toInt(), 0xFFF87F2A.toInt(), 0xFFEF7373.toInt(), 0xFF97A6BA.toInt(),
+ R.drawable.widget_bg_char,
+ 0xFFF1F5F9.toInt(), 0xFF94A3B8.toInt(), 0xFF7F8DA0.toInt(), 0xFF232D3B.toInt(),
+ 0xFF22C55E.toInt(), 0xFFF87E28.toInt(), 0xFFF07374.toInt(),
+ 0xFF689EF7.toInt(), 0xFFA988F7.toInt(),
)
// Raw pigment — `C` in lib/ui2/theme.dart. Arcs and fills only.
@@ -71,9 +81,17 @@ internal object StrapWidgets {
*/
private const val STALE_AFTER_SEC = 26L * 3600
+ /** The three home rings, in Home's order. */
+ val RING_KEYS = listOf("recovery", "strain", "sleep")
+
/** Is the published snapshot still today's answer? */
fun fresh(prefs: SharedPreferences): Boolean {
if (!prefs.getBoolean("has_data", false)) return false
+ // A snapshot written by an app version older than the rings has every
+ // ring value empty, which draws circles with nothing in them. It heals
+ // on the first push (the app publishes on every foreground); until then
+ // the no-data state is the honest picture.
+ if (RING_KEYS.all { prefs.getString("ring_${it}_value", "").isNullOrEmpty() }) return false
val at = readLong(prefs, "updated_at", 0)
// An unknown timestamp is not a claim of staleness (matching
// WidgetService.isStale); a snapshot never pushed has has_data false.
@@ -82,24 +100,17 @@ internal object StrapWidgets {
}
/**
- * Readiness tier -> arc pigment. The THRESHOLDS are not here: Dart publishes
- * `readiness_tier` (see `readinessBand` in lib/ui2/screens/home_screen.dart)
- * so the phone, the widget, the watch and Siri cannot disagree about what a
- * score of 65 means. Never re-derive a band from the raw number.
+ * Readiness tier -> its accent, arc and numeral alike. The THRESHOLDS are
+ * not here: Dart publishes `readiness_tier` (see `readinessBand` in
+ * lib/ui2/screens/home_screen.dart) so the phone, the widget, the watch and
+ * Siri cannot disagree about what a score of 65 means. Never re-derive a
+ * band from the raw number.
*/
- fun readinessArc(tier: Int): Int = when (tier) {
- 3, 2 -> GREEN
- 1 -> ORANGE
- 0 -> RED
- else -> N400
- }
-
- /** The same tier, solved for TEXT. */
- fun readinessColor(tier: Int, pal: Pal): Int = when (tier) {
- 3, 2 -> pal.onGood
- 1 -> pal.onWarn
- 0 -> pal.onBad
- else -> pal.onNone
+ fun tierColor(tier: Int, pal: Pal): Int = when (tier) {
+ 3, 2 -> pal.good
+ 1 -> pal.warn
+ 0 -> pal.bad
+ else -> pal.inkMuted
}
/// The app mirrors its in-app appearance into `theme_dark` (see
@@ -139,6 +150,35 @@ internal object StrapWidgets {
else -> def
}
+ // ── the resolved rings ───────────────────────────────────────────────────
+ /** One home ring exactly as Dart published it. */
+ class RingData(
+ /** 0 measured · 1 calibrating · 2 absent. */
+ val state: Int,
+ /** The number, or the absence IN WORDS — never a dash. */
+ val value: String,
+ /** What it is out of, the readiness band, or the nights banked. */
+ val sub: String,
+ /** The pipeline's own reason. Absent rings only. */
+ val why: String,
+ /** What to sweep, 0..1 — negative when there is nothing honest to sweep. */
+ val frac: Double,
+ ) {
+ val measured: Boolean get() = state == 0
+
+ /** Arc and numeral share one colour, and the colour IS the signal that
+ * this is not a reading. */
+ fun color(accent: Int, pal: Pal): Int = if (measured) accent else pal.inkMuted
+ }
+
+ fun ring(prefs: SharedPreferences, key: String): RingData = RingData(
+ readInt(prefs, "ring_${key}_state", 2),
+ prefs.getString("ring_${key}_value", "") ?: "",
+ prefs.getString("ring_${key}_sub", "") ?: "",
+ prefs.getString("ring_${key}_why", "") ?: "",
+ readDouble(prefs, "ring_${key}_frac", -1.0),
+ )
+
// ── formatting ───────────────────────────────────────────────────────────
/** "45m" / "7h 05m" — the phone's own `hm()` (lib/ui2/screens/home_screen.dart),
* so the same night reads identically on the phone, the widget and iOS.
@@ -181,6 +221,36 @@ internal object StrapWidgets {
return bmp
}
+ /**
+ * The dial: the arc with the ring's ICON at its centre, as on Home. The
+ * number lives UNDER the dial, not inside it — inside is where "7h 45m"
+ * overflows its own circle at the first accessibility step, and nothing
+ * about that string gets shorter.
+ *
+ * The icon is drawn into the same bitmap rather than stacked as a second
+ * RemoteViews child: one view per dial, and the tint cannot drift from the
+ * arc it sits in.
+ */
+ fun dialBitmap(
+ context: Context,
+ sizeDp: Int,
+ strokeDp: Float,
+ trackColor: Int,
+ color: Int,
+ t: Double,
+ iconRes: Int,
+ ): Bitmap {
+ val bmp = ringBitmap(context, sizeDp, strokeDp, trackColor, color, t)
+ val icon = ContextCompat.getDrawable(context, iconRes) ?: return bmp
+ val px = bmp.width
+ val side = (px * 0.34f).toInt().coerceAtLeast(1)
+ val left = (px - side) / 2
+ icon.setBounds(left, left, left + side, left + side)
+ icon.setTint(color)
+ icon.draw(Canvas(bmp))
+ return bmp
+ }
+
/** Tap anywhere on a widget → open the app. */
fun openAppIntent(context: Context): PendingIntent =
PendingIntent.getActivity(
diff --git a/android/app/src/main/res/drawable/ic_widget_hrv.xml b/android/app/src/main/res/drawable/ic_widget_hrv.xml
new file mode 100644
index 00000000..bfa895bd
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_widget_hrv.xml
@@ -0,0 +1,14 @@
+
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_widget_recovery.xml b/android/app/src/main/res/drawable/ic_widget_recovery.xml
new file mode 100644
index 00000000..2279d33b
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_widget_recovery.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_widget_sleep.xml b/android/app/src/main/res/drawable/ic_widget_sleep.xml
new file mode 100644
index 00000000..5d8748e0
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_widget_sleep.xml
@@ -0,0 +1,14 @@
+
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_widget_strain.xml b/android/app/src/main/res/drawable/ic_widget_strain.xml
new file mode 100644
index 00000000..7eef5203
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_widget_strain.xml
@@ -0,0 +1,14 @@
+
+
+
+
diff --git a/android/app/src/main/res/layout/widget_openstrap.xml b/android/app/src/main/res/layout/widget_openstrap.xml
index 02242777..73407468 100644
--- a/android/app/src/main/res/layout/widget_openstrap.xml
+++ b/android/app/src/main/res/layout/widget_openstrap.xml
@@ -1,8 +1,11 @@
+ android:padding="10dp">
-
-
-
-
-
-
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
-
-
-
-
-
-
+
-
-
-
-
-
-
-
-
-
-
+
+
+
+
diff --git a/android/app/src/main/res/layout/widget_openstrap_small.xml b/android/app/src/main/res/layout/widget_openstrap_small.xml
index 7148164a..b6cbc9b2 100644
--- a/android/app/src/main/res/layout/widget_openstrap_small.xml
+++ b/android/app/src/main/res/layout/widget_openstrap_small.xml
@@ -1,7 +1,8 @@
-
-
+ android:padding="10dp">
-
-
-
-
+ android:layout_marginTop="0dp"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+
+
+
+
-
-
+
+
-
-
-
-
+ android:layout_marginTop="6dp"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
-
-
-
+ android:layout_marginTop="6dp"
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+
+
+
+
-
-
+
+
-
diff --git a/android/app/src/main/res/layout/widget_overnight.xml b/android/app/src/main/res/layout/widget_overnight.xml
new file mode 100644
index 00000000..29f61d44
--- /dev/null
+++ b/android/app/src/main/res/layout/widget_overnight.xml
@@ -0,0 +1,105 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/layout/widget_sleep.xml b/android/app/src/main/res/layout/widget_sleep.xml
new file mode 100644
index 00000000..90a48fca
--- /dev/null
+++ b/android/app/src/main/res/layout/widget_sleep.xml
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/values/widget_strings.xml b/android/app/src/main/res/values/widget_strings.xml
index 73402773..6cb8612d 100644
--- a/android/app/src/main/res/values/widget_strings.xml
+++ b/android/app/src/main/res/values/widget_strings.xml
@@ -1,7 +1,14 @@
OpenStrap
- Readiness, strain, sleep and HRV at a glance.
+ Recovery, strain and sleep at a glance.
Band battery
Your band\'s battery, from the last connection.
+ SLEEP
+ Last night
+ How long you slept, against the need the app has learned.
+ Overnight
+ Last night\'s HRV against your own baseline, and resting heart rate.
+ HRV
+ RESTING HR
diff --git a/android/app/src/main/res/xml/widget_overnight_info.xml b/android/app/src/main/res/xml/widget_overnight_info.xml
new file mode 100644
index 00000000..7a456ee4
--- /dev/null
+++ b/android/app/src/main/res/xml/widget_overnight_info.xml
@@ -0,0 +1,15 @@
+
+
diff --git a/android/app/src/main/res/xml/widget_sleep_info.xml b/android/app/src/main/res/xml/widget_sleep_info.xml
new file mode 100644
index 00000000..80d7955d
--- /dev/null
+++ b/android/app/src/main/res/xml/widget_sleep_info.xml
@@ -0,0 +1,16 @@
+
+
diff --git a/ios/OpenStrapWidget/OpenStrapOvernightWidget.swift b/ios/OpenStrapWidget/OpenStrapOvernightWidget.swift
new file mode 100644
index 00000000..12a8f0f7
--- /dev/null
+++ b/ios/OpenStrapWidget/OpenStrapOvernightWidget.swift
@@ -0,0 +1,186 @@
+//
+// OpenStrapOvernightWidget.swift
+// OpenStrapWidget
+//
+// The two things the band actually MEASURED while you slept: nocturnal HRV
+// (RMSSD, from beat-to-beat intervals) and resting heart rate. Everything else
+// on a widget is a composite of them.
+//
+// It exists because the rebuilt home screen has three rings and HRV is not one
+// of them — Recovery, Strain and Sleep are — so the redesigned OpenStrapWidget
+// dropped the HRV ring it used to carry. This is where that number went, and
+// it is a better home for it: HRV means nothing against a population and
+// everything against your own baseline, which needs the room to say so.
+//
+// HRV IS DRAWN AGAINST YOUR OWN BASELINE AND NOTHING ELSE. Full ring at or
+// above it. With no baseline there is no denominator, so there is no arc —
+// this used to divide by a hardcoded 100, a number that exists nowhere in the
+// pipeline.
+//
+
+import WidgetKit
+import SwiftUI
+
+struct OvernightEntry: TimelineEntry {
+ var date: Date
+ let snap: SW.Snapshot
+
+ var fresh: Bool { SW.fresh(snap, at: date) }
+ static let placeholder = OvernightEntry(date: Date(), snap: .placeholder)
+
+ var hrvFrac: Double {
+ guard snap.hrv >= 0, snap.hrvBaseline > 0 else { return -1 }
+ return min(Double(snap.hrv) / Double(snap.hrvBaseline), 1)
+ }
+
+ /// Why there are no overnight numbers, as the app said it — the held-over
+ /// night's reason first, then the night's own. Empty when nothing said why,
+ /// in which case the widget says the value is missing and stops there rather
+ /// than inventing a cause.
+ var why: String {
+ if !snap.overnightWhy.isEmpty { return snap.overnightWhy }
+ return snap.sleep.why
+ }
+
+ var hasAny: Bool { snap.hrv >= 0 || snap.rhr >= 0 }
+}
+
+struct OvernightProvider: TimelineProvider {
+ func placeholder(in context: Context) -> OvernightEntry { .placeholder }
+
+ func getSnapshot(in context: Context, completion: @escaping (OvernightEntry) -> Void) {
+ completion(context.isPreview ? .placeholder : OvernightEntry(date: Date(), snap: SW.read()))
+ }
+
+ func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) {
+ let snap = SW.read()
+ completion(SW.timeline(snap, Date()) { OvernightEntry(date: $0, snap: snap) })
+ }
+}
+
+/// One measured overnight figure: label, number, unit, and what it is being
+/// read against. Absent prints the word, in the sentence weight.
+private struct Figure: View {
+ let label: String
+ let value: Int
+ let unit: String
+ let against: String
+ let accent: Color
+
+ var body: some View {
+ let p = SW.pal
+ VStack(alignment: .leading, spacing: 1) {
+ Text(label.uppercased()).font(SW.over).tracking(0.5).foregroundStyle(p.ink3)
+ if value >= 0 {
+ HStack(alignment: .firstTextBaseline, spacing: 3) {
+ Text("\(value)").font(SW.num(24)).foregroundStyle(accent)
+ Text(unit).font(SW.cap).foregroundStyle(p.ink3)
+ }
+ if !against.isEmpty {
+ Text(against).font(SW.cap).foregroundStyle(p.ink3).lineLimit(1)
+ }
+ } else {
+ Text("Not measured").font(SW.body).foregroundStyle(p.ink2)
+ }
+ }
+ }
+}
+
+private struct OvernightSmallView: View {
+ let e: OvernightEntry
+
+ var body: some View {
+ let p = SW.pal
+ let s = e.snap
+ VStack(alignment: .leading, spacing: 10) {
+ HStack(spacing: 10) {
+ // The HRV dial carries no colour judgement — green is the Health
+ // domain's accent, not a verdict. A "0.8 × baseline is amber" cut-off
+ // was invented on this surface once and appears in no analytics output.
+ SW.Dial(
+ r: SW.RingData(state: s.hrv >= 0 ? 0 : 2, value: "", sub: "", why: "",
+ frac: e.hrvFrac),
+ symbol: "waveform.path.ecg", accent: p.good, size: 40, line: 6)
+ Figure(label: "HRV", value: s.hrv, unit: "ms",
+ against: s.hrvBaseline > 0 ? "base \(s.hrvBaseline)" : "",
+ accent: p.good)
+ Spacer(minLength: 0)
+ }
+ Divider()
+ Figure(label: "Resting HR", value: s.rhr, unit: "bpm", against: "", accent: p.ink)
+ if !e.hasAny, !e.why.isEmpty {
+ Text(e.why).font(.system(size: 11)).foregroundStyle(p.ink3).lineLimit(3)
+ }
+ Spacer(minLength: 0)
+ }
+ .padding(12)
+ }
+}
+
+struct OpenStrapOvernightWidgetEntryView: View {
+ @Environment(\.widgetFamily) var family
+ var entry: OvernightEntry
+
+ var body: some View {
+ content.strapBackground(family)
+ }
+
+ private var line: String {
+ let s = entry.snap
+ let parts = [s.hrv >= 0 ? "HRV \(s.hrv) ms" : nil,
+ s.rhr >= 0 ? "RHR \(s.rhr)" : nil].compactMap { $0 }
+ return parts.isEmpty ? "" : parts.joined(separator: " ")
+ }
+
+ @ViewBuilder private var content: some View {
+ if !entry.fresh {
+ SW.NoData()
+ } else {
+ switch family {
+ case .accessoryCircular:
+ if entry.snap.hrv >= 0, entry.hrvFrac >= 0 {
+ Gauge(value: entry.hrvFrac) {
+ Text("HRV")
+ } currentValueLabel: {
+ Text("\(entry.snap.hrv)")
+ }
+ .gaugeStyle(.accessoryCircular)
+ .widgetAccentable()
+ } else {
+ VStack(spacing: 0) {
+ Image(systemName: "waveform.path.ecg").font(.system(size: 14)).widgetAccentable()
+ Text(entry.snap.hrv >= 0 ? "\(entry.snap.hrv)" : "HRV")
+ .font(.system(size: 10, weight: .semibold))
+ }
+ }
+ case .accessoryRectangular:
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Overnight").font(.system(size: 11, weight: .semibold)).widgetAccentable()
+ Text(entry.hasAny ? line : "Not measured")
+ .font(.system(size: 15, weight: .bold))
+ Text(entry.hasAny
+ ? (entry.snap.hrvBaseline > 0 ? "Your baseline \(entry.snap.hrvBaseline) ms" : "")
+ : entry.why)
+ .font(.system(size: 12)).foregroundStyle(.secondary).lineLimit(2)
+ }
+ case .accessoryInline:
+ Text(entry.snap.hrv >= 0 ? "HRV \(entry.snap.hrv) ms" : "OpenStrap · HRV not measured")
+ default: OvernightSmallView(e: entry)
+ }
+ }
+ }
+}
+
+struct OpenStrapOvernightWidget: Widget {
+ let kind: String = "OpenStrapOvernightWidget"
+
+ var body: some WidgetConfiguration {
+ StaticConfiguration(kind: kind, provider: OvernightProvider()) { entry in
+ OpenStrapOvernightWidgetEntryView(entry: entry)
+ }
+ .configurationDisplayName("Overnight")
+ .description("Last night's HRV against your own baseline, and resting heart rate.")
+ .supportedFamilies([.systemSmall, .accessoryCircular,
+ .accessoryRectangular, .accessoryInline])
+ }
+}
diff --git a/ios/OpenStrapWidget/OpenStrapSleepWidget.swift b/ios/OpenStrapWidget/OpenStrapSleepWidget.swift
new file mode 100644
index 00000000..28834e5c
--- /dev/null
+++ b/ios/OpenStrapWidget/OpenStrapSleepWidget.swift
@@ -0,0 +1,138 @@
+//
+// OpenStrapSleepWidget.swift
+// OpenStrapWidget
+//
+// Last night, on its own. The one number people look for before they open
+// anything, and the moment they want it — first unlock of the morning — is the
+// moment a lock-screen widget is on screen anyway.
+//
+// It is the trio's sleep ring at full size plus the one figure that does not
+// fit in a third of a card: efficiency. Everything it renders is resolved by
+// `WidgetService.push`, including whether there is a need to measure the night
+// against at all — a night with no LEARNED need draws an open track and says
+// "No target yet" rather than filling against a hardcoded 8 h that is not this
+// user's.
+//
+
+import WidgetKit
+import SwiftUI
+
+struct SleepEntry: TimelineEntry {
+ var date: Date
+ let snap: SW.Snapshot
+
+ var fresh: Bool { SW.fresh(snap, at: date) }
+ static let placeholder = SleepEntry(date: Date(), snap: .placeholder)
+}
+
+struct SleepProvider: TimelineProvider {
+ func placeholder(in context: Context) -> SleepEntry { .placeholder }
+
+ func getSnapshot(in context: Context, completion: @escaping (SleepEntry) -> Void) {
+ completion(context.isPreview ? .placeholder : SleepEntry(date: Date(), snap: SW.read()))
+ }
+
+ func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) {
+ let snap = SW.read()
+ completion(SW.timeline(snap, Date()) { SleepEntry(date: $0, snap: snap) })
+ }
+}
+
+private struct SleepSmallView: View {
+ let snap: SW.Snapshot
+
+ var body: some View {
+ let p = SW.pal
+ let r = snap.sleep
+ VStack(spacing: 6) {
+ SW.Dial(r: r, symbol: "moon.fill", accent: p.sleep, size: 60, line: 8)
+ SW.RingText(label: "Sleep", r: r, accent: p.sleep, valueSize: 22)
+ // Efficiency only when the night has one. It is the share of time in bed
+ // actually asleep, and there is no honest placeholder for it.
+ if r.measured, snap.efficiency >= 0 {
+ Text("\(snap.efficiency)% efficient")
+ .font(SW.cap).foregroundStyle(p.ink3).lineLimit(1)
+ } else if !r.why.isEmpty {
+ Text(r.why)
+ .font(.system(size: 11)).foregroundStyle(p.ink3)
+ .multilineTextAlignment(.center).lineLimit(3)
+ }
+ }
+ .padding(12)
+ }
+}
+
+private struct SleepRectangularView: View {
+ let snap: SW.Snapshot
+ var body: some View {
+ let r = snap.sleep
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Last night").font(.system(size: 11, weight: .semibold)).widgetAccentable()
+ Text(r.value).font(.system(size: 16, weight: .bold))
+ Text(r.measured
+ ? [r.sub, snap.efficiency >= 0 ? "\(snap.efficiency)% efficient" : nil]
+ .compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: " ")
+ : r.why)
+ .font(.system(size: 12)).foregroundStyle(.secondary).lineLimit(2)
+ }
+ }
+}
+
+struct OpenStrapSleepWidgetEntryView: View {
+ @Environment(\.widgetFamily) var family
+ var entry: SleepEntry
+
+ var body: some View {
+ content.strapBackground(family)
+ }
+
+ @ViewBuilder private var content: some View {
+ if !entry.fresh {
+ SW.NoData()
+ } else {
+ switch family {
+ case .accessoryCircular:
+ let r = entry.snap.sleep
+ // An arc only when there is a need to measure the night against.
+ if r.measured, r.frac >= 0 {
+ Gauge(value: min(r.frac, 1)) {
+ Image(systemName: "moon.fill")
+ } currentValueLabel: {
+ Text(r.value).minimumScaleFactor(0.5)
+ }
+ .gaugeStyle(.accessoryCircular)
+ .widgetAccentable()
+ } else {
+ // Measured but unscaled (no learned need) prints the duration with no
+ // ring; absent prints the glyph and the word alone. Neither draws an
+ // arc, because an arc at zero reads as a night with no sleep in it.
+ VStack(spacing: 0) {
+ Image(systemName: "moon.fill").font(.system(size: 13)).widgetAccentable()
+ Text(r.measured ? r.value : "SLEEP")
+ .font(.system(size: 10, weight: .semibold)).minimumScaleFactor(0.6)
+ }
+ }
+ case .accessoryRectangular: SleepRectangularView(snap: entry.snap)
+ case .accessoryInline:
+ Text(entry.snap.sleep.measured
+ ? "Slept \(entry.snap.sleep.value)"
+ : "OpenStrap · \(entry.snap.sleep.value.lowercased())")
+ default: SleepSmallView(snap: entry.snap)
+ }
+ }
+ }
+}
+
+struct OpenStrapSleepWidget: Widget {
+ let kind: String = "OpenStrapSleepWidget"
+
+ var body: some WidgetConfiguration {
+ StaticConfiguration(kind: kind, provider: SleepProvider()) { entry in
+ OpenStrapSleepWidgetEntryView(entry: entry)
+ }
+ .configurationDisplayName("Last night")
+ .description("How long you slept, against the need the app has learned.")
+ .supportedFamilies([.systemSmall, .accessoryCircular,
+ .accessoryRectangular, .accessoryInline])
+ }
+}
diff --git a/ios/OpenStrapWidget/OpenStrapWidget.swift b/ios/OpenStrapWidget/OpenStrapWidget.swift
index 8dfe7319..61b915de 100644
--- a/ios/OpenStrapWidget/OpenStrapWidget.swift
+++ b/ios/OpenStrapWidget/OpenStrapWidget.swift
@@ -2,221 +2,42 @@
// OpenStrapWidget.swift
// OpenStrapWidget
//
-// Home/lock-screen widget — renders the snapshot the app writes into the shared
-// App Group. Nothing else: this is a local-first app with no backend and no
-// account, so there is nothing for the widget to fetch. (It used to carry a
-// "self-refreshes hourly by fetching /today" path guarded on a JWT that no code
-// ever wrote — dead on every install, and its parser hard-wrote has_data = true,
-// which would have clobbered the app's staleness gate the moment anyone wired it
-// up.) The app calls WidgetService.refresh() after every derive; that is the
-// only refresh there is.
+// The home/lock-screen face of the app's daily snapshot. Nothing else: this is
+// a local-first app with no backend and no account, so there is nothing for a
+// widget to fetch. `WidgetService.refresh()` publishes after every derive and
+// on every foreground; that is the only refresh there is.
//
-// Shows three rings: Strain · Sleep · HRV. (Recovery was retired — the app no
-// longer surfaces a recovery score; HRV is the real measured autonomic signal.)
+// IT IS THE SAME THREE RINGS AS HOME NOW — Recovery · Strain · Sleep, in that
+// order, with the icon inside the dial and the number under it. It used to be
+// a readiness headline over Strain · Sleep · HRV, which was the previous
+// design system's home screen; HRV stopped being one of Home's rings in the
+// rebuild and lives on its own widget (OpenStrapOvernightWidget) instead.
//
-// HONESTY: nothing here is allowed to look current when it isn't. `has_data`
-// is the Dart side saying "this snapshot is empty or describes a day more than
-// one behind" — but it is a bool frozen at push time, so on a phone that stops
-// syncing it stays true forever. Freshness is therefore computed HERE, at
-// render time, from `updated_at` (see `OpenStrapEntry.fresh`), and every family
-// gates on that. An absent metric is drawn as an empty slot, never as a dash
-// over a ring pinned at zero. The readiness BANDING is not computed here: Dart
-// publishes `readiness_tier` so the phone, the widget, the watch and Siri
-// cannot disagree about what 65 means.
-
-import WidgetKit
-import SwiftUI
-
-private let kAppGroup = AppGroup.identifier
-
-// MARK: - Theme (lib/ui2/theme.dart)
-// The app writes "theme_dark" into the App Group to mirror its in-app appearance
-// (which already resolves "System" to the actual OS brightness).
+// HONESTY: nothing here may look current when it isn't, and nothing here may
+// look measured when it isn't.
//
-// These are ui2's tokens, not the retired lib/theme/tokens.dart ones — the
-// widget sits on the same home screen as the app and had been painting the
-// previous design system's palette. Surfaces are `P.card` (a widget IS a card),
-// the track is `P.track`, muted ink is `P.ink3`.
+// · Freshness is computed at RENDER time from `updated_at`, not from the
+// `has_data` bool frozen at push time — see `SW.fresh`.
+// · The four ring states (measured / calibrating / unscaled / absent) arrive
+// resolved from Dart. Before that this file drew one dimmed empty circle
+// for every absence, so "four more nights and this fills in" and "the band
+// recorded nothing all night" were the same picture, forever.
+// · An absence is a WORD and a reason, never a dash and never an arc at zero
+// — an arc at zero reads as a score of zero, which is a lie about the user.
//
-// Accents come in two forms, and the distinction is the whole point of ui2's
-// palette: RAW pigment (`C.*`) is for arcs and fills — non-text UI — while an
-// accent used as TEXT is run through `P.on()`, which nudges it toward the page
-// ink until it clears WCAG AA 4.5:1 on the worst surface it can land on. Those
-// solved values are precomputed here (`onGood`/`onWarn`/`onBad`/`onNone`);
-// re-deriving them means running P.on's binary search, not eyeballing a hex.
-
-private extension Color {
- init(_ r: Int, _ g: Int, _ b: Int) {
- self.init(red: Double(r) / 255, green: Double(g) / 255, blue: Double(b) / 255)
- }
-}
-
-/// Raw pigment — arcs and fills only. Identical in both themes, like `C` in
-/// lib/ui2/theme.dart.
-enum C {
- static let green = Color(0x22, 0xC5, 0x5E)
- static let orange = Color(0xF9, 0x73, 0x16)
- static let red = Color(0xEF, 0x44, 0x44)
- static let blue = Color(0x3B, 0x82, 0xF6) // sleep
- static let purple = Color(0x8B, 0x5C, 0xF6) // strain / movement
- static let n400 = Color(0x94, 0xA3, 0xB8)
-}
-
-private struct Pal {
- let bg: Color, ink: Color, inkMuted: Color, track: Color
- /// Tier accents solved for TEXT (`P.on`), per brightness.
- let onGood: Color, onWarn: Color, onBad: Color, onNone: Color
- static let light = Pal(bg: Color(0xFF, 0xFF, 0xFF), ink: Color(0x0F, 0x17, 0x2A),
- inkMuted: Color(0x62, 0x71, 0x88), track: Color(0xE2, 0xE8, 0xF0),
- onGood: Color(0x1A, 0x79, 0x48), onWarn: Color(0xA5, 0x52, 0x1D),
- onBad: Color(0xB9, 0x39, 0x3E), onNone: Color(0x60, 0x6B, 0x80))
- static let dark = Pal(bg: Color(0x15, 0x1C, 0x26), ink: Color(0xF1, 0xF5, 0xF9),
- inkMuted: Color(0x7F, 0x8D, 0xA0), track: Color(0x23, 0x2D, 0x3B),
- onGood: Color(0x22, 0xC5, 0x5E), onWarn: Color(0xF8, 0x7F, 0x2A),
- onBad: Color(0xEF, 0x73, 0x73), onNone: Color(0x97, 0xA6, 0xBA))
- static var isDark: Bool {
- UserDefaults(suiteName: kAppGroup)?.object(forKey: "theme_dark") as? Bool ?? false
- }
- static var current: Pal { isDark ? .dark : .light }
-}
-private extension Color {
- static var paper: Color { Pal.current.bg }
- static var ink: Color { Pal.current.ink }
- static var inkMuted: Color { Pal.current.inkMuted }
- static var surfaceAlt: Color { Pal.current.track }
-}
+import WidgetKit
+import SwiftUI
// MARK: - Model
-/// How old the snapshot may be before the widget stops presenting it as today's
-/// answer. The app pushes on every completed derivation and on every finished
-/// sync, so under normal use this is refreshed each morning; 26 h is one whole
-/// missed wake cycle plus a couple of hours of grace for a wandering wake time.
-/// Past it, the readiness on the home screen is at best the morning before
-/// last's, and the honest render is the no-data state, not a stale number with
-/// nothing on it to say so.
-///
-/// Kept in step with the same constant on the Watch (WatchMetrics.swift), in
-/// Siri (OpenStrapIntents.swift) and on Android (StrapWidgets.kt) — three
-/// separate build targets, so it cannot be one declaration.
-let kStaleAfter: TimeInterval = 26 * 3600
-
struct OpenStrapEntry: TimelineEntry {
var date: Date
- let hasData: Bool
- let updatedAt: Int // epoch sec of the last push, 0 = unknown
- let readiness: Int // -1 = none (composite 0..100) — the headline
- let tier: Int // -1 = not scored · 0 rest · 1 easy · 2 steady · 3 good
- let band: String // the phone's own label for `tier` ("Steady", …)
- let strain: Double // -1 = none
- let sleepMin: Int // -1 = none
- let needMin: Int // -1 = none (sleep need, min) — never fabricate 8h
- let hrv: Int // -1 = none (RMSSD, ms)
- let hrvBaseline: Int // -1 = none (personal RMSSD baseline, ms)
- let rhr: Int // -1 = none
- let coachLine: String
-
- static let placeholder = OpenStrapEntry(
- date: Date(), hasData: true, updatedAt: Int(Date().timeIntervalSince1970),
- readiness: 72, tier: 2, band: "Steady",
- strain: 12.4, sleepMin: 437, needMin: 480, hrv: 62, hrvBaseline: 58, rhr: 54,
- coachLine: "Room to push today")
-
- /// Is this snapshot still today's answer, AS OF THIS ENTRY'S DATE?
- ///
- /// `hasData` alone is not enough and never was: it is frozen the moment Dart
- /// writes it, so a phone that has not synced for a week keeps a week-old
- /// readiness on the home screen looking exactly like this morning's. The age
- /// is measured against `date` rather than `Date()` so that WidgetKit can
- /// render the flip from a timeline entry it already holds — see getTimeline.
- ///
- /// An unknown timestamp (0) is not a claim of staleness, matching
- /// `WidgetService.isStale`; a snapshot that never got a push has `has_data`
- /// false anyway.
- var fresh: Bool {
- guard hasData else { return false }
- guard updatedAt > 0 else { return true }
- return date.timeIntervalSince1970 - Double(updatedAt) <= kStaleAfter
- }
+ let snap: SW.Snapshot
- /// The instant this entry stops being today's answer, or nil if it already is
- /// not (or never had a timestamp to age).
- var stalenessDeadline: Date? {
- guard hasData, updatedAt > 0 else { return nil }
- let at = Date(timeIntervalSince1970: Double(updatedAt) + kStaleAfter)
- return at > date ? at : nil
- }
+ var fresh: Bool { SW.fresh(snap, at: date) }
- func at(_ d: Date) -> OpenStrapEntry { var c = self; c.date = d; return c }
-
- // Ring fractions (0…1). A negative fraction means "no measurement" — Ring
- // draws the track only, and no view fills an arc against a value we don't have.
- var readinessT: Double { readiness >= 0 ? Double(readiness) / 100.0 : -1 }
- /// Tier → colour. The THRESHOLDS live in Dart (`readinessBand` in
- /// lib/ui2/screens/home_screen.dart) and arrive as `readiness_tier`; this maps
- /// the tier onto the widget's own surface palette and nothing more. Do not
- /// re-derive a band from `readiness` here — that is how the phone, the widget
- /// and the watch ended up disagreeing about the same score.
- /// Arc pigment (raw `C`) and text pigment (`P.on`-solved) for the tier.
- var readinessArc: Color {
- switch tier {
- case 3, 2: return C.green
- case 1: return C.orange
- case 0: return C.red
- default: return C.n400
- }
- }
- var readinessColor: Color {
- let p = Pal.current
- switch tier {
- case 3, 2: return p.onGood
- case 1: return p.onWarn
- case 0: return p.onBad
- default: return p.onNone
- }
- }
- /// 0–21 is the real headline scale (`strainScore` log-maps TRIMP onto it —
- /// analytics/lib/src/onehz/clinical/load_trimp.dart:104-122), not a widget
- /// invention. Siri says "out of twenty-one" for the same reason.
- var strainT: Double { strain >= 0 ? min(strain / 21.0, 1) : -1 }
- var sleepT: Double { (sleepMin >= 0 && needMin > 0) ? min(Double(sleepMin) / Double(needMin), 1) : -1 }
- /// HRV against YOUR OWN baseline: a full ring is at or above it. There is no
- /// population scale for RMSSD, so with no baseline there is no denominator
- /// and the arc is simply not drawn — this used to divide by a hard-coded 100
- /// (and by 1.5 × baseline), neither of which exists anywhere in the pipeline.
- var hrvT: Double {
- guard hrv >= 0, hrvBaseline > 0 else { return -1 }
- return min(Double(hrv) / Double(hrvBaseline), 1)
- }
- /// HRV carries its domain accent (`C.green`, as on the phone's Health trend)
- /// and no colour judgement: a "0.8 × baseline is amber" cut-off was invented
- /// here and appears in no analytics output.
- var hrvColor: Color { hrv >= 0 ? C.green : C.n400 }
-}
-
-// MARK: - Shared store (App Group, read-only)
-
-private enum Store {
- static var defaults: UserDefaults? { UserDefaults(suiteName: kAppGroup) }
-
- static func read() -> OpenStrapEntry {
- let d = defaults
- return OpenStrapEntry(
- date: Date(),
- hasData: d?.bool(forKey: "has_data") ?? false,
- updatedAt: d?.object(forKey: "updated_at") as? Int ?? 0,
- readiness: d?.object(forKey: "readiness") as? Int ?? -1,
- tier: d?.object(forKey: "readiness_tier") as? Int ?? -1,
- band: d?.string(forKey: "readiness_band") ?? "",
- strain: d?.object(forKey: "strain") as? Double ?? -1,
- sleepMin: d?.object(forKey: "sleep_min") as? Int ?? -1,
- needMin: (d?.object(forKey: "sleep_need_min") as? Int) ?? -1,
- hrv: d?.object(forKey: "hrv") as? Int ?? -1,
- hrvBaseline: d?.object(forKey: "hrv_baseline") as? Int ?? -1,
- rhr: d?.object(forKey: "rhr") as? Int ?? -1,
- coachLine: d?.string(forKey: "coach_line") ?? "")
- }
+ static let placeholder = OpenStrapEntry(date: Date(), snap: .placeholder)
}
// MARK: - Provider
@@ -225,160 +46,106 @@ struct Provider: TimelineProvider {
func placeholder(in context: Context) -> OpenStrapEntry { .placeholder }
func getSnapshot(in context: Context, completion: @escaping (OpenStrapEntry) -> Void) {
- completion(context.isPreview ? .placeholder : Store.read())
+ completion(context.isPreview
+ ? .placeholder
+ : OpenStrapEntry(date: Date(), snap: SW.read()))
}
func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) {
- // Push-driven: the app reloads timelines after every derive. Two things
- // keep it honest when it doesn't.
- //
- // The SECOND ENTRY is the load-bearing one. `fresh` is a function of the
- // entry's own date, so an entry scheduled at the staleness deadline renders
- // the no-data state at exactly that moment — WidgetKit switches to it with
- // no process wake, no budget spend and nothing for the app to do. A widget
- // that only ever re-read a bool at push time is how a week-old readiness
- // sat on the home screen looking like this morning's.
- //
- // The hourly `.after` is the cheap belt-and-braces: it picks up a new
- // snapshot the app wrote while we were not reloaded, and re-arms the
- // deadline entry.
- let now = Date()
- let entry = Store.read().at(now)
- var entries = [entry]
- if let deadline = entry.stalenessDeadline { entries.append(entry.at(deadline)) }
- let next = Calendar.current.date(byAdding: .hour, value: 1, to: now)
- ?? now.addingTimeInterval(3600)
- completion(Timeline(entries: entries, policy: .after(next)))
+ let snap = SW.read()
+ completion(SW.timeline(snap, Date()) { OpenStrapEntry(date: $0, snap: snap) })
}
}
-// MARK: - Reusable views
+// MARK: - The trio
-private struct Ring: View {
- let t: Double
- let color: Color
- let lineWidth: CGFloat
- var body: some View {
- ZStack {
- Circle().stroke(Color.surfaceAlt, lineWidth: lineWidth)
- if t > 0 {
- Circle()
- .trim(from: 0, to: min(max(t, 0), 1))
- .stroke(color, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
- .rotationEffect(.degrees(-90))
- }
+/// Which ring. The three the app can stand behind on a home screen: what the
+/// night gave back, what the day has cost, and what the night was made of —
+/// the same three, in the same order, as `HomeRingKind` on Home.
+private enum Trio: CaseIterable {
+ case recovery, strain, sleep
+
+ var label: String {
+ switch self {
+ case .recovery: return "Recovery"
+ case .strain: return "Strain"
+ case .sleep: return "Sleep"
}
}
-}
-/// Minutes → "45m" / "7h 05m". Byte-for-byte the phone's `hm()`
-/// (lib/ui2/screens/home_screen.dart) so the same night reads the same on both.
-private func hm(_ min: Int) -> String {
- if min < 0 { return "" }
- if min < 60 { return "\(min)m" }
- return String(format: "%dh %02dm", min / 60, min % 60)
-}
+ /// The nearest SF Symbol to Home's Lucide glyph: battery-charging, zap, moon.
+ var symbol: String {
+ switch self {
+ case .recovery: return "battery.100percent.bolt"
+ case .strain: return "bolt.fill"
+ case .sleep: return "moon.fill"
+ }
+ }
-private func numFont(_ size: CGFloat) -> Font { .system(size: size, weight: .bold, design: .rounded) }
-
-/// One labelled metric ring (used for all three: Strain / Sleep / HRV).
-///
-/// An absent metric is an EMPTY slot: no number, no arc, the whole cell dimmed.
-/// The phone's contract (grammar.dart) is what/why/fix, which does not fit in a
-/// 44pt circle — but a bare "—" over a ring drawn at zero reads as "your HRV is
-/// zero", which is worse than saying nothing. The reason is one tap away.
-private struct MetricRing: View {
- let label: String
- let value: String
- let t: Double
- let color: Color
- var size: CGFloat = 58
- var line: CGFloat = 7
- var valueSize: CGFloat = 16
- private var absent: Bool { value.isEmpty }
- var body: some View {
- VStack(spacing: 5) {
- ZStack {
- Ring(t: t, color: color, lineWidth: line)
- Text(value).font(numFont(valueSize)).foregroundColor(.ink).minimumScaleFactor(0.6).lineLimit(1)
- }
- .frame(width: size, height: size)
- Text(label).font(.system(size: 9, weight: .semibold)).tracking(0.8).foregroundColor(.inkMuted)
+ func data(_ s: SW.Snapshot) -> SW.RingData {
+ switch self {
+ case .recovery: return s.recovery
+ case .strain: return s.strain
+ case .sleep: return s.sleep
+ }
+ }
+
+ /// Recovery wears its band's colour, the other two their domain accent.
+ func accent(_ s: SW.Snapshot, _ p: SW.Pal) -> Color {
+ switch self {
+ case .recovery: return SW.tierColor(s.tier, p)
+ case .strain: return p.move
+ case .sleep: return p.sleep
}
- .opacity(absent ? 0.4 : 1)
}
}
-/// The three rings in a row, each taking an equal share of the width so they're
-/// evenly distributed regardless of value width.
-private struct TripleRings: View {
- let e: OpenStrapEntry
- var size: CGFloat = 58
- var line: CGFloat = 7
- var valueSize: CGFloat = 16
+/// Home's own accessibility layout — dial left, type in the width it needs.
+/// A small widget has the same problem a 1.3× text size does: three columns of
+/// "7h 17m" do not fit across 140 points.
+private struct RingRow: View {
+ let kind: Trio
+ let snap: SW.Snapshot
+ var dial: CGFloat = 34
+
var body: some View {
- HStack(spacing: 0) {
- MetricRing(label: "STRAIN",
- value: e.strain >= 0 ? String(format: "%.1f", e.strain) : "",
- t: e.strainT, color: C.purple, size: size, line: line, valueSize: valueSize)
- .frame(maxWidth: .infinity)
- MetricRing(label: "SLEEP", value: hm(e.sleepMin),
- t: e.sleepT, color: C.blue, size: size, line: line, valueSize: valueSize - 1)
- .frame(maxWidth: .infinity)
- MetricRing(label: "HRV", value: e.hrv >= 0 ? "\(e.hrv)" : "",
- t: e.hrvT, color: e.hrvColor, size: size, line: line, valueSize: valueSize)
- .frame(maxWidth: .infinity)
+ let p = SW.pal
+ let r = kind.data(snap)
+ HStack(spacing: 10) {
+ SW.Dial(r: r, symbol: kind.symbol, accent: kind.accent(snap, p),
+ size: dial, line: 5)
+ SW.RingText(label: kind.label, r: r, accent: kind.accent(snap, p),
+ align: .leading, valueSize: 17, showSub: false)
+ Spacer(minLength: 0)
}
- .frame(maxWidth: .infinity)
}
}
-/// Readiness headline row — big ring + score + the phone's own band label.
-private struct ReadinessRow: View {
- let e: OpenStrapEntry
- var ring: CGFloat = 64
+/// The default: three across, the number under the dial.
+private struct RingColumn: View {
+ let kind: Trio
+ let snap: SW.Snapshot
+ var dial: CGFloat = 48
+
var body: some View {
- HStack(spacing: 12) {
- ZStack {
- Ring(t: e.readinessT, color: e.readinessArc, lineWidth: 9)
- if e.readiness >= 0 {
- Text("\(e.readiness)").font(numFont(22)).foregroundColor(e.readinessColor)
- }
- }
- .frame(width: ring, height: ring)
- VStack(alignment: .leading, spacing: 2) {
- Text("READINESS").font(.system(size: 10, weight: .semibold)).tracking(1.1).foregroundColor(.inkMuted)
- // "Readiness not scored" and nothing more, the same neutral line
- // `accessoryInline` uses. This said "Still building your baseline",
- // which is ONE of the reasons and not the common one: with the band
- // worn by day and off at night there is no measured night at all, and
- // no reason key crosses the App Group for this side to tell the two
- // apart. Naming the wrong one is a false claim about the user's state.
- Text(e.readiness >= 0
- ? (e.band.isEmpty ? "HRV recovery + sleep" : e.band)
- : "Readiness not scored")
- .font(.system(size: 12)).foregroundColor(.ink)
- }
- Spacer(minLength: 0)
+ let p = SW.pal
+ let r = kind.data(snap)
+ VStack(spacing: 5) {
+ SW.Dial(r: r, symbol: kind.symbol, accent: kind.accent(snap, p),
+ size: dial, line: 7)
+ SW.RingText(label: kind.label, r: r, accent: kind.accent(snap, p),
+ valueSize: 20)
}
- .opacity(e.readiness >= 0 ? 1 : 0.55)
+ .frame(maxWidth: .infinity)
}
}
private struct SmallView: View {
- let e: OpenStrapEntry
+ let snap: SW.Snapshot
var body: some View {
- // 2×2: Readiness · Strain / Sleep · HRV.
- VStack(spacing: 10) {
- HStack(spacing: 0) {
- MetricRing(label: "READY", value: e.readiness >= 0 ? "\(e.readiness)" : "",
- t: e.readinessT, color: e.readinessArc, size: 44, line: 6, valueSize: 13).frame(maxWidth: .infinity)
- MetricRing(label: "STRAIN", value: e.strain >= 0 ? String(format: "%.1f", e.strain) : "",
- t: e.strainT, color: C.purple, size: 44, line: 6, valueSize: 13).frame(maxWidth: .infinity)
- }
- HStack(spacing: 0) {
- MetricRing(label: "SLEEP", value: hm(e.sleepMin), t: e.sleepT, color: C.blue, size: 44, line: 6, valueSize: 12).frame(maxWidth: .infinity)
- MetricRing(label: "HRV", value: e.hrv >= 0 ? "\(e.hrv)" : "", t: e.hrvT, color: e.hrvColor, size: 44, line: 6, valueSize: 13).frame(maxWidth: .infinity)
+ VStack(spacing: 8) {
+ ForEach(Array(Trio.allCases.enumerated()), id: \.offset) { _, k in
+ RingRow(kind: k, snap: snap)
}
}
.padding(12)
@@ -386,120 +153,111 @@ private struct SmallView: View {
}
private struct MediumView: View {
- let e: OpenStrapEntry
- var body: some View {
- VStack(alignment: .leading, spacing: 12) {
- ReadinessRow(e: e)
- TripleRings(e: e, size: 56, line: 7, valueSize: 15)
- }
- .frame(maxWidth: .infinity, alignment: .leading)
- .padding(16)
+ let snap: SW.Snapshot
+
+ /// The first ring that is missing and said why. One line is what a medium
+ /// widget can afford; the rest is one tap away in the app.
+ private var gap: Trio? {
+ Trio.allCases.first { !$0.data(snap).why.isEmpty }
}
-}
-/// `has_data == false` — the app is telling us the snapshot is empty or is
-/// describing a day more than one behind. Say that; do not render last week's
-/// readiness at full confidence.
-private struct NoDataView: View {
- @Environment(\.widgetFamily) var family
var body: some View {
- switch family {
- case .accessoryCircular:
- Image(systemName: "bolt.heart").font(.system(size: 18)).widgetAccentable()
- case .accessoryRectangular:
- VStack(alignment: .leading, spacing: 2) {
- Text("No recent data").font(.system(size: 13, weight: .bold)).widgetAccentable()
- Text("Open OpenStrap and sync your band.")
- .font(.system(size: 12)).foregroundStyle(.secondary).lineLimit(2)
+ VStack(spacing: 8) {
+ HStack(alignment: .top, spacing: 4) {
+ ForEach(Array(Trio.allCases.enumerated()), id: \.offset) { _, k in
+ RingColumn(kind: k, snap: snap)
+ }
}
- case .accessoryInline:
- Text("OpenStrap · no recent data")
- default:
- VStack(spacing: 6) {
- Image(systemName: "bolt.heart").font(.system(size: 22)).foregroundColor(.inkMuted)
- Text("No recent data")
- .font(.system(size: 14, weight: .semibold, design: .rounded)).foregroundColor(.ink)
- Text("Open OpenStrap and sync your band.")
- .font(.system(size: 11)).multilineTextAlignment(.center).foregroundColor(.inkMuted)
+ if let g = gap {
+ SW.GapRow(label: g.label, symbol: g.symbol, why: g.data(snap).why)
+ .frame(maxWidth: .infinity, alignment: .leading)
}
- .padding(12)
}
+ .padding(14)
}
}
+// MARK: - Accessory families
+
private struct AccessoryCircularView: View {
- let e: OpenStrapEntry
+ let snap: SW.Snapshot
var body: some View {
- // No Gauge when there is no score: `Gauge(value: 0)` draws a ring pinned at
- // empty, which is indistinguishable from "your readiness is 0".
- if e.readiness >= 0 {
- Gauge(value: e.readinessT) {
- Text("RDY")
+ let r = snap.recovery
+ // No Gauge when there is no score: a gauge at zero is indistinguishable
+ // from a recovery OF zero.
+ if r.measured, r.frac >= 0 {
+ Gauge(value: min(r.frac, 1)) {
+ Text("RCV")
} currentValueLabel: {
- Text("\(e.readiness)")
+ Text(r.value)
}
.gaugeStyle(.accessoryCircular)
.widgetAccentable()
} else {
VStack(spacing: 0) {
Image(systemName: "bolt.heart").font(.system(size: 15)).widgetAccentable()
- Text("RDY").font(.system(size: 9, weight: .semibold))
+ Text("RCV").font(.system(size: 9, weight: .semibold))
}
}
}
}
private struct AccessoryRectangularView: View {
- let e: OpenStrapEntry
+ let snap: SW.Snapshot
var body: some View {
VStack(alignment: .leading, spacing: 2) {
- Text(e.readiness >= 0 ? "Readiness \(e.readiness)" : "Readiness not scored")
+ Text(snap.recovery.measured
+ ? "Recovery \(snap.recovery.value)"
+ : "Recovery · \(snap.recovery.value)")
.font(.system(size: 13, weight: .bold)).widgetAccentable()
- Text(pair("Strain", e.strain >= 0 ? String(format: "%.1f", e.strain) : nil,
- "HRV", e.hrv >= 0 ? "\(e.hrv)" : nil))
+ // Only the rings that are actually reporting. An absent metric is left
+ // out of the line rather than printed as a dash.
+ Text(pair("Strain", snap.strain, "Sleep", snap.sleep))
.font(.system(size: 13, weight: .semibold))
- Text(pair("Sleep", hm(e.sleepMin).isEmpty ? nil : hm(e.sleepMin),
- "RHR", e.rhr >= 0 ? "\(e.rhr)" : nil))
- .font(.system(size: 12)).foregroundStyle(.secondary)
+ Text(snap.recovery.measured && !snap.recovery.sub.isEmpty
+ ? snap.recovery.sub
+ : firstWhy)
+ .font(.system(size: 12)).foregroundStyle(.secondary).lineLimit(1)
}
}
- /// Two "Label value" pairs, dropping whichever side has no measurement — an
- /// absent metric is left out of the line rather than printed as a dash.
- private func pair(_ aLabel: String, _ a: String?, _ bLabel: String, _ b: String?) -> String {
- [a.map { "\(aLabel) \($0)" }, b.map { "\(bLabel) \($0)" }]
- .compactMap { $0 }.joined(separator: " ")
+ private var firstWhy: String {
+ for r in [snap.recovery, snap.sleep, snap.strain] where !r.why.isEmpty { return r.why }
+ return ""
}
-}
-private extension View {
- @ViewBuilder func widgetBackground(_ color: Color) -> some View {
- containerBackground(color, for: .widget)
+ private func pair(_ aLabel: String, _ a: SW.RingData,
+ _ bLabel: String, _ b: SW.RingData) -> String {
+ [a.measured ? "\(aLabel) \(a.value)" : nil,
+ b.measured ? "\(bLabel) \(b.value)" : nil]
+ .compactMap { $0 }.joined(separator: " ")
}
}
+// MARK: - Widget
+
struct OpenStrapWidgetEntryView: View {
@Environment(\.widgetFamily) var family
var entry: OpenStrapEntry
var body: some View {
- content.widgetBackground(isSystem ? Color.paper : Color.clear)
+ content.strapBackground(family)
}
- private var isSystem: Bool { family == .systemSmall || family == .systemMedium }
-
@ViewBuilder private var content: some View {
if !entry.fresh {
- NoDataView()
+ SW.NoData()
} else {
switch family {
- case .systemSmall: SmallView(e: entry)
- case .systemMedium: MediumView(e: entry)
- case .accessoryCircular: AccessoryCircularView(e: entry)
- case .accessoryRectangular: AccessoryRectangularView(e: entry)
+ case .systemSmall: SmallView(snap: entry.snap)
+ case .systemMedium: MediumView(snap: entry.snap)
+ case .accessoryCircular: AccessoryCircularView(snap: entry.snap)
+ case .accessoryRectangular: AccessoryRectangularView(snap: entry.snap)
case .accessoryInline:
- Text(entry.readiness >= 0 ? "Ready \(entry.readiness)" : "Readiness not scored")
- default: SmallView(e: entry)
+ Text(entry.snap.recovery.measured
+ ? "Recovery \(entry.snap.recovery.value)"
+ : "OpenStrap · \(entry.snap.recovery.value.lowercased())")
+ default: SmallView(snap: entry.snap)
}
}
}
@@ -513,7 +271,7 @@ struct OpenStrapWidget: Widget {
OpenStrapWidgetEntryView(entry: entry)
}
.configurationDisplayName("OpenStrap")
- .description("Readiness, strain, sleep and HRV at a glance.")
+ .description("Recovery, strain and sleep at a glance.")
.supportedFamilies([.systemSmall, .systemMedium,
.accessoryCircular, .accessoryRectangular, .accessoryInline])
}
diff --git a/ios/OpenStrapWidget/OpenStrapWidgetBundle.swift b/ios/OpenStrapWidget/OpenStrapWidgetBundle.swift
index 3f83a519..9c7c2e33 100644
--- a/ios/OpenStrapWidget/OpenStrapWidgetBundle.swift
+++ b/ios/OpenStrapWidget/OpenStrapWidgetBundle.swift
@@ -12,6 +12,8 @@ import SwiftUI
struct OpenStrapWidgetBundle: WidgetBundle {
var body: some Widget {
OpenStrapWidget()
+ OpenStrapSleepWidget()
+ OpenStrapOvernightWidget()
OpenStrapBatteryWidget()
OpenStrapWidgetLiveActivity()
OpenStrapBreathingLiveActivity()
diff --git a/ios/OpenStrapWidget/StrapWidgetKit.swift b/ios/OpenStrapWidget/StrapWidgetKit.swift
new file mode 100644
index 00000000..d5c45610
--- /dev/null
+++ b/ios/OpenStrapWidget/StrapWidgetKit.swift
@@ -0,0 +1,342 @@
+//
+// StrapWidgetKit.swift
+// OpenStrapWidget
+//
+// The palette, the snapshot reader and the ring, shared by every widget that
+// renders what the app publishes into the App Group (OpenStrapWidget, the
+// Sleep widget and the Overnight widget). One namespace rather than free
+// functions and `extension Color` statics, because the Live Activity files
+// already own a `Pal` and a `Color.ink` of their own and two of those in one
+// module is a fight nobody wins.
+//
+// WHAT THIS SIDE IS ALLOWED TO DECIDE: layout, and nothing else. The numbers,
+// their labels, what they are out of, whether a ring is a reading or
+// calibration progress, and why one is missing all arrive already resolved —
+// see `WidgetService.push` (lib/widget/widget_service.dart), which mirrors
+// `RingTrio` on Home. Rules that used to live here in Swift AND in Kotlin AND
+// in Dart disagreed about the same day; there is one copy now.
+//
+
+import WidgetKit
+import SwiftUI
+
+enum SW {
+ static let appGroup = AppGroup.identifier
+
+ // MARK: - Theme (lib/ui2/theme.dart)
+
+ /// ui2's tokens, resolved for both appearances.
+ ///
+ /// The accents are `P.on(accent)` — ui2 nudges an accent toward the page ink
+ /// until it clears WCAG AA 4.5:1 on the worst surface it can land on, and a
+ /// ring spends that solved value for BOTH its arc and its number (see
+ /// `_RingState.arc` / `.ink` in home_screen.dart). Recomputing these means
+ /// running `P.on`'s binary search, not eyeballing a hex.
+ struct Pal {
+ let card, ink, ink2, ink3, track: Color
+ /// Readiness tiers, then the two domain accents the other rings carry.
+ let good, warn, bad, sleep, move: Color
+
+ static let light = Pal(
+ card: c(0xFFFFFF), ink: c(0x0F172A), ink2: c(0x475569),
+ ink3: c(0x627188), track: c(0xE2E8F0),
+ good: c(0x1A7A48), warn: c(0xA5521D), bad: c(0xB9393E),
+ sleep: c(0x2F66C0), move: c(0x734FCF))
+ static let dark = Pal(
+ card: c(0x151C26), ink: c(0xF1F5F9), ink2: c(0x94A3B8),
+ ink3: c(0x7F8DA0), track: c(0x232D3B),
+ good: c(0x22C55E), warn: c(0xF87E28), bad: c(0xF07374),
+ sleep: c(0x689EF7), move: c(0xA988F7))
+ }
+
+ static func c(_ hex: Int) -> Color {
+ Color(red: Double((hex >> 16) & 0xFF) / 255,
+ green: Double((hex >> 8) & 0xFF) / 255,
+ blue: Double(hex & 0xFF) / 255)
+ }
+
+ /// The app mirrors its own resolved appearance into `theme_dark` (including
+ /// an in-app override of the OS), so the widget follows the app rather than
+ /// the system.
+ static var pal: Pal {
+ (UserDefaults(suiteName: appGroup)?.object(forKey: "theme_dark") as? Bool ?? false)
+ ? .dark : .light
+ }
+
+ /// Readiness tier → its accent. The CUT-OFFS are not here: Dart publishes
+ /// `readiness_tier` (`readinessBand`, home_screen.dart) so the phone, the
+ /// widget, the Watch and Siri cannot disagree about what a 65 means.
+ static func tierColor(_ tier: Int, _ p: Pal) -> Color {
+ switch tier {
+ case 3, 2: return p.good
+ case 1: return p.warn
+ case 0: return p.bad
+ default: return p.ink3
+ }
+ }
+
+ // MARK: - Type (F, lib/ui2/theme.dart)
+
+ /// The numeral ramp. Tabular so a value never jitters its own layout, and SF
+ /// Pro Text rather than the rounded face — the app's numbers are not round.
+ static func num(_ size: CGFloat) -> Font {
+ .system(size: size, weight: .bold).monospacedDigit()
+ }
+ /// `F.over` — the uppercase label over every ring.
+ static let over = Font.system(size: 11, weight: .semibold)
+ /// `F.cap` / `F.body`.
+ static let cap = Font.system(size: 13)
+ static let body = Font.system(size: 15)
+
+ // MARK: - Freshness
+
+ /// How old the snapshot may be before the widget stops presenting it as
+ /// today's answer. The app pushes after every derive and on every foreground,
+ /// so under normal use this is refreshed each morning; 26 h is one whole
+ /// missed wake cycle plus grace for a wandering wake time.
+ ///
+ /// Kept in step with the same constant on the Watch (WatchMetrics.swift), in
+ /// Siri (OpenStrapIntents.swift) and on Android (StrapWidgets.kt) — four
+ /// separate build targets, so it cannot be one declaration.
+ static let staleAfter: TimeInterval = 26 * 3600
+
+ // MARK: - Snapshot
+
+ /// One home ring as Dart resolved it. `state` is the same four-way split the
+ /// phone draws: a reading, calibration progress, or an absence with the
+ /// pipeline's own reason attached.
+ struct RingData {
+ let state: Int // 0 measured · 1 calibrating · 2 absent
+ let value: String // the number, or the absence IN WORDS — never a dash
+ let sub: String // what it is out of, the band, or the nights banked
+ let why: String // absent rings only
+ let frac: Double // negative = nothing honest to sweep
+
+ var measured: Bool { state == 0 }
+ var calibrating: Bool { state == 1 }
+
+ /// Arc and numeral share one colour on the phone, and the colour IS the
+ /// signal that this is not a reading.
+ func color(_ accent: Color, _ p: Pal) -> Color { measured ? accent : p.ink3 }
+ }
+
+ struct Snapshot {
+ let hasData: Bool
+ let updatedAt: Int // epoch sec of the last push, 0 = unknown
+ let tier: Int // -1 not scored · 0 rest · 1 easy · 2 steady · 3 good
+ let recovery, strain, sleep: RingData
+ let hrv, hrvBaseline, rhr, efficiency: Int // -1 = none
+ /// Why the overnight figures are missing, when they are held over from a
+ /// night that is not today's. "" when they are today's own.
+ let overnightWhy: String
+
+ /// Has any ring at all been published? False for a snapshot written by an
+ /// app version older than the rings — every value would be the empty
+ /// string, which draws three circles with nothing in them. It heals on the
+ /// first push (the app publishes on every foreground), and until then the
+ /// no-data state is the honest picture.
+ var usable: Bool {
+ !recovery.value.isEmpty || !strain.value.isEmpty || !sleep.value.isEmpty
+ }
+
+ static let placeholder = Snapshot(
+ hasData: true, updatedAt: Int(Date().timeIntervalSince1970), tier: 3,
+ recovery: RingData(state: 0, value: "72", sub: "Good to go", why: "", frac: 0.72),
+ strain: RingData(state: 0, value: "12.4", sub: "of 21", why: "", frac: 12.4 / 21),
+ sleep: RingData(state: 0, value: "7h 17m", sub: "of 7h 45m", why: "", frac: 437.0 / 465),
+ hrv: 62, hrvBaseline: 58, rhr: 54, efficiency: 91,
+ overnightWhy: "")
+ }
+
+ private static func ring(_ d: UserDefaults?, _ key: String) -> RingData {
+ RingData(
+ state: d?.object(forKey: "ring_\(key)_state") as? Int ?? 2,
+ value: d?.string(forKey: "ring_\(key)_value") ?? "",
+ sub: d?.string(forKey: "ring_\(key)_sub") ?? "",
+ why: d?.string(forKey: "ring_\(key)_why") ?? "",
+ frac: d?.object(forKey: "ring_\(key)_frac") as? Double ?? -1)
+ }
+
+ static func read() -> Snapshot {
+ let d = UserDefaults(suiteName: appGroup)
+ func i(_ k: String) -> Int { d?.object(forKey: k) as? Int ?? -1 }
+ return Snapshot(
+ hasData: d?.bool(forKey: "has_data") ?? false,
+ updatedAt: d?.object(forKey: "updated_at") as? Int ?? 0,
+ tier: i("readiness_tier"),
+ recovery: ring(d, "recovery"), strain: ring(d, "strain"), sleep: ring(d, "sleep"),
+ hrv: i("hrv"), hrvBaseline: i("hrv_baseline"), rhr: i("rhr"),
+ efficiency: i("sleep_efficiency"),
+ overnightWhy: d?.string(forKey: "overnight_why") ?? "")
+ }
+
+ /// Is [s] still today's answer AS OF [date]?
+ ///
+ /// `has_data` alone is not enough and never was: it is frozen the moment Dart
+ /// writes it, so a phone that has not synced for a week keeps a week-old
+ /// readiness on the home screen looking exactly like this morning's. Measured
+ /// against the ENTRY's date rather than `Date()` so WidgetKit can render the
+ /// flip from a timeline entry it already holds. An unknown timestamp is not a
+ /// claim of staleness (matching `WidgetService.isStale`).
+ static func fresh(_ s: Snapshot, at date: Date) -> Bool {
+ guard s.hasData, s.usable else { return false }
+ guard s.updatedAt > 0 else { return true }
+ return date.timeIntervalSince1970 - Double(s.updatedAt) <= staleAfter
+ }
+
+ /// The instant [s] stops being today's answer, or nil if it already is not.
+ static func stalenessDeadline(_ s: Snapshot, after date: Date) -> Date? {
+ guard s.hasData, s.updatedAt > 0 else { return nil }
+ let at = Date(timeIntervalSince1970: Double(s.updatedAt) + staleAfter)
+ return at > date ? at : nil
+ }
+
+ /// The one timeline policy all three snapshot widgets share: now, the moment
+ /// the snapshot goes stale (so the honest empty state appears with no process
+ /// wake and no budget spent), and an hourly re-read as belt and braces.
+ static func timeline(
+ _ s: Snapshot, _ now: Date, _ make: (Date) -> E
+ ) -> Timeline {
+ var entries = [make(now)]
+ if let deadline = stalenessDeadline(s, after: now) { entries.append(make(deadline)) }
+ let next = Calendar.current.date(byAdding: .hour, value: 1, to: now)
+ ?? now.addingTimeInterval(3600)
+ return Timeline(entries: entries, policy: .after(next))
+ }
+
+ // MARK: - Views
+
+ /// Track circle + progress arc from 12 o'clock, round caps.
+ struct Ring: View {
+ let frac: Double
+ let color: Color
+ var lineWidth: CGFloat = 8
+
+ var body: some View {
+ let p = SW.pal
+ ZStack {
+ Circle().stroke(p.track, lineWidth: lineWidth)
+ if frac > 0 {
+ Circle()
+ .trim(from: 0, to: min(frac, 1))
+ .stroke(color, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
+ .rotationEffect(.degrees(-90))
+ }
+ }
+ }
+ }
+
+ /// The dial: the arc with the ring's ICON at its centre, exactly as on Home.
+ ///
+ /// The number lives UNDER the dial, not inside it — inside is where "7h 45m"
+ /// overflows its own circle at the first accessibility step, and nothing
+ /// about that string gets shorter.
+ struct Dial: View {
+ let r: RingData
+ let symbol: String
+ let accent: Color
+ var size: CGFloat = 56
+ var line: CGFloat = 7
+
+ var body: some View {
+ let p = SW.pal
+ let tint = r.color(accent, p)
+ ZStack {
+ Ring(frac: r.frac, color: tint, lineWidth: line)
+ Image(systemName: symbol)
+ .font(.system(size: size * 0.32, weight: .medium))
+ .foregroundStyle(tint)
+ }
+ .frame(width: size, height: size)
+ }
+ }
+
+ /// Label over, value under, what-it-is-out-of under that. An absence takes
+ /// the SENTENCE weight rather than the numeral one, because it is a sentence:
+ /// "No sleep" set in 24pt bold would read as a score.
+ struct RingText: View {
+ let label: String
+ let r: RingData
+ let accent: Color
+ var align: HorizontalAlignment = .center
+ var valueSize: CGFloat = 22
+ var showSub: Bool = true
+
+ var body: some View {
+ let p = SW.pal
+ VStack(alignment: align, spacing: 1) {
+ Text(label.uppercased()).font(SW.over).tracking(0.5).foregroundStyle(p.ink3)
+ Text(r.value)
+ .font(r.measured ? SW.num(valueSize) : SW.body)
+ .foregroundStyle(r.measured ? p.ink : p.ink2)
+ .lineLimit(1).minimumScaleFactor(0.65)
+ if showSub && !r.sub.isEmpty {
+ Text(r.sub).font(SW.cap).foregroundStyle(p.ink3)
+ .lineLimit(1).minimumScaleFactor(0.7)
+ }
+ }
+ .multilineTextAlignment(align == .leading ? .leading : .center)
+ }
+ }
+
+ /// WHY a ring is empty. The row Home puts under the trio, at the size a
+ /// widget can afford: what is missing, and the reason the pipeline gave.
+ /// Never a reason invented here.
+ struct GapRow: View {
+ let label: String
+ let symbol: String
+ let why: String
+
+ var body: some View {
+ let p = SW.pal
+ HStack(alignment: .top, spacing: 6) {
+ Image(systemName: symbol).font(.system(size: 11)).foregroundStyle(p.ink3)
+ // Interpolated rather than concatenated: `Text + Text` is deprecated,
+ // and a nested Text keeps the label's weight without a second view.
+ Text("\(Text(label).fontWeight(.semibold).foregroundColor(p.ink2)) · \(why)")
+ .font(.system(size: 11))
+ .foregroundStyle(p.ink3)
+ }
+ .lineLimit(2)
+ }
+ }
+
+ /// `has_data` is false, or the snapshot has aged past [staleAfter]. Say that;
+ /// do not render last week's readiness at full confidence.
+ struct NoData: View {
+ @Environment(\.widgetFamily) var family
+
+ var body: some View {
+ let p = SW.pal
+ switch family {
+ case .accessoryCircular:
+ Image(systemName: "bolt.heart").font(.system(size: 18)).widgetAccentable()
+ case .accessoryRectangular:
+ VStack(alignment: .leading, spacing: 2) {
+ Text("No recent data").font(.system(size: 13, weight: .bold)).widgetAccentable()
+ Text("Open OpenStrap and sync your band.")
+ .font(.system(size: 12)).foregroundStyle(.secondary).lineLimit(2)
+ }
+ case .accessoryInline:
+ Text("OpenStrap · no recent data")
+ default:
+ VStack(spacing: 6) {
+ Image(systemName: "bolt.heart").font(.system(size: 22)).foregroundStyle(p.ink3)
+ Text("No recent data").font(.system(size: 14, weight: .semibold)).foregroundStyle(p.ink)
+ Text("Open OpenStrap and sync your band.")
+ .font(.system(size: 11)).multilineTextAlignment(.center).foregroundStyle(p.ink3)
+ }
+ .padding(12)
+ }
+ }
+ }
+}
+
+extension View {
+ /// Systems families get the app's card surface; accessory families must stay
+ /// clear so the lock screen's own material shows through.
+ @ViewBuilder func strapBackground(_ family: WidgetFamily) -> some View {
+ let system = family == .systemSmall || family == .systemMedium || family == .systemLarge
+ containerBackground(system ? SW.pal.card : Color.clear, for: .widget)
+ }
+}
diff --git a/lib/widget/widget_service.dart b/lib/widget/widget_service.dart
index ba5c5c36..6684571d 100644
--- a/lib/widget/widget_service.dart
+++ b/lib/widget/widget_service.dart
@@ -11,8 +11,9 @@ import 'package:home_widget/home_widget.dart';
import 'package:flutter/services.dart';
import '../data/local_repository.dart';
+import '../models/metric.dart';
import '../models/payloads.dart';
-import '../ui2/screens/home_screen.dart' show readinessBand;
+import '../ui2/screens/home_screen.dart' show hm, readinessBand;
class WidgetService {
static const _platform = MethodChannel('openstrap/ios_config');
@@ -34,6 +35,22 @@ class WidgetService {
/// Android provider class for the Band Battery widget.
static const String _batteryAndroidName = 'OpenStrapBatteryWidgetProvider';
+ /// The other two faces on the same snapshot: last night's sleep, and the
+ /// overnight autonomic pair (HRV + resting HR). They read the keys [push]
+ /// writes, so they reload with it — a widget left holding yesterday because
+ /// nobody told it to re-read is the failure this list exists to stop.
+ static const List<(String, String)> _snapshotWidgets = [
+ (_iOSName, _androidName),
+ ('OpenStrapSleepWidget', 'SleepWidgetProvider'),
+ ('OpenStrapOvernightWidget', 'OvernightWidgetProvider'),
+ ];
+
+ static Future _reloadSnapshotWidgets() async {
+ for (final (ios, android) in _snapshotWidgets) {
+ await HomeWidget.updateWidget(iOSName: ios, androidName: android);
+ }
+ }
+
static bool _inited = false;
static Future init() async {
if (_inited) return;
@@ -105,11 +122,26 @@ class WidgetService {
static Future push(TodayData t) async {
try {
await init();
- final hrv = t.hrv;
+ // WHICH NIGHT IS THIS. `getToday` holds the last night that scored over
+ // until today's settles, so every morning before the first sync the
+ // overnight block belongs to the night BEFORE last. Home refuses those
+ // numbers rather than printing them in the today slot (`overnightMetric`
+ // in lib/ui2/screens/home_screen.dart) — a figure in the today slot is
+ // read as today's before any caption under it is, and that is even truer
+ // on a home screen than in the app. So the same refusal happens here, and
+ // the reason travels in the numbers' place.
+ final heldWhy = _heldOverWhy(t.status);
+ Metric ov(Metric m) => heldWhy == null ? m : Metric(note: heldWhy);
+
+ final readiness = ov(t.readiness);
+ // The DAY's strain, not the night's — Home does not refuse it either
+ // (home_screen.dart: `strain: metricOf(d('strain'))`).
final s = t.strain;
- final sleep = t.sleepDuration;
+ final sleep = ov(t.sleepDuration);
final need = t.sleepNeed;
- final rhr = t.restingHr;
+ final eff = ov(t.sleepEfficiency);
+ final rhr = ov(t.restingHr);
+ final hrv = heldWhy == null ? t.hrv : null;
Future setI(String k, int v) =>
HomeWidget.saveWidgetData(k, v);
@@ -122,8 +154,8 @@ class WidgetService {
// answer, and the alternative is a readiness score from last week with
// nothing on it to say so.
await HomeWidget.saveWidgetData('has_data', !t.isEmpty && !isStale(t));
- // Headline composite Readiness + the three rings (Strain · Sleep · HRV).
- final rv = t.readiness.isEmpty ? null : t.readiness.value;
+ // Headline composite Readiness — the Recovery ring on Home.
+ final rv = readiness.isEmpty ? null : readiness.value;
await setI('readiness', rv == null ? -1 : rv.round());
// The banding, published rather than re-derived. The widget, the Watch
// and Siri each carried their own thresholds, so the same 65 read green
@@ -159,16 +191,66 @@ class WidgetService {
// it empty.
await setI('sleep_need_min', need.isEmpty ? -1 : need.value!.round());
await setI('rhr', rhr.isEmpty ? -1 : rhr.value!.round());
+ // Sleep efficiency, % — the second number the Sleep widget shows. -1 when
+ // the night has none, like every other int key here.
+ await setI('sleep_efficiency', eff.isEmpty ? -1 : eff.value!.round());
+ // Why the overnight numbers are missing, for the surfaces that show only
+ // those (the Overnight widget's HRV and resting HR). '' when they are
+ // today's own.
+ await HomeWidget.saveWidgetData('overnight_why', heldWhy ?? '');
await HomeWidget.saveWidgetData(
'coach_line',
_coachLine(t.coach),
);
+
+ // THE THREE HOME RINGS, RESOLVED HERE. Recovery · Strain · Sleep, the
+ // same trio and the same four states as `RingTrio` on Home.
+ //
+ // Resolved in Dart rather than three times in Swift, Kotlin and Watch
+ // Swift for the reason `readiness_tier` already exists: a rule copied
+ // into four build targets is four rules. Two of these states cannot be
+ // worked out natively at all — the calibration counts and the pipeline's
+ // own reason both live in a metric's `note`, which never crossed the App
+ // Group. Until now a widget drew a blank dimmed circle for BOTH of them,
+ // so "four more nights and this fills in" and "the band recorded nothing"
+ // looked identical, forever.
+ for (final r in [
+ rv == null
+ ? _gapRing('recovery', readiness, 'Not scored')
+ : _Ring('recovery',
+ value: '${rv.round()}',
+ sub: band.label,
+ frac: rv / 100),
+ s.isEmpty
+ // 0–21 is the scale's own ceiling, not a target invented here.
+ ? _gapRing('strain', s, 'No strain', unit: 'days')
+ : _Ring('strain',
+ value: s.value!.toStringAsFixed(1),
+ sub: 'of 21',
+ frac: s.value! / 21),
+ sleep.isEmpty
+ ? _gapRing('sleep', sleep, 'No sleep',
+ fallbackWhy: 'No night long enough to score was recorded.')
+ : _Ring('sleep',
+ value: hm(sleep.value),
+ // No computed need means no denominator. The hardcoded 480 in
+ // the sleep bundle is not this user's need and must never be
+ // shown as one, so the ring stays open and says so.
+ sub: need.isEmpty ? 'No target yet' : 'of ${hm(need.value)}',
+ frac: need.isEmpty || need.value! <= 0
+ ? null
+ : sleep.value! / need.value!),
+ ]) {
+ await setI('ring_${r.key}_state', r.state);
+ await HomeWidget.saveWidgetData('ring_${r.key}_value', r.value);
+ await HomeWidget.saveWidgetData('ring_${r.key}_sub', r.sub);
+ await HomeWidget.saveWidgetData('ring_${r.key}_why', r.why);
+ await HomeWidget.saveWidgetData('ring_${r.key}_frac', r.frac);
+ }
+
await setI('updated_at', DateTime.now().millisecondsSinceEpoch ~/ 1000);
- await HomeWidget.updateWidget(
- iOSName: _iOSName,
- androidName: _androidName,
- );
+ await _reloadSnapshotWidgets();
await _syncWatch();
} catch (_) {
/* widgets unavailable / not configured yet — ignore */
@@ -197,14 +279,25 @@ class WidgetService {
'hrv_baseline',
'sleep_min',
'sleep_need_min',
+ 'sleep_efficiency',
'rhr',
'batt_pct',
]) {
await HomeWidget.saveWidgetData(k, -1);
}
await HomeWidget.saveWidgetData('strain', -1.0);
+ // The three resolved home rings. `state: 2` with no reason and no arc is
+ // the honest shape of a wiped database — not a ring reporting zero.
+ for (final r in const ['recovery', 'strain', 'sleep']) {
+ await HomeWidget.saveWidgetData('ring_${r}_state', 2);
+ await HomeWidget.saveWidgetData('ring_${r}_frac', -1.0);
+ for (final f in const ['value', 'sub', 'why']) {
+ await HomeWidget.saveWidgetData('ring_${r}_$f', '');
+ }
+ }
for (final k in const [
'readiness_band',
+ 'overnight_why',
'coach_line',
'batt_name',
// A route a Siri intent asked for before the wipe is not a route we
@@ -223,10 +316,7 @@ class WidgetService {
]) {
await HomeWidget.saveWidgetData(k, false);
}
- await HomeWidget.updateWidget(
- iOSName: _iOSName,
- androidName: _androidName,
- );
+ await _reloadSnapshotWidgets();
await HomeWidget.updateWidget(
iOSName: _batteryIOSName,
androidName: _batteryAndroidName,
@@ -277,10 +367,7 @@ class WidgetService {
try {
await init();
await HomeWidget.saveWidgetData('theme_dark', dark);
- await HomeWidget.updateWidget(
- iOSName: _iOSName,
- androidName: _androidName,
- );
+ await _reloadSnapshotWidgets();
// The battery widget shares the Ember/Char surface — retheme it too.
await HomeWidget.updateWidget(
iOSName: _batteryIOSName,
@@ -348,6 +435,45 @@ class WidgetService {
return false;
}
+ /// Why the overnight block on offer is not today's, or null when it is.
+ ///
+ /// Two absences that are not interchangeable and the same two sentences
+ /// `staleOvernightNote` uses on Home — one resolves on its own, the other
+ /// wants a sync. Written out here rather than imported because that helper
+ /// takes the raw `getToday()` map and this seam is handed the parsed payload.
+ static String? _heldOverWhy(TodayStatus? s) {
+ if (s == null || !s.showingPriorOvernight) return null;
+ return s.overnightBuilding
+ ? 'Last night is still being worked out.'
+ : 'Nothing from last night has reached the app yet.';
+ }
+
+ /// The absent half of a ring: CALIBRATING when the note says the gate is a
+ /// baseline still filling — the one absence that is progress and can honestly
+ /// draw an arc — otherwise the word and the pipeline's own reason.
+ /// Mirrors `_gap` in lib/ui2/screens/home_screen.dart.
+ static _Ring _gapRing(String key, Metric m, String word,
+ {String unit = 'nights', String fallbackWhy = ''}) {
+ final counts = baselineCountsFromNote(m.note);
+ if (counts != null) {
+ return _Ring(key,
+ state: 1,
+ value: 'Calibrating',
+ sub: '${counts.have} of ${counts.need} $unit',
+ frac: (counts.have / counts.need).clamp(0.0, 1.0));
+ }
+ return _Ring(key,
+ state: 2,
+ value: word,
+ // THE PIPELINE'S REASON FIRST, a sentence written here second, and
+ // where there is neither the ring says it does not know rather than
+ // guessing a cause.
+ why: whyFromNote(m.note, unit: unit) ??
+ (fallbackWhy.isNotEmpty
+ ? fallbackWhy
+ : 'Nothing recorded says why this is missing.'));
+ }
+
static String _coachLine(CoachData? c) {
if (c == null) return '';
if (c.plan.isNotEmpty) return c.plan.first.title;
@@ -356,3 +482,36 @@ class WidgetService {
return c.summary;
}
}
+
+/// One home ring as the native surfaces receive it: already-formatted text, a
+/// sweep, and which of the four states it is in. Nothing downstream of this
+/// decides what a metric means.
+class _Ring {
+ final String key;
+
+ /// 0 measured · 1 calibrating (arc is progress, drawn muted) · 2 absent.
+ final int state;
+
+ /// The number, or the absence in words. Never a bare dash and never empty:
+ /// a widget is the surface most likely to be read out of context, and a blank
+ /// circle says nothing at all.
+ final String value;
+
+ /// What the number is out of ("of 21", "of 7h 30m", the readiness band), or
+ /// the calibration count.
+ final String sub;
+
+ /// The pipeline's own reason, absent rings only. '' otherwise.
+ final String why;
+
+ /// What to sweep, 0…1 — negative when there is nothing honest to sweep.
+ final double frac;
+
+ const _Ring(this.key,
+ {this.state = 0,
+ required this.value,
+ this.sub = '',
+ this.why = '',
+ double? frac})
+ : frac = frac ?? -1;
+}
diff --git a/test/widget_service_sentinels_test.dart b/test/widget_service_sentinels_test.dart
index 99b26d26..03a9ae67 100644
--- a/test/widget_service_sentinels_test.dart
+++ b/test/widget_service_sentinels_test.dart
@@ -200,6 +200,125 @@ void main() {
});
});
+ // The three home rings, resolved in Dart because two of their four states
+ // CANNOT be worked out natively: the calibration counts and the pipeline's
+ // reason both live in a metric's `note`, which never used to cross the App
+ // Group. The widget drew one dimmed empty circle for both, so "four more
+ // nights and this fills in" and "the band recorded nothing" were the same
+ // picture, forever.
+ group('the home rings', () {
+ test('a measured ring publishes the number, what it is out of, and a sweep',
+ () async {
+ await WidgetService.push(TodayData.fromJson({
+ 'daily': {
+ 'readiness': 74,
+ 'strain': 12.4,
+ },
+ 'sleep': {'duration_min': 437, 'need_min': 465},
+ }));
+ expect(written['ring_recovery_state'], 0);
+ expect(written['ring_recovery_value'], '74');
+ expect(written['ring_recovery_sub'], 'Good to go');
+ expect(written['ring_strain_value'], '12.4');
+ expect(written['ring_strain_sub'], 'of 21');
+ expect(written['ring_sleep_value'], '7h 17m');
+ expect(written['ring_sleep_sub'], 'of 7h 45m');
+ expect(written['ring_sleep_frac'], closeTo(437 / 465, 1e-9));
+ // Nothing is missing, so nothing has a reason.
+ expect(written['ring_recovery_why'], '');
+ });
+
+ // The one absence that is PROGRESS rather than a gap, and the only one a
+ // ring may honestly draw an arc for.
+ test('a baseline still filling is calibration progress, not a low score',
+ () async {
+ await WidgetService.push(TodayData.fromJson({
+ 'daily': {
+ 'readiness': {'value': null, 'note': 'need_baseline:have=2,need=5'},
+ },
+ }));
+ expect(written['readiness'], -1);
+ expect(written['ring_recovery_state'], 1);
+ expect(written['ring_recovery_value'], 'Calibrating');
+ expect(written['ring_recovery_sub'], '2 of 5 nights');
+ expect(written['ring_recovery_frac'], closeTo(0.4, 1e-9));
+ });
+
+ test('an absence is a word and a reason — never a dash, never an arc',
+ () async {
+ await WidgetService.push(TodayData.fromJson({
+ 'daily': {'readiness': null},
+ 'sleep': const {},
+ }));
+ expect(written['ring_sleep_state'], 2);
+ expect(written['ring_sleep_value'], 'No sleep');
+ expect(written['ring_sleep_frac'], -1.0);
+ // Every absent ring says something. A blank circle says nothing at all,
+ // which on a home screen is worse than a number.
+ for (final r in const ['recovery', 'strain', 'sleep']) {
+ expect(written['ring_${r}_value'], isNotEmpty, reason: r);
+ expect(written['ring_${r}_value'], isNot(contains('—')), reason: r);
+ expect(written['ring_${r}_why'], isNotEmpty, reason: r);
+ }
+ });
+
+ test('sleep with no learned need is measured but unscaled, not filled to 8h',
+ () async {
+ await WidgetService.push(TodayData.fromJson({
+ 'sleep': {'duration_min': 437},
+ }));
+ expect(written['ring_sleep_state'], 0);
+ expect(written['ring_sleep_value'], '7h 17m');
+ expect(written['ring_sleep_sub'], 'No target yet');
+ expect(written['ring_sleep_frac'], -1.0);
+ });
+ });
+
+ // `getToday` holds the last night that scored over until today's settles, so
+ // every morning before the first sync the overnight block belongs to the
+ // night BEFORE last. Home refuses those numbers rather than printing them in
+ // the today slot (`overnightMetric`), and a home screen is the surface where
+ // a number is read as today's hardest of all.
+ group('a night that is not today\'s', () {
+ Map heldOver(String state) => {
+ 'daily': {'readiness': 74, 'resting_hr': 52, 'strain': 9.1},
+ 'sleep': {'duration_min': 437},
+ 'hrv': {'rmssd': 62.0, 'baseline': 58.0},
+ 'status': {
+ 'showing_prior_overnight': true,
+ 'overnight_state': state,
+ 'overnight_day': todayLabel(),
+ },
+ };
+
+ test('its numbers are refused and the reason travels in their place',
+ () async {
+ await WidgetService.push(TodayData.fromJson(heldOver('missing')));
+ expect(written['readiness'], -1);
+ expect(written['readiness_tier'], -1);
+ expect(written['sleep_min'], -1);
+ expect(written['hrv'], -1);
+ expect(written['rhr'], -1);
+ expect(written['ring_recovery_value'], 'Not scored');
+ expect(written['ring_recovery_why'],
+ 'Nothing from last night has reached the app yet.');
+ expect(written['overnight_why'],
+ 'Nothing from last night has reached the app yet.');
+ });
+
+ test('a night still being worked out is a different sentence — it resolves '
+ 'on its own and asks nothing of anyone', () async {
+ await WidgetService.push(TodayData.fromJson(heldOver('building')));
+ expect(written['ring_sleep_why'], 'Last night is still being worked out.');
+ });
+
+ test('the DAY\'s strain is not an overnight figure and survives', () async {
+ await WidgetService.push(TodayData.fromJson(heldOver('missing')));
+ expect(written['strain'], 9.1);
+ expect(written['ring_strain_value'], '9.1');
+ });
+ });
+
// The widget, the Watch mirror and the Siri intents all render whatever was
// last written here, with no way to notice how old it is — the native readers
// gate on `has_data` and nothing else. So a snapshot the app KNOWS is old has
From 066759d2b0cd4a97c682be692b1cd2340f57196e Mon Sep 17 00:00:00 2001
From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com>
Date: Thu, 20 Aug 2026 01:29:22 +0530
Subject: [PATCH 50/64] screens didn't notice writes landing under them
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
imports never bumped insightsRevision, and health/nutrition/wellness/cycle
never listened to it at all — the shell keeps those tabs alive forever, so
leaving the tab and coming back was the only way to see an import. home and
workouts had each hand-rolled the same twenty lines around the same notifier,
so that's a mixin now (RevisionReload) and everyone uses it.
signal stays on the ValueNotifier, not notifyListeners — that one ticks at 1 hz
with live hr and watching it broadly is the slow-app bug, not the stale-screen
one.
also: overview rows show a direction arrow instead of the 52pt sparkline you
couldn't read a number off. only claims a direction when the last 3 days clear
half an sd of the 14 behind them, nothing at all under a week of data, and
green/orange is per metric (rhr down is good, hrv down isn't) with the arrow
carrying the direction on its own.
---
lib/state/app_state.dart | 18 ++-
lib/ui2/README.md | 16 ++-
lib/ui2/grammar.dart | 119 ++++++++++++++--
lib/ui2/onboarding/welcome.dart | 4 +
lib/ui2/profile/gallery.dart | 20 ++-
lib/ui2/revision.dart | 84 ++++++++++++
lib/ui2/screens/cycle_screen.dart | 10 +-
lib/ui2/screens/health_screen.dart | 52 ++++++-
lib/ui2/screens/home_screen.dart | 45 ++-----
lib/ui2/screens/nutrition_screen.dart | 9 +-
lib/ui2/screens/wellness_screen.dart | 8 +-
lib/ui2/screens/workout_screen.dart | 64 ++-------
lib/ui2/ui2.dart | 1 +
test/ui2_metric_row_trend_test.dart | 155 +++++++++++++++++++++
test/ui2_revision_reload_test.dart | 186 ++++++++++++++++++++++++++
15 files changed, 679 insertions(+), 112 deletions(-)
create mode 100644 lib/ui2/revision.dart
create mode 100644 test/ui2_metric_row_trend_test.dart
create mode 100644 test/ui2_revision_reload_test.dart
diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart
index f2479d3b..f6a091fa 100644
--- a/lib/state/app_state.dart
+++ b/lib/state/app_state.dart
@@ -371,6 +371,11 @@ class AppState extends ChangeNotifier {
onProgress: onProgress,
);
lastNoopImport = res;
+ // The rows are durable — tell the screens that read them. Without this an
+ // import landed days, sessions and journal rows into a database every live
+ // tab had already finished reading, and the only way to see them was to
+ // relaunch the app.
+ bumpInsights();
notifyListeners();
return res.days;
}
@@ -399,6 +404,7 @@ class AppState extends ChangeNotifier {
onProgress: onProgress,
);
lastWhoopImport = res;
+ bumpInsights(); // see importNoopCsv — imported rows have to reach the tabs
notifyListeners();
return res.days;
}
@@ -450,6 +456,7 @@ class AppState extends ChangeNotifier {
} catch (e) {
importRollupError = '$e';
}
+ bumpInsights(); // see importNoopCsv — imported rows have to reach the tabs
notifyListeners();
// DAYS, not rows. `_days` is a distinct day_id count taken from the source
// file; the caller reports "N days imported" and a row total is not that.
@@ -2372,7 +2379,16 @@ class AppState extends ChangeNotifier {
}
}
- void _bumpInsightsRevision() {
+ void _bumpInsightsRevision() => bumpInsights();
+
+ /// Say that the DURABLE data changed, so every screen reading it re-reads.
+ ///
+ /// Public because the writers are not all in here: the log-workout sheet
+ /// writes a session, and an import writes days, sessions and journal rows.
+ /// `notifyListeners` is NOT that signal — it also ticks at ~1 Hz with live
+ /// HR, so screens listen to this instead and re-read only when something
+ /// actually landed.
+ void bumpInsights() {
insightsRevision.value = insightsRevision.value + 1;
}
diff --git a/lib/ui2/README.md b/lib/ui2/README.md
index e6de6fe2..54a4e3f7 100644
--- a/lib/ui2/README.md
+++ b/lib/ui2/README.md
@@ -191,8 +191,22 @@ profile with an age costs more trust than no button at all.
```dart
MetricRow(IconData icon, Color color, String name, String value,
- {String sub = '', String unit = '', List spark = const [],
+ {String sub = '', String unit = '', List series = const [],
+ Rising rising = Rising.neither,
String? status, VoidCallback? onTap})
+```
+
+The trailing slot is a DIRECTION ARROW, not a sparkline: a 52 pt line chart
+showed a shape nobody could read a number off. `series` is read by `trendOf`,
+which only calls a direction if the newest three values clear half a standard
+deviation of the fourteen before them — inside that it is steady, and with
+fewer than seven recorded days it is nothing at all (an empty slot, with the
+reason in the semantics, because a flat arrow would claim a measured "no
+change"). `rising` says which way is good news for THIS metric and is the only
+thing the hue carries; the glyph carries the direction on its own, for the
+readers who cannot see the hue.
+
+```dart
InlineMetrics(List<(String label, String value, Color color)> items)
```
diff --git a/lib/ui2/grammar.dart b/lib/ui2/grammar.dart
index b73c0e2b..5c8d2ac8 100644
--- a/lib/ui2/grammar.dart
+++ b/lib/ui2/grammar.dart
@@ -1081,6 +1081,75 @@ class DeepDiveCard extends StatelessWidget {
}
// ══════════════════ ROWS — for lists, not cards ══════════════════
+
+/// Which way is good news for THIS metric.
+///
+/// Resting heart rate falling is good, HRV rising is good, and skin
+/// temperature moving is neither — it is a deviation signal, and calling a
+/// rise "worse" would be a claim this project does not make. Metrics with no
+/// settled direction get [neither] and an arrow with no hue: the direction is
+/// still stated, the judgement is not invented.
+enum Rising { good, bad, neither }
+
+/// Which way a series is going, or null when there is no basis for saying.
+enum Trend { rising, falling, steady }
+
+/// The direction of the newest few days against the ones before them.
+///
+/// THE RULE, so it is one rule and not a feeling: the mean of the newest 3
+/// recorded values against the mean of up to 14 before them, and the move
+/// only counts as a direction if it clears HALF A STANDARD DEVIATION of that
+/// baseline (Cohen's small effect, 1988). Inside that, a day-to-day wobble
+/// and a trend look identical, and an arrow would be pointing at a coin flip
+/// — so it reads [Trend.steady].
+///
+/// Null is a different answer from steady: fewer than 3 + 4 recorded values
+/// is not a weak comparison, it is no comparison, and the row draws nothing
+/// rather than a flat arrow that would read as a measured "no change".
+///
+/// A baseline with zero spread (a quantized series that really did sit still)
+/// does NOT abstain — any move off it is a real move. Abstaining on a zero
+/// spread is the readiness bug this codebase has already paid for once.
+Trend? trendOf(List series) {
+ final v = [
+ for (final x in series)
+ if (x != null && x.isFinite) x,
+ ];
+ const recentN = 3, baseMax = 14, baseMin = 4;
+ if (v.length < recentN + baseMin) return null;
+ double mean(Iterable l) =>
+ l.fold(0, (a, b) => a + b) / l.length;
+ final recent = v.sublist(v.length - recentN);
+ final base = v.sublist(
+ math.max(0, v.length - recentN - baseMax), v.length - recentN);
+ final mb = mean(base);
+ final delta = mean(recent) - mb;
+ final sd = math.sqrt(
+ base.map((x) => (x - mb) * (x - mb)).fold(0, (a, b) => a + b) /
+ (base.length - 1));
+ if (delta.abs() <= 0.5 * sd) return Trend.steady;
+ return delta > 0 ? Trend.rising : Trend.falling;
+}
+
+/// Whether this move is good news — hue only, never direction.
+///
+/// Steady is not good or bad news, and a metric with no settled direction has
+/// no news at all: both draw in ink.
+Color _trendHue(P p, Trend trend, Rising rising) {
+ if (trend == Trend.steady || rising == Rising.neither) return p.ink3;
+ final good = (trend == Trend.rising) == (rising == Rising.good);
+ return good ? p.on(C.green) : p.on(C.orange);
+}
+
+/// What the arrow says, in words, for the screen reader — including the case
+/// where there is no arrow, so an empty slot is not a silent hole.
+String _trendWord(Trend? t) => switch (t) {
+ Trend.rising => 'trending up',
+ Trend.falling => 'trending down',
+ Trend.steady => 'steady',
+ null => 'no trend yet, not enough days recorded',
+ };
+
/// A metric in a list: name → value → trend.
class MetricRow extends StatelessWidget {
final IconData icon;
@@ -1089,7 +1158,15 @@ class MetricRow extends StatelessWidget {
/// DENSE — one slot per calendar day, `null` for a day with no record. A
/// compacted list draws a gap as continuity.
- final List spark;
+ ///
+ /// Read for a DIRECTION, not drawn: the trailing slot used to hold a 52 pt
+ /// sparkline, which at that size showed a shape nobody could read a number
+ /// off. See [trendOf] for what counts as a direction.
+ final List series;
+
+ /// Which way is good news here. Defaults to [Rising.neither] — a caller that
+ /// has not said gets a direction and no judgement, never a guess.
+ final Rising rising;
final String? status;
final VoidCallback? onTap;
@@ -1102,7 +1179,8 @@ class MetricRow extends StatelessWidget {
super.key,
this.sub = '',
this.unit = '',
- this.spark = const [],
+ this.series = const [],
+ this.rising = Rising.neither,
this.status,
this.onTap,
});
@@ -1143,28 +1221,41 @@ class MetricRow extends StatelessWidget {
],
],
);
- // The trailing slot is fixed only for the spark, which genuinely has a
- // fixed size. `status` is a word — 'ON TRACK' needs 92 pt at 1.0× and was
- // being silently clipped inside a 52 pt box before any scaling at all — so
- // it gets measured space instead.
+ // `status` is a word — 'ON TRACK' needs 92 pt at 1.0× and was being
+ // silently clipped inside a 52 pt box before any scaling at all — so it
+ // gets measured space rather than the arrow's fixed slot.
+ final trend = trendOf(series);
+ // NO ARROW AND NO EXPLANATION IN THE ROW: a metric with too little history
+ // has nothing to say here, and a horizontal arrow would say "no change",
+ // which is a measurement it has not made. The reason goes to the screen
+ // reader and the row stays quiet — see [_trendWord].
final trailing = status != null
? Text(
status!,
style: F.over.copyWith(color: p.on(C.green)),
textAlign: TextAlign.end,
)
- : spark.isEmpty
+ : trend == null
? const SizedBox.shrink()
- : SizedBox(
- width: 52,
- height: 22,
- child: CustomPaint(
- painter: LineChart(spark, p.on(color), fill: false),
- ),
+ : Icon(
+ switch (trend) {
+ Trend.rising => LucideIcons.arrowUpRight,
+ Trend.falling => LucideIcons.arrowDownRight,
+ Trend.steady => LucideIcons.arrowRight,
+ },
+ size: 18,
+ // THE GLYPH CARRIES THE DIRECTION and the hue only carries the
+ // judgement, because roughly one man in twelve cannot read the
+ // hue at all. Green/orange is the pair TrendCard already spends on
+ // this judgement — red in this system is the heart's category
+ // colour, not a verdict.
+ color: _trendHue(p, trend, rising),
);
return Pressable(
onTap: onTap,
- semanticLabel: '$name, $value $unit'.trim(),
+ semanticLabel: '$name, $value $unit ${_trendWord(trend)}'
+ .replaceAll(RegExp(r'\s+'), ' ')
+ .trim(),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: S.x2),
child: bigText(c)
diff --git a/lib/ui2/onboarding/welcome.dart b/lib/ui2/onboarding/welcome.dart
index b16d826f..920a19d4 100644
--- a/lib/ui2/onboarding/welcome.dart
+++ b/lib/ui2/onboarding/welcome.dart
@@ -384,6 +384,10 @@ Future runImport(
try {
final r = await importJournalCsvFile(p);
journalRows += r.imported;
+ // This one writes straight to the journal store rather than through
+ // AppState, so it has to raise the signal itself — every other importer
+ // here does it from its AppState method.
+ if (r.imported > 0) app.bumpInsights();
rejected.addAll(r.rejected.map((x) => x.toString()));
if (!sources.contains('Journal CSV')) sources.add('Journal CSV');
} on JournalCsvFormatException {
diff --git a/lib/ui2/profile/gallery.dart b/lib/ui2/profile/gallery.dart
index 13ade8cc..230ae998 100644
--- a/lib/ui2/profile/gallery.dart
+++ b/lib/ui2/profile/gallery.dart
@@ -61,6 +61,12 @@ import 'profile.dart';
final _series =
List.generate(24, (i) => 52 + (i * 37 % 23) - (i % 5) * 2.0);
+/// The three answers [trendOf] can give: a move clear of its own noise, a move
+/// inside it, and a series too short to compare at all.
+const _rising = [50, 51, 50, 52, 51, 53, 58, 59, 60];
+const _flat = [50, 51, 50, 51, 50, 51, 50, 51, 50];
+const _tooShort = [50, 51, 50];
+
// ── the three rings, in the four states a ring has ──
//
// Fed as HomeData through the SAME mapping the screen uses, so a state the
@@ -196,8 +202,20 @@ Map goldenCases() => {
size: Size.infinite, painter: LineChart(_series, C.purple)),
)),
'metric_row': const Column(children: [
+ // The four trailing states, in order: good news, bad news, a move
+ // inside its own noise, and a series with no basis for a direction
+ // (which draws nothing rather than a flat arrow that would read as a
+ // measured "no change").
+ MetricRow(LucideIcons.activity, C.green, 'HRV', '64',
+ unit: 'ms', series: _rising, rising: Rising.good),
+ MetricRow(LucideIcons.heart, C.red, 'Resting heart rate', '58',
+ unit: 'bpm', series: _rising, rising: Rising.bad),
MetricRow(LucideIcons.thermometer, C.orange, 'Skin temperature', '+0.3',
- sub: 'RELATIVE TO BASELINE', unit: '°'),
+ sub: 'RELATIVE TO BASELINE', unit: '°', series: _rising),
+ MetricRow(LucideIcons.brain, C.purple, 'Stress', '31',
+ unit: '/100', series: _flat, rising: Rising.bad),
+ MetricRow(LucideIcons.wind, C.teal, 'Respiratory rate', '14.2',
+ unit: 'br/min', series: _tooShort),
// A long name, a thousands-separated value and a word in the trailing
// slot — 'ON TRACK' needs 92 pt at 1.0x and was clipped inside a fixed
// 52 pt box before any scaling at all.
diff --git a/lib/ui2/revision.dart b/lib/ui2/revision.dart
new file mode 100644
index 00000000..7f2852ad
--- /dev/null
+++ b/lib/ui2/revision.dart
@@ -0,0 +1,84 @@
+// "The data under this screen changed — re-read it."
+//
+// One idiom, not two. A screen that loads in `initState` and never reads
+// again is correct exactly until something else writes: an import lands sixty
+// sessions, a derive rewrites the day, a workout is logged from the sheet, and
+// the screen keeps rendering what it read at launch. Three of the five tabs
+// are kept alive forever by the shell's IndexedStack, so "for the life of the
+// widget" means "until the app is relaunched" — which is why the workaround
+// was to leave the tab and come back.
+//
+// The signal already existed: [AppState.insightsRevision], a ValueNotifier the
+// derive, the session writer and now the importers tick. Home and Workouts had
+// each hand-rolled the same twenty lines of subscribe/compare/dispose around
+// it; this is those twenty lines, once.
+//
+// WHY A ValueNotifier AND NOT notifyListeners: AppState ticks at ~1 Hz while a
+// session is live (live HR, log lines). Anything watching AppState broadly
+// rebuilds on every one of those — the reason `select` is used everywhere in
+// this app. `insightsRevision` moves only when DURABLE data changed, and it is
+// off the ChangeNotifier path entirely, so a subscriber re-reads on a derive
+// or an import and never on a heartbeat.
+import 'package:flutter/widgets.dart';
+import 'package:provider/provider.dart';
+
+import '../state/app_state.dart';
+
+/// Re-read on [AppState.insightsRevision].
+///
+/// The screen keeps its own first load in `initState`; this only says when to
+/// do it again. Mix in, implement [reload], and delete the plumbing.
+mixin RevisionReload on State {
+ /// Held so [dispose] can unsubscribe without a context, and so a second
+ /// `didChangeDependencies` cannot subscribe twice — `addListener` stacks
+ /// duplicates.
+ ValueNotifier? _rev;
+
+ /// The revision this screen's data was read at.
+ int _seen = -1;
+
+ /// Read the database again. Called ONLY when the revision actually moves —
+ /// never on a rebuild, so it is safe for it to be expensive.
+ void reload();
+
+ /// False for a screen that was handed its data (a golden, the gallery, a
+ /// preview): there is nothing behind it to re-read.
+ bool get revisionReloads => true;
+
+ @override
+ void didChangeDependencies() {
+ super.didChangeDependencies();
+ if (!revisionReloads) return;
+ // No AppState above us in a golden or a widget test — such a screen just
+ // renders what it has, exactly as it did before this mixin existed.
+ final AppState app;
+ try {
+ app = context.read();
+ } catch (_) {
+ return;
+ }
+ if (identical(_rev, app.insightsRevision)) return;
+ _rev?.removeListener(_onRevision);
+ _rev = app.insightsRevision..addListener(_onRevision);
+ _seen = app.insightsRevision.value;
+ }
+
+ // ponytail: a parked tab re-reads too — the IndexedStack keeps all five
+ // alive, so a derive costs five loads instead of one. They are the same
+ // queries opening the tab would run, and they run on a derive or an import,
+ // not on a frame. Gate on route/tab visibility if profiling ever says so.
+ void _onRevision() {
+ final r = _rev;
+ // A bump that landed while this screen was being torn down, or one it has
+ // already read, is not a reason to hit the database.
+ if (!mounted || r == null || r.value == _seen) return;
+ _seen = r.value;
+ reload();
+ }
+
+ @override
+ void dispose() {
+ _rev?.removeListener(_onRevision);
+ super.dispose();
+ }
+}
diff --git a/lib/ui2/screens/cycle_screen.dart b/lib/ui2/screens/cycle_screen.dart
index 00298734..499d6586 100644
--- a/lib/ui2/screens/cycle_screen.dart
+++ b/lib/ui2/screens/cycle_screen.dart
@@ -191,7 +191,7 @@ class CycleTab extends StatefulWidget {
State