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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] =?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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] =?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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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 8972379dd19e3b1fe36bcae056ecc8763b21a519 Mon Sep 17 00:00:00 2001
From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com>
Date: Thu, 20 Aug 2026 02:19:16 +0530
Subject: [PATCH 45/50] a revocation that didn't reach disk isn't a revocation
prefs writes are fire-and-forget on purpose, and for a tab index that's
right. for this one it isn't: shared_preferences updates its cache before
the platform answers and never rolls it back, so a failed revoke reads as
off all session and is quietly back on next launch. awaits the ack now and
says so when it fails.
---
lib/data/off_lookup.dart | 13 ++++++++++++-
lib/state/prefs.dart | 11 +++++++++++
lib/ui2/profile/settings.dart | 23 ++++++++++++++++++----
lib/ui2/screens/log_food.dart | 6 +++++-
test/off_lookup_test.dart | 36 ++++++++++++++++++++++++++++++++++-
5 files changed, 82 insertions(+), 7 deletions(-)
diff --git a/lib/data/off_lookup.dart b/lib/data/off_lookup.dart
index 993b4681..6b0c3c28 100644
--- a/lib/data/off_lookup.dart
+++ b/lib/data/off_lookup.dart
@@ -84,7 +84,18 @@ const kOffConsentKey = 'nutrition.barcode_lookup';
bool get offLookupAllowed =>
Prefs.loaded && Prefs.getBool(kOffConsentKey, true);
-void setOffLookupAllowed(bool on) => Prefs.setBool(kOffConsentKey, on);
+/// Record the choice, and say whether it was actually RECORDED.
+///
+/// The one write in this app that is not fire-and-forget. SharedPreferences
+/// updates its cache optimistically and never rolls it back, so a revocation
+/// whose disk write fails reads as off for the rest of the session and is
+/// silently back ON at the next launch — the app sending a barcode for
+/// somebody who turned it off, which is the single thing a revocable consent
+/// must never do. In-session it is already fail-closed (the cache is off, so
+/// nothing goes out); what the caller has to do with a `false` here is TELL
+/// the person, because it will not survive a restart.
+Future setOffLookupAllowed(bool on) =>
+ Prefs.setBoolAcked(kOffConsentKey, on);
// ══════════════════ RESULT ══════════════════
diff --git a/lib/state/prefs.dart b/lib/state/prefs.dart
index 287b89a9..dd5f132a 100644
--- a/lib/state/prefs.dart
+++ b/lib/state/prefs.dart
@@ -53,6 +53,17 @@ class Prefs {
_sp?.setBool(key, value);
}
+ /// The same write, with SharedPreferences' own acknowledgement handed back —
+ /// false when there is no storage, or when the platform refused it.
+ ///
+ /// For a tab index nobody can be hurt by a write that quietly failed. For a
+ /// CONSENT they can: SharedPreferences updates its cache OPTIMISTICALLY and
+ /// never rolls it back, so a failed revocation reads as off for the rest of
+ /// the session and is back ON at the next launch, with nobody told. The one
+ /// caller that must know is `setOffLookupAllowed`.
+ static Future setBoolAcked(String key, bool value) async =>
+ await _sp?.setBool(key, value) ?? false;
+
// ── selection keys (one namespace; keep them disjoint) ──────────────────────
static const String shellTab = 'ui.shell_tab';
static const String recapRange = 'ui.recap_range';
diff --git a/lib/ui2/profile/settings.dart b/lib/ui2/profile/settings.dart
index 74dc3632..203d702f 100644
--- a/lib/ui2/profile/settings.dart
+++ b/lib/ui2/profile/settings.dart
@@ -115,6 +115,24 @@ class _MoreSettingsState extends State {
_setDev(true);
}
+ /// The one preference here that is awaited. A revocation that never reached
+ /// storage is back ON at the next launch, so it does not get to fail quietly
+ /// — the switch still moves (in-session it really is off, nothing is sent),
+ /// and the person is told it did not stick.
+ Future _toggleBarcode(BuildContext c) async {
+ final want = !_barcode;
+ final messenger = ScaffoldMessenger.of(c);
+ final saved = await setOffLookupAllowed(want);
+ if (!mounted) return;
+ setState(() => _barcode = want);
+ if (!saved) {
+ messenger.showSnackBar(const SnackBar(
+ content: Text('That could not be saved — it may be back next time you '
+ 'open the app.'),
+ ));
+ }
+ }
+
void _setDev(bool on) {
Prefs.setBool(Prefs.devMode, on);
setState(() {
@@ -171,10 +189,7 @@ class _MoreSettingsState extends State {
? app.disablePhoneSteps()
: app.requestPhoneSteps(),
onToggleTelemetry: () => app.setTelemetryConsent(!app.telemetryConsent),
- onToggleBarcodeLookup: () {
- setOffLookupAllowed(!_barcode);
- setState(() => _barcode = !_barcode);
- },
+ onToggleBarcodeLookup: () => _toggleBarcode(c),
onToggleHealthShare: () => _toggleHealthShare(c, app),
onToggleHealthSync: () => _toggleHealthSync(app),
onToggleUpdateChecks: () =>
diff --git a/lib/ui2/screens/log_food.dart b/lib/ui2/screens/log_food.dart
index 7e476b8c..1630cdd8 100644
--- a/lib/ui2/screens/log_food.dart
+++ b/lib/ui2/screens/log_food.dart
@@ -158,7 +158,11 @@ class _LogFoodSheetState extends State {
if (!offLookupAllowed) {
final agreed = await _askLookupConsent(context);
if (agreed != true || !mounted) return;
- setOffLookupAllowed(true);
+ // Awaited so the consent is on disk before the camera opens. A write
+ // that fails only means being asked again next launch — the direction
+ // that cannot hurt anyone.
+ await setOffLookupAllowed(true);
+ if (!mounted) return;
}
final code = await scanBarcode(context);
if (code == null || !mounted) return;
diff --git a/test/off_lookup_test.dart b/test/off_lookup_test.dart
index 37f5df28..e5e377a2 100644
--- a/test/off_lookup_test.dart
+++ b/test/off_lookup_test.dart
@@ -19,6 +19,24 @@ 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';
+// The store behind SharedPreferences, so a write can be made to FAIL. Pulled
+// in through shared_preferences rather than pinned here — a test-only import
+// of its own platform interface is not a dependency this app takes on.
+// ignore: depend_on_referenced_packages
+import 'package:shared_preferences_platform_interface/shared_preferences_platform_interface.dart';
+
+/// A store whose every write fails — the phone with no room left on it.
+class _RefusingStore extends SharedPreferencesStorePlatform {
+ @override
+ Future clear() async => false;
+ @override
+ Future> getAll() async => {};
+ @override
+ Future remove(String key) async => false;
+ @override
+ Future setValue(String valueType, String key, Object value) async =>
+ false;
+}
/// An api/v2 body, in the shape the endpoint actually returns.
Map body({
@@ -423,10 +441,26 @@ void main() {
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(await setOffLookupAllowed(false), isTrue);
expect(offLookupAllowed, isFalse);
final r = await fetchOffProduct('8901719101090');
expect(r.outcome, OffOutcome.refused);
});
+
+ test('a revocation that storage refused does not report as saved',
+ () async {
+ // SharedPreferences updates its cache before the platform answers and
+ // never rolls it back, so a refused write is off in memory and ON again
+ // at the next launch. In-session that is fail-closed and fine; what must
+ // not happen is the app calling it saved, because then nobody is told
+ // the barcode will start going out again tomorrow.
+ final real = SharedPreferencesStorePlatform.instance;
+ SharedPreferencesStorePlatform.instance = _RefusingStore();
+ addTearDown(() => SharedPreferencesStorePlatform.instance = real);
+ expect(await setOffLookupAllowed(false), isFalse);
+ expect(offLookupAllowed, isFalse);
+ expect((await fetchOffProduct('8901719101090')).outcome,
+ OffOutcome.refused);
+ });
});
}
From 3ff2a0c016dd0461c070e6d8e219a1ad1f530d71 Mon Sep 17 00:00:00 2001
From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com>
Date: Thu, 20 Aug 2026 02:19:24 +0530
Subject: [PATCH 46/50] a full buffer and a 4096-byte file are not the same
thing
read one byte past the window. otherwise a file exactly as long as the head
read counts as truncated, drops its last record if there's no trailing
newline, and a valid noop export goes to the vendor importer again.
---
lib/import/import_container.dart | 9 +++++++--
test/import_container_test.dart | 11 +++++++++++
2 files changed, 18 insertions(+), 2 deletions(-)
diff --git a/lib/import/import_container.dart b/lib/import/import_container.dart
index 32773067..78fc62d5 100644
--- a/lib/import/import_container.dart
+++ b/lib/import/import_container.dart
@@ -169,14 +169,19 @@ Future isNoopExport(String path) async {
// 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);
+ //
+ // One byte PAST the window, because "the buffer filled" and "the file
+ // stopped" are the same length otherwise: a file of exactly [_headBytes]
+ // read as truncated loses its last record, and a one-record export with no
+ // trailing newline loses the only record it has.
+ head = await raf.read(_headBytes + 1);
} finally {
await raf.close();
}
switch (sniffImportContainer(head.take(64).toList())) {
case ImportContainer.text:
return noopCsvFirstRecordMatches(String.fromCharCodes(head),
- truncated: head.length == _headBytes);
+ 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 f8915ed9..44cce1b4 100644
--- a/test/import_container_test.dart
+++ b/test/import_container_test.dart
@@ -604,6 +604,17 @@ void main() {
expect(await isNoopExport(path), isFalse);
});
+ test('a file exactly as long as the read window keeps its last record',
+ () async {
+ // A full buffer used to MEAN truncated, so a file whose length is exactly
+ // the window — and whose final record has no trailing newline — had that
+ // record thrown away and went to the vendor importer.
+ final body = '${'# pad\n' * 678}unix_s,iso_utc,stream,hr_bpm';
+ expect(body.length, 4096);
+ final path = await write('export.csv', utf8.encode(body));
+ expect(await isNoopExport(path), isTrue);
+ });
+
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.
From a32b121e3e002b4cc6942ee275f83c316f63c3c0 Mon Sep 17 00:00:00 2001
From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com>
Date: Thu, 20 Aug 2026 02:19:24 +0530
Subject: [PATCH 47/50] the seen cap could evict an app that was still buzzing
picker rows come off the seen list, so an armed package that aged out kept
firing the strap with no row to turn it off from. cap skips armed ones now,
bound still holds.
---
lib/notify/notification_relay.dart | 14 +++++++++---
test/band_notifications_test.dart | 35 ++++++++++++++++++++++++++++++
2 files changed, 46 insertions(+), 3 deletions(-)
diff --git a/lib/notify/notification_relay.dart b/lib/notify/notification_relay.dart
index 0a908092..b615a0ad 100644
--- a/lib/notify/notification_relay.dart
+++ b/lib/notify/notification_relay.dart
@@ -228,12 +228,20 @@ class NotificationRelay extends ChangeNotifier with WidgetsBindingObserver {
/// 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) {
+ @visibleForTesting
+ 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);
+ // Oldest first, but an ARMED app is never evicted. The picker is built
+ // from this list, so dropping one you turned ON leaves it buzzing the
+ // strap with no row to turn it off from — a thing that keeps acting on
+ // you with no way to stop it. The bound survives: the overflow is at most
+ // the apps you chose yourself.
+ for (var i = _seen.length - 1; i >= 0 && _seen.length > maxSeen; i--) {
+ if (!_packages.contains(_seen[i])) _seen.removeAt(i);
+ }
// 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
@@ -257,7 +265,7 @@ class NotificationRelay extends ChangeNotifier with WidgetsBindingObserver {
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);
+ noteSeen(pkg, e.appIcon);
if (!_packages.contains(pkg)) return;
if (!isConnected()) return;
diff --git a/test/band_notifications_test.dart b/test/band_notifications_test.dart
index 82c60dc3..0eedb069 100644
--- a/test/band_notifications_test.dart
+++ b/test/band_notifications_test.dart
@@ -9,6 +9,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
+import 'package:shared_preferences/shared_preferences.dart';
import 'package:openstrap_edge/notify/notification_relay.dart';
import 'package:openstrap_edge/ui2/profile/band_notifications.dart';
@@ -116,6 +117,40 @@ void main() {
});
});
+ // The picker draws one row per SEEN package, so what falls out of that list
+ // is what the user can no longer reach.
+ group('the seen list', () {
+ test('an armed app is never evicted out of the picker', () async {
+ TestWidgetsFlutterBinding.ensureInitialized();
+ SharedPreferences.setMockInitialValues(const {});
+ final relay =
+ NotificationRelay(buzz: () async {}, isConnected: () => false);
+ relay.packages.add('com.armed');
+ relay.noteSeen('com.armed', null);
+ // A week of a chatty phone on top of it. The armed one is now the
+ // OLDEST, which is exactly the entry the old cap threw away — leaving an
+ // app that still buzzes the strap with no row to turn it off from.
+ for (var i = 0; i < NotificationRelay.maxSeen + 20; i++) {
+ relay.noteSeen('com.chatty.$i', null);
+ }
+ expect(relay.seenPackages, contains('com.armed'));
+ // And the cap still holds — an unarmed neighbour went instead.
+ expect(relay.seenPackages.length, NotificationRelay.maxSeen);
+ });
+
+ test('unarmed apps are still capped', () async {
+ TestWidgetsFlutterBinding.ensureInitialized();
+ SharedPreferences.setMockInitialValues(const {});
+ final relay =
+ NotificationRelay(buzz: () async {}, isConnected: () => false);
+ for (var i = 0; i < NotificationRelay.maxSeen + 20; i++) {
+ relay.noteSeen('com.chatty.$i', null);
+ }
+ expect(relay.seenPackages.length, NotificationRelay.maxSeen);
+ expect(relay.seenPackages.first, 'com.chatty.79');
+ });
+ });
+
group('appLabel', () {
test('takes the last meaningful segment, capitalised', () {
expect(appLabel('com.whatsapp'), 'Whatsapp');
From 385eef45641454f4a2f19de20a9c0894d1dfd10b Mon Sep 17 00:00:00 2001
From: "coderabbitai[bot]"
<136622811+coderabbitai[bot]@users.noreply.github.com>
Date: Thu, 20 Aug 2026 02:47:57 +0000
Subject: [PATCH 48/50] fix: apply CodeRabbit auto-fixes
Fixed 1 file(s) based on 1 unresolved review comment.
Co-authored-by: CodeRabbit
---
lib/data/off_lookup.dart | 9 +++++++--
1 file changed, 7 insertions(+), 2 deletions(-)
diff --git a/lib/data/off_lookup.dart b/lib/data/off_lookup.dart
index 6b0c3c28..bfa73df2 100644
--- a/lib/data/off_lookup.dart
+++ b/lib/data/off_lookup.dart
@@ -94,8 +94,13 @@ bool get offLookupAllowed =>
/// must never do. In-session it is already fail-closed (the cache is off, so
/// nothing goes out); what the caller has to do with a `false` here is TELL
/// the person, because it will not survive a restart.
-Future setOffLookupAllowed(bool on) =>
- Prefs.setBoolAcked(kOffConsentKey, on);
+Future setOffLookupAllowed(bool on) async {
+ try {
+ return await Prefs.setBoolAcked(kOffConsentKey, on);
+ } catch (_) {
+ return false;
+ }
+}
// ══════════════════ RESULT ══════════════════
From 20aafc0d7fae3ff291991cd4d783c896081ae763 Mon Sep 17 00:00:00 2001
From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com>
Date: Thu, 20 Aug 2026 18:00:53 +0530
Subject: [PATCH 49/50] a consent write that throws is a false, not an
exception
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
letting it propagate skipped the caller's 'could not save that' warning and
took out the scanner before the camera opened — the one path that exists to
tell the person never ran.
---
lib/state/prefs.dart | 14 ++++++++++++--
1 file changed, 12 insertions(+), 2 deletions(-)
diff --git a/lib/state/prefs.dart b/lib/state/prefs.dart
index dd5f132a..412cdaf2 100644
--- a/lib/state/prefs.dart
+++ b/lib/state/prefs.dart
@@ -61,8 +61,18 @@ class Prefs {
/// never rolls it back, so a failed revocation reads as off for the rest of
/// the session and is back ON at the next launch, with nobody told. The one
/// caller that must know is `setOffLookupAllowed`.
- static Future setBoolAcked(String key, bool value) async =>
- await _sp?.setBool(key, value) ?? false;
+ /// A THROW is the same answer as a false: the write did not land. Letting it
+ /// propagate is worse than useless here — it skips the caller's "we could not
+ /// save that" warning and takes out the flow that was asking (the scanner
+ /// exits before the camera opens), so the one path that exists to TELL the
+ /// person never runs. Failure is reported, never raised.
+ static Future setBoolAcked(String key, bool value) async {
+ try {
+ return await _sp?.setBool(key, value) ?? false;
+ } catch (_) {
+ return false;
+ }
+ }
// ── selection keys (one namespace; keep them disjoint) ──────────────────────
static const String shellTab = 'ui.shell_tab';
From 5fcee62e42b67363681feb5911ee51e3b9d5d638 Mon Sep 17 00:00:00 2001
From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com>
Date: Thu, 20 Aug 2026 18:01:40 +0530
Subject: [PATCH 50/50] one catch, at the source
setBoolAcked already answers false when the write does not land, so the
caller's try/catch can never fire.
---
lib/data/off_lookup.dart | 9 ++-------
1 file changed, 2 insertions(+), 7 deletions(-)
diff --git a/lib/data/off_lookup.dart b/lib/data/off_lookup.dart
index bfa73df2..6b0c3c28 100644
--- a/lib/data/off_lookup.dart
+++ b/lib/data/off_lookup.dart
@@ -94,13 +94,8 @@ bool get offLookupAllowed =>
/// must never do. In-session it is already fail-closed (the cache is off, so
/// nothing goes out); what the caller has to do with a `false` here is TELL
/// the person, because it will not survive a restart.
-Future setOffLookupAllowed(bool on) async {
- try {
- return await Prefs.setBoolAcked(kOffConsentKey, on);
- } catch (_) {
- return false;
- }
-}
+Future setOffLookupAllowed(bool on) =>
+ Prefs.setBoolAcked(kOffConsentKey, on);
// ══════════════════ RESULT ══════════════════