diff --git a/.github/scripts/test_shard.sh b/.github/scripts/test_shard.sh new file mode 100755 index 000000000..c80ea6509 --- /dev/null +++ b/.github/scripts/test_shard.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Run one slice of the test suite: test_shard.sh . +# +# Every *_test.dart under test/ belongs to exactly one slice (its position in +# the sorted list, modulo ), so the slices together are the whole suite. +# Inside a slice the files still run one at a time (--concurrency=1): the suite +# uses sqflite_common_ffi against real database files and parallel workers on +# ONE machine race on them. Separate CI jobs are separate machines. +# +# ponytail: slices are balanced by file COUNT, not by duration. If one slice +# becomes the slow one, split by recorded timings instead. +set -euo pipefail + +index="$1" +total="$2" + +files=() +i=0 +while IFS= read -r f; do + if [ $((i % total)) -eq "$index" ]; then files+=("$f"); fi + i=$((i + 1)) +done < <(find test -name '*_test.dart' | LC_ALL=C sort) + +if [ "${#files[@]}" -eq 0 ]; then + echo "slice $index of $total holds no test files" >&2 + exit 1 +fi + +echo "slice $index of $total: ${#files[@]} of $i test files" +if [ "${TEST_SHARD_LIST_ONLY:-}" = 1 ]; then + printf '%s\n' "${files[@]}" + exit 0 +fi +flutter test --concurrency=1 --reporter=expanded "${files[@]}" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 64385d7c9..e48ae940e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,7 +16,9 @@ permissions: contents: read jobs: - test: + # Analyze, the lock guard and the zone-dependent tests: everything that is + # not the main suite. + checks: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -68,17 +70,8 @@ jobs: - name: Analyze run: flutter analyze - # --concurrency=1: the suite uses sqflite_common_ffi against real database - # files, and parallel workers race on them. - # - # The golden capture (whoop_hist.jsonl) is a real band recording kept - # beside the repo rather than committed to it, so the two derivation-replay - # tests SKIP here and run locally. - - name: Test - run: flutter test --concurrency=1 --reporter=expanded - # The WHOOP freeze goldens are per timezone (day labels are local), and - # the run above is UTC, so it only enforces the *_utc files and the other + # the main suite runs in UTC, so it only enforces the *_utc files and the other # two skip. Re-run that one file under each committed offset. The Mi # scale clock test reads the process zone and is a no-op under UTC, so it # rides along. @@ -86,3 +79,49 @@ jobs: run: | TZ=Asia/Kolkata flutter test --concurrency=1 test/whoop_freeze_golden_test.dart test/adapters/miscale_test.dart TZ=America/New_York flutter test --concurrency=1 test/whoop_freeze_golden_test.dart test/adapters/miscale_test.dart + + # The main suite, split across machines. It ran as ONE job for about 75 + # minutes, and a newer push to main cancels the run in progress + # (`cancel-in-progress` above), so runs on main were cancelled about as often + # as they finished. Each slice still runs its files one at a time; see + # .github/scripts/test_shard.sh. + # + # The golden capture (whoop_hist.jsonl) is a real band recording kept beside + # the repo rather than committed to it, so the two derivation-replay tests + # SKIP here and run locally. + suite: + runs-on: ubuntu-latest + strategy: + # One failing slice must not hide failures in the others. + fail-fast: false + matrix: + shard: [0, 1, 2, 3, 4, 5] + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + # Same pin as `checks`; see the note there. + - uses: subosito/flutter-action@v2 + with: + flutter-version: 3.41.6 + channel: stable + cache: true + + - name: Install dependencies + run: flutter pub get + + - name: Test (slice ${{ matrix.shard }} of 6) + run: bash .github/scripts/test_shard.sh ${{ matrix.shard }} 6 + + # One check with the old job's name, so anything that waits for `test` + # (branch protection, a badge) keeps working. Fails unless every job above + # passed. + test: + if: always() + needs: [checks, suite] + runs-on: ubuntu-latest + steps: + - name: All checks and slices passed + run: | + [ "${{ needs.checks.result }}" = success ] && [ "${{ needs.suite.result }}" = success ] diff --git a/docs/proposals/whoop-spo2.md b/docs/proposals/whoop-spo2.md new file mode 100644 index 000000000..3b83659fe --- /dev/null +++ b/docs/proposals/whoop-spo2.md @@ -0,0 +1,196 @@ +# Proposal: WHOOP SpO2 (blood oxygen) — evidence review and staged plan + +Status: proposal only, no code yet. Written so other contributors can build on +the current state of `protocol` and `edge` without re-doing this research. + +## Process (what was done, openly) + +1. Collected the existing claims about the 96-byte realtime packet on the data + characteristic: SpO2 at byte 5 (range gate 50–100), CRC-32 + (poly 0x04C11DB7, xor-out 0xF43F44AC) at bytes 92–95. Single source, + unverified. +2. Checked which claims more than one independent source agrees on, and which + rest on a single source. +3. Compared with what our own repos already know: `protocol` (`live.dart`, + `records.dart`, `gen5_records.dart`), `whoop-rs` (`whoop-protocol/src/live.rs`, + `spo2_from_paired`, v18 `spo2_pct`), `edge` (`ble_engine.dart`, + `adapters/whoop_gen4.dart`, `adapters/signals.dart`). + +What was NOT done: no real-device captures, no ground-truth comparison against a +reference oximeter, no CRC verification against our own captures. Everything +below that is not marked "verified in our repos" is an unverified claim with +the confidence level stated. + +## Findings by generation + +### WHOOP 4.0 — no secured SpO2 + +- Byte 5 of the 96-byte realtime packet is claimed to be SpO2 %. Single + source, no ground truth, only a 50–100 plausibility gate. +- The same source says blood oxygen on the 4.0 is only measured once a day, + during sleep, so byte 5 is expected to carry a value only inside sleep windows + and 0/invalid otherwise. Unverified. +- Our `protocol` repo verified that `spo2RedRaw`/`spo2IrRaw` (inner 64/66) move + in lockstep with a fixed offset: a red/IR ratio from them tracks channel drift, + not oxygenation (`records.dart` explicitly forbids building SpO2 on them). + whoop-rs therefore only computes a ratio-of-ratios estimate on paired v24 + windows and never a calibrated %. +- Open discrepancy: the single source maps the data stream to characteristic + `61080004-…`, while whoop-rs maps `GEN4_DATA` to `61080005-…` and `…0004` to + EVENTS (`whoop-client/src/uuids.rs`). One mapping is off by one; must be + resolved against our own captures before any wiring. +- Constraint from edge AGENTS.md invariant 14: live high-rate streams are + RAM-only, never persisted. That rules out persisting the stream, NOT a + day metric: an in-memory aggregate over a validated sleep window can still + produce a nightly value without persisting anything. Whether Gen4 ever gets + a nightly metric is decided by validation + coverage, not by invariant 14 + alone. + +### WHOOP 5.0 / MG — sleep-only, agreement with the vendor app unverified + +- v18 historical records carry the strap's own computed SpO2 % at frame-abs + byte 82 (= `inner[74]`), which our `protocol` already exposes as + `spo2CandidateRaw` (`gen5_records.dart`) but deliberately does not publish as + a percentage. +- Measurement schedule, reported by more than one independent source: 30 s + measurement bursts every 1,200 s, only while the band declares sleep. 5/MG is + reported to never send red/IR raw channels. Not verified by us. +- Reported comparison with the vendor app: nightly means 93.4–96.7 vs + WHOOP's own 93.1–96.5 over the same weeks (period means 94.4 vs 94.8). No + night had both readings, so this stays an estimate, not a validation. +- No usable offline derivation from raw PPG on 5.0: time multiplexing alone + would NOT make SpO2 impossible (pulse oximeters alternate red/IR), but the + decoded Gen5 data has no identified red/IR channel pair and the optical + channel bursts are widely separated in time — so no derivation path is + available from what we can decode. The byte-82 device value is the only + usable source. +- Encoding unknown. Treat byte 82 as an opaque byte until a validation run + pins its encoding; this is why `spo2Candidate` is deprecated. + +## Self-critical assessment + +- "Matches the WHOOP app" is agreement, not validation; only a reference + oximeter (or PSG-grade device) validates. We state this explicitly, following + the precedent in `vendor-staged-sleep.md`. +- The gen4 byte-5 claim is single-source; the sleep-only measurement reports + do NOT contradict it (a realtime packet can carry SpO2 only during sleep, + which is consistent with the claim). It stays unverified because it has one + source and no ground-truth validation. +- No project anywhere publishes a live (daytime) SpO2 for any WHOOP + generation. The realistic product surface is a nightly sleep SpO2 for + 5/MG, plus at most a raw/diagnostic byte for gen4. +- All sources agree the raw ADC channels cannot yield SpO2; any PR deriving % + from red/IR on 4.0 contradicts our own verified findings and should be + rejected. +- Relation to merged PR #472: its import surface already owns the `spo2` + MetricSpec (suppressed row, vendor-import provenance, not charted). Live + band-derived SpO2 must NOT create a second `spo2` row or chart key — + provenance (vendor import vs band-derived) stays separate. Our raw capture + (`Sample.spo2CandidateRaw`) is deliberately unread until the gate passes; + the single publication path afterwards is `Spo2Data` in + `lib/models/payloads.dart`. + +## Staged plan (proposed) + +1. **protocol** — raw, gated decode only: + - Keep `spo2CandidateRaw` as-is. Do not document any interpretation of the + byte until the validation workstream pins its encoding; it stays labeled + candidate until validated. + - Add a decoder for the 96-byte realtime packet exposing byte 5 as **raw + + CRC flag only** — no percentage publishing. Resolve the characteristic + UUID discrepancy from real captures first. + - Tests from real frames; extend parity fixtures where applicable. +2. **whoop-rs** — DEFERRED. Current scope is `protocol` + `edge` + `analytics` + (analytics owns the nightly metric, see step 3); only whoop-rs is out of + scope for now. When picked up: mirror the decoders in `whoop-protocol` (live 96-byte packet, + v18 byte 82 interpretation), expose raw values via `whoop-ffi`; keep the + existing `spo2_pct` v18 and `spo2_from_paired` paths unchanged. +3. **edge** — pass through the band-reported `deviceSpo2` samples for + gen5/5.0/MG; the per-burst medians and the nightly mean are computed in + `OpenStrap/analytics` (a metric, not signal wiring — AGENTS.md assigns + metrics there), labeled as a device estimate, same labeling discipline as + `vendor_staged`. + - After the gate passes: feed `Sample.spo2CandidateRaw` into `Spo2Data` + (`lib/models/payloads.dart`) as the single publication path — no separate + display path, no second `spo2` metric row (see #472 note above). + - NO user-facing display on either generation until the validation + workstream lands: gen5 byte 82 stays raw until its encoding (bit-7 flag) + is pinned and validated against a reference device; gen4 byte 5 stays + diagnostic, never user-facing, until ground-truth validated. + - Diagnostics stay RAM-ONLY, never in `_log`/FileLog. FileLog persists to + disk (`lib/sync/file_log.dart`). Check where it lands on each Android + version before claiming who can read it; regardless of readability the + data would still be PERSISTED, which is the reason health diagnostics stay + in RAM. + - Respect invariants: live streams RAM-only (inv. 14), never fabricate a + metric (inv. 3), bump `kAlgoVersion` when any output changes (inv. 4). +4. **Validation workstream** (prerequisite for ANY user-facing SpO2 value, + both generations): paired measurements against a reference oximeter over + at least 10 nights / 30 paired samples across at least 3 distinct wearers (one + wearer's nights can satisfy the counts alone, so the wearer minimum is + separate); the display gate opens only when + accuracy criteria are met — MAE <= 2 pp AND |mean bias| <= 1 pp AND + Bland-Altman 95% limits of agreement within +/-4 pp. Correlation + (Pearson/Spearman) is reported separately and is NEVER treated as proof of + accuracy. Burst-level pairs are NOT sufficient: the gate must also compare + the actual NIGHTLY OUTPUT (our aggregation over the band-declared sleep + window, burst weighting included) against the reference aggregated over the + same night, because burst-level agreement can pass while the nightly + calculation uses the wrong window or weights. COVERAGE REQUIREMENT (else a + constant estimate passes by construction): reference samples must span the + saturation range (e.g. stratified bins across 85–100%) and participants must + be representative (FDA oximeter guidance explicitly calls out skin + pigmentation); accuracy is assessed per range/group, and a degenerate + constant estimate (all reference samples ~97%) must NOT open the gate. + BURST COVERAGE: the reference must provide time-aligned readings for EVERY + burst used in the nightly aggregate — sparse reference samples can match the + nightly device output while missing errors during most of the night — and + MINIMUM SLEEP-WINDOW COVERAGE must be defined with an independent check for + omitted bursts before approving a nightly estimate (data loss leaving only a + few bursts must not pass the gate). WINDOW MEMBERSHIP: included/excluded + bursts are checked independently against the band-declared sleep boundaries, + and the reference aggregate is constructed from the correct window — two + aggregates sharing the same WRONG window can match each other. WEIGHTS: the + expected burst weights are defined independently and the implementation is + checked against them; the reference aggregate must NOT copy the device's + weights (e.g. bursts of 90/90/99 with weights 1/1/8 yield 97.2 instead of + the equal-weight mean 93 — a weighting defect the copied weights would + conceal). Gen4 has NO nightly SpO2 output — + its byte 5 is a live-stream byte only — so any Gen4 display gate must + instead use time-aligned LIVE reference measurements (same accuracy + thresholds: MAE <= 2 pp, |bias| <= 1 pp, 95% LoA within +/-4 pp), never the + nightly gate. If Gen4 LATER gains a nightly metric (in-memory aggregate over + a validated sleep window), that nightly output must be validated exactly + like the Gen5 one — against the reference aggregated over the same window — + because accurate live samples alone cannot detect wrong window selection or + weighting. Plus: byte-82 encoding pin-down, UUID discrepancy resolution. + +## Publication path after the gate (explicit) + +The gate and the publication path are separate concerns; both are specified +here so the gate is evaluable without shipping a display: + +- **Validation-only read is allowed BEFORE the gate passes.** Evaluating the + gate requires the actual nightly output, so the same nightly aggregation + intended for publication is computed and compared against the reference — + internally only. User-facing output stays withheld until the gate passes; + `Sample.spo2CandidateRaw` remains unread by the UI in the meantime. +- **Payload changes (edge `payloads.dart`):** `Spo2Data` currently has no + percentage accessor and `TodayData.spo2` returns null while `odi_per_hour` + is null. Publication therefore adds: a `spo2Pct` field on `Spo2Data`, a + `double? get spo2Pct` accessor, and a getter condition on `TodayData.spo2` + that accepts the payload once validation has passed (flagged by the + validation record, not by the mere presence of a byte). +- **UI integration:** publishing through `Spo2Data` alone does NOT display + anything — the chart reads `metric_series` and the Explore row stays hidden + without stored `spo2` history. The integration step therefore writes the + validated nightly value into the existing `spo2` series with + band-derived provenance, reusing the #472 import row's provenance axis — + NO second `spo2` row, no second chart key. + +## Out of scope + +- Any medical claim or diagnosis (terms.html: wellness only, not a medical + device). +- Deriving SpO2 from red/IR ADC counts. +- Persisting live-stream data. diff --git a/lib/app.dart b/lib/app.dart index 3e58c40e0..33bcc2551 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -437,14 +437,18 @@ ShellDomain domainForRoute(String route) => switch (routePath(route)) { // Water is a journal field logged beside food, and food is a section of // Activity. kRouteWater => ShellDomain.activity, - // The movement/sedentary nudges, recovery-ready and step-goal notes, - // and the detected-activity reviews: all about what is on Today. + // The movement/sedentary nudges, recovery-ready and step-goal notes: + // all about what is on Today. kRouteMovement || kRouteRecovery || kRouteSteps || - kRouteDetectedActivities || kRouteWorkoutSuggestion => ShellDomain.today, + // The detected-activity reviews (naps and workouts alike). The + // suggestion cards they are about live on the Activity tab, so the + // pushed review sits over Activity — the tap lands where the content + // is, not on Home (issue #113's promise, extended to naps). + kRouteDetectedActivities => ShellDomain.activity, // The forgotten-workout nudge. Activity is the destination itself — the // live session bar with its finish control is pinned to the shell there // — so screenForRoute stays null for it. @@ -519,12 +523,10 @@ class _ShellState extends State<_Shell> { late ShellDomain _domain = restoredShellTab(); /// AppShell owns its own selection and takes only an `initial`, so a - /// programmatic jump re-keys it. - // ponytail: re-keying rebuilds the tab stack, so a deep link loses the - // scroll position of whatever tab was open. Deep links are rare and arrive - // from outside the app; give AppShell an external controller if that ever - // stops being true. - int _rev = 0; + /// programmatic jump drives it through this notifier instead of re-keying: + /// recreating AppShell would rebuild the tab stack and lose every visited + /// tab's state (scroll position included) on a notification deep link. + late final ValueNotifier _selection; AppState? _app; @@ -534,6 +536,7 @@ class _ShellState extends State<_Shell> { // A tapped notification asks for a tab via AppState.navRequest. Jump there, // then clear the request so it isn't replayed on rebuild. _app = context.read(); + _selection = ValueNotifier(_domain); _app!.navRequest.addListener(_consumeSoon); _app!.screenRequest.addListener(_consumeSoon); // Cold launch from a tapped notification: the route may already be set @@ -591,17 +594,17 @@ class _ShellState extends State<_Shell> { void _go(ShellDomain d) { if (d == _domain) return; - setState(() { - _domain = d; - _rev++; - }); + setState(() => _domain = d); Prefs.setInt(Prefs.shellTabV2, d.index); + // AppShell follows this without being recreated, so tab state survives. + _selection.value = d; } @override void dispose() { _app?.navRequest.removeListener(_consumeSoon); _app?.screenRequest.removeListener(_consumeSoon); + _selection.dispose(); super.dispose(); } @@ -611,12 +614,16 @@ class _ShellState extends State<_Shell> { // AppState tick. final live = context.select((a) => a.activeWorkout != null); return AppShell( - key: ValueKey(_rev), initial: _domain, + selection: _selection, banner: live ? const _LiveSessionBar() : null, onSelect: (d) { _domain = d; Prefs.setInt(Prefs.shellTabV2, d.index); + // Manual tab changes (and Back) must update the notifier too: + // otherwise it goes stale and a later _go to that stale value fires + // no change event, so AppShell would not follow the deep link. + _selection.value = d; }, builder: (c, d) => switch (d) { ShellDomain.today => const HomeScreen(), diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index 3d7f07476..21109483f 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -307,6 +307,10 @@ Sample? sampleFromGen5Historical(Gen5HistoricalRecord? g) { // enum: a stored name freezes a meaning, a stored 2-bit code does not. // Corroboration only, never a stage — see Sample.bandSleepState. bandSleepState: g.sleepStateRawNibble, + // Raw candidate byte, never a percentage — the encoding is not pinned and + // no ground truth exists, so this is carried for diagnostics only and is + // not persisted (see Sample.spo2CandidateRaw). + spo2CandidateRaw: g.spo2CandidateRaw, ); } diff --git a/lib/compute/inputs/measurement_inputs.dart b/lib/compute/inputs/measurement_inputs.dart index a24dcc227..a9b29565a 100644 --- a/lib/compute/inputs/measurement_inputs.dart +++ b/lib/compute/inputs/measurement_inputs.dart @@ -28,12 +28,50 @@ const double kWeighingBand = 0.10; bool nearWeight(num kg, num? near) => near == null || near <= 0 || (kg - near).abs() <= near * kWeighingBand; -/// The newest weighing any paired health-measurement device with its flag -/// on (rule R6, [wearableEnabled]) banked (kg, epoch ms), or null. With -/// [near] (the profile's weight), only weighings within [kWeighingBand] of -/// it count. -Future<({double kg, int atMs})?> newestWeighing({num? near}) async { - ({double kg, int atMs})? newest; +/// The household-band chain over [ws], in time order: the first weighing +/// seeds it and a later one is kept while within [kWeighingBand] of the +/// previous kept one. A run of 3 consecutive rejected weighings, each near +/// the one before it, is a lasting change (a guest seeded the chain, or a +/// real step in weight), so the whole run is kept and its last member becomes +/// the anchor; a rejected weighing that breaks the run starts a new run of 1. +/// Pure and deterministic: same input, same output, however often it runs. +List<({double kg, int atMs})> weighingChain( + Iterable<({double kg, int atMs})> ws, +) { + final sorted = [...ws] + ..sort((a, b) { + final c = a.atMs.compareTo(b.atMs); + return c != 0 ? c : a.kg.compareTo(b.kg); + }); + final chain = <({double kg, int atMs})>[]; + var run = <({double kg, int atMs})>[]; + for (final w in sorted) { + if (chain.isEmpty || nearWeight(w.kg, chain.last.kg)) { + chain.add(w); + run = []; + continue; + } + run = run.isNotEmpty && nearWeight(w.kg, run.last.kg) ? [...run, w] : [w]; + if (run.length >= 3) { + chain.addAll(run); + run = []; + } + } + return chain; +} + +/// Every weighing (kg, epoch ms) of a paired health-measurement device with +/// its flag on (rule R6, [wearableEnabled]), from [fromMs]. With [near] (the +/// profile's weight), only weighings within [kWeighingBand] of it count. +Future> weighings({ + num? near, + int fromMs = 0, +}) => _eachWeighing(near: near, fromMs: fromMs).toList(); + +Stream<({double kg, int atMs})> _eachWeighing({ + num? near, + int fromMs = 0, +}) async* { for (final row in await LocalDb.deviceRows()) { final adapter = row['adapter_id'] as String?; if (adapter == null || @@ -42,14 +80,21 @@ Future<({double kg, int atMs})?> newestWeighing({num? near}) async { continue; } final w = await LocalDb.deviceObservationValues( - row['id'] as String, 'weight'); + row['id'] as String, 'weight', + fromMs: fromMs); for (final MapEntry(key: at, value: kg) in w.entries) { - if (!nearWeight(kg, near)) continue; - if (newest == null || at > newest.atMs) { - newest = (kg: kg.toDouble(), atMs: at); - } + if (nearWeight(kg, near)) yield (kg: kg.toDouble(), atMs: at); } } +} + +/// The newest of [weighings], or null. A running maximum: the whole history +/// is never held at once. +Future<({double kg, int atMs})?> newestWeighing({num? near}) async { + ({double kg, int atMs})? newest; + await for (final w in _eachWeighing(near: near)) { + if (newest == null || w.atMs > newest.atMs) newest = w; + } return newest; } diff --git a/lib/data/models.dart b/lib/data/models.dart index 95f7f1a46..35b772a14 100644 --- a/lib/data/models.dart +++ b/lib/data/models.dart @@ -152,6 +152,17 @@ class Sample { /// (analytics `band_offset.dart`) — never to create, extend or stage one. final int? bandSleepState; + /// The gen5 v18 SpO2 candidate byte (protocol `spo2CandidateRaw`, + /// inner[74]) — GEN5/MG ONLY, raw and unvalidated. + /// + /// The encoding is unknown and no ground truth exists, so this is the opaque + /// byte as received: never gated, never shown, never stored or converted as + /// a percentage. It lives in memory only, on the live decode path: there is + /// no column, so a sample rebuilt from stored rows has it null. Never write + /// it to a log. The validation gate for any later use is in + /// docs/proposals/whoop-spo2.md. + final int? spo2CandidateRaw; + Sample({ required this.tsEpoch, required this.counter, @@ -177,6 +188,7 @@ class Sample { this.dynAccelG, this.tsSubsec, this.bandSleepState, + this.spo2CandidateRaw, }); /// Copy with an overridden [tsEpoch] — used by the clock-offset salvage path @@ -208,6 +220,7 @@ class Sample { dynAccelG: dynAccelG, tsSubsec: tsSubsec, bandSleepState: bandSleepState, + spo2CandidateRaw: spo2CandidateRaw, ); bool get hasDecodedOneHz => diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index fe1fa7f57..32339bc6f 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -811,7 +811,7 @@ "journalComposeNotEnoughEntriesTitle": "Nicht genug Einträge für einen Verlauf", "journalComposeNotEnoughEntriesBody": "Die Linie ist ein Sieben-Tage-Durchschnitt deiner Einträge und braucht daher mindestens zwei Tage. Lücken dazwischen werden nicht aufgefüllt.", "journalComposeSevenDayTrend": "Sieben-Tage-Trend", - "journalComposeTrendFootnote": "Von dir eingegeben. Tage ohne Eintrag bleiben leer.", + "journalComposeTrendFootnote": "Von dir oder deiner Waage eingegeben. Tage ohne Eintrag bleiben leer.", "journalComposeWeightTrendExplainer": "Von dir oder deiner Waage eingetragen – das Band misst kein Gewicht. Angezeigt wird ein Sieben-Tage-Durchschnitt, denn allein durch Wasser und Essen schwankt die Waage um ein bis zwei Kilo, und die Rohwerte würden das wie eine echte Veränderung aussehen lassen. {count, plural, one{{count} Tag eingetragen.} other{{count} Tage eingetragen.}}", "nutritionTabToday": "Heute", "nutritionTabWeek": "Woche", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 3dc93be2b..0d39a254a 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -3858,7 +3858,7 @@ "@journalComposeSevenDayTrend": { "description": "Title of the weight trend chart." }, - "journalComposeTrendFootnote": "Entered by you. Days with no entry are left empty.", + "journalComposeTrendFootnote": "Entered by you or your scale. Days with no entry are left empty.", "@journalComposeTrendFootnote": { "description": "Footnote under the weight trend chart." }, diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 415c2de32..0fb047874 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -774,7 +774,7 @@ "journalComposeNotEnoughEntriesTitle": "No hay suficientes registros para una tendencia", "journalComposeNotEnoughEntriesBody": "La línea es un promedio de siete días con lo que introdujiste, así que hacen falta al menos dos días. No se rellena nada entre ellos.", "journalComposeSevenDayTrend": "Tendencia de siete días", - "journalComposeTrendFootnote": "Introducido por ti. Los días sin registro quedan vacíos.", + "journalComposeTrendFootnote": "Introducido por ti o por tu báscula. Los días sin registro quedan vacíos.", "journalComposeWeightTrendExplainer": "Introducido por ti o por tu báscula — la banda no mide el peso. Lo que se dibuja es un promedio de siete días, porque una báscula puede variar uno o dos kilos solo por agua y comida, y las lecturas sin procesar mostrarían eso como algo que le sucede a tu cuerpo. {count, plural, one{Se registró {count} día.} other{Se registraron {count} días.}}", "nutritionTabToday": "Hoy", "nutritionTabWeek": "Semana", @@ -2890,5 +2890,7 @@ "type": "int" } } - } + }, + "reportContactLabel": "Contacto (opcional)", + "reportContactHint": "Correo o Discord, solo si quieres una respuesta" } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 7c26d7e89..41cab9ff7 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -2861,5 +2861,7 @@ "type": "int" } } - } + }, + "reportContactLabel": "Contact (facultatif)", + "reportContactHint": "E-mail ou Discord, seulement si vous voulez une réponse" } diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 990d67975..6159de0a0 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -2861,5 +2861,7 @@ "type": "int" } } - } + }, + "reportContactLabel": "संपर्क (वैकल्पिक)", + "reportContactHint": "ईमेल या Discord, केवल अगर आप जवाब चाहते हैं" } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index c86544a36..84bc7cfc2 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -7904,5 +7904,8 @@ "type": "int" } } - } + }, + "whoopFirmwareWarningTitle": "Пользователям WHOOP: не подключайтесь снова к приложению WHOOP", + "whoopFirmwareWarningAction": "Если вы пользуетесь OpenStrap или другим приложением с открытым кодом либо планируете перейти на него позже, не подключайте браслет к официальному приложению WHOOP снова. Оно может автоматически установить обновления прошивки. Удалите или отключите приложение WHOOP, чтобы оно не обновляло ваш браслет.", + "whoopFirmwareWarningDetails": "В сообщениях о прошивках WHOOP 5.0 / MG 50.43.1.0 и 50.43.2.0 описывается заблокированный доступ к данным. Bluetooth при этом может подключаться. OpenStrap не может выпустить подписанный WHOOP токен членства, необходимый для разблокировки затронутых браслетов. Мы работаем над решением, но исправления пока нет." } diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 924d3dbf9..6347b62ca 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -16325,5 +16325,8 @@ "type": "int" } } - } + }, + "whoopFirmwareWarningTitle": "ผู้ใช้ WHOOP: อย่าเชื่อมต่อกับแอป WHOOP อีก", + "whoopFirmwareWarningAction": "หากคุณใช้ OpenStrap หรือแอปโอเพนซอร์สอื่น หรือวางแผนจะเปลี่ยนไปใช้ในภายหลัง อย่าเชื่อมต่อสายรัดกับแอป WHOOP อย่างเป็นทางการอีก แอปอาจติดตั้งอัปเดตเฟิร์มแวร์โดยอัตโนมัติ ให้ถอนการติดตั้งหรือปิดใช้งานแอป WHOOP เพื่อไม่ให้แอปอัปเดตสายรัดของคุณ", + "whoopFirmwareWarningDetails": "มีรายงานเกี่ยวกับเฟิร์มแวร์ WHOOP 5.0 / MG เวอร์ชัน 50.43.1.0 และ 50.43.2.0 ว่าเข้าถึงข้อมูลไม่ได้ บลูทูธอาจยังเชื่อมต่อได้ OpenStrap ไม่สามารถออกโทเค็นสมาชิกที่ WHOOP ลงนามซึ่งจำเป็นต่อการปลดล็อกสายรัดที่ได้รับผลกระทบ เรากำลังหาทางแก้ไข แต่ยังไม่มีวิธีแก้ในตอนนี้" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 1cf2c9848..472e0d493 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -2861,5 +2861,7 @@ "type": "int" } } - } + }, + "reportContactLabel": "联系方式(可选)", + "reportContactHint": "邮箱或 Discord,仅在你希望得到回复时填写" } diff --git a/lib/notify/tap_router.dart b/lib/notify/tap_router.dart index 82e9549f7..f25f5fb68 100644 --- a/lib/notify/tap_router.dart +++ b/lib/notify/tap_router.dart @@ -16,10 +16,11 @@ const String kRouteBreathing = '/breathing'; /// that still needs a scroll to find the control is a different promise. const String kRouteWater = '/water'; -/// "Did you work out?" auto-detect notification. Lands on the Workouts tab and -/// pushes a focused review of the detected activity (log or adjust) — the plain -/// `/workouts` route only selected the tab, leaving the suggestion buried in the -/// history list (issue #113). +/// "Did you work out?" / "Did you nap?" auto-detect notification. Lands on the +/// Activity tab (the old Workouts tab) and pushes a focused review of the +/// detected activity (log or adjust) — the plain `/workouts` route only +/// selected the tab, leaving the suggestion buried in the history list +/// (issue #113). /// /// Carries the bout it is about as `?id=` — see /// [workoutSuggestionRoute]. The bare path still resolves (older payloads, and @@ -134,8 +135,9 @@ const Map _tabRoutes = { }; // Sub-screen routes → the shell tab they sit on top of. Most briefing/journal -// deep links live over Today (0), and so do the detected-workout and -// detected-activity reviews: Home is where they are surfaced and land on close. +// deep links live over Today (0). The detected-activity reviews keep base 0 +// here (the payload's own base) but land on the Activity domain — app.dart's +// `domainForRoute` decides whenever a screen route is present. // // /profile and /recap were BOTH being emitted with neither table knowing them, // so resolveTapRoute fell through to Today and every band-battery alert landed diff --git a/lib/ui2/app_shell.dart b/lib/ui2/app_shell.dart index 3163977ae..6d1c9a58b 100644 --- a/lib/ui2/app_shell.dart +++ b/lib/ui2/app_shell.dart @@ -71,12 +71,19 @@ class AppShell extends StatefulWidget { /// belongs inside the domain. final Widget? banner; + /// External selection control. When provided, the shell follows changes to + /// it and switches tabs WITHOUT being recreated, so a programmatic jump + /// (a notification deep link) keeps every visited tab's state — scroll + /// position included — alive in the [IndexedStack]. + final ValueNotifier? selection; + const AppShell({ super.key, required this.builder, this.initial = ShellDomain.today, this.onSelect, this.banner, + this.selection, }); @override @@ -93,7 +100,50 @@ class _AppShellState extends State { if (widget.initial != ShellDomain.today) ShellDomain.today, ]; - void _select(ShellDomain d) { + @override + void initState() { + super.initState(); + widget.selection?.addListener(_followSelection); + } + + @override + void didUpdateWidget(covariant AppShell oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.selection != oldWidget.selection) { + oldWidget.selection?.removeListener(_followSelection); + widget.selection?.addListener(_followSelection); + // Reconcile with the new notifier's current value. didUpdateWidget + // runs during the build phase, so the onSelect callback is deferred + // to after the frame — it may setState on the parent. + final s = widget.selection; + if (s != null && s.value != _current) { + final d = s.value; + _select(d, notify: false); + WidgetsBinding.instance.addPostFrameCallback((_) { + // Discard the callback if the shell was unmounted, the notifier + // was swapped, or the selection moved on since scheduling — + // reporting the stale domain would write it back via onSelect. + if (mounted && widget.selection == s && s.value == d) { + widget.onSelect?.call(d); + } + }); + } + } + } + + @override + void dispose() { + widget.selection?.removeListener(_followSelection); + super.dispose(); + } + + void _followSelection() { + final s = widget.selection; + if (s == null) return; + if (s.value != _current) _select(s.value); + } + + void _select(ShellDomain d, {bool notify = true}) { setState(() { // Each tab at most once, most recent last, so switching back and forth // between two tabs does not add a Back press per switch. Today stays in @@ -112,7 +162,7 @@ class _AppShellState extends State { _current = d; _built.add(d); }); - widget.onSelect?.call(d); + if (notify) widget.onSelect?.call(d); } void _back() { diff --git a/lib/ui2/profile/gallery.dart b/lib/ui2/profile/gallery.dart index 02809f51c..5fd5a0966 100644 --- a/lib/ui2/profile/gallery.dart +++ b/lib/ui2/profile/gallery.dart @@ -53,6 +53,7 @@ import '../activity/poster.dart' show PosterCard, PosterFormat, PosterStatRow, kPosterMapH, kPosterMapW; import '../activity/setup.dart' show ActivitySetup; import '../activity/tiles.dart'; +import '../pairing/whoop_firmware_warning.dart'; import '../activity/share.dart' show ShareSheet, shareOrigin; import '../activity/summary.dart'; import '../onboarding/welcome.dart' show ImportOutcome, ImportReport; @@ -210,6 +211,7 @@ Map componentCases() => { 'ecg_reading_row': Surface( pad: EdgeInsets.zero, child: EcgReadingRow(reading: _ecgDemoReading, onTap: () {})), + 'whoop_firmware_warning': const WhoopFirmwareWarning(), 'signal': const SignalCard( LucideIcons.heartPulse, C.blue, 'Resting heart rate', '52', unit: 'bpm', sub: '4 BELOW YOUR BASELINE'), diff --git a/lib/ui2/screens/journal_compose.dart b/lib/ui2/screens/journal_compose.dart index 344f8f4ff..b0febf35d 100644 --- a/lib/ui2/screens/journal_compose.dart +++ b/lib/ui2/screens/journal_compose.dart @@ -18,6 +18,7 @@ import 'package:provider/provider.dart'; import '../../ai/journal_ai.dart'; import '../../coach/coach_config.dart'; +import '../../compute/inputs/measurement_inputs.dart' show weighingChain, weighings; import '../../data/day_label.dart'; import '../../data/db.dart'; import '../../data/journal_fields.dart'; @@ -920,6 +921,52 @@ class _WeightRow extends StatelessWidget { } } +/// Per-day weight for the trend: the journal's [manual] value and the scale +/// [scaled] weighings (see `weighings`: flag-on, in band), bucketed by LOCAL +/// day, the day's latest weighing standing for it. When a day has both, the +/// later one wins: the journal's time of day against the weighing's, and a +/// journal value with no time loses to the scale. Days with neither are absent. +/// Scale weighings dated after [nowMs] (a band clock set ahead) are dropped. +/// The chain runs over every weighing in [scaled]; only those at or after +/// [fromMs] are plotted, so the window's start does not decide which readings +/// anchor the chain (and the same data charts the same on every day). +@visibleForTesting +Map weightByDay( + Map> manual, + List<({double kg, int atMs})> scaled, { + int? nowMs, + int fromMs = 0, +}) { + final until = nowMs ?? DateTime.now().millisecondsSinceEpoch; + bool ok(double v) => v.isFinite && v > 0; + final out = {}; + for (final e in manual.entries) { + final v = e.value['weight_kg']; + if (v != null && ok(v.value)) out[e.key] = v.value; + } + // A scale weighs whoever steps on it, and banding against TODAY's profile + // weight would drop a long loss's old readings. So chain instead (see + // weighingChain): off-band weighings are someone else's unless they persist. + final chain = weighingChain( + scaled.where((w) => ok(w.kg) && w.atMs <= until), + ); + final at = {}; + for (final w in chain) { + if (w.atMs < fromMs) continue; + final t = DateTime.fromMillisecondsSinceEpoch(w.atMs); + final day = dayLabelOf(t); + if (at[day] != null && at[day]! >= w.atMs) continue; + final m = t.hour * 60 + t.minute; + final jm = manual[day]?['weight_kg']?.atMinuteOfDay; + if (out.containsKey(day) && !at.containsKey(day) && jm != null && jm > m) { + continue; + } + at[day] = w.atMs; + out[day] = w.kg; + } + return out; +} + /// The trend, and only the trend. /// /// One line, no headline number, no delta, no arrow, no target and no colour @@ -947,22 +994,27 @@ class _WeightTrendState extends State<_WeightTrend> { // Day arithmetic, not a subtracted duration: a DST day is 23 or 25 hours // long and `now - 180 days` lands on the wrong calendar date across one. final now = DateTime.now(); - final since = dayLabelOf( - DateTime(now.year, now.month, now.day - (kWeightTrendDays - 1)), + final start = DateTime( + now.year, + now.month, + now.day - (kWeightTrendDays - 1), ); + final since = dayLabelOf(start); final rows = await LocalDb.journalMetricsByDay(sinceDaysEpoch: since); + // Its own guard: a failing scale read must not blank the manual trend. + var scale = const <({double kg, int atMs})>[]; + try { + // All history, not just the window: the chain's anchor must not + // depend on where the window starts (see weightByDay). + scale = await weighings(); + } catch (e) { + debugPrint('weight trend: scale read failed: $e'); + } if (!mounted) return; setState(() { - _byDay = { - for (final e in rows.entries) - // The `when` does real work — a non-finite or non-positive weight - // is not a weight — and the null-aware element form has nowhere to - // put a guard. - // ignore: use_null_aware_elements - if (e.value['weight_kg']?.value case final v? - when v.isFinite && v > 0) - e.key: v, - }; + // Local midnight, matching the local-day buckets; parsing the label + // would read it as UTC midnight. + _byDay = weightByDay(rows, scale, fromMs: start.millisecondsSinceEpoch); _loading = false; }); } catch (_) { @@ -1025,7 +1077,7 @@ class _WeightTrendState extends State<_WeightTrend> { yAxis: axis, xLabels: [days.first, days.last], footnote: l?.journalComposeTrendFootnote ?? - 'Entered by you. Days with no entry are left empty.', + 'Entered by you or your scale. Days with no entry are left empty.', series: vals, empty: axis == null ? const NoData() : null, child: axis == null diff --git a/test/app_shell_back_test.dart b/test/app_shell_back_test.dart index 7071a06c5..801754814 100644 --- a/test/app_shell_back_test.dart +++ b/test/app_shell_back_test.dart @@ -204,6 +204,119 @@ void main() { await back(tester); expect(find.text('Today count 1'), findsOneWidget); }); + + testWidgets( + 'an external selection switch keeps visited tab state alive', + (tester) async { + // A notification deep link drives AppShell through `selection` rather + // than a re-key: recreating the shell would wipe the IndexedStack state. + final selection = ValueNotifier(ShellDomain.today); + await tester.pumpWidget( + MaterialApp( + theme: buildTheme( + Brightness.light, + ).copyWith(platform: TargetPlatform.android), + home: AppShell( + initial: ShellDomain.today, + selection: selection, + builder: (_, domain) => _TestTab(domain: domain), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('increment Today')); + await tester.pump(); + + selection.value = ShellDomain.activity; + await tester.pumpAndSettle(); + expect(find.text('screen Activity'), findsOneWidget); + await tester.tap(find.text('increment Activity')); + await tester.pump(); + + selection.value = ShellDomain.today; + await tester.pumpAndSettle(); + expect(find.text('Today count 1'), findsOneWidget); + selection.value = ShellDomain.activity; + await tester.pumpAndSettle(); + expect(find.text('Activity count 1'), findsOneWidget); + }, + ); + + testWidgets('swapping the selection notifier re-syncs to its current value', ( + tester, + ) async { + final notified = []; + Widget shell(ValueNotifier? s) => MaterialApp( + theme: buildTheme( + Brightness.light, + ).copyWith(platform: TargetPlatform.android), + home: AppShell( + initial: ShellDomain.today, + selection: s, + onSelect: notified.add, + builder: (_, domain) => _TestTab(domain: domain), + ), + ); + final first = ValueNotifier(ShellDomain.today); + final second = ValueNotifier(ShellDomain.activity); + await tester.pumpWidget(shell(first)); + await tester.pumpAndSettle(); + expect(find.text('screen Today'), findsOneWidget); + + // Same element position, new notifier instance: didUpdateWidget must + // move the listener and reconcile to the new value, delivering onSelect + // after the frame rather than during the build. + await tester.pumpWidget(shell(second)); + await tester.pumpAndSettle(); + expect(find.text('screen Activity'), findsOneWidget); + expect(notified, [ShellDomain.activity]); + + // The old notifier must no longer drive the shell. + first.value = ShellDomain.health; + await tester.pumpAndSettle(); + expect(find.text('screen Activity'), findsOneWidget); + + // Removing the notifier entirely must not throw. + await tester.pumpWidget(shell(null)); + await tester.pumpAndSettle(); + expect(find.text('screen Activity'), findsOneWidget); + }); + + testWidgets('a deferred selection is discarded when the notifier moves on', ( + tester, + ) async { + final notified = []; + Widget shell(ValueNotifier s) => MaterialApp( + theme: buildTheme( + Brightness.light, + ).copyWith(platform: TargetPlatform.android), + home: AppShell( + initial: ShellDomain.today, + selection: s, + onSelect: notified.add, + builder: (_, domain) => _TestTab(domain: domain), + ), + ); + final first = ValueNotifier(ShellDomain.today); + final second = ValueNotifier(ShellDomain.activity); + await tester.pumpWidget(shell(first)); + await tester.pumpAndSettle(); + + // didUpdateWidget schedules the deferred onSelect for Activity; moving + // the notifier to Health before the frame ends must win, and the stale + // Activity callback must be discarded (onSelect writes back to the + // notifier in the real shell, which would flip the tab back). + // pumpWidget runs a whole frame, so the move has to be queued ahead of + // the shell's own post-frame callback to land between schedule and fire. + WidgetsBinding.instance.addPostFrameCallback( + (_) => second.value = ShellDomain.health, + ); + await tester.pumpWidget(shell(second)); + await tester.pumpAndSettle(); + expect(find.text('screen Health'), findsOneWidget); + expect(notified, isNot(contains(ShellDomain.activity))); + expect(notified, contains(ShellDomain.health)); + }); } class _TestTab extends StatefulWidget { diff --git a/test/gen5_spo2_candidate_test.dart b/test/gen5_spo2_candidate_test.dart new file mode 100644 index 000000000..432558fdb --- /dev/null +++ b/test/gen5_spo2_candidate_test.dart @@ -0,0 +1,77 @@ +// The gen5 v18 SpO2 candidate byte (protocol `spo2CandidateRaw`, inner[74]) +// must be carried through `decodeGen5HistoricalSample` as a RAW byte — never +// gated, never converted to a percentage. The byte is unvalidated, so the +// contract under test is passthrough + null on gen4-shaped input, not a value +// claim. + +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ble/ble_engine.dart'; +import 'package:openstrap_edge/data/models.dart'; +import 'package:openstrap_edge/models/payloads.dart'; +import 'package:openstrap_protocol/openstrap_protocol.dart'; + +/// A minimal decodable v18 inner: length 112, version byte 18 at inner[1]. +Uint8List v18Inner({int spo2Candidate = 0, int stateByte = 2}) { + final b = Uint8List(kGen5V18InnerLen); + b[1] = 18; + final v = ByteData.sublistView(b); + v.setUint32(7, 1780916150, Endian.little); // unix + b[73] = stateByte; // sleep-state byte; the sleep code sits in bits 4-5 + b[74] = spo2Candidate; + return b; +} + +void main() { + test('gen5 v18 spo2CandidateRaw is carried through as the raw byte', () { + final s = decodeGen5HistoricalSample(v18Inner(spo2Candidate: 97)); + expect(s, isNotNull); + expect(s!.spo2CandidateRaw, 97); + expect(s.bandSleepState, isNotNull); + }); + + // The candidate is carried whatever the band says about sleep: the decoder + // must not gate it on the sleep code, so a nonzero byte with a wake state + // still comes through unchanged. + test('a nonzero candidate is carried with a wake sleep state too', () { + final s = decodeGen5HistoricalSample( + v18Inner(spo2Candidate: 97, stateByte: 0x00)); + expect(s!.spo2CandidateRaw, 97); + }); + + test('zero candidate stays zero — absent measurement, not a gated null', () { + final s = decodeGen5HistoricalSample(v18Inner(spo2Candidate: 0)); + expect(s!.spo2CandidateRaw, 0); + }); + + test('flag-style values above 128 are passed through unmasked', () { + final s = decodeGen5HistoricalSample(v18Inner(spo2Candidate: 128 + 97)); + expect(s!.spo2CandidateRaw, 128 + 97); + }); + + // copyWith must carry the candidate along — a regression that drops + // spo2CandidateRaw from copyWith would silently lose it on every copy. + // (copyWith only overrides tsEpoch; the field must pass through unchanged.) + test('copyWith preserves a nonzero spo2CandidateRaw', () { + final s = decodeGen5HistoricalSample(v18Inner(spo2Candidate: 97))!; + expect(s.copyWith(tsEpoch: s.tsEpoch + 1).spo2CandidateRaw, 97); + }); + + test('copyWith preserves an absent (null) spo2CandidateRaw', () { + // A gen4-shaped sample carries no candidate byte at all. + final s = Sample(tsEpoch: 1000, counter: 0, hr: 60); + expect(s.spo2CandidateRaw, isNull); + expect(s.copyWith(tsEpoch: 1001).spo2CandidateRaw, isNull); + }); + + // The seam to PR #472: a captured candidate byte must NOT unlock the + // publication path. `Spo2Data` (payloads.dart) stays unread until the + // validation gate passes — capturing is not displaying. + test('captured candidate stays unread: Spo2Data refusal is intact', () { + final s = decodeGen5HistoricalSample(v18Inner(spo2Candidate: 97))!; + expect(s.spo2CandidateRaw, 97); + final payload = TodayData.fromJson({'spo2': const {}}); + expect(payload.spo2, isNull, reason: 'no odi_per_hour -> Spo2Data unread'); + }); +} diff --git a/test/russian_review_findings_test.dart b/test/russian_review_findings_test.dart index 59ac92395..9bfbee005 100644 --- a/test/russian_review_findings_test.dart +++ b/test/russian_review_findings_test.dart @@ -327,6 +327,10 @@ void main() { ); testWidgets('category search accepts original and Russian blurb', (t) async { + // The firmware warning banner sits above the list; a tall surface keeps + // the filtered row built instead of lazily off-screen. + await t.binding.setSurfaceSize(const Size(800, 3000)); + addTearDown(() => t.binding.setSurfaceSize(null)); await t.pumpWidget(page(const DevicePickerScreen())); await t.pumpAndSettle(); final entry = kBleHrs; diff --git a/test/ui2_router_test.dart b/test/ui2_router_test.dart index 07b5db88c..08446c76a 100644 --- a/test/ui2_router_test.dart +++ b/test/ui2_router_test.dart @@ -32,6 +32,8 @@ import 'package:openstrap_edge/ui2/onboarding/welcome.dart' show isEncryptedBackup; import 'package:openstrap_edge/ui2/screens/log_workout.dart' show WorkoutSuggestionScreen; +import 'package:openstrap_edge/ui2/screens/detected_activities.dart' + show DetectedActivitiesScreen; import 'package:openstrap_edge/ui2/profile/devices.dart'; import 'package:openstrap_edge/ui2/profile/profile.dart'; import 'package:openstrap_edge/ui2/screens/nutrition_screen.dart' @@ -158,7 +160,7 @@ void main() { kRouteBreathing: ShellDomain.health, kRouteMeds: ShellDomain.health, kRouteWorkoutSuggestion: ShellDomain.today, - kRouteDetectedActivities: ShellDomain.today, + kRouteDetectedActivities: ShellDomain.activity, kRouteWater: ShellDomain.activity, kRouteWorkoutIdle: ShellDomain.activity, kRouteAlarm: ShellDomain.sleep, @@ -196,6 +198,21 @@ void main() { isNull); }); + test('the detected-nap deep link lands on the Activity tab, focused', () { + // "Did you nap?" has to open the review over the Activity tab — where + // the suggestion cards live — not over Home, with the nap's own card + // scrolled into view. + const id = '2026-08-19:nap:1755625800'; + final route = activitySuggestionRoute(id, 'nap'); + expect(domainForRoute(route), ShellDomain.activity); + final screen = screenForRoute(route); + expect(screen, isA()); + expect((screen! as DetectedActivitiesScreen).focusId, id); + // A detected workout rides the same route and the same domain. + expect(domainForRoute(activitySuggestionRoute(id, 'workout')), + ShellDomain.activity); + }); + test('the tab index the shell persists round-trips through the enum', () { for (final d in ShellDomain.values) { expect(ShellDomain.values[d.index], d); diff --git a/test/weight_trend_scale_test.dart b/test/weight_trend_scale_test.dart new file mode 100644 index 000000000..0ed1476be --- /dev/null +++ b/test/weight_trend_scale_test.dart @@ -0,0 +1,243 @@ +// The weight trend merges scale weighings with the journal's manual weight. +// Pins: one value per LOCAL day, the later of journal/scale wins, a flag-off +// scale and an out-of-band (someone else's) weighing never reach the line. + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/compute/inputs/canonical.dart'; +import 'package:openstrap_edge/compute/inputs/measurement_inputs.dart'; +import 'package:openstrap_edge/data/db.dart'; +import 'package:openstrap_edge/data/journal_fields.dart'; +import 'package:openstrap_edge/data/observation.dart'; +import 'package:openstrap_edge/ui2/screens/journal_compose.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +class _FakePathProvider extends PathProviderPlatform { + _FakePathProvider(this.root); + final String root; + @override + Future getTemporaryPath() async => root; + @override + Future getApplicationSupportPath() async => root; + @override + Future getApplicationDocumentsPath() async => root; + @override + Future getApplicationCachePath() async => root; + @override + Future getLibraryPath() async => root; + @override + Future getDownloadsPath() async => root; +} + +int _ms(int d, int h, [int m = 0]) => + DateTime(2026, 10, d, h, m).millisecondsSinceEpoch; + +/// `weightByDay` with a fixed clock after every fixture reading, so the +/// future-dated filter never depends on the date the tests run. +Map _byDay( + Map> manual, + List<({double kg, int atMs})> scaled, { + int? nowMs, + int fromMs = 0, +}) => weightByDay(manual, scaled, nowMs: nowMs ?? _ms(30, 0), fromMs: fromMs); + +({double kg, int atMs}) _w(double kg, int atMs) => (kg: kg, atMs: atMs); + +Map> _j( + String day, + double kg, [ + int? atMin, +]) => { + day: {'weight_kg': JournalMetricValue(kg, atMinuteOfDay: atMin)}, +}; + +void main() { + group('weightByDay', () { + test('scale-only day', () { + expect(_byDay({}, [_w(80, _ms(3, 7))]), {'2026-10-03': 80.0}); + }); + + test('manual-only day', () { + expect(_byDay(_j('2026-10-03', 79.5), []), {'2026-10-03': 79.5}); + }); + + test('no data stays absent', () { + expect(_byDay({}, []), isEmpty); + }); + + test('latest weighing of the day stands for it', () { + expect(_byDay({}, [_w(81, _ms(3, 20)), _w(80, _ms(3, 7))]), { + '2026-10-03': 81.0, + }); + }); + + test('both on one day: the later one wins', () { + // Scale at 07:00 (420), journal at 21:00 (1260): journal is later. + expect(_byDay(_j('2026-10-03', 79, 1260), [_w(80, _ms(3, 7))]), { + '2026-10-03': 79.0, + }); + // Journal at 06:00, scale at 07:00: scale is later. + expect(_byDay(_j('2026-10-03', 79, 360), [_w(80, _ms(3, 7))]), { + '2026-10-03': 80.0, + }); + // Journal without a time: the scale wins. + expect(_byDay(_j('2026-10-03', 79), [_w(80, _ms(3, 7))]), { + '2026-10-03': 80.0, + }); + }); + + test('a gradual 95 to 80 kg loss keeps every weighing', () { + final ws = [ + for (var i = 0; i < 16; i++) + _w(95.0 - i, _ms(1, 7) + i * 86400000), + ]; + expect(_byDay({}, ws).length, 16); + }); + + test('a household outlier amid ~80 kg is dropped', () { + final out = _byDay({}, [ + _w(80, _ms(3, 7)), + _w(120, _ms(4, 7)), + _w(79.5, _ms(5, 7)), + ]); + expect(out, {'2026-10-03': 80.0, '2026-10-05': 79.5}); + }); + + test('a guest seeding the chain does not lock the user out', () { + final out = _byDay({}, [ + _w(20, _ms(1, 7)), + _w(70, _ms(2, 7)), + _w(71, _ms(3, 7)), + _w(70, _ms(4, 7)), + ]); + expect(out['2026-10-02'], 70.0); + expect(out['2026-10-03'], 71.0); + expect(out['2026-10-04'], 70.0); + }); + + test('a lasting step in weight re-seeds after three weighings', () { + final out = _byDay({}, [ + _w(90, _ms(1, 7)), + _w(79, _ms(2, 7)), + _w(79, _ms(3, 7)), + _w(80, _ms(4, 7)), + _w(80, _ms(5, 7)), + ]); + expect(out, { + '2026-10-01': 90.0, + '2026-10-02': 79.0, + '2026-10-03': 79.0, + '2026-10-04': 80.0, + '2026-10-05': 80.0, + }); + }); + + test('a lone outlier or a two-weighing run stays dropped', () { + final lone = _byDay({}, [ + _w(70, _ms(1, 7)), + _w(120, _ms(2, 7)), + _w(71, _ms(3, 7)), + ]); + expect(lone, {'2026-10-01': 70.0, '2026-10-03': 71.0}); + final two = _byDay({}, [ + _w(70, _ms(1, 7)), + _w(120, _ms(2, 7)), + _w(121, _ms(3, 7)), + _w(71, _ms(4, 7)), + ]); + expect(two, {'2026-10-01': 70.0, '2026-10-04': 71.0}); + }); + + test('three unrelated outliers do not re-seed', () { + final out = _byDay({}, [ + _w(70, _ms(1, 7)), + _w(120, _ms(2, 7)), + _w(40, _ms(3, 7)), + _w(120, _ms(4, 7)), + _w(71, _ms(5, 7)), + ]); + expect(out, {'2026-10-01': 70.0, '2026-10-05': 71.0}); + }); + + test('readings before the window anchor the chain; only the window plots', + () { + final ws = [ + _w(70, _ms(1, 7)), + _w(70, _ms(2, 7)), + _w(30, _ms(5, 7)), + _w(71, _ms(6, 7)), + ]; + // The window opens on the 4th: the 30 kg reading is judged against the + // earlier 70 kg history, not allowed to seed a chain of its own. + expect(_byDay({}, ws, fromMs: _ms(4, 0)), {'2026-10-06': 71.0}); + // Moving the window start must not change a day that is still inside it. + expect(_byDay({}, ws, fromMs: _ms(5, 0)), {'2026-10-06': 71.0}); + }); + + test('weighings dated after now are dropped', () { + final out = _byDay( + {}, + [_w(80, _ms(3, 7)), _w(80, _ms(9, 7))], + nowMs: _ms(5, 7), + ); + expect(out, {'2026-10-03': 80.0}); + }); + + test('buckets by local day near midnight', () { + final out = _byDay({}, [ + _w(80, DateTime(2026, 10, 3, 23, 59).millisecondsSinceEpoch), + _w(81, DateTime(2026, 10, 4, 0, 1).millisecondsSinceEpoch), + ]); + expect(out, {'2026-10-03': 80.0, '2026-10-04': 81.0}); + }); + }); + + group('weighings gate', () { + late Directory tmp; + const scale = 'scale-1'; + + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + tmp = await Directory.systemTemp.createTemp('openstrap_weight_trend_'); + PathProviderPlatform.instance = _FakePathProvider(tmp.path); + LocalDb.dbName = 'openstrap_weight_trend_test.db'; + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + await LocalDb.upsertDevice(id: scale, adapterId: 'miscale_bc'); + for (final (d, kg) in [(3, 78.0), (4, 120.0)]) { + await LocalDb.putObservation( + Observation( + sourceKind: ObservationSource.vendor, + key: 'weight', + value: kg, + unit: 'kg', + attribution: 'Mi Scale', + at: DateTime(2026, 10, d, 7), + ), + deviceId: scale, + ); + } + }); + + tearDownAll(() async { + await LocalDb.close(); + if (await tmp.exists()) await tmp.delete(recursive: true); + }); + + test('flag off: excluded; flag on: only in-band weighings', () async { + await LocalDb.setCursor(wearableEnabledCursor('miscale_bc'), '0'); + expect(await weighings(near: 80), isEmpty); + + await LocalDb.setCursor(wearableEnabledCursor('miscale_bc'), '1'); + final w = await weighings(near: 80); // 120 kg is outside the band + expect([for (final x in w) x.kg], [78.0]); + expect(w.single.atMs, _ms(3, 7)); + // No profile weight: nothing to compare against, both count. + expect((await weighings()).length, 2); + }); + }); +}