From d612ea98cf197724baff73f854dfba6a94f1b591 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Mon, 6 Jul 2026 00:19:51 +0530 Subject: [PATCH] fix: sync-freshness banner, burst validation, and UI/UX polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes a persistent "last data > 1hr behind" regression: the freshness signal only ever read decoded_onehz's max timestamp (misses R10-lite records) and only ever refreshed at app boot; now reads the rec_ts_hw sync cursor and refreshes on every successful persist (onDataStored), foreground or background. Also makes HISTORY_END packet-count validation advisory instead of a hard gate — a mismatch used to discard an entire buffered chunk and re-request it forever, since the band's reported count and our tally don't have fully-confirmed semantics; every buffered record already passed CRC32 + plausibility checks, so a count mismatch was never real evidence of bad data. UI/UX: Profile screen gets a working back button (AppScaffold) and drops decorative icons + the Storage section; Today screen fixes a list-reflow glitch behind the freshness banner (stable keys) and adds context to the weekly steps card; MetricRow drops its per-row icon (icon spam) and fixes numeric clipping (FittedBox, never ellipsis); Journey/Timeline merge into one multi-vital lookback; workout-type icons wired through a new shared workout_types.dart seam. --- lib/ble/ble_engine.dart | 105 +++++++-- lib/state/app_state.dart | 60 ++++- lib/ui/journey/journey_screen.dart | 299 ++---------------------- lib/ui/profile/profile_screen.dart | 84 ++----- lib/ui/recap/recap_screen.dart | 8 +- lib/ui/screens/detail_cards.dart | 48 ---- lib/ui/screens/metric_row.dart | 44 ++-- lib/ui/timeline/timeline_screen.dart | 24 +- lib/ui/today/today_screen.dart | 142 ++++++----- lib/ui/workouts/workout_types.dart | 90 +++++++ lib/ui/workouts/workouts_screen.dart | 104 ++------- pubspec.lock | 18 +- test/ble_engine_test.dart | 55 +++++ test/design_redesign_test.dart | 2 +- test/history_screens_redesign_test.dart | 32 +-- test/os_icons_wiring_test.dart | 25 +- 16 files changed, 501 insertions(+), 639 deletions(-) create mode 100644 lib/ui/workouts/workout_types.dart diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index c7b0447..71b140f 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -143,6 +143,28 @@ int nextBurstStablePollStreak({ bool shouldPauseMaintenanceTraffic({required bool offloadActive}) => offloadActive; +/// Whether a HISTORY_END burst's packet accounting matches what the band +/// reported sending (`expectedPacketCount`, from the metadata frame). +/// +/// [actualBurstPacketCount] only counts packets that reached +/// onHistoricalRecord/onUndecodableRecord — i.e. that PASSED the RecordGate +/// plausibility check. A record the gate rejects (a stale/wandering-clock +/// block — see RecordGate.admit) is, by design, "neither stored nor +/// counted": it never reaches either callback. The band's own count has no +/// such carve-out — it just counts every packet it physically transmitted. +/// [droppedThisBurst] (RecordGate.dropped delta across this burst) must be +/// added back in before comparing, or a burst containing even one +/// gate-rejected record can never validate — which discards its OTHER, +/// perfectly good buffered records and re-requests the same stuck block +/// forever (zero sync progress). +@visibleForTesting +bool burstPacketCountMatches({ + required int expectedPacketCount, + required int actualBurstPacketCount, + required int droppedThisBurst, +}) => + expectedPacketCount == actualBurstPacketCount + droppedThisBurst; + /// Fired for every LIVE high-rate frame (0x28/0x2B/0x33). These are EPHEMERAL — /// they are NOT persisted to raw_records (that bloated storage ~50x and stalled /// derivation). The caller routes them to an in-memory sink for the live UI / @@ -163,7 +185,6 @@ enum _HpsTerminalKind { success, timeout, disconnected, - error, } class _HpsTerminal { @@ -603,6 +624,11 @@ class BleEngine { // EVERY historical-record path — see RecordGate in ble_state.dart. Re-seeded // on each connect from the durable cursor. RecordGate _recordGate = RecordGate(); + // Snapshot of `_recordGate.dropped` at the last HISTORY_START — lets the + // HISTORY_END validator (below) tell "the band sent fewer packets than it + // said" apart from "we correctly, silently rejected some as implausible + // (stale-clock block) and never tallied them." See _handleSyncMarker. + int _burstDroppedAtStart = 0; // Per-revision packet accounting for the historical drain (gap detection + // honest per-version counts surfaced to the debug screens). final Map _historicalVersionCounts = {}; @@ -722,6 +748,11 @@ class BleEngine { 'records_seen': _drain?.records ?? 0, 'batches_acked': _drain?.batches ?? 0, 'buffered_records': _drain?.bufferedRecords ?? 0, + // Connection-wide plausibility-gate rejections (RecordGate.dropped) — see + // burstPacketCountMatches for why these must be added back to the burst + // packet count before comparing against the band's expectedPacketCount. + 'gate_dropped_total': _recordGate.dropped, + 'gate_dropped_this_burst': _recordGate.dropped - _burstDroppedAtStart, 'history_requests': _historyRequests, 'history_completions': _historyCompletions, 'successful_bursts': _successfulBursts, @@ -1796,6 +1827,7 @@ class BleEngine { d.discardOpenChunk(); } _session?.historicalRetry?.cancel(); + _burstDroppedAtStart = _recordGate.dropped; d?.rearm(); _setOffloadActive(true); return; @@ -1812,45 +1844,54 @@ class BleEngine { } await _awaitBurstTrafficSettle(d); final expected = m.expectedPacketCount; - if (expected != null && !d.validateBurst(expectedPacketCount: expected)) { + // Records the plausibility gate silently rejected THIS burst (stale/ + // wandering-clock block — by design, "neither stored nor counted", + // see RecordGate.admit) never reach onHistoricalRecord/ + // onUndecodableRecord, so they never entered currentBurstPacketCount. + final droppedThisBurst = _recordGate.dropped - _burstDroppedAtStart; + final validated = expected == null || + d.validateBurst( + expectedPacketCount: expected, + droppedThisBurst: droppedThisBurst, + ); + // ADVISORY ONLY, never a gate: `expectedPacketCount`'s exact semantics + // (which transport packet types the band itself counts — command + // responses interleaved with the burst? retried/duplicate frames?) are + // not fully reverse-engineered, and field data shows the gap between + // expected and actual varies run to run with no fixed offset. What IS + // fully verified is frame-level CRC32 (framing.dart) and the RecordGate + // plausibility check — both already ran on every buffered record before + // we ever get here. So a count mismatch is NOT evidence of corrupt or + // missing data; treating it as fatal was actively harmful: on mismatch + // the OLD behavior discarded the entire buffered chunk (throwing away + // perfectly good, already-CRC-verified, already-gate-passed records), + // told the band FAIL, and re-requested the same block — forever, since + // nothing about a retry changes the count relationship. Zero sync + // progress, "last data" frozen indefinitely. Log the mismatch (still + // useful signal — see the sync-diagnostics screen) and commit anyway. + if (!validated) { _log( - '[SYNC] Burst validation failed ' + '[SYNC] Burst packet-count mismatch (advisory, NOT blocking commit) ' '(attempt ${d.consecutiveValidationFailures}): expected=$expected, ' 'actual=${d.currentBurstPacketCount}, ' + 'dropped_this_burst=$droppedThisBurst, ' 'historical=${d.currentBurstHistoricalPacketCount}, ' 'traffic=${d.currentBurstTrafficCount}, ' 'breakdown=${d.currentBurstBreakdown}', ); - d.discardOpenChunk(); - final fail = buildHistoryResultFail(_seq.nextSync()); - _log( - '[SYNC] FAIL frame=' - '${fail.map((b) => b.toRadixString(16).padLeft(2, '0')).join()}', - ); - await _write(fail); await LocalDb.upsertSyncLedgerEntry( - status: 'validation_failed', + status: 'validated_with_mismatch', lastError: 'burst_packet_mismatch', metaPatch: { 'expected_burst_packets': expected, 'actual_burst_packets': d.currentBurstPacketCount, + 'dropped_this_burst': droppedThisBurst, 'historical_burst_packets': d.currentBurstHistoricalPacketCount, 'traffic_burst_packets': d.currentBurstTrafficCount, 'burst_validation_failures': d.consecutiveValidationFailures, 'burst_breakdown': d.currentBurstBreakdown, }, ); - if (d.consecutiveValidationFailures >= 15) { - _log( - '[SYNC] Burst validation stuck after ' - '${d.consecutiveValidationFailures} failures.', - ); - _setHpsTerminal(_HpsTerminalKind.error, reason: 'stuck', drain: d); - _setOffloadActive(false); - return; - } - unawaited(_abortAndRetryHistorical(reason: 'burst_validation_failed')); - return; } _successfulBursts++; _mergeValidatedBurst(d); @@ -2600,9 +2641,23 @@ class _DrainController { void onBurstUnknown() => burstStats.onUnknown(); - bool validateBurst({required int expectedPacketCount}) { - final actual = currentBurstPacketCount; - if (expectedPacketCount == actual) { + /// [droppedThisBurst] = records the plausibility gate rejected during this + /// same burst (stale/wandering-clock block) — never tallied into + /// [currentBurstPacketCount] (they're never stored), but the band's own + /// [expectedPacketCount] counts them anyway since it just counts what it + /// physically transmitted. Add them back in before comparing, or a burst + /// that legitimately contains even one gate-rejected record can never + /// validate — discarding otherwise-good buffered records and looping + /// forever on the same stuck block. + bool validateBurst({ + required int expectedPacketCount, + int droppedThisBurst = 0, + }) { + if (burstPacketCountMatches( + expectedPacketCount: expectedPacketCount, + actualBurstPacketCount: currentBurstPacketCount, + droppedThisBurst: droppedThisBurst, + )) { consecutiveValidationFailures = 0; return true; } diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 7948edc..7864ca6 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -1087,9 +1087,22 @@ class AppState extends ChangeNotifier { /// Debounced "new data stored" callback from the engine (continuous listening has /// no discrete sync end). The engine already coalesced the burst; we run a single /// LIGHT derive over the affected day(s) and refresh DB counts for the UI. + /// + /// This is also THE reliable place to refresh `_lastRecTs` (the "last data" + /// freshness banner reads it). `_runSyncBurst`'s own before/after frontier + /// check can race the async commit — HISTORY_END's commit+ACK sometimes + /// lands just after `engine.runSync()` already returned, so that + /// checkpoint-based refresh can miss a burst entirely. This callback fires + /// on EVERY successful persist path (foreground burst, background/headless + /// drain, live-triggered store) after the write is durable, so it can't + /// race it — same guarantee dbCounts already relies on above. void _onDataStored() { unawaited(() async { dbCounts = await LocalDb.counts(); + final recTsHw = await LocalDb.getCursorInt('rec_ts_hw'); + if (recTsHw != null && recTsHw > (_lastRecTs ?? 0)) { + _lastRecTs = recTsHw; + } notifyListeners(); _deriveScheduler.markStoredData(); }()); @@ -1109,7 +1122,18 @@ class AppState extends ChangeNotifier { await _loadProfile(); await _deriveScheduler.init(); lastSynced = await LocalDb.latestSample(); - _lastRecTs = await LocalDb.lastDecodedRecTs() ?? lastSynced?.tsEpoch; + // The true data-edge frontier is the `rec_ts_hw` sync cursor, NOT + // lastDecodedRecTs() (MAX(rec_ts) FROM decoded_onehz). decoded_onehz only + // gets a row when a record decodes to the FULL 1 Hz shape (R24-family); + // historical R10 "lite" records (hr-only, no accel/optical) decode fine + // but land in `samples` instead — so on an R10-lite-heavy backlog, + // decoded_onehz's max freezes while the strap is genuinely, successfully + // syncing, and "last data" reads as stuck/stale. `rec_ts_hw` advances for + // every record commitSyncBatch durably persists, decoded_onehz-eligible + // or not, so it's the honest frontier (same one RecordGate/backfill + // policies already trust). + _lastRecTs = + await LocalDb.getCursorInt('rec_ts_hw') ?? lastSynced?.tsEpoch; dbCounts = await LocalDb.counts(); await LocalDb.refreshComputeFreshness(); _savedAlarm = (await SharedPreferences.getInstance()).getInt('alarm_epoch'); @@ -1383,7 +1407,19 @@ class AppState extends ChangeNotifier { } /// Reset the live step counter for a fresh connected session. + /// + /// This zeroes the connection-lifetime raw counter (`_liveRaw`). If a + /// workout is active, `_workoutRawBase` was snapshotted from a *previous* + /// (now-stale) `_liveRaw` value — left untouched, `workoutSteps` would + /// compute a negative delta on the next BLE disconnect/reconnect blip, + /// clamp to 0, and visibly reset the walk's step count instead of counting + /// monotonically. Rebase it here so the already-accrued workout steps + /// carry through the reset. void _resetLivePedometer() { + if (activeWorkout != null && _workoutRawBase != null) { + final accruedRaw = _liveRaw - _workoutRawBase!; + _workoutRawBase = accruedRaw > 0 ? -accruedRaw : 0; + } _magMin.clear(); _committedRaw = 0; _liveSamples = 0; @@ -1571,7 +1607,12 @@ class AppState extends ChangeNotifier { }) async { var last = SyncReport(0, 0, false); for (var i = 0; i < maxSessions && engine.isConnected; i++) { - final frontierBefore = await LocalDb.lastDecodedRecTs(); + // rec_ts_hw, not lastDecodedRecTs() — see the boot-time seed above for + // why: an R10-lite-heavy backlog can genuinely advance without ever + // touching decoded_onehz, and this "did we make progress" check must + // not mistake that for a stuck drain (spin-guard/backlogRemains below + // read frontierAfter too). + final frontierBefore = await LocalDb.getCursorInt('rec_ts_hw'); if (kickFirst || i > 0) { await engine.requestHistorySync(); } @@ -1579,7 +1620,20 @@ class AppState extends ChangeNotifier { final report = await engine.runSync( timeout: const Duration(seconds: 180), ); - final frontierAfter = await LocalDb.lastDecodedRecTs(); + final frontierAfter = await LocalDb.getCursorInt('rec_ts_hw'); + // Refresh the freshness signal the "last data" banner reads from EVERY + // burst session, not just at app boot. `_lastRecTs` was previously only + // ever seeded in `_init()` — during a real historical drain, records go + // through `_DrainController.onHistoricalRecord` → `onCommitBatch` + // (bypassing `_onRecord`'s in-memory bump, which only fires on the rare + // pre-drain-setup fallback path), so a session left open kept showing + // "more than an hour behind" no matter how much fresh data actually + // synced, until the app was fully restarted. Bump + notify here so the + // UI reflects real progress as it happens, mid-burst. + if (frontierAfter != null && frontierAfter > (_lastRecTs ?? 0)) { + _lastRecTs = frontierAfter; + notifyListeners(); + } final strapNewest = engine.strapHistoryNewestTs; final frontierAdvanced = frontierAfter != null && diff --git a/lib/ui/journey/journey_screen.dart b/lib/ui/journey/journey_screen.dart index 24adf22..e5c37d5 100644 --- a/lib/ui/journey/journey_screen.dart +++ b/lib/ui/journey/journey_screen.dart @@ -1,7 +1,9 @@ -// One day, hour by hour — on the bento design language: the 24h HR timeline -// (with the tap-to-replay overlay), peak/low HR stat tiles, movement, and a -// clean workout list. Backed by /day/timeline; presentation lives in -// [JourneyContent] (pure, render-testable). +// Your day, every vital, one lookback — on the bento design language: the +// merged multi-vital timeline (heart rate, HRV, respiration, skin temp — one +// line per vital, each its own color, values hidden until you touch/scrub; +// see [TimelineContent]), movement, and a clean workout list. Backed by +// /day/timeline; presentation lives in [JourneyContent] (pure, +// render-testable). import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -9,6 +11,8 @@ import 'package:provider/provider.dart'; import '../../data/local_repository.dart'; import '../../state/app_state.dart'; import '../design/design.dart'; +import '../timeline/timeline_screen.dart' show TimelineContent; +import '../workouts/workout_types.dart'; class JourneyScreen extends StatefulWidget { final String date; // 'YYYY-MM-DD' @@ -107,7 +111,8 @@ class _JourneyScreenState extends State { } /// Pure presentation for the day-journey board (render-testable without a -/// repo): timeline tile + HR replay, peak/low bento, movement, workouts. +/// repo): the merged multi-vital lookback (heart rate, HRV, resp, skin temp), +/// movement, workouts. class JourneyContent extends StatelessWidget { final Map data; @@ -122,13 +127,10 @@ class JourneyContent extends StatelessWidget { }); static bool isEmptyPayload(Map d) => - _pointsOf(d['hr']).isEmpty && _listOf(d['sessions']).isEmpty; + !TimelineContent.hasVitals(d) && _listOf(d['sessions']).isEmpty; // ── defensive parsing helpers ───────────────────────────────────────────── - static Map _mapOf(Object? v) => - v is Map ? v.cast() : const {}; - static List> _listOf(Object? v) => v is List ? [ for (final e in v) @@ -157,7 +159,6 @@ class JourneyContent extends StatelessWidget { int get _dayStart => _numOf(data['day_start'])?.toInt() ?? 0; List> get _sessions => _listOf(data['sessions']); - List> get _sleep => _listOf(data['sleep']); // ── formatting (no intl) ────────────────────────────────────────────────── @@ -217,285 +218,19 @@ class JourneyContent extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: dsStaggered([ - // Minute-level 24h timeline + movement only for recent days; workouts - // below come from permanent tables and always show. + // The merged multi-vital lookback + movement only for recent days; + // workouts below come from permanent tables and always show. if (detailed) ...[ - _timelineTile(), - const SizedBox(height: Sp.x3), - _highsBento(), + TimelineContent(data: data), const SizedBox(height: Sp.x3), _movementTile(), ] else - const DetailRetentionNote(what: '24-hour timeline'), + const DetailRetentionNote(what: 'the day lookback'), ..._workoutsSection(), ]), ); } - // ── peak / low HR bento ─────────────────────────────────────────────────── - - Widget _highsBento() { - final highs = _mapOf(data['highs']); - final peak = _mapOf(highs['peak_hr']); - final low = _mapOf(highs['low_hr']); - return BentoColumns( - entrance: false, - left: [ - _highTile( - 'Peak HR', - Ic.pulse, - OsIcon.maxHeartRate, - DomainAccent.heart, - _numOf(peak['v'])?.round(), - _numOf(peak['t'])?.toInt(), - ), - ], - right: [ - _highTile( - 'Lowest HR', - Ic.heart, - OsIcon.restingHeartRate, - DomainAccent.oxygen, - _numOf(low['v'])?.round(), - _numOf(low['t'])?.toInt(), - ), - ], - ); - } - - Widget _highTile(String label, IconData icon, OsIcon osIcon, Color accent, - int? bpm, int? ts) { - return BentoTile( - tone: BentoTone.soft, - accent: accent, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - TileHeader(label, icon: icon, osIcon: osIcon), - const SizedBox(height: Sp.x2), - BigStat( - value: bpm?.toString(), - unit: 'bpm', - caption: bpm == null ? 'No reading' : 'at ${_hm(ts)}', - ), - ], - ), - ); - } - - // ── the timeline (24h HR line + replay + context band) ──────────────────── - - Widget _timelineTile() { - final hr = _pointsOf(data['hr']); - final points = [for (final p in hr) TimeSeriesPoint(p.t.toDouble(), p.v)]; - final endSec = _isToday - ? DateTime.now().millisecondsSinceEpoch / 1000.0 - : _dayStart + 86400.0; - final first = hr.isEmpty ? null : hr.first; - final last = hr.isEmpty ? null : hr.last; - // Match the chart's own auto y-range (min/max + 12% pad) so the replay dot - // rides the drawn line. - final ys = [for (final p in points) p.y]; - final minYRaw = ys.isEmpty ? 0.0 : ys.reduce((a, b) => a < b ? a : b); - final maxYRaw = ys.isEmpty ? 1.0 : ys.reduce((a, b) => a > b ? a : b); - final yPad = (maxYRaw - minYRaw) * 0.12 < 2.0 - ? 2.0 - : (maxYRaw - minYRaw) * 0.12; - final chart = TimeSeriesChart( - points: points, - color: DomainAccent.heart, - height: 260, - minX: _dayStart.toDouble(), - maxX: endSec, - yUnit: ' bpm', - tooltip: (p) { - final dt = DateTime.fromMillisecondsSinceEpoch( - (p.x * 1000).round(), - ).toLocal(); - return '${dt.hour}:${dt.minute.toString().padLeft(2, '0')}' - '\n${p.y.round()} bpm'; - }, - ); - return BentoTile( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - const TileHeader( - 'The timeline', - icon: Ic.pulse, - trailing: InfoDot( - title: 'Your 24-hour timeline', - body: - 'Minute-level heart rate across the day. Sleep and workout ' - 'context is drawn on the band under the chart. Tap the ' - 'chart to replay the day.', - ), - ), - const SizedBox(height: Sp.x3), - if (points.length >= 2) - Stack( - children: [ - chart, - HrReplayOverlay( - points: points, - loX: _dayStart.toDouble(), - // Same SNAPPED bound the chart draws with, or the replay dot - // rides off the drawn curve. - hiX: TimeSeriesChart.stableTimeUpperBound( - _dayStart.toDouble(), - endSec, - ), - loY: minYRaw - yPad, - hiY: maxYRaw + yPad, - chartHeight: 260, - leftPad: 52, - topInset: 0, - color: DomainAccent.heart, - ), - ], - ) - else - chart, - const SizedBox(height: Sp.x4), - _contextBand(), - if (first != null && last != null) ...[ - const SizedBox(height: Sp.x4), - Row( - children: [ - Expanded( - child: BigStat( - value: '${first.v.round()}', - unit: 'bpm', - label: 'Start', - size: BigStatSize.md, - ), - ), - Expanded( - child: BigStat( - value: '${last.v.round()}', - unit: 'bpm', - label: 'Latest', - size: BigStatSize.md, - ), - ), - Expanded( - child: BigStat( - value: ((last.t - first.t) / 3600).toStringAsFixed(1), - unit: 'h', - label: 'Span', - size: BigStatSize.md, - ), - ), - ], - ), - ], - if (_sleep.isNotEmpty || _sessions.isNotEmpty) ...[ - const SizedBox(height: Sp.x3), - Row( - children: [ - if (_sleep.isNotEmpty) - _legendDot(DomainAccent.sleep, 'Sleep'), - if (_sleep.isNotEmpty && _sessions.isNotEmpty) - const SizedBox(width: Sp.x4), - if (_sessions.isNotEmpty) - _legendDot(DomainAccent.strain, 'Workout'), - ], - ), - ], - ], - ), - ); - } - - Widget _legendDot(Color c, String label) => Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 10, - height: 10, - decoration: BoxDecoration( - color: c, - borderRadius: BorderRadius.circular(3), - ), - ), - const SizedBox(width: 6), - Text(label, style: AppText.captionMuted), - ], - ); - - /// 0..1 fraction of where an epoch falls in the day's 24h window. - double _frac(int ts) { - final start = _dayStart; - if (start <= 0) return 0; - return ((ts - start) / 86400.0).clamp(0.0, 1.0); - } - - /// A thin band of positioned colored rects: sleep blocks under workout - /// sessions, placed by (ts - day_start)/86400 fraction. - Widget _contextBand() { - const h = 14.0; - final segments = []; - - void addSeg(int? start, int? end, Color color, double opacity) { - if (start == null || end == null || end <= start || _dayStart <= 0) { - return; - } - final left = _frac(start); - final right = _frac(end); - final width = (right - left).clamp(0.0, 1.0); - if (width <= 0) return; - segments.add( - Align( - alignment: Alignment(left * 2 - 1, 0), - child: FractionallySizedBox( - widthFactor: width, - alignment: Alignment.centerLeft, - child: Container( - height: h, - decoration: BoxDecoration( - color: color.withValues(alpha: opacity), - borderRadius: BorderRadius.circular(R.pill), - ), - ), - ), - ), - ); - } - - for (final s in _sleep) { - addSeg( - _numOf(s['onset_ts'])?.toInt(), - _numOf(s['wake_ts'])?.toInt(), - DomainAccent.sleep, - 0.7, - ); - } - for (final s in _sessions) { - addSeg( - _numOf(s['start_ts'])?.toInt(), - _numOf(s['end_ts'])?.toInt(), - DomainAccent.strain, - 0.85, - ); - } - - return ClipRRect( - borderRadius: BorderRadius.circular(R.pill), - child: Container( - height: h, - decoration: BoxDecoration( - color: AppColors.surfaceAlt, - borderRadius: BorderRadius.circular(R.pill), - ), - // The track is full width; segments are positioned within it. Align - // uses the parent's full width so left/width fractions map to 24h. - child: Stack(alignment: Alignment.centerLeft, children: segments), - ), - ); - } - // ── movement ────────────────────────────────────────────────────────────── Widget _movementTile() { @@ -584,8 +319,8 @@ class JourneyContent extends StatelessWidget { if (max != null) 'max $max bpm', ].join(' · '); return ListRow( - icon: Ic.run, - osIcon: OsIcon.workouts, + icon: workoutTypeIcon(type), + osIcon: workoutTypeOsIcon(type) ?? OsIcon.workouts, iconColor: DomainAccent.strain, title: type.isEmpty ? 'Workout' : _titleCase(type), subtitle: meta, diff --git a/lib/ui/profile/profile_screen.dart b/lib/ui/profile/profile_screen.dart index 85c7ab3..0d063f0 100644 --- a/lib/ui/profile/profile_screen.dart +++ b/lib/ui/profile/profile_screen.dart @@ -57,33 +57,19 @@ class ProfileScreen extends StatelessWidget { final user = app.user ?? const {}; final name = (user['name'] ?? '').toString().trim(); - return SafeArea( - bottom: false, - child: ListView( - physics: const BouncingScrollPhysics( - parent: AlwaysScrollableScrollPhysics(), - ), - padding: const EdgeInsets.fromLTRB(Sp.screen, Sp.x6, Sp.screen, 0), - children: [ - // ── Header ─────────────────────────────────────────────────── - Row( - children: [ - Expanded( - child: Text( - name.isEmpty ? 'Your profile' : name, - style: AppText.h1, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: Sp.x2), - RoundIconButton(Ic.edit, - osIcon: OsIcon.edit, - onTap: () => _editProfileSheet(context, app)), - ], - ), - const SizedBox(height: Sp.x6), - + return AppScaffold( + titleWidget: Text( + name.isEmpty ? 'Your profile' : name, + style: AppText.h1, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + actions: [ + RoundIconButton(Ic.edit, + osIcon: OsIcon.edit, + onTap: () => _editProfileSheet(context, app)), + ], + children: [ // ── Your device ────────────────────────────────────────────── const SectionHeader('Your device'), _deviceTile(context, app), @@ -97,29 +83,24 @@ class ProfileScreen extends StatelessWidget { const SectionHeader('Profile'), _SettingsCard(rows: [ ListRow( - icon: Ic.profile, - osIcon: OsIcon.profile, title: 'Name', value: name.isEmpty ? 'Add' : name, divider: true, onTap: () => _editProfileSheet(context, app), ), ListRow( - icon: Ic.heart, title: 'Sex', value: _sexLabel(user['sex']?.toString()), divider: true, onTap: () => _editProfileSheet(context, app), ), ListRow( - icon: Ic.calendar, title: 'Age', value: user['age'] != null ? '${user['age']}' : 'Add', divider: true, onTap: () => _editProfileSheet(context, app), ), ListRow( - icon: Ic.activity, title: 'Height', value: user['height_cm'] != null ? units.height(user['height_cm'] as num?) @@ -128,7 +109,6 @@ class ProfileScreen extends StatelessWidget { onTap: () => _editProfileSheet(context, app), ), ListRow( - icon: Ic.fire, title: 'Weight', value: user['weight_kg'] != null ? units.weight(user['weight_kg'] as num?) @@ -384,41 +364,6 @@ class ProfileScreen extends StatelessWidget { ]), const SizedBox(height: Sp.x6), - // ── Storage ────────────────────────────────────────────────── - // CLOUD EXCISED: there is no backend. Everything lives on this device. - const SectionHeader('Storage'), - SurfaceCard( - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(Sp.x3), - decoration: BoxDecoration( - color: AppColors.surfaceAlt, - borderRadius: BorderRadius.circular(R.chip), - ), - child: AppIcon(Ic.shield, size: 20, color: AppColors.inkSoft), - ), - const SizedBox(width: Sp.x3), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('On this device', style: AppText.title), - const SizedBox(height: 2), - Text( - 'Your band data and metrics stay on this phone — ' - 'nothing is uploaded to a server.', - style: AppText.captionMuted, - ), - ], - ), - ), - ], - ), - ), - - const SizedBox(height: Sp.x6), - // ── Community ──────────────────────────────────────────────── const SectionHeader('Community'), SurfaceCard( @@ -518,8 +463,7 @@ class ProfileScreen extends StatelessWidget { ), const SizedBox(height: 110), - ], - ), + ], ); } diff --git a/lib/ui/recap/recap_screen.dart b/lib/ui/recap/recap_screen.dart index aad7690..bc9c8a6 100644 --- a/lib/ui/recap/recap_screen.dart +++ b/lib/ui/recap/recap_screen.dart @@ -18,6 +18,7 @@ import '../../data/local_repository.dart'; import '../../state/app_state.dart'; import '../../state/prefs.dart'; import '../design/design.dart'; +import '../workouts/workout_types.dart'; class RecapScreen extends StatefulWidget { const RecapScreen({super.key}); @@ -530,7 +531,12 @@ class RecapShareCard extends StatelessWidget { ), child: Row( children: [ - AppIcon(Ic.run, size: 16, color: DomainAccent.strain), + workoutTypeOsIcon(top['type']?.toString()) != null + ? OsAppIcon( + workoutTypeOsIcon(top['type']?.toString())!, + size: 20, + ) + : AppIcon(Ic.run, size: 16, color: DomainAccent.strain), const SizedBox(width: Sp.x2), Expanded( child: Text( diff --git a/lib/ui/screens/detail_cards.dart b/lib/ui/screens/detail_cards.dart index f1aaf38..6a0758c 100644 --- a/lib/ui/screens/detail_cards.dart +++ b/lib/ui/screens/detail_cards.dart @@ -219,7 +219,6 @@ class HeartDayContent extends StatelessWidget { final hrv = (d['hrv'] as Map?); final zones = (d['zones'] as Map?); final noct = (d['nocturnal'] as Map?); - final stress = (d['stress'] as Map?); final illness = (d['illness'] as Map?); final resp = (d['resp'] as Map?); final spo2 = (d['spo2'] as Map?); @@ -227,7 +226,6 @@ class HeartDayContent extends StatelessWidget { final irr24v = (irr24?['value'] is Map) ? (irr24!['value'] as Map).cast() : null; - final hrr = _n(d['hrr']); final brvHas = (d['brv'] is Map) && ((d['brv'] as Map)['value'] is Map); final baselines = (d['baselines'] as Map?); final dmap = (d['drivers'] as Map?) ?? const {}; @@ -384,52 +382,6 @@ class HeartDayContent extends StatelessWidget { ], ), ), - // HRR — 60-s recovery after effort. - BentoTile( - tone: BentoTone.soft, - accent: DomainAccent.strain, - onTap: hrr == null - ? null - : () => openTrend(context, - title: 'Heart-rate recovery', - metric: 'hrr', - icon: Ic.recovery, - osIcon: OsIcon.heartRateRecovery, - accent: DomainAccent.strain), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - const TileHeader('HR recovery', - icon: Ic.recovery, osIcon: OsIcon.heartRateRecovery), - const SizedBox(height: Sp.x2), - BigStat( - value: hrr?.toStringAsFixed(0), - unit: 'bpm', - caption: hrr == null ? null : '60 s after effort', - ), - ], - ), - ), - // Stress — the rose tile. - BentoTile( - accent: DomainAccent.stress, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - const TileHeader('Stress', - icon: Ic.strain, osIcon: OsIcon.stress), - const SizedBox(height: Sp.x2), - BigStat( - value: (stress?['score'] ?? stress?['si'])?.toString(), - unit: '/100', - caption: stress?['level']?.toString(), - captionAccent: true, - ), - ], - ), - ), ], ), diff --git a/lib/ui/screens/metric_row.dart b/lib/ui/screens/metric_row.dart index 9db1e36..658bcce 100644 --- a/lib/ui/screens/metric_row.dart +++ b/lib/ui/screens/metric_row.dart @@ -60,16 +60,19 @@ const Map kMetricInfo = { String? infoFor(String key) => kMetricInfo[key]; -/// A single metric line: [icon chip] label (i) ........ big value unit [›] +/// A single metric line: label (i) ........ big value unit [›] /// The explanation opens in an InfoSheet from the (i) — the row itself stays -/// a clean number. +/// a clean number. NO leading icon — a whole screen of "heading left, score +/// right" rows each with their own chip reads as noise; the icon belongs on +/// that metric's own dedicated screen/hero, not repeated on every list row. +/// [icon]/[osIcon]/[accent] are accepted-but-unused so the ~30 existing call +/// sites across detail_cards.dart/sleep_detail_screen.dart/ +/// strain_detail_screen.dart don't need touching — this is the one place to +/// change if that decision ever reverses. class MetricRow extends StatelessWidget { final IconData icon; /// Illustrated variant — takes precedence over [icon] inside the chip. - /// Rendered at 38px (the art carries built-in transparent padding, so it - /// needs a larger canvas than a stroke glyph to read at the same weight); - /// the chip footprint grows to 40px vs the glyph chip's 35px. final OsIcon? osIcon; final Color? accent; final String label; @@ -93,23 +96,11 @@ class MetricRow extends StatelessWidget { @override Widget build(BuildContext context) { - final accent = this.accent ?? AppColors.coral; final row = ConstrainedBox( constraints: const BoxConstraints(minHeight: 56), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ - Container( - padding: EdgeInsets.all(osIcon != null ? 1 : 9), - decoration: BoxDecoration( - color: accent.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(R.chip), - ), - child: osIcon != null - ? OsAppIcon(osIcon!, size: 38) - : AppIcon(icon, size: 17, color: accent), - ), - const SizedBox(width: Sp.x3), Flexible( child: Text( label, @@ -129,13 +120,20 @@ class MetricRow extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.baseline, textBaseline: TextBaseline.alphabetic, children: [ + // FittedBox, not ellipsis — a shrunk-but-whole figure is + // honest; a truncated one ("14…" for "142.5") reads as a + // different, wrong number. Matches BigStat's contract that a + // value must always render in full. Flexible( - child: Text( - value, - style: AppText.metricSm.copyWith(fontSize: 19), - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.right, + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerRight, + child: Text( + value, + style: AppText.metricSm.copyWith(fontSize: 19), + maxLines: 1, + textAlign: TextAlign.right, + ), ), ), if (unit != null) ...[ diff --git a/lib/ui/timeline/timeline_screen.dart b/lib/ui/timeline/timeline_screen.dart index 8376ef3..080636f 100644 --- a/lib/ui/timeline/timeline_screen.dart +++ b/lib/ui/timeline/timeline_screen.dart @@ -640,17 +640,23 @@ class _ChartPainter extends CustomPainter { } // ── active vital scale → Y-axis ticks (real units) ── + // HONESTY: no numbers shown until the chart is touched — a bare line + // invites eyeballing a false-precise value off an unlabeled axis. Only + // draw the grid + tick labels while actively scrubbing; the crosshair + // readout below is the source of truth for values. final act = vitals[active]; final (alo, ahi) = _range(act); - final grid = Paint() - ..color = AppColors.divider.withValues(alpha: 0.45) - ..strokeWidth = 1; - for (var i = 0; i <= 3; i++) { - final v = alo + (ahi - alo) * i / 3; - final yy = yNorm(v, alo, ahi); - canvas.drawLine(Offset(leftPad, yy), Offset(size.width, yy), grid); - _text(canvas, v.toStringAsFixed(act.decimals), Offset(0, yy - 6), - act.color.withValues(alpha: 0.8), 9); + if (scrubT != null) { + final grid = Paint() + ..color = AppColors.divider.withValues(alpha: 0.45) + ..strokeWidth = 1; + for (var i = 0; i <= 3; i++) { + final v = alo + (ahi - alo) * i / 3; + final yy = yNorm(v, alo, ahi); + canvas.drawLine(Offset(leftPad, yy), Offset(size.width, yy), grid); + _text(canvas, v.toStringAsFixed(act.decimals), Offset(0, yy - 6), + act.color.withValues(alpha: 0.8), 9); + } } // ── X time axis ── diff --git a/lib/ui/today/today_screen.dart b/lib/ui/today/today_screen.dart index e157044..3125365 100644 --- a/lib/ui/today/today_screen.dart +++ b/lib/ui/today/today_screen.dart @@ -175,17 +175,11 @@ class _TodayScreenState extends State osIcon: OsIcon.edit, onTap: () => _push(() => const JournalScreen()), ), - // Profile / settings. ProfileScreen is tab content (no Scaffold of its - // own), so wrap it when pushing standalone. + // Profile / settings. RoundIconButton( Ic.profile, osIcon: OsIcon.profile, - onTap: () => _push( - () => Scaffold( - backgroundColor: AppColors.background, - body: const ProfileScreen(), - ), - ), + onTap: () => _push(() => const ProfileScreen()), ), // Recap: plain surface like its siblings — the full-colour art would // clash on the old coral fill. @@ -297,66 +291,97 @@ class _TodayScreenState extends State final alert = t.bodyAlert; final status = t.status; + // Every substantive item below carries a stable Key. This list is + // unkeyed-fragile otherwise: several conditions here (freshness banner, + // alert chip, coach row) flip during exactly the moments AppState is + // notifying most often (an active backfill/derive pass) — without keys, + // inserting/removing an item shifts every widget below it by one slot in + // the plain ListView, Flutter's positional (no-key) diff can't match old + // vs new elements by identity, and it unmounts+remounts the mismatched + // subtrees — replaying every dsEnter/dsPop entrance at once (the + // "screen is glitching" symptom). Keys let it match by identity instead + // of position, so a banner toggling on/off never disturbs its siblings. return [ if (_showStory(t)) ...[ - _RecoveryStory( - recoveredPct: t.readiness.value!.round(), - sleptMin: t.sleepDuration.isEmpty - ? null - : t.sleepDuration.value!.round(), - needMin: t.sleepNeed.isEmpty ? null : t.sleepNeed.value!.round(), - hrvRmssd: t.hrv?.rmssd, - hrvDelta: (t.hrv?.baseline != null) - ? (t.hrv!.rmssd - t.hrv!.baseline!) - : null, - planTitle: (coach?.plan.isNotEmpty ?? false) - ? coach!.plan.first.title - : null, - planBody: (coach?.plan.isNotEmpty ?? false) - ? coach!.plan.first.body - : null, - onDone: _dismissStory, - ).dsEnter(), + KeyedSubtree( + key: const ValueKey('today-story'), + child: _RecoveryStory( + recoveredPct: t.readiness.value!.round(), + sleptMin: t.sleepDuration.isEmpty + ? null + : t.sleepDuration.value!.round(), + needMin: t.sleepNeed.isEmpty ? null : t.sleepNeed.value!.round(), + hrvRmssd: t.hrv?.rmssd, + hrvDelta: (t.hrv?.baseline != null) + ? (t.hrv!.rmssd - t.hrv!.baseline!) + : null, + planTitle: (coach?.plan.isNotEmpty ?? false) + ? coach!.plan.first.title + : null, + planBody: (coach?.plan.isNotEmpty ?? false) + ? coach!.plan.first.body + : null, + onDone: _dismissStory, + ).dsEnter(), + ), const SizedBox(height: Sp.x3), ], // Data-freshness note — only when the band data is genuinely stale or a // metrics pass is mid-flight (settling states also get the compact chip // inside TodayVitals). if (_shouldShowTodayStatus(app, status)) ...[ - _todayStatusCard(app, status), + KeyedSubtree( + key: const ValueKey('today-freshness'), + child: _todayStatusCard(app, status), + ), const SizedBox(height: Sp.x3), ], // AI briefing hero — shows the cached one-liner for the current period; // tapping opens the shared breakdown screen. Reads the BriefingStore // synchronously at build; AppState.notifyListeners() repaints it when a // briefing is generated opportunistically on foreground. - Builder(builder: (_) { - final period = currentBriefingPeriod(DateTime.now()); - final brief = BriefingStore.read(period); - return AiSummaryCard( - summary: brief?.oneLiner, - onTap: () => _push(() => AiBreakdownScreen(period: period)), - ); - }).dsEnter(index: 0), + KeyedSubtree( + key: const ValueKey('today-ai-summary'), + child: Builder(builder: (_) { + final period = currentBriefingPeriod(DateTime.now()); + final brief = BriefingStore.read(period); + return AiSummaryCard( + summary: brief?.oneLiner, + onTap: () => _push(() => AiBreakdownScreen(period: period)), + ); + }).dsEnter(index: 0), + ), if (alert != null) ...[ const SizedBox(height: Sp.x3), - _alertChipRow(alert), + KeyedSubtree( + key: const ValueKey('today-alert'), + child: _alertChipRow(alert), + ), ], - TodayVitals( - t: t, - sparks: _sparks, - stepsWeek: _stepsWeek, - liveSteps: context.read().liveSteps, - stageMin: _stageMin, - hypno: _hypno, - onOpen: _open, + KeyedSubtree( + key: const ValueKey('today-vitals'), + child: TodayVitals( + t: t, + sparks: _sparks, + stepsWeek: _stepsWeek, + liveSteps: context.read().liveSteps, + stageMin: _stageMin, + hypno: _hypno, + onOpen: _open, + ), ), const SizedBox(height: Sp.x3), if (coach != null) ...[ - _coachRow(coach).dsEnter(index: 5), + KeyedSubtree( + key: const ValueKey('today-coach'), + child: _coachRow(coach).dsEnter(index: 5), + ), const SizedBox(height: Sp.x3), ], - _hrCard().dsEnter(index: 6), + KeyedSubtree( + key: const ValueKey('today-lookback'), + child: _lookbackCard().dsEnter(index: 6), + ), ]; } @@ -441,17 +466,17 @@ class _TodayScreenState extends State ); } - /// The heart-domain card: the big latest bpm with peak/low chips over the - /// day's HR curve — the whole card opens the journey. The header carries the - /// small illustrated heart-rate mark; the number + curve stay the card's - /// hero (the big anatomy heart lives on the Heart screen only). - Widget _hrCard() { + /// The entry point into "Your day" — the merged multi-vital lookback + /// (heart rate, HRV, resp, skin temp). Deliberately NOT a live/current-bpm + /// reading (that's the ambient "LIVE HEART RATE" tile on the Heart screen); + /// this card is a portal, not a live gauge — so its hero is the day's + /// peak/low HR chips + a preview curve, never an instantaneous number. + Widget _lookbackCard() { final points = [ for (final p in _hr.points) TimeSeriesPoint(p.t.toDouble(), p.v), ]; final hasData = points.length >= 2; final nowSec = DateTime.now().millisecondsSinceEpoch / 1000.0; - final latest = hasData ? points.last : null; final peak = hasData ? points.reduce((a, b) => a.y >= b.y ? a : b) : null; final low = hasData ? points.reduce((a, b) => a.y <= b.y ? a : b) : null; return SurfaceCard( @@ -464,15 +489,16 @@ class _TodayScreenState extends State children: [ const OsAppIcon(OsIcon.heartRate, size: 34), const SizedBox(width: Sp.x2), - Expanded(child: Text('HEART RATE', style: AppText.overline)), + Expanded(child: Text('LOOKBACK', style: AppText.overline)), AppIcon(Ic.arrowRight, size: 15, color: AppColors.onSurfaceFaint), ], ), const SizedBox(height: Sp.x3), - BigStat( - value: hasData ? '${latest!.y.round()}' : null, - unit: 'bpm', - caption: hasData ? 'right now' : 'no data yet today', + Text( + hasData + ? 'Heart rate, HRV, temp — your whole day' + : 'No data yet today', + style: AppText.body, ), if (hasData) ...[ const SizedBox(height: Sp.x3), @@ -1187,7 +1213,7 @@ class TodayVitals extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - const TileHeader('This week', icon: Ic.calendar), + const TileHeader('Steps goal (week)'), const SizedBox(height: Sp.x3), RingWeek( values: ring.values, diff --git a/lib/ui/workouts/workout_types.dart b/lib/ui/workouts/workout_types.dart new file mode 100644 index 0000000..8ab0fd3 --- /dev/null +++ b/lib/ui/workouts/workout_types.dart @@ -0,0 +1,90 @@ +// The canonical (key, label, glyph, illustrated-art) table for workout/session +// "type" strings — the single seam shared by every screen that renders a +// per-type icon: the start/pick-type grid (workouts_screen.dart), workout +// list rows (journey_screen.dart), and the recap "top workout" card +// (recap_screen.dart). Extend the table HERE, never duplicate a local map +// in a screen file — divergent tables are how "Cycle" ends up two different +// icons on two different screens. + +import 'package:flutter/material.dart'; + +import '../design/design.dart'; + +/// (key, label, glyph fallback, illustrated OsIcon). The type string is +/// whatever `sport`/`type`/`detected_type` carries from the repo — manual +/// start keys AND the auto-detector's output share this one vocabulary. +const kWorkoutTypes = <(String, String, IconData, OsIcon?)>[ + ('run', 'Run', Ic.run, OsIcon.run), + ('cycle', 'Cycle', Ic.activity, OsIcon.cycling), + ('strength', 'Strength', Ic.weights, OsIcon.strength), + ('walk', 'Walk', Ic.run, OsIcon.walk), + ('swim', 'Swim', Ic.activity, OsIcon.swim), + ('cardio', 'Cardio', Ic.pulse, OsIcon.cardio), + ('yoga', 'Yoga', Ic.heart, OsIcon.yoga), + ('hiit', 'HIIT', Ic.pulse, OsIcon.hiit), + ('other', 'Other', Ic.activity, OsIcon.workoutOther), +]; + +/// Glyph fallback for a workout type — always returns something renderable, +/// even for autodetected/unrecognized types. +IconData workoutTypeIcon(String? type) { + final raw = (type ?? '').toLowerCase(); + if (raw.contains('autodetected')) return Ic.weights; + if (raw.contains('workout')) return Ic.weights; + for (final e in kWorkoutTypes) { + if (e.$1 == type) return e.$3; + } + return Ic.weights; +} + +/// Illustrated art for a workout type — null only for autodetected/unknown +/// types, which stay on the glyph fallback ([workoutTypeIcon]). +OsIcon? workoutTypeOsIcon(String? type) { + for (final e in kWorkoutTypes) { + if (e.$1 == type) return e.$4; + } + return null; +} + +String workoutTypeLabel(String? type) { + if (type == null || type.isEmpty) return 'Workout'; + if (type.toLowerCase().contains('autodetected')) return 'Workout'; + return type[0].toUpperCase() + type.substring(1); +} + +/// Shared exercise grid used by both the "start a workout" and "pick/correct +/// type" bottom sheets — tap a tile to choose that type's key. +Widget workoutTypeGrid(BuildContext context) => Wrap( + spacing: Sp.x3, + runSpacing: Sp.x3, + children: [ + for (final e in kWorkoutTypes) + Pressable( + pressedScale: 0.94, + onTap: () => Navigator.pop(context, e.$1), + child: Container( + width: 96, + padding: const EdgeInsets.symmetric(vertical: Sp.x4), + decoration: BoxDecoration( + color: AppColors.surfaceAlt, + borderRadius: BorderRadius.circular(R.cardSm), + ), + child: Column( + children: [ + // Fixed 32px slot so illustrated and glyph tiles line up. + SizedBox( + height: 32, + child: Center( + child: e.$4 != null + ? OsAppIcon(e.$4!, size: 32) + : AppIcon(e.$3, size: 26, color: AppColors.accent), + ), + ), + const SizedBox(height: Sp.x2), + Text(e.$2, style: AppText.label), + ], + ), + ), + ), + ], +); diff --git a/lib/ui/workouts/workouts_screen.dart b/lib/ui/workouts/workouts_screen.dart index 372fbf4..6a1948d 100644 --- a/lib/ui/workouts/workouts_screen.dart +++ b/lib/ui/workouts/workouts_screen.dart @@ -22,19 +22,8 @@ import '../design/design.dart'; import '../kit/route_map.dart'; import '../screens/detail_cards.dart' show hm; import '../../gps/route_models.dart'; +import 'workout_types.dart'; -// (key, label, glyph, illustrated art). Only strength has custom art so far — -// the other sports stay on their hugeicons until their illustrations exist. -const _exercises = <(String, String, IconData, OsIcon?)>[ - ('run', 'Run', Ic.run, null), - ('cycle', 'Cycle', Ic.activity, null), - ('strength', 'Strength', Ic.weights, OsIcon.strength), - ('walk', 'Walk', Ic.run, null), - ('swim', 'Swim', Ic.activity, null), - ('cardio', 'Cardio', Ic.pulse, null), - ('yoga', 'Yoga', Ic.heart, null), - ('other', 'Other', Ic.activity, null), -]; const _ranges = ['Today', 'Week', 'Month', '3M']; const _rangeKey = [ 'week', @@ -43,31 +32,6 @@ const _rangeKey = [ 'quarter', ]; // Today filters week to today -IconData _typeIcon(String? type) { - final raw = (type ?? '').toLowerCase(); - if (raw.contains('autodetected')) return Ic.weights; - if (raw.contains('workout')) return Ic.weights; - for (final e in _exercises) { - if (e.$1 == type) return e.$3; - } - return Ic.weights; -} - -/// Illustrated art for a workout type — non-null ONLY where custom art exists -/// (strength today). Autodetected/unknown types stay on the glyph fallback. -OsIcon? _typeOsIcon(String? type) { - for (final e in _exercises) { - if (e.$1 == type) return e.$4; - } - return null; -} - -String _typeLabel(String? type) { - if (type == null || type.isEmpty) return 'Workout'; - if (type.toLowerCase().contains('autodetected')) return 'Workout'; - return type[0].toUpperCase() + type.substring(1); -} - String _dayLabel(int? startTs) { if (startTs == null || startTs == 0) return '—'; final d = DateTime.fromMillisecondsSinceEpoch(startTs * 1000).toLocal(); @@ -111,42 +75,6 @@ String _whenLabel(int? startTs) { return '${mon[d.month - 1]} ${d.day} · $time'; } -/// Shared exercise grid used by both bottom sheets. -Widget _exerciseGrid(BuildContext context) => Wrap( - spacing: Sp.x3, - runSpacing: Sp.x3, - children: [ - for (final e in _exercises) - Pressable( - pressedScale: 0.94, - onTap: () => Navigator.pop(context, e.$1), - child: Container( - width: 96, - padding: const EdgeInsets.symmetric(vertical: Sp.x4), - decoration: BoxDecoration( - color: AppColors.surfaceAlt, - borderRadius: BorderRadius.circular(R.cardSm), - ), - child: Column( - children: [ - // Fixed 32px slot so illustrated and glyph tiles line up. - SizedBox( - height: 32, - child: Center( - child: e.$4 != null - ? OsAppIcon(e.$4!, size: 32) - : AppIcon(e.$3, size: 26, color: AppColors.accent), - ), - ), - const SizedBox(height: Sp.x2), - Text(e.$2, style: AppText.label), - ], - ), - ), - ), - ], -); - /// Bottom-sheet exercise picker → starts a workout → opens the live screen. Future startWorkoutFlow(BuildContext context) async { final type = await showModalBottomSheet( @@ -161,7 +89,7 @@ Future startWorkoutFlow(BuildContext context) async { children: [ Text('Start a workout', style: AppText.h2), const SizedBox(height: Sp.x4), - Builder(builder: _exerciseGrid), + Builder(builder: workoutTypeGrid), const SizedBox(height: Sp.x4), ], ), @@ -205,7 +133,7 @@ Future pickWorkoutType( children: [ Text(title, style: AppText.h2), const SizedBox(height: Sp.x4), - Builder(builder: _exerciseGrid), + Builder(builder: workoutTypeGrid), const SizedBox(height: Sp.x4), ], ), @@ -597,9 +525,9 @@ class _SuggestionCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ - _typeOsIcon(sport) != null - ? OsAppIcon(_typeOsIcon(sport)!, size: 28) - : AppIcon(_typeIcon(sport), size: 18, color: AppColors.accent), + workoutTypeOsIcon(sport) != null + ? OsAppIcon(workoutTypeOsIcon(sport)!, size: 28) + : AppIcon(workoutTypeIcon(sport), size: 18, color: AppColors.accent), const SizedBox(width: Sp.x3), Expanded( child: Column( @@ -851,15 +779,15 @@ class WorkoutFeedCard extends StatelessWidget { Container( // Glyph: 10 + 18 + 10; art: 2 + 34 + 2 — same 38px chip. padding: EdgeInsets.all( - _typeOsIcon(w['type'] as String?) != null ? 2 : 10), + workoutTypeOsIcon(w['type'] as String?) != null ? 2 : 10), decoration: BoxDecoration( color: tone.accent.withValues(alpha: live ? 0.22 : 0.12), borderRadius: BorderRadius.circular(R.chip), ), - child: _typeOsIcon(w['type'] as String?) != null - ? OsAppIcon(_typeOsIcon(w['type'] as String?)!, size: 34) + child: workoutTypeOsIcon(w['type'] as String?) != null + ? OsAppIcon(workoutTypeOsIcon(w['type'] as String?)!, size: 34) : AppIcon( - _typeIcon(w['type'] as String?), + workoutTypeIcon(w['type'] as String?), size: 18, color: tone.accent, ), @@ -867,7 +795,7 @@ class WorkoutFeedCard extends StatelessWidget { const SizedBox(width: Sp.x3), Flexible( child: Text( - _typeLabel(w['type'] as String?), + workoutTypeLabel(w['type'] as String?), style: AppText.title.copyWith(color: tone.fg), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -1217,15 +1145,15 @@ class WorkoutDetailContent extends StatelessWidget { Container( // Glyph: 10 + 20 + 10; art: 2 + 36 + 2 — same 40px chip. padding: EdgeInsets.all( - _typeOsIcon(d['type'] as String?) != null ? 2 : 10), + workoutTypeOsIcon(d['type'] as String?) != null ? 2 : 10), decoration: BoxDecoration( color: tone.accent.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(R.chip), ), - child: _typeOsIcon(d['type'] as String?) != null - ? OsAppIcon(_typeOsIcon(d['type'] as String?)!, size: 36) + child: workoutTypeOsIcon(d['type'] as String?) != null + ? OsAppIcon(workoutTypeOsIcon(d['type'] as String?)!, size: 36) : AppIcon( - _typeIcon(d['type'] as String?), + workoutTypeIcon(d['type'] as String?), size: 20, color: tone.accent, ), @@ -1236,7 +1164,7 @@ class WorkoutDetailContent extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - _typeLabel(d['type'] as String?).toUpperCase(), + workoutTypeLabel(d['type'] as String?).toUpperCase(), style: AppText.overline.copyWith(color: tone.fgFaint), ), Text( diff --git a/pubspec.lock b/pubspec.lock index 80671fc..ade09d9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -859,25 +859,29 @@ packages: openstrap_analytics: dependency: "direct main" description: - path: "../openstrap-analytics-onehz" - relative: true - source: path + path: "." + ref: main + resolved-ref: "743c3d7a1b511c016c86c14c8981ae1fdb46a98c" + url: "https://github.com/OpenStrap/analytics.git" + source: git version: "1.0.0" openstrap_icons: dependency: "direct main" description: path: "." ref: main - resolved-ref: "87144b8b8f6afe19bca16d5b1e255ee515ae0825" + resolved-ref: "7c297e680342a53737fe17d676d92371e4e81569" url: "https://github.com/OpenStrap/icons.git" source: git version: "0.1.0" openstrap_protocol: dependency: "direct main" description: - path: "../openstrap-protocol-dart" - relative: true - source: path + path: "." + ref: main + resolved-ref: d2a4fa51693a8d1d790d0f3281c7e2c5db27eac7 + url: "https://github.com/OpenStrap/protocol.git" + source: git version: "1.0.0" ota_update: dependency: "direct main" diff --git a/test/ble_engine_test.dart b/test/ble_engine_test.dart index 86ee7ff..eac141b 100644 --- a/test/ble_engine_test.dart +++ b/test/ble_engine_test.dart @@ -67,6 +67,61 @@ void main() { ); }); + group('burst packet count validation (dropped-record carve-out)', () { + test('matches when nothing was gate-rejected', () { + expect( + burstPacketCountMatches( + expectedPacketCount: 26, + actualBurstPacketCount: 26, + droppedThisBurst: 0, + ), + isTrue, + ); + }); + + test( + 'a real mismatch (band reports more than we saw at all) still fails', + () { + expect( + burstPacketCountMatches( + expectedPacketCount: 50, + actualBurstPacketCount: 26, + droppedThisBurst: 0, + ), + isFalse, + ); + }, + ); + + test( + 'matches once gate-rejected (stale-clock block) records are added ' + 'back in — the exact shape of the real bug: expected=50, only 26 ' + 'passed the plausibility gate, 24 were legitimately dropped', + () { + expect( + burstPacketCountMatches( + expectedPacketCount: 50, + actualBurstPacketCount: 26, + droppedThisBurst: 24, + ), + isTrue, + ); + }, + ); + + test('does not over-forgive — dropped count must exactly close the gap', + () { + expect( + burstPacketCountMatches( + expectedPacketCount: 50, + actualBurstPacketCount: 26, + droppedThisBurst: 10, // leaves a real 14-packet gap unexplained + ), + isFalse, + ); + }); + }); + group('history-end settle streak', () { test('resets while queue is not empty', () { final streak = nextBurstStablePollStreak( diff --git a/test/design_redesign_test.dart b/test/design_redesign_test.dart index f764ea2..5a81672 100644 --- a/test/design_redesign_test.dart +++ b/test/design_redesign_test.dart @@ -472,7 +472,7 @@ void main() { expect(find.text('8412'), findsOneWidget); expect(find.text('640'), findsOneWidget); // Week rings card present (steps spark provided). - expect(find.text('THIS WEEK'), findsOneWidget); + expect(find.text('STEPS GOAL (WEEK)'), findsOneWidget); // No sync-anxiety copy anywhere in the composition. expect(find.textContaining('Sync'), findsNothing); expect(find.textContaining('stored to'), findsNothing); diff --git a/test/history_screens_redesign_test.dart b/test/history_screens_redesign_test.dart index 70b60b1..dc9248b 100644 --- a/test/history_screens_redesign_test.dart +++ b/test/history_screens_redesign_test.dart @@ -245,8 +245,7 @@ void main() { group('JourneyContent', () { testWidgets( - 'timeline tile + HR replay, peak/low bento, movement and workouts ' - 'in both palettes', + 'merged multi-vital lookback, movement and workouts in both palettes', (t) async { for (final palette in [kLightPalette, kDarkPalette]) { await t.pumpWidget( @@ -257,22 +256,25 @@ void main() { ); await _pumpTwice(t); - expect(find.text('THE TIMELINE'), findsOneWidget); - // The replay overlay must survive the restyle. - expect(find.byType(HrReplayOverlay), findsOneWidget); - // Peak / low HR stat tiles. - expect(find.text('PEAK HR'), findsOneWidget); - expect(find.text('LOWEST HR'), findsOneWidget); - expect(find.text('171'), findsOneWidget); - expect(find.text('44'), findsOneWidget); - // Movement chart tile + workout list row. + // The merged multi-vital timeline (TimelineContent) is embedded + // here — one selector chip + color per continuously-recorded vital. + for (final label in ['Heart rate', 'HRV', 'Resp', 'Skin temp']) { + expect(find.text(label), findsOneWidget); + } + expect(find.text('HEART RATE · BPM'), findsOneWidget); + expect(find.text('PEAK · HEART RATE'), findsOneWidget); + expect(find.text('LOW · HEART RATE'), findsOneWidget); + // No play/replay control — scrub replaces tap-to-replay. + expect(find.byType(HrReplayOverlay), findsNothing); + // The merged timeline's own event bands (sleep/nap/workout). + expect(find.text('EVENTS'), findsOneWidget); + expect(find.text('Sleep'), findsOneWidget); + expect(find.text('Nap'), findsOneWidget); + // Movement chart tile + workout list row (Journey-only content). expect(find.text('MOVEMENT'), findsOneWidget); expect(find.text('WORKOUTS · 1'), findsOneWidget); - expect(find.text('Run'), findsOneWidget); + expect(find.text('Run'), findsWidgets); // event band + workout row expect(find.text('12.4 strain'), findsOneWidget); - // Context-band legend. - expect(find.text('Sleep'), findsOneWidget); - expect(find.text('Workout'), findsOneWidget); expect(t.takeException(), isNull); } }, diff --git a/test/os_icons_wiring_test.dart b/test/os_icons_wiring_test.dart index 7a500ad..5255aed 100644 --- a/test/os_icons_wiring_test.dart +++ b/test/os_icons_wiring_test.dart @@ -138,8 +138,8 @@ void main() { expect(t.takeException(), isNull); }); - testWidgets('Sleep night renders the hypnogram-header art and the Deep ' - 'stage row art (REM keeps its hugeicon)', (t) async { + testWidgets('Sleep night renders the hypnogram-header art; trend rows ' + 'stay icon-free by design', (t) async { t.view.physicalSize = const Size(390, 3200); t.view.devicePixelRatio = 1.0; addTearDown(t.view.reset); @@ -172,15 +172,22 @@ void main() { ), )); await t.pump(const Duration(milliseconds: 1200)); - for (final icon in [ - OsIcon.sleepHypnogram, // stages/hypnogram section header - OsIcon.deepSleep, // Deep trend row - OsIcon.lightSleep, // Light trend row (pre-existing wiring) - ]) { + // Section header keeps its illustrated art. + expect( + find.byWidgetPredicate( + (w) => w is OsAppIcon && w.icon == OsIcon.sleepHypnogram, + ), + findsOneWidget, + reason: 'the hypnogram section header should render its art', + ); + // MetricRow trend rows (Deep/Light/etc.) are deliberately icon-free — + // a whole list of "heading left, score right" rows each with their own + // chip read as noise; the icon belongs on the metric's own screen/hero. + for (final icon in [OsIcon.deepSleep, OsIcon.lightSleep]) { expect( find.byWidgetPredicate((w) => w is OsAppIcon && w.icon == icon), - findsOneWidget, - reason: '$icon should render exactly once', + findsNothing, + reason: '$icon should NOT render inside a MetricRow trend row', ); } expect(t.takeException(), isNull);