diff --git a/assets/images/2.0x/mascot_wellness.png b/assets/images/2.0x/mascot_wellness.png index 9f27cb58..5ba7690d 100644 Binary files a/assets/images/2.0x/mascot_wellness.png and b/assets/images/2.0x/mascot_wellness.png differ diff --git a/assets/images/3.0x/mascot_wellness.png b/assets/images/3.0x/mascot_wellness.png index 7a405695..f48df933 100644 Binary files a/assets/images/3.0x/mascot_wellness.png and b/assets/images/3.0x/mascot_wellness.png differ diff --git a/assets/images/mascot_wellness.png b/assets/images/mascot_wellness.png index 065546db..06b0e95a 100644 Binary files a/assets/images/mascot_wellness.png and b/assets/images/mascot_wellness.png differ diff --git a/lib/app.dart b/lib/app.dart index bfbc3d50..f4e8fd97 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -27,7 +27,6 @@ import 'ui2/screens/what_changed.dart'; import 'ui2/screens/health_screen.dart'; import 'ui2/screens/home_screen.dart'; import 'ui2/screens/journal_compose.dart'; -import 'ui2/screens/log_water.dart'; import 'ui2/screens/nutrition_screen.dart'; import 'ui2/screens/wellness_screen.dart'; import 'ui2/screens/workout_screen.dart'; @@ -352,9 +351,12 @@ Widget? screenForRoute(String route) => switch (route) { const AiBriefingScreen(period: BriefingPeriod.evening), kRouteJournalCompose => const JournalCompose(), kRouteBreathing => const CalmBreathing(), - // The hydration reminder promises that one more tap logs a glass, so it - // opens the control itself rather than the tab the control is on. - kRouteWater => const LogWaterScreen(), + // The hydration reminder lands on Nutrition, where the water tile carries + // its own − / + and is beside the food it belongs with. There used to be + // a whole screen for this one field; it was reachable ONLY from here, + // which is how the tile that everybody actually used stayed add-only for + // so long — the thing that could clear a value was behind a notification. + kRouteWater => const NutritionScreen(), // Battery, band and sources all live behind this one. kRouteProfile => const ProfileHome(), // The weekly recap used to land on the Health tab and push nothing, diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index ce62e704..64246c8f 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -3040,7 +3040,44 @@ class AppState extends ChangeNotifier { } } + /// The last readings the live stream delivered, newest last. + /// + /// Lives here rather than in the widget that draws it: `lib/ui2` is + /// presentation, and a `Timer.periodic` inside a card is both a design-system + /// violation (see the ungated-Duration rule) and a trace that resets every + /// time the screen is opened. The engine already pushes state at about 1 Hz + /// while streaming, so appending here is the natural sampling point. + static const int liveHrTraceMax = 90; + final List _liveHrTrace = []; + List get liveHrTrace => List.unmodifiable(_liveHrTrace); + int? _liveHrTraceAt; + + /// Bumped on every appended sample. A `select` on the trace's LENGTH stops + /// firing the moment the buffer is full — length is pinned at + /// [liveHrTraceMax] from then on — so a card watching length would draw the + /// first 90 readings and then freeze while the numbers kept arriving. This is + /// the thing that actually changes. + int liveHrTraceRev = 0; + void _onEngineState(DeviceState s) { + // One sample per DELIVERED reading. Keyed on the stamp, not the value, or a + // steady 60 bpm would record a single point and the trace would flatline + // for reasons that have nothing to do with the heart. + final hr = s.liveHr, at = s.liveHrAt; + if (hr != null && hr > 0 && at != null && at != _liveHrTraceAt) { + _liveHrTraceAt = at; + _liveHrTrace.add(hr); + if (_liveHrTrace.length > liveHrTraceMax) _liveHrTrace.removeAt(0); + liveHrTraceRev++; + } + // Bank the name the moment the band says it, so it survives the + // disconnect. Written through `cleanDeviceLabel` for the same reason the + // BLE side reads through it: a garbled response must never become the + // remembered name. + final nm = cleanDeviceLabel(s.strapName); + if (nm != null && nm != Prefs.getString(_kStrapName, '')) { + Prefs.setString(_kStrapName, nm); + } // Battery-low / charging OS notifications (edge-triggered + de-duped inside). _deviceAlerts.onDeviceState( batteryPct: s.batteryPct, @@ -3497,7 +3534,20 @@ class AppState extends ChangeNotifier { // clobbering the display (see the parked block in ble_engine._onDecoded). // device.alarmEpoch = this-session optimistic set; _savedAlarm = persisted. int? get alarmEpoch => device.alarmEpoch ?? _savedAlarm; - String? get strapName => device.strapName; + /// The band's advertising name, LAST KNOWN when the link has not answered. + /// + /// `DeviceState.strapName` only exists after a connect and a GET round-trip, + /// and [PairedDevice] persists the remote id and serial but never this — so + /// every cold start, and every minute spent disconnected, showed the generic + /// "WHOOP band" instead of whatever the user named their strap. A name the + /// band told us once does not stop being true while the radio is off. + static const String _kStrapName = 'band.strap_name'; + String? get strapName { + final live = device.strapName; + if (live != null && live.isNotEmpty) return live; + final saved = Prefs.getString(_kStrapName, ''); + return saved.isEmpty ? null : saved; + } int? _savedAlarm; // ── alarm confirmation state machine ──────────────────────────────────────── diff --git a/lib/ui2/live_hr.dart b/lib/ui2/live_hr.dart new file mode 100644 index 00000000..d018668b --- /dev/null +++ b/lib/ui2/live_hr.dart @@ -0,0 +1,153 @@ +// The heart rate arriving RIGHT NOW. +// +// Live HR is not a workout-only quantity: `openSession()` calls +// `enableLiveStreams()` whenever the app is foregrounded with the band +// connected, so a beat is usually a second old while someone is looking at the +// app. It was simply never surfaced outside the workout screen. +// +// THE RULES THIS FILE HOLDS: +// +// · It OWNS NO TIME. The first version ran a `Timer.periodic` and a heart +// that pulsed at the measured rate off a repeating controller — which the +// design-system tests correctly rejected: an endless loop cannot be stopped +// by the reduced-motion gate, and raw `Duration`s bypass `motion()`. The +// deeper problem was architectural: the trace also died every time the +// screen closed. The buffer lives on [AppState] now and this is a pure +// renderer. +// · The trace is READINGS, not seconds, and says so. Samples arrive when the +// band delivers them, so calling it "the last 90 seconds" would be a claim +// about spacing nothing here guarantees. +// · Absence states its reason and never a number. `AppState.liveHr` returns +// null past [AppState.liveHrMaxAge], so an unworn band, a dropped link and +// a backgrounded HR-only downgrade all arrive as null — and each gets the +// sentence that is true for it. +// · It repaints ALONE. A 1 Hz stream hung off a `watch` in a parent would +// rebuild that whole tree once a second for the life of the connection. + +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:provider/provider.dart'; + +import '../state/app_state.dart'; +import 'ui2.dart'; + +/// The live reading as a card: the number, and the recent readings behind it. +class LiveHrCard extends StatelessWidget { + /// The real one: reads the live stream off [AppState]. + const LiveHrCard({super.key}) + : _hr = null, + _trace = null, + _preview = false; + + /// A fixed reading, for the gallery. The gallery has no band, no stream and + /// no Provider above it, and a card that reached for one would either throw + /// there or force every caller to thread state through. This is the same + /// widget with its inputs handed to it. + const LiveHrCard.preview({super.key, required int hr, required List trace}) + : _hr = hr, + _trace = trace, + _preview = true; + + final int? _hr; + final List? _trace; + final bool _preview; + + @override + Widget build(BuildContext c) { + final p = P.of(c); + final hr = _preview ? _hr : c.select((a) => a.liveHr); + if (hr == null) { + if (_preview) return _absent(paired: true, connected: true); + // SELECTED, not read: with no reading the only thing this widget watched + // was `liveHr`, which stays null through both pairing and connecting — so + // the card went on saying "No band is paired" after the band was paired + // and connected. These are what change in that state. + return _absent( + paired: c.select((a) => a.isPaired), + connected: c.select((a) => a.isConnected), + ); + } + + // A REVISION, not the length. Length is pinned at the cap once the buffer + // is full, so watching it drew the first 90 readings and then froze. + final List trace; + if (_preview) { + trace = _trace ?? const []; + } else { + c.select((a) => a.liveHrTraceRev); + trace = c.read().liveHrTrace; + } + + return Surface( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + // At 3.1x text an n48 number plus a pill does not fit a phone width, so + // the number is allowed to scale down inside the space that is left + // rather than the row overflowing. The pill keeps its size: it is two + // short words and shrinking it is how a label becomes unreadable. + Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ + Icon(LucideIcons.heart, size: 26, color: p.on(C.red)), + const SizedBox(width: S.x3), + Expanded( + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text('$hr', style: F.n48.copyWith(color: p.ink)), + const SizedBox(width: S.x2), + Text('bpm', style: F.body.copyWith(color: p.ink3)), + ], + ), + ), + ), + const SizedBox(width: S.x2), + const Pill('LIVE', C.red, icon: LucideIcons.radio), + ]), + if (trace.length > 2) ...[ + const SizedBox(height: S.x3), + SizedBox( + height: 56, + child: CustomPaint( + painter: LineChart( + [for (final v in trace) v.toDouble()], + C.red, + fill: false, + ), + size: Size.infinite, + ), + ), + const SizedBox(height: S.x2), + Text( + 'The last ${trace.length} readings — ${trace.reduce((a, b) => a < b ? a : b)}' + '–${trace.reduce((a, b) => a > b ? a : b)} bpm. Not stored; this is ' + 'the live stream, not a record of your day.', + style: F.over.copyWith(color: p.ink3), + ), + ], + ]), + ); + } + + /// No live reading. Three different facts, and only the one the app can + /// actually see is stated. + Widget _absent({required bool paired, required bool connected}) { + final (String why, String fix) = !paired + ? ('No band is paired.', 'Pair one from Profile to read live beats.') + : !connected + ? ( + 'Your band is not connected.', + 'Live beats need an open link — the app connects when you open ' + 'it with the band in range.' + ) + : ( + 'No beat in the last ${AppState.liveHrMaxAge.inSeconds} ' + 'seconds.', + 'The band streams while it is on your wrist and the app is ' + 'open.' + ); + return StatusCard('No live reading', why, + fix: fix, icon: LucideIcons.heartOff); + } +} diff --git a/lib/ui2/profile/devices.dart b/lib/ui2/profile/devices.dart index 4c36bc5c..c9cab86c 100644 --- a/lib/ui2/profile/devices.dart +++ b/lib/ui2/profile/devices.dart @@ -22,6 +22,7 @@ import 'package:provider/provider.dart'; import '../../ble/ble_state.dart' show BandStatus; import '../../data/db.dart' show LocalDb; import '../../notify/battery_forecast.dart'; +import '../../sync/paired_device.dart' show cleanDeviceLabel; import '../../state/app_state.dart'; import '../onboarding/pairing.dart'; import '../onboarding/profile_setup.dart' show formatDay; @@ -497,11 +498,83 @@ class _DeviceDetailState extends State { health: _health, forecast: _forecast, onFind: app?.buzzBand, + liveHr: s.isBand ? app?.liveHr : null, + onRename: (app != null && app.isConnected) + ? () => _renameBand(c, app, s.name) + : null, onForget: app == null ? null : () => _confirmForget(c, app, s.name), ); } } +/// Rename the strap. +/// +/// This was in the old UI and did not survive the rebuild, which left the +/// advertising name readable and not writable — and it is the one label a user +/// with two straps needs, since both otherwise read "WHOOP band". +/// +/// The band's own limits are the field's limits, checked here so a refusal is +/// immediate instead of a silent truncation 20 characters in: 20 ASCII +/// characters, the charset `cleanDeviceLabel` accepts on the way back, and at +/// least one letter or digit. Anything the strap would reject or mangle is +/// rejected in the sheet, where the user can still fix it. +Future _renameBand(BuildContext c, AppState app, String current) async { + // Captured before the dialog: `c` is not safe to touch after the await. + final messenger = ScaffoldMessenger.maybeOf(c); + final ctl = TextEditingController(text: current); + final err = ValueNotifier(null); + final name = await showDialog( + context: c, + builder: (d) => AlertDialog( + title: const Text('Name this band'), + content: Column(mainAxisSize: MainAxisSize.min, children: [ + ValueListenableBuilder( + valueListenable: err, + builder: (_, e, _) => TextField( + controller: ctl, + autofocus: true, + maxLength: 20, + decoration: InputDecoration( + hintText: 'WHOOP band', + errorText: e, + helperText: "Letters, numbers, space, and ' . _ -", + ), + ), + ), + ]), + actions: [ + TextButton( + onPressed: () => Navigator.of(d).pop(), child: const Text('Cancel')), + TextButton( + onPressed: () { + final v = ctl.text.trim(); + if (v.isEmpty) { + err.value = 'Give it a name'; + } else if (cleanDeviceLabel(v) == null) { + err.value = "Only letters, numbers, space, and ' . _ -"; + } else { + Navigator.of(d).pop(v); + } + }, + child: const Text('Save'), + ), + ], + ), + ); + ctl.dispose(); + err.dispose(); + if (name == null) return; + try { + await app.renameStrap(name); + messenger?.showSnackBar(SnackBar(content: Text('Renamed to $name'))); + } catch (e) { + // The write goes to the strap, so a dropped link loses it. Say that, + // rather than leaving the old name on screen with no explanation. + messenger?.showSnackBar( + SnackBar(content: Text('Could not rename the band: $e'))); + } +} + /// Forgetting a band is destructive — it ends the only connection to the one /// sensor in the app that measures anything continuously — and it used to /// happen on a single tap with no confirmation, leaving this screen still @@ -538,6 +611,16 @@ class DeviceDetailView extends StatelessWidget { final HealthSource s; final VoidCallback? onFind, onForget; + /// The beat arriving right now, or null when nothing fresh is streaming. + /// Passed IN rather than read from a provider here: this view is rendered in + /// tests with no Provider above it, which is the point of it being a view. + final int? liveHr; + + /// Rename the band. Null when there is nothing to rename (the phone) or no + /// link to carry the write — the name lives on the strap, not on the phone, + /// so an offline rename would be a lie the next connect quietly undoes. + final VoidCallback? onRename; + /// The band's own state, from `bandStatusFor`. Null for a non-band source. final BandStatus? status; @@ -554,6 +637,8 @@ class DeviceDetailView extends StatelessWidget { {super.key, this.onFind, this.onForget, + this.onRename, + this.liveHr, this.status, this.health, this.forecast}); @@ -614,6 +699,18 @@ class DeviceDetailView extends StatelessWidget { Surface( pad: const EdgeInsets.symmetric(horizontal: S.x4), child: Column(children: [ + // The name is the band's own advertising name, written + // to the strap — not a phone-side label. So it is only + // editable on a live link, and the row says so rather + // than opening an editor whose save cannot land. + SetRow(LucideIcons.tag, C.blue, 'Name', + value: s.name, + sub: onRename == null + ? 'Connect to the band to change it' + : '', + chevron: onRename != null, + onTap: onRename), + Divider(color: p.line, height: 1), SetRow(LucideIcons.batteryMedium, C.green, 'Battery', value: battery == null ? '' : '${battery.round()}%', // L11 — the band's own charge history, on the row @@ -637,6 +734,16 @@ class DeviceDetailView extends StatelessWidget { ].join(' · '), chevron: false), Divider(color: p.line, height: 1), + // Live, not a stored reading: present only while the + // band is actually streaming, and gone the moment it + // stops. + if (liveHr != null) ...[ + SetRow(LucideIcons.heartPulse, C.red, 'Heart rate', + value: '$liveHr bpm', + sub: 'Right now', + chevron: false), + Divider(color: p.line, height: 1), + ], SetRow(LucideIcons.refreshCw, C.purple, 'Last data', value: last == null ? '' : formatDayTime(last), sub: last == null ? 'Nothing banked yet' : '', diff --git a/lib/ui2/profile/gallery.dart b/lib/ui2/profile/gallery.dart index 79947247..13ade8cc 100644 --- a/lib/ui2/profile/gallery.dart +++ b/lib/ui2/profile/gallery.dart @@ -455,6 +455,12 @@ Map _nutritionAndWellnessCases() { FoodRow(entry: known, trailing: LucideIcons.circlePlus), FoodRow(entry: bare), ])), + // `.preview`, because the gallery has no band and no Provider. The numbers + // are a fixture and are shaped like one — a resting wobble, not a workout. + 'live_hr_card': const LiveHrCard.preview(hr: 68, trace: [ + 64, 65, 65, 66, 67, 66, 65, 66, 68, 69, 70, 69, 68, 67, 66, 66, 67, 68, + 69, 68, 67, 67, 68, 69, 70, 71, 70, 69, 68, 68, + ]), 'mood_picker': MoodPicker(value: 4, onChanged: (_) {}), 'mood_picker_blank': MoodPicker(onChanged: (_) {}), 'field_stepper': Surface( diff --git a/lib/ui2/screens/log_water.dart b/lib/ui2/screens/log_water.dart deleted file mode 100644 index 48721f6f..00000000 --- a/lib/ui2/screens/log_water.dart +++ /dev/null @@ -1,108 +0,0 @@ -// Where the hydration reminder lands. -// -// One screen, one control. The notification says "tap to log a glass", so the -// destination has to be a place where the next tap logs a glass — the Nutrition -// tab is not that, because the water row sits below the day's occasions and -// needs a scroll to reach. -// -// Nothing is re-invented here: the value is the existing `water_ml` journal -// field, written through the same repo call the Nutrition row uses, and the -// stepper is the same `FieldStepper` the journal uses. This screen owns only -// the framing. -// -// Water is a reminder, not a measurement. It may be logged; nothing here — and -// nothing downstream — may ever score it, streak it, or call a number good. - -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import '../../data/day_label.dart'; -import '../../data/journal_fields.dart'; -import '../../state/app_state.dart'; -import '../ui2.dart'; -import 'journal_compose.dart' show FieldStepper; - -class LogWaterScreen extends StatefulWidget { - const LogWaterScreen({super.key}); - - @override - State createState() => _LogWaterScreenState(); -} - -class _LogWaterScreenState extends State { - /// Read on every use: this screen can be opened by a notification at 21:50 - /// and left standing past midnight. - String get _date => todayLabel(); - - static final JournalFieldSpec _spec = kJournalFieldsByKey['water_ml']!; - - double? _ml; - bool _loading = true; - - @override - void initState() { - super.initState(); - _load(); - } - - Future _load() async { - final repo = context.read().repo; - final v = - repo == null ? null : (await repo.getJournalMetrics(_date))['water_ml']; - if (!mounted) return; - setState(() { - _ml = v?.value; - _loading = false; - }); - } - - /// Absent stays absent: a blank field is "did not say", and stepping down - /// from zero returns it there rather than pinning a zero nobody asserted. - Future _set(double? next) async { - final repo = context.read().repo; - if (repo == null) return; - setState(() => _ml = next); - final all = await repo.getJournalMetrics(_date); - // Drop the key rather than just not adding it: `...all` still carries the - // OLD water_ml, so stepping back to blank re-wrote the number it was meant - // to erase. putJournalMetrics clears the day and re-inserts the map it is - // given, so leaving the key out is what "no answer today" looks like on - // disk — same rule the rest of the journal uses. - final fields = {...all}..remove('water_ml'); - if (next != null) fields['water_ml'] = JournalMetricValue(next); - await repo.postJournalMetrics(_date, fields); - } - - @override - Widget build(BuildContext c) { - final p = P.of(c); - return Scaffold( - backgroundColor: p.bg, - body: SafeArea( - child: ListView( - padding: const EdgeInsets.fromLTRB(S.x4, 0, S.x4, S.x16), - children: [ - const NavBar('Water', sub: 'TODAY'), - const SizedBox(height: S.x2), - if (_loading) - const Center(child: CircularProgressIndicator()) - else - Surface( - child: FieldStepper( - spec: _spec, - value: _ml, - onChanged: _set, - ), - ), - const SizedBox(height: S.x3), - Text( - 'A log, not a measurement. The band reads no hydration and ' - 'nothing here is scored.', - style: F.cap.copyWith(color: p.ink3, height: 1.5), - ), - ], - ), - ), - ); - } -} diff --git a/lib/ui2/screens/metric_detail.dart b/lib/ui2/screens/metric_detail.dart index 3092de6e..b1b0d690 100644 --- a/lib/ui2/screens/metric_detail.dart +++ b/lib/ui2/screens/metric_detail.dart @@ -516,6 +516,18 @@ class _MetricDetailState extends State { final vals = [for (final v in series) ?v]; return detailScaffold(c, spec.title, [ + // Resting heart rate is the NIGHT's number; this is what the chest is + // doing this second. Two different quantities, so the live one gets its + // own card above the trend rather than a second figure on the same card, + // where it would read as a correction to the headline. + // `data == null` is the LIVE path: every fixture and golden injects its + // own MetricData, and those render with no Provider above them by design. + // The live card reads AppState, so it belongs only on the real one. + if (widget.metricKey == 'resting_hr' && widget.data == null) ...[ + const SizedBox(height: S.x2), + const LiveHrCard(), + const SizedBox(height: S.x5), + ], if (spec.suppress != null) ...[ const SizedBox(height: S.x2), StatusCard( diff --git a/lib/ui2/screens/nutrition_screen.dart b/lib/ui2/screens/nutrition_screen.dart index 1216ef73..dcee426c 100644 --- a/lib/ui2/screens/nutrition_screen.dart +++ b/lib/ui2/screens/nutrition_screen.dart @@ -20,9 +20,9 @@ import 'package:provider/provider.dart'; import '../../data/db.dart'; import '../../data/day_label.dart'; -import '../../data/journal_fields.dart'; import '../../data/nutrition_store.dart'; import '../../models/metric.dart'; +import '../../data/journal_fields.dart'; import '../../state/app_state.dart'; import '../ui2.dart'; import '../onboarding/profile_setup.dart' show formatDay; @@ -98,19 +98,57 @@ class _NutritionScreenState extends State { } /// One tap's worth of water, from the field spec that performs the tap. - static double get _waterStep => kJournalFieldsByKey['water_ml']!.step; - - Future _addWater() async { + static JournalFieldSpec get _waterSpec => kJournalFieldsByKey['water_ml']!; + + /// Step the day's water up or down, in place. + /// + /// This row used to be add-only: every tap wrote `+250 ml` and nothing on the + /// screen could take one back. Same ladder as the journal's own stepper — a + /// step down off the last glass lands on a logged ZERO ("none today"), and a + /// step down from zero clears the field, because absence and zero are + /// different answers. + /// One write at a time. `_stepWater` reads the day, then awaits, then writes + /// it back — and `postJournalMetrics` replaces the whole day — so two taps + /// during that await both read the same map and the second write silently + /// eats the first tap. Same guard the wellness screen already uses for its + /// journal fields. + bool _writingWater = false; + + Future _stepWater(int dir) async { final repo = context.read().repo; - if (repo == null) return; - final spec = kJournalFieldsByKey['water_ml']!; - final next = ((_waterMl ?? 0) + spec.step).clamp(0, spec.max).toDouble(); - final all = await repo.getJournalMetrics(_date); - await repo.postJournalMetrics(_date, { - ...all, - 'water_ml': JournalMetricValue(next), - }); - await _load(); + if (repo == null || _writingWater) return; + _writingWater = true; + final spec = _waterSpec; + final v = _waterMl; + double? next; + if (dir > 0) { + next = ((v ?? 0) + spec.step).clamp(0, spec.max).toDouble(); + } else { + final down = (v ?? 0) - spec.step; + next = down <= 0 ? (v == 0 ? null : 0.0) : down; + } + setState(() => _waterMl = next); + try { + // Inside the try, not before it: the READ can throw too, and with the + // guard already set that left both buttons dead until the screen was + // rebuilt — the flag outliving the operation it was protecting. + // + // Drop the key rather than omitting it from a spread: `putJournalMetrics` + // clears the day and re-inserts what it is handed, so leaving `water_ml` + // out is what "no answer today" looks like on disk — and spreading the + // old map back in is exactly what made this un-clearable. + final fields = + {...await repo.getJournalMetrics(_date)}..remove('water_ml'); + if (next != null) fields['water_ml'] = JournalMetricValue(next); + await repo.postJournalMetrics(_date, fields); + await _load(); + } finally { + // Cleared unconditionally; the setState is only for the repaint. Gating + // the assignment on `mounted` would strand it again on the path where + // the screen goes away mid-write. + _writingWater = false; + if (mounted) setState(() {}); + } } /// Removing a log is destructive and there is no undo, so the entry is named @@ -205,17 +243,20 @@ class _NutritionScreenState extends State { onAction: _logFood, ), const SizedBox(height: S.x4), - MetricRow( - LucideIcons.glassWater, - C.blue, - 'Water', - _waterMl == null ? 'Not logged' : (_waterMl! / 1000).toStringAsFixed(1), - unit: _waterMl == null ? '' : 'L', - // The step is the spec's, and `_addWater` already reads it from - // there. Two copies of one constant is one copy too many. - sub: 'TAP TO ADD ${_waterStep.round()} ML', - onTap: _addWater, - ), + // Gated on the repository too: with no repo `_stepWater` returns at + // its first line, so an enabled + button was a control that did + // nothing — worse than a disabled one, which at least says so. + Builder(builder: (bc) { + final live = bc.select((a) => a.repo != null) && + !_writingWater; + return _WaterRow( + ml: _waterMl, + onDown: (!live || _waterMl == null) ? null : () => _stepWater(-1), + onUp: (!live || (_waterMl ?? 0) >= _waterSpec.max) + ? null + : () => _stepWater(1), + ); + }), if (day != null && day.logged && day.kcal.isFloor) ...[ const SizedBox(height: S.x4), StatusCard( @@ -780,3 +821,82 @@ class _Mean extends StatelessWidget { ); } } + +/// Water, with the plus and minus ON the tile — `− 1.8 L +`. +/// +/// A separate widget only because the shared [MetricRow] carries one tap for +/// the whole row, and water needs two targets pointing opposite ways. Nothing +/// else about it departs from that row's shape. +class _WaterRow extends StatelessWidget { + const _WaterRow({required this.ml, this.onDown, this.onUp}); + + /// Null is NOT logged, which is a different answer from a logged zero and + /// reads differently here: "Not logged" against "0.0 L". + final double? ml; + final VoidCallback? onDown, onUp; + + @override + Widget build(BuildContext c) { + final p = P.of(c); + return Surface( + child: Row(children: [ + Icon(LucideIcons.glassWater, size: 18, color: p.on(C.blue)), + const SizedBox(width: S.x3), + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('Water', style: F.body.copyWith(color: p.ink)), + Text( + ml == null ? 'Not logged' : 'Tap − or + to change', + style: F.over.copyWith(color: p.ink3), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ]), + ), + _WaterStep(LucideIcons.minus, onDown), + // Fixed width so the number does not shove the buttons sideways as it + // steps through 0.8 → 1.0 → 1.2. + SizedBox( + width: 78, + child: Text( + // NEVER a bare em-dash. An absent value says the word; a dash is a + // shrug the reader has to interpret, and the suite pins this. + ml == null ? 'None yet' : '${(ml! / 1000).toStringAsFixed(1)} L', + textAlign: TextAlign.center, + style: ml == null + ? F.cap.copyWith(color: p.ink3) + : F.n24.copyWith(color: p.ink), + maxLines: 1, + ), + ), + _WaterStep(LucideIcons.plus, onUp), + ]), + ); + } +} + +class _WaterStep extends StatelessWidget { + const _WaterStep(this.icon, this.onTap); + final IconData icon; + final VoidCallback? onTap; + + @override + Widget build(BuildContext c) { + final p = P.of(c); + final on = onTap != null; + return Pressable( + onTap: onTap, + semanticLabel: icon == LucideIcons.plus ? 'Add water' : 'Remove water', + child: Container( + width: S.tap, + height: S.tap, + alignment: Alignment.center, + decoration: BoxDecoration( + color: on ? p.wash(C.blue) : p.card2, + shape: BoxShape.circle, + ), + child: Icon(icon, size: 18, color: on ? p.on(C.blue) : p.ink3), + ), + ); + } +} diff --git a/lib/ui2/screens/start_card.dart b/lib/ui2/screens/start_card.dart index ea645b8b..ed42fa35 100644 --- a/lib/ui2/screens/start_card.dart +++ b/lib/ui2/screens/start_card.dart @@ -73,8 +73,11 @@ class StartCard extends StatelessWidget { final String sub; - /// Tuned per mascot. The wellness one is wider than it is tall, so at the - /// workout one's height it took enough width to squeeze the copy. + /// The height of the ART, which is only true while the assets are cropped to + /// their own alpha bounds. A mascot exported with transparent padding renders + /// smaller than its sibling at the same value here, and the temptation is to + /// fix that with a bigger number — which scales the padding too and takes the + /// extra width out of the copy. Crop the asset instead. final double mascotHeight; final VoidCallback? onTap; diff --git a/lib/ui2/screens/wellness_screen.dart b/lib/ui2/screens/wellness_screen.dart index c9267e3f..29bd2179 100644 --- a/lib/ui2/screens/wellness_screen.dart +++ b/lib/ui2/screens/wellness_screen.dart @@ -208,9 +208,23 @@ class _WellnessScreenState extends State { asset: 'mascot_wellness.png', accent: C.domMind, deep: C.teal, - // This mascot is WIDER than it is tall — at the workout one's - // height it took enough width to squeeze the copy. - mascotHeight: 118, + // Sized so the CHARACTER matches Workout's, not the frame. + // Two corrections got us here: the asset carried ~30% + // transparent padding (cropped away), and what is left still + // has a soft halo above the head, so the figure is 87% of the + // frame height where the workout mascot is 100% of its own. + // 145 x 0.87 puts the character at ~126, the same as Workout. + // Not cropped tighter than this on purpose — the halo is nearly + // opaque, so trimming it slices a hard arc through the artwork. + // The 118 here was originally compensating for + // ~30% transparent padding baked into the asset, which made + // the art render a third smaller than the workout one at the + // same height. The asset is cropped to its own alpha bounds, + // so the height is the art's height and the two mascots read + // as the same size. Still slightly wider than tall (1.03 vs + // 0.93), and at 126 that is 130 px — narrower than the padded + // asset was, so the copy has more room than before, not less. + mascotHeight: 145, onTap: () async { await Navigator.of(c).push( MaterialPageRoute( diff --git a/lib/ui2/ui2.dart b/lib/ui2/ui2.dart index 4a13f8e3..31742fb2 100644 --- a/lib/ui2/ui2.dart +++ b/lib/ui2/ui2.dart @@ -8,6 +8,7 @@ export 'app_shell.dart'; export 'charts.dart'; export 'grammar.dart'; +export 'live_hr.dart'; export 'paint_activity.dart'; export 'scroll_hint.dart'; export 'theme.dart'; diff --git a/test/ui2_router_test.dart b/test/ui2_router_test.dart index 8456b959..01759882 100644 --- a/test/ui2_router_test.dart +++ b/test/ui2_router_test.dart @@ -27,9 +27,9 @@ import 'package:openstrap_edge/ui2/onboarding/pairing.dart'; import 'package:openstrap_edge/ui2/onboarding/profile_setup.dart'; import 'package:openstrap_edge/ui2/onboarding/welcome.dart' show isEncryptedBackup; +import 'package:openstrap_edge/ui2/screens/nutrition_screen.dart'; import 'package:openstrap_edge/ui2/profile/devices.dart'; import 'package:openstrap_edge/ui2/profile/profile.dart'; -import 'package:openstrap_edge/ui2/screens/log_water.dart'; import 'package:openstrap_edge/ui2/ui2.dart'; /// A viewport tall enough that nothing under test is below the fold. The @@ -148,7 +148,10 @@ void main() { }); // The hydration reminder says "tap to log a glass": it has to open the // control, not the tab the control is buried on. - expect(screenForRoute(kRouteWater), isA()); + // The water reminder lands on Nutrition now — the water tile there + // steps and clears in place, and the single-field screen it used to + // open was reachable from nowhere else. + expect(screenForRoute(kRouteWater), isA()); // Payload routes that predate the five-tab shell, and that // `resolveTapRoute` does not carry yet — the destinations exist here so // they stop landing on Home the moment it does. diff --git a/test/ui2_tokens_test.dart b/test/ui2_tokens_test.dart index 2d0dddb0..465025ee 100644 --- a/test/ui2_tokens_test.dart +++ b/test/ui2_tokens_test.dart @@ -224,7 +224,6 @@ const _notComponents = { // Where the hydration notification lands: a Scaffold route that reads and // writes the day's journal metrics. The one control on it — FieldStepper — // IS in the gallery. - 'LogWaterScreen', // The coach chat and its BYOK setup: Scaffold routes that own an engine, a // 120 s network call and the keychain. `CoachFigure` — the part a gallery can // actually hold — IS in it.