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/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
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/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/coach/coach_actions.dart b/lib/coach/coach_actions.dart
index 7596c2fa..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,6 +265,11 @@ class CoachActions {
endTs: startTs + mins * 60,
type: type,
);
+ // 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/coach/coach_config.dart b/lib/coach/coach_config.dart
index 3593fd8c..c4211c92 100644
--- a/lib/coach/coach_config.dart
+++ b/lib/coach/coach_config.dart
@@ -66,12 +66,51 @@ 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.
+ ///
+ /// [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 +189,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 +270,32 @@ 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 */}
+ }
+ });
+ // 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/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart
index 337026ad..b37f810b 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';
@@ -1233,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.
@@ -1244,14 +1271,20 @@ 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.
+// 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
@@ -2264,6 +2297,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,
@@ -3750,6 +3818,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.
///
@@ -4609,10 +4704,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,8 +4736,13 @@ class DerivationEngine {
sex: _workoutSex(profile.sex),
),
hrmax: hrmax,
+ 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);
}
@@ -5278,6 +5383,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;
@@ -5331,6 +5439,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,
);
@@ -5340,11 +5451,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 35330dd1..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
@@ -270,6 +271,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;
@@ -322,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();
@@ -562,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 29b8e5a6..6df7c356 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,
@@ -480,6 +481,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 +537,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 +582,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,
};
@@ -684,10 +724,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,7 +743,11 @@ Map deriveDayBundle(Map inputJson) {
sex: workoutSex(sex),
),
hrmax: hrMax,
- ).active; // active-energy component (Keytel surplus over basal)
+ restingHr: rhrForTrimp,
+ // `?.` — `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)
}
}
@@ -708,6 +758,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',
);
@@ -1008,11 +1069,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(),
};
@@ -1236,6 +1309,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
@@ -1519,7 +1599,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.
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.
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/lib/data/off_lookup.dart b/lib/data/off_lookup.dart
index bd213d05..6b0c3c28 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,18 +60,42 @@ 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.
+///
+/// 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, false);
+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/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/health/health_export.dart b/lib/health/health_export.dart
index 5bd82fe6..f4681ade 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,14 +40,38 @@ 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,
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` 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,
@@ -58,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
@@ -72,6 +98,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,
@@ -168,6 +212,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;
@@ -202,7 +278,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,
];
@@ -679,14 +758,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 +993,42 @@ 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) {
+ // 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
+ // 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/lib/import/import_container.dart b/lib/import/import_container.dart
index 3746f6c0..78fc62d5 100644
--- a/lib/import/import_container.dart
+++ b/lib/import/import_container.dart
@@ -111,6 +111,111 @@ 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,';
+
+/// 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`
+/// 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'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 {
+ // Enough to reach the first RECORD, not just the first byte — see
+ // [noopCsvFirstRecordMatches]. The container sniff still only reads the
+ // magic at the front.
+ //
+ // 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);
+ 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/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(',');
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_relay.dart b/lib/notify/notification_relay.dart
index 5de52fea..b615a0ad 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,50 @@ 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.
+ @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) {
+ // 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
+ // 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))
+ .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 +287,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/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/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';
diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart
index 64246c8f..77994b99 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,12 +456,18 @@ 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;
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;
@@ -692,13 +700,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 +1152,7 @@ class AppState extends ChangeNotifier {
log: _log,
onMarkMoment: _markMomentFromGesture,
onWorkoutToggle: _toggleWorkoutFromGesture,
+ onLogWater: _logWaterFromGesture,
);
engine = BleEngine(
onRecord: _onRecord,
@@ -1225,6 +1240,7 @@ class AppState extends ChangeNotifier {
log: _log,
onMarkMoment: _markMomentFromGesture,
onWorkoutToggle: _toggleWorkoutFromGesture,
+ onLogWater: _logWaterFromGesture,
);
this.engine = engine ??
BleEngine(
@@ -1772,6 +1788,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 +5222,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 {
@@ -5365,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;
@@ -5378,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/lib/state/prefs.dart b/lib/state/prefs.dart
index 465db398..412cdaf2 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) =>
@@ -44,6 +53,27 @@ 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`.
+ /// 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';
static const String recapRange = 'ui.recap_range';
diff --git a/lib/ui2/onboarding/welcome.dart b/lib/ui2/onboarding/welcome.dart
index 9d2e6171..b16d826f 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');
@@ -363,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;
@@ -370,6 +388,10 @@ Future runImport(
if (!sources.contains('Journal CSV')) sources.add('Journal CSV');
} on JournalCsvFormatException {
vendor.add(p);
+ } on FormatException {
+ // 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);
}
}
@@ -460,11 +482,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/lib/ui2/profile/band_notifications.dart b/lib/ui2/profile/band_notifications.dart
new file mode 100644
index 00000000..6b7832a5
--- /dev/null
+++ b/lib/ui2/profile/band_notifications.dart
@@ -0,0 +1,277 @@
+// 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',
+ // 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. 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)),
+ 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. The names stay on this phone and '
+ 'nothing leaves it.',
+ 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/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/lib/ui2/profile/settings.dart b/lib/ui2/profile/settings.dart
index 751768db..203d702f 100644
--- a/lib/ui2/profile/settings.dart
+++ b/lib/ui2/profile/settings.dart
@@ -35,8 +35,10 @@ 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 'gestures.dart';
import 'profile.dart';
/// Unwind the profile stack back to the gate.
@@ -113,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(() {
@@ -169,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: () =>
@@ -497,7 +514,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,
@@ -600,6 +617,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
@@ -727,6 +752,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 +784,7 @@ class _NotificationSettingsState extends State {
prefs: p ?? const NotificationPrefs(),
loaded: p != null,
granted: _granted,
+ relaySupported: _relaySupported,
onChanged: _apply,
onRequestPermission: _requestPermission,
);
@@ -762,6 +794,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 +807,7 @@ class NotificationSettingsView extends StatelessWidget {
this.prefs = const NotificationPrefs(),
this.loaded = true,
this.granted = true,
+ this.relaySupported = false,
this.onChanged,
this.onRequestPermission,
});
@@ -828,6 +866,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 +911,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',
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) {
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/log_food.dart b/lib/ui2/screens/log_food.dart
index 20c6b9b5..1630cdd8 100644
--- a/lib/ui2/screens/log_food.dart
+++ b/lib/ui2/screens/log_food.dart
@@ -147,16 +147,22 @@ 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);
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/lib/ui2/screens/log_workout.dart b/lib/ui2/screens/log_workout.dart
new file mode 100644
index 00000000..af7958de
--- /dev/null
+++ b/lib/ui2/screens/log_workout.dart
@@ -0,0 +1,755 @@
+// 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 '../../health/health_export.dart';
+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);
+ // 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;
+ 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 {
+ 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) {
+ // 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.
+ //
+ // 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;
+ }
+ });
+ }
+
+ 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);
+ // 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
+ // 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;
+ }
+}
+
+/// 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.
+Future> activeSuggestions() async {
+ if (!await autoDetectOn()) return const [];
+ try {
+ return [
+ for (final r in await LocalDb.activeWorkoutSuggestions())
+ ?Suggestion.from(r),
+ ];
+ } catch (_) {
+ return const [];
+ }
+}
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/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/pubspec.lock b/pubspec.lock
index f80f52ac..9262bb70 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:
@@ -924,8 +924,8 @@ packages:
dependency: "direct main"
description:
path: "."
- ref: bfea5e56e74f336c3e3d83743123e58da225617d
- resolved-ref: bfea5e56e74f336c3e3d83743123e58da225617d
+ 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: fe3b681a3e9ca76f8a0865339035f949f36f6000
- resolved-ref: fe3b681a3e9ca76f8a0865339035f949f36f6000
+ 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 ee5192f7..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: fe3b681a3e9ca76f8a0865339035f949f36f6000
+ #
+ # 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: bfea5e56e74f336c3e3d83743123e58da225617d
+ #
+ # 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
@@ -280,7 +291,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
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']));
+ }
});
});
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/band_notifications_test.dart b/test/band_notifications_test.dart
new file mode 100644
index 00000000..0eedb069
--- /dev/null
+++ b/test/band_notifications_test.dart
@@ -0,0 +1,174 @@
+// 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:shared_preferences/shared_preferences.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 — 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);
+ });
+
+ 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);
+ });
+ });
+
+ // 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');
+ 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(''), '');
+ });
+ });
+}
diff --git a/test/coach_config_key_test.dart b/test/coach_config_key_test.dart
index 11933ae8..708de47f 100644
--- a/test/coach_config_key_test.dart
+++ b/test/coach_config_key_test.dart
@@ -24,13 +24,20 @@ class _FakeKeychain {
bool throwOnRead = false;
bool throwOnWrite = false;
bool hangReads = false;
- final List> _hung = [];
-
- void releaseHung() {
- for (final c in _hung) {
- if (!c.isCompleted) c.complete();
+ bool hangWrites = false;
+ 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 {
@@ -40,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
@@ -49,6 +56,11 @@ class _FakeKeychain {
return items[args['key'] as String];
case 'write':
if (throwOnWrite) throw PlatformException(code: 'keychain');
+ if (hangWrites) {
+ final c = Completer();
+ _hungWrites.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 +271,92 @@ 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');
+ });
+
+ // 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;
+ 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;
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,
);
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);
+ });
+ });
}
diff --git a/test/health_sleep_export_test.dart b/test/health_sleep_export_test.dart
index 045f0125..56e8a184 100644
--- a/test/health_sleep_export_test.dart
+++ b/test/health_sleep_export_test.dart
@@ -248,6 +248,51 @@ 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);
+ 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);
diff --git a/test/import_container_test.dart b/test/import_container_test.dart
index eb3e78d5..44cce1b4 100644
--- a/test/import_container_test.dart
+++ b/test/import_container_test.dart
@@ -490,4 +490,139 @@ 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);
+ });
+ });
+
+ // 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 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.
+ 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);
+ });
+ });
}
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'));
+ });
+}
diff --git a/test/live_rescore_calorie_parity_test.dart b/test/live_rescore_calorie_parity_test.dart
index 0dd760a5..2e4b1beb 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,37 @@ 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.
+ // `!` — 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 = [
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',
);
});
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/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);
diff --git a/test/off_lookup_test.dart b/test/off_lookup_test.dart
index 6afc8783..e5e377a2 100644
--- a/test/off_lookup_test.dart
+++ b/test/off_lookup_test.dart
@@ -17,6 +17,26 @@
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({
@@ -392,13 +412,55 @@ 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 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 fresh install may look up', () async {
+ TestWidgetsFlutterBinding.ensureInitialized();
+ 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.
+ 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);
+ });
});
}
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', () {
diff --git a/test/ui2_tokens_test.dart b/test/ui2_tokens_test.dart
index 465025ee..bc14ebfd 100644
--- a/test/ui2_tokens_test.dart
+++ b/test/ui2_tokens_test.dart
@@ -198,10 +198,21 @@ 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.
'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
@@ -245,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',
};
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', () {
diff --git a/test/widget_service_sentinels_test.dart b/test/widget_service_sentinels_test.dart
index f94cbfb7..99b26d26 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);
@@ -171,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');
});
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',
+ );
});
});