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
42 changes: 32 additions & 10 deletions lib/ui/insights/coach_cards.dart
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,27 @@ Map<String, dynamic>? _val(Object? metric) {
return v is Map ? v.cast<String, dynamic>() : null;
}

/// The alarm-status caption for the Sleep page, from the SESSION-scoped
/// confirmation machine only. Pure + public so the "only speak after an
/// in-session SET" rule is unit-tested.
///
/// The confirmation state machine ([AppState.alarm…]) is reset on every launch
/// and only advances once the user actually writes a SET this session, so all
/// three inputs are false on a fresh open. A persisted `alarmEpoch` (an alarm
/// set in a PREVIOUS session) is deliberately NOT an input: keying the caption
/// off it made the page read "Alarm sent, waiting for the strap to confirm." on
/// a fresh open when the user never tapped the button. All-false → null (idle).
String? alarmStatusCaption({
required bool confirmed,
required bool pending,
required bool unconfirmed,
}) {
if (confirmed) return 'Alarm set ✓';
if (pending) return 'Setting alarm…';
if (unconfirmed) return 'Alarm sent, waiting for the strap to confirm.';
return null;
}

// ── SLEEP COACH ──────────────────────────────────────────────────────────────

/// Tonight's sleep need + recommended bedtime / wake, last night's sleep
Expand Down Expand Up @@ -95,21 +116,22 @@ class _SleepCoachCardState extends State<SleepCoachCard> {
}

/// Live alarm-status caption driven by the strap's own confirmation events.
/// Null when no alarm has been set yet — nothing to say.
String? _alarmCaption(AppState app) {
if (app.alarmEpoch == null) return null;
if (app.alarmConfirmed) return 'Alarm set ✓';
if (app.alarmPending) return 'Setting alarm…';
return 'Alarm sent, waiting for the strap to confirm.';
}
/// Null when no alarm is being tracked THIS session — nothing to say.
String? _alarmCaption(AppState app) => alarmStatusCaption(
confirmed: app.alarmConfirmed,
pending: app.alarmPending,
unconfirmed: app.alarmUnconfirmed,
);

@override
Widget build(BuildContext context) {
// Rebuild only on the 3 alarm fields _alarmCaption reads — was two
// separate context.watch<AppState>() calls below (each one subscribing
// to ALL 67 notifyListeners() sources, not just alarm state).
context.select<AppState, (int?, bool, bool)>(
(a) => (a.alarmEpoch, a.alarmConfirmed, a.alarmPending),
// to ALL 67 notifyListeners() sources, not just alarm state). The grace
// timer's notifyListeners() flips alarmPending true→false, which changes
// this tuple, so the unconfirmed transition still triggers a rebuild.
context.select<AppState, (bool, bool, bool)>(
(a) => (a.alarmConfirmed, a.alarmPending, a.alarmUnconfirmed),
);
if (_loading) return const SizedBox.shrink();
final need = _val(_coach?['need']);
Expand Down
82 changes: 82 additions & 0 deletions test/sleep_alarm_caption_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// Regression tests for the Sleep page showing "Alarm sent, waiting for the
// strap to confirm." when the user never set an alarm.
//
// Root cause: the caption used to fall through to the "awaiting confirmation"
// text whenever a PERSISTED alarm epoch existed (an alarm set in a previous
// session), even though the strap-confirmation state machine is session-scoped
// and had recorded no SET this session. On a fresh open the epoch was restored
// from SharedPreferences but confirmed/pending/unconfirmed were all false, so
// the fallthrough fired the pending caption with no user tap.
//
// The fix routes the caption purely through the session-scoped machine's
// confirmed/pending/unconfirmed flags — never the persisted epoch — so an
// all-false state (fresh launch, no tap) yields no caption. These tests pin the
// mapping, driven by the real [AlarmConfirmation] transitions.

import 'package:flutter_test/flutter_test.dart';
import 'package:openstrap_edge/ble/ble_state.dart';
import 'package:openstrap_edge/ui/insights/coach_cards.dart';

// Caption as the widget builds it, from a confirmation machine at [nowMs].
String? captionFor(AlarmConfirmation a, int nowMs) => alarmStatusCaption(
confirmed: a.confirmed,
pending: a.isPending(nowMs),
unconfirmed: a.isUnconfirmed(nowMs),
);

void main() {
group('alarmStatusCaption', () {
test('fresh session (no in-session SET) shows NO caption', () {
// This is the bug: a persisted epoch alone must not surface a caption.
final a = AlarmConfirmation();
expect(captionFor(a, 0), isNull);
});

test('after a SET this session → "Setting alarm…" inside the grace window', () {
final a = AlarmConfirmation(graceMs: 6000);
a.set(1750000000, 0);
expect(captionFor(a, 0), 'Setting alarm…');
expect(captionFor(a, 5999), 'Setting alarm…');
});

test('SET with no confirm past the grace window → awaiting-confirm caption', () {
final a = AlarmConfirmation(graceMs: 6000);
a.set(1750000000, 0);
expect(captionFor(a, 6000), 'Alarm sent, waiting for the strap to confirm.');
});

test('strap confirms (event 56) → "Alarm set ✓"', () {
final a = AlarmConfirmation(graceMs: 6000);
a.set(1750000000, 0);
a.onEvent(AlarmConfirmation.kEvtSet, 100);
expect(captionFor(a, 10000), 'Alarm set ✓');
});

test('disable clears the caption back to idle', () {
final a = AlarmConfirmation(graceMs: 6000);
a.set(1750000000, 0);
a.onEvent(AlarmConfirmation.kEvtSet, 100);
a.onEvent(AlarmConfirmation.kEvtDisabled, 200);
expect(captionFor(a, 10000), isNull);
});

test('pure mapping: precedence confirmed > pending > unconfirmed > null', () {
expect(
alarmStatusCaption(confirmed: true, pending: true, unconfirmed: true),
'Alarm set ✓',
);
expect(
alarmStatusCaption(confirmed: false, pending: true, unconfirmed: true),
'Setting alarm…',
);
expect(
alarmStatusCaption(confirmed: false, pending: false, unconfirmed: true),
'Alarm sent, waiting for the strap to confirm.',
);
expect(
alarmStatusCaption(confirmed: false, pending: false, unconfirmed: false),
isNull,
);
});
});
}