Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified assets/images/2.0x/mascot_wellness.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/images/3.0x/mascot_wellness.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/images/mascot_wellness.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 6 additions & 4 deletions lib/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
52 changes: 51 additions & 1 deletion lib/state/app_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<int> _liveHrTrace = [];
List<int> 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,
Expand Down Expand Up @@ -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 ────────────────────────────────────────
Expand Down
153 changes: 153 additions & 0 deletions lib/ui2/live_hr.dart
Original file line number Diff line number Diff line change
@@ -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<int> trace})
: _hr = hr,
_trace = trace,
_preview = true;

final int? _hr;
final List<int>? _trace;
final bool _preview;

@override
Widget build(BuildContext c) {
final p = P.of(c);
final hr = _preview ? _hr : c.select<AppState, int?>((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<AppState, bool>((a) => a.isPaired),
connected: c.select<AppState, bool>((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<int> trace;
if (_preview) {
trace = _trace ?? const [];
} else {
c.select<AppState, int>((a) => a.liveHrTraceRev);
trace = c.read<AppState>().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);
}
}
107 changes: 107 additions & 0 deletions lib/ui2/profile/devices.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -497,11 +498,83 @@ class _DeviceDetailState extends State<DeviceDetail> {
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<void> _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<String?>(null);
final name = await showDialog<String>(
context: c,
builder: (d) => AlertDialog(
title: const Text('Name this band'),
content: Column(mainAxisSize: MainAxisSize.min, children: [
ValueListenableBuilder<String?>(
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
Expand Down Expand Up @@ -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;

Expand All @@ -554,6 +637,8 @@ class DeviceDetailView extends StatelessWidget {
{super.key,
this.onFind,
this.onForget,
this.onRename,
this.liveHr,
this.status,
this.health,
this.forecast});
Expand Down Expand Up @@ -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
Expand All @@ -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' : '',
Expand Down
Loading
Loading