From 52c3ff5f1e28dd6b6a5df7185a48facac0153cdf Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Wed, 11 Nov 2026 12:45:10 +0100 Subject: [PATCH] Make wake-alarm text-free (textgate-rework) Todo: 364208fc --- DOCS-wake-enforcement.md | 3 +- .../com/kuhy/wake_alarm_sync/AlarmService.kt | 5 +- .../com/kuhy/wake_alarm_sync/SessionNotice.kt | 6 +- .../lib/probe/file_driven_scheduler.dart | 1 + phone_app/lib/screens/day_calendar.dart | 48 ++-- phone_app/lib/screens/energy_picker.dart | 7 +- phone_app/lib/screens/escape_dialog.dart | 65 ++++-- phone_app/lib/screens/free_days_body.dart | 83 ++++--- phone_app/lib/screens/free_days_screen.dart | 12 +- .../lib/screens/github_mirror_screen.dart | 65 ++---- phone_app/lib/screens/home_body.dart | 79 ++++--- phone_app/lib/screens/home_history_card.dart | 84 ++++++- .../lib/screens/home_location_screen.dart | 106 ++++++--- phone_app/lib/screens/home_presence_line.dart | 21 +- phone_app/lib/screens/home_screen.dart | 8 +- phone_app/lib/screens/home_tiles.dart | 11 +- phone_app/lib/screens/ring_body.dart | 23 +- phone_app/lib/screens/ring_challenge.dart | 97 ++++++-- phone_app/lib/screens/ring_checklist.dart | 107 +++++++-- phone_app/lib/screens/ring_stakes_line.dart | 51 ++-- phone_app/lib/screens/room_proof_screen.dart | 58 +++-- phone_app/lib/screens/session_body.dart | 130 +++++++---- phone_app/lib/screens/session_checklist.dart | 186 +++++++++++---- .../lib/screens/session_tags_screen.dart | 66 ++++-- phone_app/lib/screens/settings_screen.dart | 48 +++- phone_app/lib/screens/sick_day_sheet.dart | 50 ++-- phone_app/lib/screens/sick_days_body.dart | 122 ++++++---- phone_app/lib/screens/sick_days_screen.dart | 30 ++- phone_app/lib/screens/stakes_card.dart | 46 ++-- phone_app/lib/screens/stakes_marks.dart | 135 +++++++++++ phone_app/lib/services/challenge.dart | 2 +- phone_app/lib/services/session_notice.dart | 15 +- phone_app/lib/services/stakes_view.dart | 91 ++++++++ phone_app/lib/services/today_stakes.dart | 73 +++--- phone_app/lib/ui/cues.dart | 218 ++++++++++++++++++ phone_app/lib/ui/dashed_slot.dart | 146 ++++++++++++ phone_app/lib/ui/spoken_label.dart | 34 +++ phone_app/pubspec.lock | 44 ++-- phone_app/pubspec.yaml | 8 +- phone_app/test/free_days_screen_test.dart | 76 +++--- .../test/free_days_sick_conflict_test.dart | 10 +- phone_app/test/home_fit_test.dart | 9 +- phone_app/test/home_history_card_test.dart | 41 +++- phone_app/test/home_location_screen_test.dart | 37 +-- phone_app/test/ring_away_test.dart | 32 ++- phone_app/test/ring_bypass_test.dart | 32 ++- phone_app/test/ring_route_test.dart | 48 ++-- phone_app/test/ring_session_handoff_test.dart | 12 +- phone_app/test/ring_stakes_test.dart | 15 +- phone_app/test/room_proof_screen_test.dart | 65 ++++-- .../services/session_legs_fixture_test.dart | 3 +- .../test/services/session_notice_test.dart | 10 +- .../test/services/today_stakes_test.dart | 24 ++ .../test/session_screen_catch_up_test.dart | 2 +- .../test/session_screen_checkin_test.dart | 17 +- .../test/session_screen_relaunch_test.dart | 10 +- phone_app/test/session_screen_test.dart | 39 ++-- phone_app/test/session_tags_screen_test.dart | 40 ++-- phone_app/test/settings_screen_test.dart | 33 +-- phone_app/test/sick_day_sheet_test.dart | 52 +++-- phone_app/test/sick_days_screen_test.dart | 56 +++-- phone_app/test/stakes_card_test.dart | 84 ++++--- phone_app/test/text_free_days_test.dart | 143 ++++++++++++ phone_app/test/text_free_ring_test.dart | 216 +++++++++++++++++ phone_app/test/text_free_support.dart | 64 +++++ phone_app/test/text_free_sync_test.dart | 46 ++++ phone_app/test/text_free_test.dart | 207 +++++++++++++++++ phone_app/test/ui/cues_haptics_test.dart | 162 +++++++++++++ phone_app/test/ui/cues_test.dart | 149 ++++++++++++ phone_app/test/widget_test.dart | 2 +- requirements.txt | 2 +- wake_alarm/_session_strip.py | 9 +- wake_alarm/_strip_pips.py | 31 +++ wake_alarm/tests/test_session_strip.py | 39 +++- wake_alarm/tests/test_session_strip_run.py | 5 +- 75 files changed, 3319 insertions(+), 887 deletions(-) create mode 100644 phone_app/lib/screens/stakes_marks.dart create mode 100644 phone_app/lib/services/stakes_view.dart create mode 100644 phone_app/lib/ui/cues.dart create mode 100644 phone_app/lib/ui/dashed_slot.dart create mode 100644 phone_app/lib/ui/spoken_label.dart create mode 100644 phone_app/test/text_free_days_test.dart create mode 100644 phone_app/test/text_free_ring_test.dart create mode 100644 phone_app/test/text_free_support.dart create mode 100644 phone_app/test/text_free_sync_test.dart create mode 100644 phone_app/test/text_free_test.dart create mode 100644 phone_app/test/ui/cues_haptics_test.dart create mode 100644 phone_app/test/ui/cues_test.dart create mode 100644 wake_alarm/_strip_pips.py diff --git a/DOCS-wake-enforcement.md b/DOCS-wake-enforcement.md index a51478c..cc2b0e8 100644 --- a/DOCS-wake-enforcement.md +++ b/DOCS-wake-enforcement.md @@ -131,7 +131,8 @@ it as not the morning's ring. ### Escape hatch (until 2026-10-15 inclusive) -Home screen button "That was a bug". Needs a typed reason ≥ 40 characters. +Bug-glyph button on the stakes card (spoken label "That was a bug"; the +app is text-free since 2026-11-10). Needs a typed reason ≥ 40 characters. It lifts the phone lockdown, clears the streak, cancels today's PC penalty, stops a ringing PC alarm, and writes everything into the incident report. Unlimited uses — every use is listed at the top of that day's report. diff --git a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmService.kt b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmService.kt index acc5974..78cff58 100644 --- a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmService.kt +++ b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmService.kt @@ -105,10 +105,9 @@ class AlarmService : Service() { // IMPORTANCE_HIGH is required for a full-screen intent to be honoured. val channel = NotificationChannel( CHANNEL_ID, - "Wake alarm", + "WakeAlarm", NotificationManager.IMPORTANCE_HIGH, ).apply { - description = "The ringing wake alarm" // The service owns sound and vibration, so the channel must not add // its own — a channel sound would play on the notification stream // and be silenced by DND, unlike STREAM_ALARM. @@ -129,8 +128,6 @@ class AlarmService : Service() { PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) return Notification.Builder(this, CHANNEL_ID) - .setContentTitle("Wake alarm") - .setContentText("Tap to dismiss") .setSmallIcon(android.R.drawable.ic_lock_idle_alarm) .setCategory(Notification.CATEGORY_ALARM) .setOngoing(true) diff --git a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/SessionNotice.kt b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/SessionNotice.kt index c699eb0..ad38019 100644 --- a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/SessionNotice.kt +++ b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/SessionNotice.kt @@ -15,7 +15,7 @@ import java.time.LocalDate * * Two jobs, one record. The launcher starts [MainActivity], and it has to * know *before any Dart runs* whether to step aside into the session screen; - * the notification needs the leg text with no engine running behind it. + * the notification needs the leg glyph with no engine running behind it. * Dart rewrites this cache on every leg change (`updateSessionNotice`) and * `finishSession` clears it, so it is only ever as stale as the last change. * @@ -50,8 +50,7 @@ object SessionNotice { .apply() val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager manager.createNotificationChannel( - NotificationChannel(CHANNEL_ID, "Morning session", NotificationManager.IMPORTANCE_LOW) - .apply { description = "The live morning checklist" }, + NotificationChannel(CHANNEL_ID, "WakeAlarm", NotificationManager.IMPORTANCE_LOW), ) manager.notify(NOTIFICATION_ID, build(context, label, deadlineMillis)) } @@ -87,7 +86,6 @@ object SessionNotice { PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) val builder = Notification.Builder(context, CHANNEL_ID) - .setContentTitle("Morning session") .setContentText(label) .setSmallIcon(android.R.drawable.ic_lock_idle_alarm) .setCategory(Notification.CATEGORY_REMINDER) diff --git a/phone_app/lib/probe/file_driven_scheduler.dart b/phone_app/lib/probe/file_driven_scheduler.dart index 78f4cda..252fd1d 100644 --- a/phone_app/lib/probe/file_driven_scheduler.dart +++ b/phone_app/lib/probe/file_driven_scheduler.dart @@ -1,3 +1,4 @@ +// phone-slot: not-shared-storage (path is per-package: Android/data//files) import 'dart:async'; import 'dart:io'; diff --git a/phone_app/lib/screens/day_calendar.dart b/phone_app/lib/screens/day_calendar.dart index 59965dc..2ef787b 100644 --- a/phone_app/lib/screens/day_calendar.dart +++ b/phone_app/lib/screens/day_calendar.dart @@ -74,15 +74,24 @@ class DayCalendar extends StatelessWidget { /// Latest month the forward arrow reaches; null means no limit. final DateTime? lastMonth; - static const _labels = ['M', 'T', 'W', 'T', 'F', 'S', 'S']; - static const _months = [ - 'January', 'February', 'March', 'April', 'May', 'June', 'July', // - 'August', 'September', 'October', 'November', 'December', - ]; - bool _before(DateTime a, DateTime b) => a.year < b.year || (a.year == b.year && a.month < b.month); + Widget _weekdayDot(BuildContext context, int index) { + final theme = Theme.of(context); + final isWeekend = index == 5 || index == 6; + return Container( + width: isWeekend ? 8 : 6, + height: isWeekend ? 8 : 6, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isWeekend + ? theme.colorScheme.primary + : theme.colorScheme.onSurfaceVariant, + ), + ); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -97,22 +106,26 @@ class DayCalendar extends StatelessWidget { Row( children: [ IconButton( - icon: const Icon(Icons.chevron_left), - tooltip: 'Previous month', + icon: const Icon( + Icons.chevron_left, + semanticLabel: 'Previous month', + ), onPressed: canBack ? () => onMonth(DateTime(month.year, month.month - 1)) : null, ), Expanded( child: Text( - '${_months[month.month - 1]} ${month.year}', + '${month.month.toString().padLeft(2, '0')}/${month.year}', textAlign: TextAlign.center, style: theme.textTheme.titleMedium, ), ), IconButton( - icon: const Icon(Icons.chevron_right), - tooltip: 'Next month', + icon: const Icon( + Icons.chevron_right, + semanticLabel: 'Next month', + ), onPressed: canForward ? () => onMonth(DateTime(month.year, month.month + 1)) : null, @@ -121,17 +134,8 @@ class DayCalendar extends StatelessWidget { ), Row( children: [ - for (final label in _labels) - Expanded( - child: Center( - child: Text( - label, - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ), - ), + for (var i = 0; i < 7; i++) + Expanded(child: Center(child: _weekdayDot(context, i))), ], ), const SizedBox(height: 4), diff --git a/phone_app/lib/screens/energy_picker.dart b/phone_app/lib/screens/energy_picker.dart index 939a56f..1d95b12 100644 --- a/phone_app/lib/screens/energy_picker.dart +++ b/phone_app/lib/screens/energy_picker.dart @@ -28,7 +28,12 @@ class EnergyPicker extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text('Energy', style: Theme.of(context).textTheme.labelLarge), + const Row( + children: [ + Icon(Icons.bolt, semanticLabel: 'Energy'), + SizedBox(width: 8), + ], + ), const SizedBox(height: 4), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, diff --git a/phone_app/lib/screens/escape_dialog.dart b/phone_app/lib/screens/escape_dialog.dart index dcaf92e..456f135 100644 --- a/phone_app/lib/screens/escape_dialog.dart +++ b/phone_app/lib/screens/escape_dialog.dart @@ -34,50 +34,67 @@ class _EscapeDialogState extends State { Widget build(BuildContext context) { final theme = Theme.of(context); final ready = _length >= escapeReasonMinChars; + const reasonWords = 'What went wrong?'; return AlertDialog( // The keyboard covers half a small screen; scroll instead of overflow. scrollable: true, - title: const Text('That was a bug'), + icon: const Icon( + Icons.bug_report_outlined, + semanticLabel: 'That was a bug', + ), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const Text( - 'Lifts the app lockdown, clears the streak, cancels today’s PC ' - 'penalty and stops a ringing PC. Every use is listed in the ' - 'incident report.', - ), - const SizedBox(height: 16), - TextField( - controller: _reason, - autofocus: true, - maxLines: 4, - minLines: 2, - decoration: const InputDecoration( - labelText: 'What went wrong?', - border: OutlineInputBorder(), + MergeSemantics( + child: Semantics( + label: reasonWords, + child: TextField( + controller: _reason, + autofocus: true, + maxLines: 4, + minLines: 2, + decoration: const InputDecoration( + prefixIcon: Icon(Icons.edit_note), + border: OutlineInputBorder(), + ), + onChanged: (_) => setState(() {}), + ), ), - onChanged: (_) => setState(() {}), ), const SizedBox(height: 8), - Text( - '$_length / $escapeReasonMinChars characters', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), + Row( + children: [ + Expanded( + child: LinearProgressIndicator( + value: (_length / escapeReasonMinChars) + .clamp(0, 1) + .toDouble(), + ), + ), + const SizedBox(width: 8), + Text( + '$_length/$escapeReasonMinChars', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], ), ], ), actions: [ - TextButton( + IconButton( + key: const Key('escape-cancel'), onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), + icon: const Icon(Icons.close, semanticLabel: 'Cancel'), ), FilledButton( + key: const Key('escape-confirm'), onPressed: ready ? () => Navigator.of(context).pop(_reason.text.trim()) : null, - child: const Text('Excuse this morning'), + child: const Icon(Icons.check, semanticLabel: 'Excuse this morning'), ), ], ); diff --git a/phone_app/lib/screens/free_days_body.dart b/phone_app/lib/screens/free_days_body.dart index 8af8e3c..30a416d 100644 --- a/phone_app/lib/screens/free_days_body.dart +++ b/phone_app/lib/screens/free_days_body.dart @@ -1,7 +1,8 @@ import 'package:flutter/material.dart'; import 'package:wake_alarm_sync/screens/day_calendar.dart'; import 'package:wake_alarm_sync/services/free_day_pool.dart'; -import 'package:wake_alarm_sync/ui/theme.dart'; +import 'package:wake_alarm_sync/ui/cues.dart'; +import 'package:wake_alarm_sync/ui/dashed_slot.dart'; /// One upcoming free day, as the list shows it. typedef UpcomingFreeDay = ({DateTime day, String reason}); @@ -45,70 +46,78 @@ class FreeDaysBody extends StatelessWidget { /// Called when the user gives a day back. final void Function(DateTime day) onRelease; - static const _weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; - - String _label(DateTime day) => - '${_weekdays[day.weekday - 1]} ${FreeDayPool.toIso(day)}'; + String _toDdMmYyyy(DateTime day) { + final d = day.day.toString().padLeft(2, '0'); + final m = day.month.toString().padLeft(2, '0'); + return '$d.$m.${day.year}'; + } @override Widget build(BuildContext context) { final theme = Theme.of(context); - final statusColors = theme.extension()!; return SafeArea( child: ListView( padding: const EdgeInsets.all(16), children: [ - Text( - '$remaining of ${FreeDayPool.annualBudget}', - style: theme.textTheme.displayMedium?.copyWith( - fontWeight: FontWeight.bold, - ), + Row( + children: [ + const Icon(Icons.event_available), + const SizedBox(width: 8), + Text( + '$remaining/${FreeDayPool.annualBudget}', + style: theme.textTheme.displayMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], ), Text( - 'free days left in $year', + '$year', style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: 8), - Text( - 'On a free day nothing locks, nothing rings and nothing asks. ' - 'Every app, at once.', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), if (status.isNotEmpty) ...[ const SizedBox(height: 16), - Text( - status, - maxLines: 4, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: status.contains('Error') - ? theme.colorScheme.error - : statusColors.success, - ), - ), + StatusCue(semanticLabel: status), ], const SizedBox(height: 16), calendar, const SizedBox(height: 24), - Text('Booked ahead', style: theme.textTheme.titleMedium), + Row( + children: [ + Icon( + Icons.upcoming, + color: theme.colorScheme.onSurfaceVariant, + semanticLabel: 'Booked ahead', + ), + ], + ), const SizedBox(height: 8), if (upcoming.isEmpty) - Text( - 'None. Tap a date above to book one.', - style: TextStyle(color: theme.colorScheme.onSurfaceVariant), + const SpokenLabel( + semanticLabel: 'None. Tap a date above to book one.', + child: DashedSlot(), ), for (final entry in upcoming) ListTile( contentPadding: EdgeInsets.zero, - title: Text(_label(entry.day)), - subtitle: entry.reason.isEmpty ? null : Text(entry.reason), - trailing: TextButton( + title: Text(_toDdMmYyyy(entry.day)), + subtitle: entry.reason.isEmpty + ? null + : KeyedSubtree( + key: ValueKey( + 'content:reason-${FreeDayPool.toIso(entry.day)}', + ), + child: Text(entry.reason), + ), + trailing: IconButton( + key: ValueKey( + 'release-${FreeDayPool.toIso(entry.day)}', + ), + icon: const Icon(Icons.event_busy, semanticLabel: 'Release'), onPressed: busy ? null : () => onRelease(entry.day), - child: const Text('Release'), ), ), ], diff --git a/phone_app/lib/screens/free_days_screen.dart b/phone_app/lib/screens/free_days_screen.dart index c13cefe..9d1597c 100644 --- a/phone_app/lib/screens/free_days_screen.dart +++ b/phone_app/lib/screens/free_days_screen.dart @@ -7,6 +7,7 @@ import 'package:wake_alarm_sync/services/free_day_pool.dart'; import 'package:wake_alarm_sync/services/free_day_sync.dart'; import 'package:wake_alarm_sync/services/sick_day_pool.dart'; import 'package:wake_alarm_sync/services/sick_day_sync.dart'; +import 'package:wake_alarm_sync/ui/cues.dart'; /// The one picker for the shared free-day pool. /// @@ -173,17 +174,20 @@ class _FreeDaysScreenState extends State { final pool = _pool; return Scaffold( appBar: AppBar( - title: const Text('Free days'), + // title removed actions: [ IconButton( - icon: const Icon(Icons.sync), - tooltip: 'Sync now', + icon: const Icon(Icons.sync, semanticLabel: 'Sync now'), onPressed: _busy ? null : () => unawaited(_sync()), ), ], ), body: pool == null - ? Center(child: Text(_status.isEmpty ? 'Loading…' : _status)) + ? Center( + child: _status.isEmpty + ? const CircularProgressIndicator() + : StatusCue(semanticLabel: _status), + ) : FreeDaysBody( year: _today.year, remaining: pool.remaining(_today.year), diff --git a/phone_app/lib/screens/github_mirror_screen.dart b/phone_app/lib/screens/github_mirror_screen.dart index 15368a0..ecf2c1a 100644 --- a/phone_app/lib/screens/github_mirror_screen.dart +++ b/phone_app/lib/screens/github_mirror_screen.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:github_device_auth/github_device_auth.dart'; import 'package:wake_alarm_sync/services/sync_settings.dart'; +import 'package:wake_alarm_sync/ui/cues.dart'; /// The GitHub mirror screen: cutover-only sync transport, not a peer of /// Firebase. @@ -86,62 +87,44 @@ class _GitHubMirrorScreenState extends State { Widget build(BuildContext context) { final isConnected = _settings.isConfigured; return Scaffold( - appBar: AppBar(title: const Text('Advanced sync (GitHub)')), + appBar: AppBar(), body: SafeArea( child: SingleChildScrollView( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Align( - alignment: Alignment.centerLeft, - child: Text( - isConnected - ? 'Connected to GitHub.' - : 'Not connected to GitHub.', - style: TextStyle( - fontSize: 16, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), + Icon( + isConnected ? Icons.link : Icons.link_off, + color: isConnected + ? Theme.of(context).colorScheme.tertiary + : Theme.of(context).colorScheme.onSurfaceVariant, + size: 32, + semanticLabel: isConnected + ? 'Connected to GitHub.' + : 'Not connected to GitHub.', ), const SizedBox(height: 12), if (!isConnected) - ElevatedButton.icon( + ElevatedButton( + key: const ValueKey('github-connect'), onPressed: _busy ? null : _connectGitHub, - icon: const Icon(Icons.link), - label: const Text('Connect GitHub'), + child: const Icon( + Icons.link, + semanticLabel: 'Connect GitHub', + ), ) else - OutlinedButton.icon( + OutlinedButton( + key: const ValueKey('github-disconnect'), onPressed: _busy ? null : _disconnect, - icon: const Icon(Icons.link_off), - label: const Text('Disconnect GitHub'), - ), - const SizedBox(height: 16), - Text( - 'PC config — the PC reads Firebase first and falls back ' - 'to GitHub. For the GitHub fallback, create:\n' - '~/.config/wake_alarm/sync.json\n\n' - '{ "token": "" }\n\n' - 'The token needs "repo" scope so the PC can read ' - 'kuhyx/syncs.', - style: TextStyle( - fontSize: 16, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - if (_status.isNotEmpty) ...[ - const SizedBox(height: 16), - Text( - _status, - style: TextStyle( - color: _status.startsWith('Error') - ? Theme.of(context).colorScheme.error - : null, + child: const Icon( + Icons.link_off, + semanticLabel: 'Disconnect GitHub', ), ), - ], + const SizedBox(height: 16), + StatusCue(semanticLabel: _status), ], ), ), diff --git a/phone_app/lib/screens/home_body.dart b/phone_app/lib/screens/home_body.dart index ae97765..1954be7 100644 --- a/phone_app/lib/screens/home_body.dart +++ b/phone_app/lib/screens/home_body.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:wake_alarm_sync/screens/home_presence_line.dart'; import 'package:wake_alarm_sync/screens/home_tiles.dart'; +import 'package:wake_alarm_sync/ui/cues.dart'; import 'package:wake_alarm_sync/ui/theme.dart'; /// The home screen's body: the armed time, the earlier-only control and the @@ -53,6 +54,13 @@ class HomeBody extends StatelessWidget { /// Called after the escape hatch was used, so the screen publishes it. final VoidCallback? onEscaped; + IconData? _statusIcon(String status) { + if (status.startsWith('Connect a sync backend')) return Icons.cloud_off; + if (status.startsWith('Sync store unavailable')) return Icons.sync_problem; + if (status.contains('✓')) return Icons.cloud_done; + return null; + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -69,9 +77,11 @@ class HomeBody extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - const Text( - 'Alarm is armed for', - style: TextStyle(fontSize: 16), + Icon( + Icons.alarm_on, + size: 32, + color: theme.colorScheme.primary, + semanticLabel: 'Alarm is armed for', ), HomePresenceLine(key: ValueKey(syncEpoch)), const SizedBox(height: 16), @@ -82,40 +92,46 @@ class HomeBody extends StatelessWidget { ), ), const SizedBox(height: 8), - Text( - isOverridden - ? 'One-shot early start — back to $baseline ' - 'after it rings' - : 'Every day. It cannot be turned off.', - textAlign: TextAlign.center, - style: const TextStyle(fontSize: 13), - ), + if (isOverridden) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.restore, + size: 18, + semanticLabel: + 'One-shot early start, back to $baseline ' + 'after it rings', + ), + Text(baseline), + ], + ) + else + Icon( + Icons.all_inclusive, + size: 18, + color: theme.colorScheme.onSurfaceVariant, + semanticLabel: 'Every day. It cannot be turned off.', + ), const SizedBox(height: 32), SizedBox( width: double.infinity, - child: OutlinedButton.icon( + child: OutlinedButton( onPressed: busy ? null : onPickEarlier, - icon: const Icon(Icons.arrow_upward), - label: const Text('Wake me earlier tomorrow'), style: OutlinedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 16), ), + child: const Icon( + Icons.arrow_upward, + semanticLabel: 'Wake me earlier tomorrow', + ), ), ), const SizedBox(height: 24), if (status.isNotEmpty) ...[ - Text( - status, - textAlign: TextAlign.center, - // Bounded: status can be a raw `'Error: $e'`, i.e. an - // arbitrarily long exception toString(). - maxLines: 4, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: status.contains('Error') - ? theme.colorScheme.error - : theme.extension()!.success, - ), + StatusCue( + semanticLabel: status, + icon: _statusIcon(status), ), const SizedBox(height: 24), ], @@ -126,12 +142,11 @@ class HomeBody extends StatelessWidget { ), if (!configured) ...[ const SizedBox(height: 16), - Text( - 'Open Settings (gear icon) to connect Firebase.', - textAlign: TextAlign.center, - style: TextStyle( - color: theme.extension()!.warning, - ), + Icon( + Icons.cloud_off, + size: 28, + color: theme.extension()!.warning, + semanticLabel: 'Open Settings to connect Firebase', ), ], ], diff --git a/phone_app/lib/screens/home_history_card.dart b/phone_app/lib/screens/home_history_card.dart index 60c10c2..4199c1c 100644 --- a/phone_app/lib/screens/home_history_card.dart +++ b/phone_app/lib/screens/home_history_card.dart @@ -4,6 +4,9 @@ import 'package:flutter/material.dart'; import 'package:wake_alarm_sync/services/morning_session.dart'; import 'package:wake_alarm_sync/services/session_store.dart'; import 'package:wake_alarm_sync/services/weight_entry.dart'; +import 'package:wake_alarm_sync/ui/dashed_slot.dart'; +import 'package:wake_alarm_sync/ui/spoken_label.dart'; +import 'package:wake_alarm_sync/ui/theme.dart'; /// How many mornings the home screen shows. const int kHistoryShown = 7; @@ -52,20 +55,20 @@ class _HomeHistoryCardState extends State { Widget build(BuildContext context) { if (_sessions.isEmpty) { final empty = widget.emptyText; - return _loaded && empty != null ? Text(empty) : const SizedBox.shrink(); + return _loaded && empty != null + ? SpokenLabel(semanticLabel: empty, child: const DashedSlot()) + : const SizedBox.shrink(); } - final theme = Theme.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text('Last mornings', style: theme.textTheme.titleMedium), - const SizedBox(height: 8), for (var i = 0; i < _sessions.length; i++) Padding( - padding: const EdgeInsets.symmetric(vertical: 2), - child: Text( - describeMorning(_sessions[i], previousWeight: _previousWeight(i)), - style: theme.textTheme.bodyMedium, + padding: const EdgeInsets.symmetric(vertical: 4), + child: _MorningRow( + key: ValueKey('history-${_sessions[i].date}'), + session: _sessions[i], + previousWeight: _previousWeight(i), ), ), ], @@ -82,6 +85,67 @@ class _HomeHistoryCardState extends State { } } +/// One morning as glyphs and numbers; [describeMorning] is its spoken form. +class _MorningRow extends StatelessWidget { + const _MorningRow({ + required this.session, + required this.previousWeight, + super.key, + }); + + final MorningSession session; + final double? previousWeight; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final status = theme.extension(); + final muted = theme.colorScheme.onSurfaceVariant; + final (IconData outcome, Color colour) = switch (session.outcome) { + SessionOutcome.completed => ( + Icons.check_circle, + status?.success ?? Colors.green, + ), + SessionOutcome.failed => (Icons.cancel, theme.colorScheme.error), + SessionOutcome.active => (Icons.more_horiz, muted), + }; + final kg = session.weightKg; + final previous = previousWeight; + final energy = session.energy; + return SpokenLabel( + semanticLabel: describeMorning(session, previousWeight: previous), + // Wrap, not Row: a long weight delta must not overflow a narrow sheet. + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text(session.date.substring(5)), + const SizedBox(width: 8), + Icon(outcome, color: colour, size: 20), + const SizedBox(width: 12), + Icon(Icons.replay, size: 16, color: muted), + Text('${session.reRings}'), + if (kg != null) ...[ + const SizedBox(width: 12), + Icon(Icons.monitor_weight_outlined, size: 16, color: muted), + Text('$kg'), + if (previous != null) Text(' (${_signed(kg - previous)})'), + ], + if (energy != null) ...[ + const SizedBox(width: 12), + Icon(Icons.bolt, size: 16, color: muted), + Text('$energy/$maxEnergy'), + ], + ], + ), + ); + } +} + +String _signed(double delta) { + final sign = delta > 0 ? '+' : (delta < 0 ? '−' : '±'); + return '$sign${delta.abs().toStringAsFixed(1)}'; +} + /// One line for [session]: `09-16 ✓ 0 re-rings · 72.4 kg (−0.3) · energy 3/5`. String describeMorning(MorningSession session, {double? previousWeight}) { final mark = switch (session.outcome) { @@ -95,9 +159,7 @@ String describeMorning(MorningSession session, {double? previousWeight}) { if (kg != null) { weight = ' · $kg kg'; if (previousWeight != null) { - final delta = kg - previousWeight; - final sign = delta > 0 ? '+' : (delta < 0 ? '−' : '±'); - weight += ' ($sign${delta.abs().toStringAsFixed(1)})'; + weight += ' (${_signed(kg - previousWeight)})'; } } final energy = session.energy == null diff --git a/phone_app/lib/screens/home_location_screen.dart b/phone_app/lib/screens/home_location_screen.dart index 9f9d9d9..89e880b 100644 --- a/phone_app/lib/screens/home_location_screen.dart +++ b/phone_app/lib/screens/home_location_screen.dart @@ -3,6 +3,8 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:wake_alarm_sync/services/alarm_scheduler.dart'; import 'package:wake_alarm_sync/services/home_location_settings.dart'; +import 'package:wake_alarm_sync/ui/cues.dart'; +import 'package:wake_alarm_sync/ui/dashed_slot.dart'; /// Registers the Wi-Fi and coordinate that mean "home". /// @@ -111,53 +113,95 @@ class _HomeLocationScreenState extends State { _ => 'Nothing readable here.', }; + Widget _valueRow(IconData icon, String semanticLabel, Widget value) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, semanticLabel: semanticLabel, size: 24), + const SizedBox(width: 12), + Expanded(child: value), + ], + ), + ); + } + @override Widget build(BuildContext context) { - final theme = Theme.of(context); return Scaffold( - appBar: AppBar(title: const Text('Home location')), + appBar: AppBar(), body: ListView( padding: const EdgeInsets.all(16), children: [ - Text( - 'Away from home the two NFC tags are unreachable, so they are ' - 'skipped and the alarm asks the usual questions instead. The ' - 'walk is always required.', - style: theme.textTheme.bodyMedium, + _valueRow( + Icons.wifi, + 'Home Wi-Fi', + KeyedSubtree( + key: const ValueKey('content:home-ssid'), + child: _home.ssid != null + ? Text(_home.ssid!) + : const DashedSlot(), + ), ), - const SizedBox(height: 20), - Text('Home Wi-Fi', style: theme.textTheme.titleMedium), - Text(_home.ssid ?? 'not captured'), - const SizedBox(height: 12), - Text('Access points', style: theme.textTheme.titleMedium), - Text(_home.bssids.isEmpty ? 'none' : _home.bssids.join('\n')), - const SizedBox(height: 12), - Text('Coordinate', style: theme.textTheme.titleMedium), - Text( - _home.hasCoordinate - ? '${_home.lat!.toStringAsFixed(5)}, ' + _valueRow( + Icons.router, + 'Access points', + KeyedSubtree( + key: const ValueKey('content:home-bssids'), + child: _home.bssids.isEmpty + ? const DashedSlot() + : Text(_home.bssids.join('\n')), + ), + ), + _valueRow( + Icons.place, + 'Coordinate', + KeyedSubtree( + key: const ValueKey('content:home-coord'), + child: _home.hasCoordinate + ? Text( + '${_home.lat!.toStringAsFixed(5)}, ' '${_home.lon!.toStringAsFixed(5)} ' - '(±${_home.radiusMeters.round()} m)' - : 'not captured', + '(±${_home.radiusMeters.round()} m)', + ) + : const DashedSlot(), + ), ), const SizedBox(height: 24), - FilledButton( - onPressed: _busy ? null : _capture, - child: const Text('Capture this place as home'), - ), - const SizedBox(height: 8), - Text( - 'Press once on each Wi-Fi band (2.4 GHz and 5 GHz) so a band ' - 'switch overnight is not mistaken for travelling.', - style: theme.textTheme.bodySmall, + Row( + children: [ + FilledButton( + onPressed: _busy ? null : _capture, + child: const Icon( + Icons.my_location, + semanticLabel: 'Capture this place as home', + ), + ), + const SizedBox(width: 12), + Icon( + Icons.network_wifi_2_bar, + color: Theme.of(context).colorScheme.onSurfaceVariant, + semanticLabel: 'Press once on each Wi-Fi band, 2.4 and 5 GHz', + ), + const SizedBox(width: 8), + Icon( + Icons.network_wifi, + color: Theme.of(context).colorScheme.onSurfaceVariant, + semanticLabel: 'Press once on each Wi-Fi band, 2.4 and 5 GHz', + ), + ], ), const SizedBox(height: 16), TextButton( onPressed: _home.isConfigured ? _clear : null, - child: const Text('Forget home'), + child: const Icon( + Icons.delete_outline, + semanticLabel: 'Forget home', + ), ), const SizedBox(height: 16), - Text(_status, style: theme.textTheme.bodyMedium), + StatusCue(semanticLabel: _status), ], ), ); diff --git a/phone_app/lib/screens/home_presence_line.dart b/phone_app/lib/screens/home_presence_line.dart index 99346b2..d32e7d1 100644 --- a/phone_app/lib/screens/home_presence_line.dart +++ b/phone_app/lib/screens/home_presence_line.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:wake_alarm_sync/services/home_presence.dart'; +import 'package:wake_alarm_sync/ui/cues.dart'; /// One line saying what tonight's alarm will demand, and why. /// @@ -52,14 +53,26 @@ class _HomePresenceLineState extends State { final verdict = _verdict; if (verdict == null) return const SizedBox.shrink(); final atHome = verdict.state == PresenceState.home; + final theme = Theme.of(context); return Padding( padding: const EdgeInsets.only(top: 12), - child: Text( - atHome + child: SpokenLabel( + semanticLabel: atHome ? 'Tonight: tags required — ${verdict.detail}' : 'Tonight: tags skipped — ${verdict.detail}', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodySmall, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + atHome ? Icons.nfc : Icons.directions_walk, + size: 18, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 4), + Icon(atHome ? Icons.home : Icons.explore, size: 18), + ], + ), ), ); } diff --git a/phone_app/lib/screens/home_screen.dart b/phone_app/lib/screens/home_screen.dart index 6c0086a..66f7e70 100644 --- a/phone_app/lib/screens/home_screen.dart +++ b/phone_app/lib/screens/home_screen.dart @@ -114,7 +114,7 @@ class _HomeScreenState extends State { final picked = await showTimePicker( context: context, initialTime: TimeOfDay(hour: _hour, minute: _minute), - helpText: 'Earlier than $current only', + helpText: '', ); if (picked == null) return; final accepted = await AlarmPolicy.setEarlier(picked.hour, picked.minute); @@ -171,14 +171,13 @@ class _HomeScreenState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: const Text('Wake Alarm Sync'), actions: [ IconButton( icon: Icon( Icons.event_available, color: Theme.of(context).colorScheme.onSurfaceVariant, + semanticLabel: 'Free days', ), - tooltip: 'Free days', onPressed: () => Navigator.push( context, MaterialPageRoute(builder: (_) => const FreeDaysScreen()), @@ -188,8 +187,8 @@ class _HomeScreenState extends State { icon: Icon( Icons.sick_outlined, color: Theme.of(context).colorScheme.onSurfaceVariant, + semanticLabel: 'Sick days', ), - tooltip: 'Sick days', onPressed: () => Navigator.push( context, MaterialPageRoute(builder: (_) => const SickDaysScreen()), @@ -200,6 +199,7 @@ class _HomeScreenState extends State { Icons.settings, // Dimmed relative to the adjacent title text (rule 28). color: Theme.of(context).colorScheme.onSurfaceVariant, + semanticLabel: 'Settings', ), onPressed: () async { await Navigator.push( diff --git a/phone_app/lib/screens/home_tiles.dart b/phone_app/lib/screens/home_tiles.dart index 5004483..707e145 100644 --- a/phone_app/lib/screens/home_tiles.dart +++ b/phone_app/lib/screens/home_tiles.dart @@ -34,7 +34,7 @@ class HomeTiles extends StatelessWidget { child: _Tile( key: const Key('home-tile-stakes'), icon: Icons.gavel_outlined, - label: "Today's stakes", + semanticLabel: "Today's stakes", sheet: StakesCard( key: ValueKey('stakes-$syncEpoch'), busy: busy, @@ -48,7 +48,7 @@ class HomeTiles extends StatelessWidget { child: _Tile( key: const Key('home-tile-history'), icon: Icons.history, - label: 'Last mornings', + semanticLabel: 'Last mornings', sheet: HomeHistoryCard( key: ValueKey('history-$syncEpoch'), emptyText: 'No finished morning yet.', @@ -63,13 +63,13 @@ class HomeTiles extends StatelessWidget { class _Tile extends StatelessWidget { const _Tile({ required this.icon, - required this.label, + required this.semanticLabel, required this.sheet, super.key, }); final IconData icon; - final String label; + final String semanticLabel; final Widget sheet; @override @@ -95,9 +95,8 @@ class _Tile extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(icon, color: theme.colorScheme.onSurfaceVariant), + Icon(icon, size: 32, semanticLabel: semanticLabel), const SizedBox(height: 8), - Text(label, textAlign: TextAlign.center, maxLines: 1), ], ), ), diff --git a/phone_app/lib/screens/ring_body.dart b/phone_app/lib/screens/ring_body.dart index 5513a60..78dc517 100644 --- a/phone_app/lib/screens/ring_body.dart +++ b/phone_app/lib/screens/ring_body.dart @@ -4,6 +4,7 @@ import 'package:wake_alarm_sync/screens/ring_checklist.dart'; import 'package:wake_alarm_sync/screens/ring_stakes_line.dart'; import 'package:wake_alarm_sync/services/challenge.dart'; import 'package:wake_alarm_sync/services/dismiss_gate.dart'; +import 'package:wake_alarm_sync/ui/cues.dart'; /// The ring screen's body: the checklist, or the live challenge, plus status. /// @@ -38,7 +39,6 @@ class RingBody extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = Theme.of(context); final inChallenges = gate.stage == DismissStage.challenges; return PopScope( // The alarm cannot be escaped with back; dismissing is the only way out. @@ -50,7 +50,6 @@ class RingBody extends StatelessWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Text('Wake up', style: theme.textTheme.headlineMedium), const SizedBox(height: 16), const RingStakesLine(), const SizedBox(height: 8), @@ -65,7 +64,7 @@ class RingBody extends StatelessWidget { else RingChecklist(gate: gate), const SizedBox(height: 16), - Text(status, style: theme.textTheme.bodyMedium), + _StatusRow(status: status), ], ), ), @@ -74,3 +73,21 @@ class RingBody extends StatelessWidget { ); } } + +class _StatusRow extends StatelessWidget { + const _StatusRow({required this.status}); + + final String status; + + @override + Widget build(BuildContext context) { + final match = RegExp(r'\d+(/\d+)?').firstMatch(status); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + StatusCue(semanticLabel: status), + if (match != null) ...[const SizedBox(width: 8), Text(match.group(0)!)], + ], + ); + } +} diff --git a/phone_app/lib/screens/ring_challenge.dart b/phone_app/lib/screens/ring_challenge.dart index 0d173ad..a0432e0 100644 --- a/phone_app/lib/screens/ring_challenge.dart +++ b/phone_app/lib/screens/ring_challenge.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:wake_alarm_sync/services/challenge.dart'; import 'package:wake_alarm_sync/services/dismiss_gate.dart'; +import 'package:wake_alarm_sync/ui/spoken_label.dart'; /// The question half of the ring screen, shown once the walk is proven. class RingChallenge extends StatelessWidget { @@ -32,37 +33,99 @@ class RingChallenge extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + const answerWords = 'Answer'; return Column( children: [ - Text( - 'Question ${passed + 1} of $total', - style: theme.textTheme.labelLarge, + SpokenLabel( + semanticLabel: 'Question ${passed + 1} of $total', + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('${passed + 1}/$total', style: theme.textTheme.labelLarge), + const SizedBox(width: 8), + Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(total, (i) { + final filled = i < passed; + final current = i == passed; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: Icon( + filled + ? Icons.circle + : current + ? Icons.circle_outlined + : Icons.circle_outlined, + size: 8, + color: filled + ? colorScheme.primary + : current + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + ); + }), + ), + ], + ), ), const SizedBox(height: 8), - Text(challenge.hint, style: theme.textTheme.bodyLarge), + SpokenLabel( + semanticLabel: challenge.hint, + child: Icon( + _kindIcon(challenge.kind), + size: 40, + color: colorScheme.primary, + ), + ), const SizedBox(height: 12), - Text( - // Always visible: a challenge that hides itself is unsolvable when - // the user reaches the screen having walked in from another room. - challenge.display, - textAlign: TextAlign.center, - style: theme.textTheme.displaySmall, + KeyedSubtree( + key: const ValueKey('content:challenge'), + child: Text( + challenge.display, + textAlign: TextAlign.center, + style: theme.textTheme.displaySmall, + ), ), const SizedBox(height: 16), - TextField( - controller: controller, - autofocus: true, - textCapitalization: TextCapitalization.characters, - decoration: const InputDecoration(labelText: 'Answer'), - onSubmitted: (_) => onSubmit(), + MergeSemantics( + child: Semantics( + label: answerWords, + child: TextField( + controller: controller, + autofocus: true, + textCapitalization: TextCapitalization.characters, + decoration: const InputDecoration(prefixIcon: Icon(Icons.edit)), + onSubmitted: (_) => onSubmit(), + ), + ), ), const SizedBox(height: 16), - FilledButton(onPressed: onSubmit, child: const Text('Submit')), + FilledButton( + key: const Key('ring-submit'), + style: FilledButton.styleFrom(minimumSize: const Size(160, 48)), + onPressed: onSubmit, + child: const Icon(Icons.check, semanticLabel: 'Submit'), + ), ], ); } } +IconData _kindIcon(ChallengeKind kind) { + switch (kind) { + case ChallengeKind.math: + return Icons.calculate_outlined; + case ChallengeKind.sort: + return Icons.trending_up; + case ChallengeKind.sequence: + return Icons.more_horiz; + case ChallengeKind.reverse: + return Icons.swap_horiz; + } +} + /// Logs [challenge] so a hardware test can be scripted. /// /// Debug aid only: the answer is already on screen for whoever is holding diff --git a/phone_app/lib/screens/ring_checklist.dart b/phone_app/lib/screens/ring_checklist.dart index 618f2b8..fa26efe 100644 --- a/phone_app/lib/screens/ring_checklist.dart +++ b/phone_app/lib/screens/ring_checklist.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; import 'package:wake_alarm_sync/services/dismiss_gate.dart'; +import 'package:wake_alarm_sync/services/room_proof.dart'; +import 'package:wake_alarm_sync/ui/spoken_label.dart'; /// The three-step progress list shown while the room proof is incomplete. /// @@ -25,6 +27,7 @@ class RingChecklist extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final colorScheme = theme.colorScheme; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -32,42 +35,98 @@ class RingChecklist extends StatelessWidget { if (!(gate.bypassActive && stage != DismissStage.steps)) Padding( padding: const EdgeInsets.symmetric(vertical: 6), - child: Row( - children: [ - Icon( - _isDone(stage) - ? Icons.check_circle - : Icons.radio_button_unchecked, - color: _isDone(stage) - ? theme.colorScheme.primary - : theme.disabledColor, - ), - const SizedBox(width: 12), - Text( - label, - style: gate.stage == stage - ? theme.textTheme.titleMedium - : theme.textTheme.bodyMedium, - ), - ], + child: SpokenLabel( + semanticLabel: label, + key: ValueKey('ring-row-${stage.name}'), + child: Row( + children: [ + Icon( + _isDone(stage) + ? Icons.check_circle + : Icons.radio_button_unchecked, + color: _isDone(stage) + ? colorScheme.primary + : theme.disabledColor, + size: gate.stage == stage ? 32 : 24, + ), + const SizedBox(width: 12), + _stageGlyphs(stage, gate.stage == stage, colorScheme), + ], + ), ), ), if (gate.bypassActive) Padding( padding: const EdgeInsets.only(top: 12), - child: Text( - // This line persists; the status below it is overwritten by the - // live step count within seconds, so the reason the tags went - // away has to live here or the user never sees it. - gate.awayActive + child: SpokenLabel( + semanticLabel: gate.awayActive ? 'Away from home — the tags are elsewhere. Walk, then ' 'answer ${gate.challengesRequired} questions.' : 'Tags unavailable — walk, then answer ' '${gate.challengesRequired} questions instead.', - style: theme.textTheme.bodyMedium, + key: const Key('ring-bypass'), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + gate.awayActive ? Icons.explore : Icons.block, + color: colorScheme.error, + ), + const Icon(Icons.arrow_forward, size: 16), + const Icon(Icons.directions_walk), + const Icon(Icons.arrow_forward, size: 16), + const Icon(Icons.quiz_outlined), + Text('${gate.challengesRequired}'), + ], + ), ), ), ], ); } + + Widget _stageGlyphs(DismissStage stage, bool current, ColorScheme cs) { + final primary = cs.primary; + final muted = cs.onSurfaceVariant; + final color = current ? primary : muted; + final size = current ? 32.0 : 24.0; + switch (stage) { + case DismissStage.tag1: + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.nfc, size: size, color: color), + Text( + '1', + style: TextStyle(fontSize: size, color: color), + ), + ], + ); + case DismissStage.steps: + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.directions_walk, size: size, color: color), + Text( + '${RoomProof.requiredSteps}', + style: TextStyle(fontSize: size, color: color), + ), + ], + ); + case DismissStage.tag2: + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.nfc, size: size, color: color), + Text( + '2', + style: TextStyle(fontSize: size, color: color), + ), + ], + ); + case DismissStage.challenges: + case DismissStage.done: + return const SizedBox.shrink(); + } + } } diff --git a/phone_app/lib/screens/ring_stakes_line.dart b/phone_app/lib/screens/ring_stakes_line.dart index 16b56f7..e070d88 100644 --- a/phone_app/lib/screens/ring_stakes_line.dart +++ b/phone_app/lib/screens/ring_stakes_line.dart @@ -3,6 +3,8 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:wake_alarm_sync/services/enforcement_bridge.dart'; import 'package:wake_alarm_sync/services/today_stakes.dart'; +import 'package:wake_alarm_sync/ui/cues.dart'; +import 'package:wake_alarm_sync/ui/spoken_label.dart'; import 'package:wake_alarm_sync/ui/theme.dart'; /// The ring screen's one line: what not dismissing costs today. @@ -23,7 +25,7 @@ class RingStakesLine extends StatefulWidget { } class _RingStakesLineState extends State { - String? _line; + ({DateTime by, DateTime lockThrough})? _stakes; @override void initState() { @@ -32,32 +34,53 @@ class _RingStakesLineState extends State { } Future _load() async { - String? line; + ({DateTime by, DateTime lockThrough})? stakes; try { final state = await (widget.bridge ?? EnforcementBridge()).state(); - line = state == null ? null : ringStakesLine(state); + stakes = state == null ? null : ringStakes(state); } on Exception { - line = null; + stakes = null; } - if (!mounted || line == null) return; - setState(() => _line = line); + if (!mounted || stakes == null) return; + setState(() => _stakes = stakes); } @override Widget build(BuildContext context) { - final line = _line; - if (line == null) return const SizedBox.shrink(); + final stakes = _stakes; + if (stakes == null) return const SizedBox.shrink(); final theme = Theme.of(context); + final statusColors = theme.extension(); + final warningColor = statusColors?.warning ?? Colors.amber; + final line = + 'Stick day: no dismiss by ${hhmm(stakes.by)} → apps locked through ' + '${formatDay(stakes.lockThrough)}'; return Padding( padding: const EdgeInsets.only(bottom: 16), - child: Text( - line, - textAlign: TextAlign.center, - style: theme.textTheme.bodyMedium?.copyWith( - // Null-safe: a missing theme extension must not break a ring. - color: theme.extension()?.warning, + child: SpokenLabel( + semanticLabel: line, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.gavel_outlined, color: warningColor), + const SizedBox(width: 4), + Text(hhmm(stakes.by), style: TextStyle(color: warningColor)), + const SizedBox(width: 4), + const Icon(Icons.arrow_forward, size: 16), + const SizedBox(width: 4), + Icon(Icons.lock_outline, color: warningColor), + const SizedBox(width: 4), + Text( + _formatLockDate(stakes.lockThrough), + style: TextStyle(color: warningColor), + ), + ], ), ), ); } + + String _formatLockDate(DateTime d) => + '${d.day.toString().padLeft(2, '0')}' + '.${d.month.toString().padLeft(2, '0')}'; } diff --git a/phone_app/lib/screens/room_proof_screen.dart b/phone_app/lib/screens/room_proof_screen.dart index 27592ab..2fedb46 100644 --- a/phone_app/lib/screens/room_proof_screen.dart +++ b/phone_app/lib/screens/room_proof_screen.dart @@ -3,6 +3,8 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:wake_alarm_sync/services/alarm_scheduler.dart'; import 'package:wake_alarm_sync/services/room_proof_settings.dart'; +import 'package:wake_alarm_sync/ui/cues.dart'; +import 'package:wake_alarm_sync/ui/dashed_slot.dart'; /// Registers the two NFC tags the dismiss sequence walks between. /// @@ -43,7 +45,17 @@ class _RoomProofScreenState extends State { Future _load() async { final settings = await RoomProofSettings.load(); if (!mounted) return; - setState(() => _settings = settings); + var status = ''; + for (var i = 0; i < RoomProofSettings.requiredTagCount; i++) { + if (settings.tagAt(i) != null) { + status = 'Tag ${i + 1} registered.'; + break; + } + } + setState(() { + _settings = settings; + _status = status; + }); } Future _startScan(int slot) async { @@ -89,18 +101,33 @@ class _RoomProofScreenState extends State { final scanning = _scanningSlot == slot; return Padding( padding: const EdgeInsets.only(bottom: 24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text('Tag ${slot + 1}: ${id ?? "none registered"}'), - const SizedBox(height: 8), + child: Row( + children: [ + CircleAvatar(radius: 14, child: Text('${slot + 1}')), + const SizedBox(width: 12), + Expanded( + child: id != null + ? KeyedSubtree( + key: ValueKey('content:tag-$slot'), + child: Text(id), + ) + : const DashedSlot(), + ), + const SizedBox(width: 12), FilledButton( + key: ValueKey('register-$slot'), onPressed: _scanningSlot != null ? null : () => _startScan(slot), - child: Text( - scanning - ? 'Waiting for tag…' - : 'Register tag ${slot + 1}${id == null ? "" : " again"}', - ), + child: scanning + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Icon( + id == null ? Icons.add : Icons.refresh, + semanticLabel: id == null + ? 'Register tag ${slot + 1}' + : 'Register tag ${slot + 1} again', + ), ), ], ), @@ -110,20 +137,15 @@ class _RoomProofScreenState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text('Room proof')), + appBar: AppBar(), body: Padding( padding: const EdgeInsets.all(24), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const Text( - 'Put the two tags in different rooms. Dismissing the alarm ' - 'means tapping tag 1, walking, then tapping tag 2.', - ), - const SizedBox(height: 24), for (var i = 0; i < RoomProofSettings.requiredTagCount; i++) _slotRow(i), - Text(_status), + StatusCue(semanticLabel: _status), ], ), ), diff --git a/phone_app/lib/screens/session_body.dart b/phone_app/lib/screens/session_body.dart index 23dd16b..8945b37 100644 --- a/phone_app/lib/screens/session_body.dart +++ b/phone_app/lib/screens/session_body.dart @@ -2,6 +2,10 @@ import 'package:flutter/material.dart'; import 'package:wake_alarm_sync/screens/energy_picker.dart'; import 'package:wake_alarm_sync/screens/session_checklist.dart'; import 'package:wake_alarm_sync/services/morning_session.dart'; +import 'package:wake_alarm_sync/ui/cues.dart'; +import 'package:wake_alarm_sync/ui/theme.dart'; + +const _weightSemantics = 'Weight (kg)'; /// The session screen's body: the checklist, the check-in, the clocks. /// @@ -49,8 +53,9 @@ class SessionBody extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = Theme.of(context); final leg = session.currentLeg; + final reRingWords = + 'Re-rings: ${session.reRings}/${MorningSession.maxReRings}'; return Scaffold( body: SafeArea( child: SingleChildScrollView( @@ -58,58 +63,54 @@ class SessionBody extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text('Stay up', style: theme.textTheme.headlineMedium), - const SizedBox(height: 4), - Text( - 'The alarm is off. It comes back if a step below is missed. ' - 'Leaving the app is fine — the notification brings you back.', - style: theme.textTheme.bodyMedium, - ), - const SizedBox(height: 24), SessionChecklist(session: session, now: now), const SizedBox(height: 24), if (_awaitingWeight) ...[ EnergyPicker(selected: energy, onSelect: onSelectEnergy), const SizedBox(height: 12), - TextField( - controller: controller, - keyboardType: const TextInputType.numberWithOptions( - decimal: true, + MergeSemantics( + child: Semantics( + label: _weightSemantics, + child: TextField( + controller: controller, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + decoration: const InputDecoration( + prefixIcon: Icon(Icons.monitor_weight_outlined), + border: OutlineInputBorder(), + ), + onSubmitted: (_) => onSubmitWeight(), + ), ), - decoration: const InputDecoration( - labelText: 'Weight (kg)', - border: OutlineInputBorder(), - ), - onSubmitted: (_) => onSubmitWeight(), ), const SizedBox(height: 12), FilledButton( + key: const Key('session-log-weight'), onPressed: onSubmitWeight, - child: const Text('Log weight'), + child: const Icon(Icons.check, semanticLabel: 'Log weight'), ), const SizedBox(height: 16), ], - if (leg != null) - Text( - _deadlineLine(leg), - textAlign: TextAlign.center, - style: theme.textTheme.titleMedium, + if (leg != null) ...[ + SpokenLabel( + semanticLabel: _deadlineWords(leg), + child: _DeadlineRow(leg: leg, session: session, now: now), + ), + const SizedBox(height: 8), + ], + SpokenLabel( + semanticLabel: reRingWords, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.replay, size: 16), + Text('${session.reRings}/${MorningSession.maxReRings}'), + ], ), - const SizedBox(height: 8), - Text( - 'Re-rings: ${session.reRings}/${MorningSession.maxReRings}', - textAlign: TextAlign.center, - style: theme.textTheme.bodySmall, ), const SizedBox(height: 16), - if (status.isNotEmpty) - Text( - status, - textAlign: TextAlign.center, - maxLines: 3, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodyLarge, - ), + Center(child: StatusCue(semanticLabel: status)), ], ), ), @@ -117,10 +118,10 @@ class SessionBody extends StatelessWidget { ); } - String _deadlineLine(SessionLeg leg) { + String _deadlineWords(SessionLeg leg) { final soonest = session.earliest(leg); if (soonest != null && now.isBefore(soonest)) { - return 'Shower — tag counts in ${_mmss(soonest.difference(now))}'; + return 'Shower tag counts in ${_mmss(soonest.difference(now))}'; } final deadline = session.deadline(leg); if (now.isBefore(deadline)) { @@ -135,3 +136,54 @@ class SessionBody extends StatelessWidget { return '$m:$s'; } } + +class _DeadlineRow extends StatelessWidget { + const _DeadlineRow({ + required this.leg, + required this.session, + required this.now, + }); + + final SessionLeg leg; + final MorningSession session; + final DateTime now; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final soonest = session.earliest(leg); + if (soonest != null && now.isBefore(soonest)) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.shower), + const Icon(Icons.hourglass_top), + Text(_mmss(soonest.difference(now))), + ], + ); + } + final deadline = session.deadline(leg); + if (now.isBefore(deadline)) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.alarm), + Text(_mmss(deadline.difference(now))), + ], + ); + } + final statusColors = theme.extension(); + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.alarm_off, color: statusColors?.warning ?? Colors.amber), + ], + ); + } + + static String _mmss(Duration d) { + final m = d.inMinutes.toString().padLeft(2, '0'); + final s = (d.inSeconds % 60).toString().padLeft(2, '0'); + return '$m:$s'; + } +} diff --git a/phone_app/lib/screens/session_checklist.dart b/phone_app/lib/screens/session_checklist.dart index 26c65c8..8ecb4de 100644 --- a/phone_app/lib/screens/session_checklist.dart +++ b/phone_app/lib/screens/session_checklist.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:wake_alarm_sync/services/morning_session.dart'; import 'package:wake_alarm_sync/services/weight_entry.dart'; +import 'package:wake_alarm_sync/ui/spoken_label.dart'; +import 'package:wake_alarm_sync/ui/theme.dart'; /// The session's progress list: the weigh-in, then every leg with a tag. /// @@ -23,50 +25,142 @@ class SessionChecklist extends StatelessWidget { final theme = Theme.of(context); final weighed = session.weightKg != null || session.pendingWeightKg != null; final current = session.currentLeg; - final rows = <(String, bool, bool)>[ - ( - 'Weigh in${_kg(session)}', - weighed, - !weighed && current == SessionLeg.bathroomIn, + final rows = [ + _WeighInRow( + session: session, + weighed: weighed, + current: current, + theme: theme, ), for (final leg in SessionLeg.values) if (session.passedHow[leg] != LegPass.skipped) - ( - _label(leg), - session.passedAt.containsKey(leg), - leg == current && (leg != SessionLeg.bathroomIn || weighed), + _LegRow( + session: session, + leg: leg, + current: current, + theme: theme, + weighed: weighed, ), ]; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - for (final (label, done, live) in rows) - Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: Row( - children: [ - Icon( - done ? Icons.check_circle : Icons.radio_button_unchecked, - color: done ? theme.colorScheme.primary : theme.disabledColor, - ), - const SizedBox(width: 12), - Expanded( - child: Text( - label, - style: live - ? theme.textTheme.titleMedium - : theme.textTheme.bodyMedium, - ), - ), - ], + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: rows); + } +} + +class _WeighInRow extends StatelessWidget { + const _WeighInRow({ + required this.session, + required this.weighed, + required this.current, + required this.theme, + }); + + final MorningSession session; + final bool weighed; + final SessionLeg? current; + final ThemeData theme; + + @override + Widget build(BuildContext context) { + final live = !weighed && current == SessionLeg.bathroomIn; + return SpokenLabel( + semanticLabel: weighed + ? session.energy == null + ? 'Weight logged' + : 'Weigh in — ${session.weightKg ?? session.pendingWeightKg} kg · energy ${session.energy}/$maxEnergy' + : 'Weigh in', + key: const ValueKey('session-row-weigh'), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + Icon( + weighed ? Icons.check_circle : Icons.radio_button_unchecked, + color: weighed ? theme.colorScheme.primary : theme.disabledColor, ), - ), - ], + const SizedBox(width: 12), + Icon( + Icons.monitor_weight_outlined, + size: 32, + color: live ? theme.colorScheme.primary : null, + ), + const SizedBox(width: 12), + if (weighed) ...[ + Text('${session.weightKg ?? session.pendingWeightKg}'), + if (session.energy != null) ...[ + const SizedBox(width: 8), + const Icon(Icons.bolt, size: 18), + Text('${session.energy}/$maxEnergy'), + ], + ], + ], + ), + ), + ); + } +} + +class _LegRow extends StatelessWidget { + const _LegRow({ + required this.session, + required this.leg, + required this.current, + required this.theme, + required this.weighed, + }); + + final MorningSession session; + final SessionLeg leg; + final SessionLeg? current; + final ThemeData theme; + final bool weighed; + + @override + Widget build(BuildContext context) { + final done = session.passedAt.containsKey(leg); + final how = session.passedHow[leg]; + final live = leg == current && (leg != SessionLeg.bathroomIn || weighed); + final bypassed = how == LegPass.bypassed; + + final statusColors = theme.extension(); + final warningColor = statusColors?.warning ?? Colors.amber; + + IconData frontIcon; + if (done) { + frontIcon = bypassed ? Icons.timer_off : Icons.check_circle; + } else { + frontIcon = Icons.radio_button_unchecked; + } + + Color frontColor; + if (done) { + frontColor = bypassed ? warningColor : theme.colorScheme.primary; + } else { + frontColor = theme.disabledColor; + } + + return SpokenLabel( + semanticLabel: how == null ? leg.label : _semanticLabel(leg, how), + key: ValueKey('session-row-${leg.name}'), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + Icon(frontIcon, color: frontColor, size: live ? 32 : 24), + const SizedBox(width: 12), + _legIcon( + leg, + size: live ? 32 : 24, + color: live ? theme.colorScheme.primary : null, + ), + const SizedBox(width: 12), + const Icon(Icons.nfc, size: 18), + ], + ), + ), ); } - String _label(SessionLeg leg) { - final how = session.passedHow[leg]; + String _semanticLabel(SessionLeg leg, LegPass how) { if (how == LegPass.bypassed) return '${leg.label} (skipped by the clock)'; if (leg == SessionLeg.bathroomIn && !session.bathroomTagRequired) { return 'Weight logged'; @@ -74,12 +168,20 @@ class SessionChecklist extends StatelessWidget { return leg.label; } - static String _kg(MorningSession session) { - final kg = session.weightKg ?? session.pendingWeightKg; - if (kg == null) return ''; - final energy = session.energy; - return energy == null - ? ' — $kg kg' - : ' — $kg kg · energy $energy/$maxEnergy'; + Widget _legIcon(SessionLeg leg, {double size = 24, Color? color}) { + return Icon(_legIconData(leg), size: size, color: color); + } + + IconData _legIconData(SessionLeg leg) { + switch (leg) { + case SessionLeg.bathroomIn: + return Icons.bathroom_outlined; + case SessionLeg.showerDone: + return Icons.shower; + case SessionLeg.dressed: + return Icons.checkroom; + case SessionLeg.atDesk: + return Icons.desk; + } } } diff --git a/phone_app/lib/screens/session_tags_screen.dart b/phone_app/lib/screens/session_tags_screen.dart index 1820609..9d77964 100644 --- a/phone_app/lib/screens/session_tags_screen.dart +++ b/phone_app/lib/screens/session_tags_screen.dart @@ -3,6 +3,8 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:wake_alarm_sync/services/alarm_scheduler.dart'; import 'package:wake_alarm_sync/services/session_tag_settings.dart'; +import 'package:wake_alarm_sync/ui/cues.dart'; +import 'package:wake_alarm_sync/ui/dashed_slot.dart'; /// Registers the three NFC tags the morning session walks through. /// @@ -81,52 +83,70 @@ class _SessionTagsScreenState extends State { }); } + IconData _iconForSlot(SessionTagSlot slot) => switch (slot) { + SessionTagSlot.bathroom => Icons.shower, + SessionTagSlot.wardrobe => Icons.checkroom, + SessionTagSlot.desk => Icons.desk, + }; + + String _semanticLabelForSlot(SessionTagSlot slot) => switch (slot) { + SessionTagSlot.bathroom => 'Bathroom (scale + shower)', + SessionTagSlot.wardrobe => 'Wardrobe', + SessionTagSlot.desk => 'Desk', + }; + Widget _slotRow(SessionTagSlot slot) { final id = _settings.tagAt(slot); final scanning = _scanning == slot; return Padding( padding: const EdgeInsets.only(bottom: 24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + child: Row( children: [ - Text('${_title(slot)}: ${id ?? "none registered"}'), - const SizedBox(height: 8), + Icon( + _iconForSlot(slot), + semanticLabel: _semanticLabelForSlot(slot), + ), + const SizedBox(width: 12), + Expanded( + child: id != null + ? KeyedSubtree( + key: ValueKey('content:tag-${slot.name}'), + child: Text(id), + ) + : const DashedSlot(), + ), + const SizedBox(width: 12), FilledButton( + key: ValueKey('register-${slot.name}'), onPressed: _scanning != null ? null : () => _startScan(slot), - child: Text( - scanning - ? 'Waiting for tag…' - : 'Register ${slot.name} tag${id == null ? "" : " again"}', - ), + child: scanning + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Icon( + id == null ? Icons.add : Icons.refresh, + semanticLabel: id == null + ? 'Register ${slot.name} tag' + : 'Register ${slot.name} tag again', + ), ), ], ), ); } - static String _title(SessionTagSlot slot) => switch (slot) { - SessionTagSlot.bathroom => 'Bathroom (scale + shower)', - SessionTagSlot.wardrobe => 'Wardrobe', - SessionTagSlot.desk => 'Desk', - }; - @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text('Morning session tags')), + appBar: AppBar(), body: Padding( padding: const EdgeInsets.all(24), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const Text( - 'After the alarm is dismissed: weigh in and tap the bathroom ' - 'tag, shower and tap it again, tap the wardrobe tag dressed, ' - 'then the desk tag. A slot left empty is skipped.', - ), - const SizedBox(height: 24), for (final slot in SessionTagSlot.values) _slotRow(slot), - Text(_status), + StatusCue(semanticLabel: _status), ], ), ), diff --git a/phone_app/lib/screens/settings_screen.dart b/phone_app/lib/screens/settings_screen.dart index 05c8ee3..b1f8207 100644 --- a/phone_app/lib/screens/settings_screen.dart +++ b/phone_app/lib/screens/settings_screen.dart @@ -11,6 +11,7 @@ import 'package:wake_alarm_sync/services/alarm_sync_service.dart'; import 'package:wake_alarm_sync/services/data_transfer.dart'; import 'package:wake_alarm_sync/services/firebase_backend.dart'; import 'package:wake_alarm_sync/services/google_sign_in_backend.dart'; +import 'package:wake_alarm_sync/ui/theme.dart'; /// Settings screen: links to the two sync surfaces. /// @@ -72,9 +73,11 @@ class SettingsScreen extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + final statusColors = theme.extension(); return Scaffold( appBar: AppBar( - title: const Text('Settings'), actions: [ IconButton( key: const Key('settings-export'), @@ -98,9 +101,10 @@ class SettingsScreen extends StatelessWidget { padding: const EdgeInsets.all(16), children: [ ListTile( + key: const Key('settings-sync'), contentPadding: EdgeInsets.zero, - title: const Text('Sync settings'), - subtitle: const Text('Firebase sync'), + leading: const Icon(Icons.cloud_sync_outlined, + semanticLabel: 'Sync settings'), trailing: const Icon(Icons.chevron_right), onTap: () => Navigator.of(context).push( MaterialPageRoute( @@ -117,37 +121,57 @@ class SettingsScreen extends StatelessWidget { ), ), ListTile( + key: const Key('settings-room-proof'), contentPadding: EdgeInsets.zero, - title: const Text('Room proof'), - subtitle: const Text('Two NFC tags, at two locations'), + leading: const Icon(Icons.nfc, semanticLabel: 'Room proof'), trailing: const Icon(Icons.chevron_right), onTap: () => Navigator.of(context).push( MaterialPageRoute(builder: (_) => const RoomProofScreen()), ), ), ListTile( + key: const Key('settings-session-tags'), contentPadding: EdgeInsets.zero, - title: const Text('Morning session tags'), - subtitle: const Text('Bathroom, wardrobe, desk — after the ring'), + leading: const Icon( + Icons.checklist, + semanticLabel: 'Morning session tags', + ), trailing: const Icon(Icons.chevron_right), onTap: () => Navigator.of(context).push( MaterialPageRoute(builder: (_) => const SessionTagsScreen()), ), ), ListTile( + key: const Key('settings-home'), contentPadding: EdgeInsets.zero, - title: const Text('Home location'), - subtitle: const Text('The Wi-Fi and place that mean you are home'), + leading: const Icon( + Icons.home_outlined, + semanticLabel: 'Home location', + ), trailing: const Icon(Icons.chevron_right), onTap: () => Navigator.of(context).push( MaterialPageRoute(builder: (_) => const HomeLocationScreen()), ), ), ListTile( + key: const Key('settings-github'), contentPadding: EdgeInsets.zero, - title: const Text('Advanced sync (GitHub)'), - subtitle: const Text('Cutover mirror — not recommended'), - trailing: const Icon(Icons.chevron_right), + leading: Icon( + Icons.code, + color: colors.onSurfaceVariant.withValues(alpha: 0.6), + semanticLabel: 'Advanced sync (GitHub)', + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.warning_amber_rounded, + color: statusColors?.warning ?? colors.error, + semanticLabel: 'Cutover mirror, not recommended', + ), + const Icon(Icons.chevron_right), + ], + ), onTap: () => Navigator.of(context).push( MaterialPageRoute(builder: (_) => const GitHubMirrorScreen()), ), diff --git a/phone_app/lib/screens/sick_day_sheet.dart b/phone_app/lib/screens/sick_day_sheet.dart index 2b30096..d0928da 100644 --- a/phone_app/lib/screens/sick_day_sheet.dart +++ b/phone_app/lib/screens/sick_day_sheet.dart @@ -4,6 +4,8 @@ import 'package:wake_alarm_sync/services/free_day_pool.dart'; import 'package:wake_alarm_sync/services/sick_day_keep.dart'; import 'package:wake_alarm_sync/services/sick_day_pool.dart'; +export 'package:wake_alarm_sync/screens/sick_day_sheet.dart'; + /// What each locker is called on screen, in `sickdays.LOCKERS` order. const sickLockerLabels = { SickLocker.workout: 'Workout lock', @@ -13,6 +15,15 @@ const sickLockerLabels = { SickLocker.alarm: 'Wake alarm', }; +/// Icons for each locker, matching [sickLockerLabels] order. +const sickLockerIcons = { + SickLocker.workout: Icons.fitness_center, + SickLocker.leetcode: Icons.code, + SickLocker.book: Icons.menu_book, + SickLocker.diet: Icons.restaurant, + SickLocker.alarm: Icons.alarm, +}; + /// The kept lockers of a day as one line, in [sickLockerLabels] order. String keptLabel(Set kept) => [ for (final MapEntry(:key, :value) in sickLockerLabels.entries) @@ -116,7 +127,11 @@ class SickDaySheet extends StatefulWidget { class _SickDaySheetState extends State { late final Set _keep = {...widget.kept}; - static const _weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + String _toDdMmYyyy(DateTime day) { + final d = day.day.toString().padLeft(2, '0'); + final m = day.month.toString().padLeft(2, '0'); + return '$d.$m.${day.year}'; + } void _close(SickSheetAction action) => Navigator.of( context, @@ -132,26 +147,19 @@ class _SickDaySheetState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text( - '${widget.isSick ? 'Sick day' : 'Mark sick'}: ' - '${_weekdays[day.weekday - 1]} ${FreeDayPool.toIso(day)}', - style: theme.textTheme.titleMedium, - ), - const SizedBox(height: 8), - Text( - 'Off = stands down for the day. Switch on any locker that ' - 'should still apply; it then behaves exactly as on a normal ' - 'day.', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), + Row( + children: [ + const Icon(Icons.sick_outlined), + const SizedBox(width: 8), + Text(_toDdMmYyyy(day), style: theme.textTheme.titleMedium), + ], ), const SizedBox(height: 8), for (final MapEntry(:key, :value) in sickLockerLabels.entries) SwitchListTile( key: ValueKey('keep-$key'), contentPadding: EdgeInsets.zero, - title: Text(value), + title: Icon(sickLockerIcons[key], semanticLabel: value), value: _keep.contains(key), onChanged: (on) => setState(() => on ? _keep.add(key) : _keep.remove(key)), @@ -159,21 +167,27 @@ class _SickDaySheetState extends State { const SizedBox(height: 16), if (widget.isSick) ...[ FilledButton( + key: const ValueKey('sick-save'), onPressed: () => _close(SickSheetAction.save), - child: const Text('Save'), + child: const Icon(Icons.check, semanticLabel: 'Save'), ), const SizedBox(height: 8), TextButton( style: TextButton.styleFrom( foregroundColor: theme.colorScheme.error, ), + key: const ValueKey('sick-clear'), onPressed: () => _close(SickSheetAction.clear), - child: const Text('Clear sick day'), + child: const Icon( + Icons.delete_outline, + semanticLabel: 'Clear sick day', + ), ), ] else FilledButton( + key: const ValueKey('sick-mark'), onPressed: () => _close(SickSheetAction.mark), - child: const Text('Mark sick'), + child: const Icon(Icons.sick, semanticLabel: 'Mark sick'), ), ], ), diff --git a/phone_app/lib/screens/sick_days_body.dart b/phone_app/lib/screens/sick_days_body.dart index 25a0833..3193a9a 100644 --- a/phone_app/lib/screens/sick_days_body.dart +++ b/phone_app/lib/screens/sick_days_body.dart @@ -1,8 +1,9 @@ import 'package:flutter/material.dart'; import 'package:wake_alarm_sync/screens/day_calendar.dart'; import 'package:wake_alarm_sync/screens/sick_day_sheet.dart'; -import 'package:wake_alarm_sync/services/free_day_pool.dart'; -import 'package:wake_alarm_sync/ui/theme.dart'; +import 'package:wake_alarm_sync/services/sick_day_keep.dart'; +import 'package:wake_alarm_sync/ui/cues.dart'; +import 'package:wake_alarm_sync/ui/dashed_slot.dart'; /// The sick-day picker's body: what a sick day does, the status line, the /// month calendar and the sick days still ahead. @@ -16,6 +17,7 @@ class SickDaysBody extends StatelessWidget { required this.upcoming, required this.status, required this.cache, + required this.cacheRead, required this.refused, required this.calendar, required this.keptOf, @@ -34,74 +36,114 @@ class SickDaysBody extends StatelessWidget { /// The status line; may be a raw `Error: …`. final String status; - /// The native ring-path cache, read back (`Alarm cache: …`). - final String cache; + /// The native ring-path cache, read back (list of ISO +/// dates or null for unreadable). + final List? cache; + + /// Whether the cache has been read at least once. + final bool cacheRead; /// Whether [status] is a refused tap, drawn like an error. final bool refused; - static const _weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; - - String _label(DateTime day) => - '${_weekdays[day.weekday - 1]} ${FreeDayPool.toIso(day)}'; + String _toDdMmYyyy(DateTime day) { + final d = day.day.toString().padLeft(2, '0'); + final m = day.month.toString().padLeft(2, '0'); + return '$d.$m.${day.year}'; + } @override Widget build(BuildContext context) { final theme = Theme.of(context); - final muted = theme.colorScheme.onSurfaceVariant; - final statusColors = theme.extension()!; final failed = refused || status.contains('Error'); return SafeArea( child: ListView( padding: const EdgeInsets.all(16), children: [ - Text( - 'Tap a day to mark it sick and choose which lockers still ' - 'apply; tap a sick day to change them or clear it. Today or ' - 'later only. No limit and no reason needed.', - style: theme.textTheme.bodyMedium, - ), - const SizedBox(height: 8), - Text( - 'On a sick day every locker left off stands down: no alarm, no ' - 'lock, and work left undone is dropped rather than carried ' - 'forward. A locker switched on behaves as on a normal day; a dot ' - 'marks a sick day that keeps one.', - style: theme.textTheme.bodySmall?.copyWith(color: muted), - ), if (status.isNotEmpty) ...[ const SizedBox(height: 16), - Text( - status, - maxLines: 4, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: failed ? theme.colorScheme.error : statusColors.success, - ), + StatusCue( + semanticLabel: status, + kind: failed ? CueKind.error : null, ), ], - if (cache.isNotEmpty) ...[ + if (cacheRead) ...[ const SizedBox(height: 8), - Text(cache, style: TextStyle(color: muted)), + Row( + children: [ + Icon( + Icons.memory, + size: 16, + semanticLabel: _cacheSemanticLabel, + ), + const SizedBox(width: 8), + _buildCacheContent(theme), + ], + ), ], const SizedBox(height: 16), calendar, const SizedBox(height: 24), - Text('Marked sick', style: theme.textTheme.titleMedium), + const Row( + children: [ + Icon(Icons.sick_outlined, semanticLabel: 'Marked sick'), + ], + ), const SizedBox(height: 8), if (upcoming.isEmpty) - Text('None ahead.', style: TextStyle(color: muted)), + const SpokenLabel( + semanticLabel: 'None ahead.', + child: DashedSlot(), + ), for (final day in upcoming) ListTile( contentPadding: EdgeInsets.zero, - title: Text(_label(day)), - subtitle: switch (keptLabel(keptOf(day))) { - '' => null, - final kept => Text('Kept: $kept'), - }, + title: Text(_toDdMmYyyy(day)), + subtitle: _keptIcons(keptOf(day)), ), ], ), ); } + + String get _cacheSemanticLabel { + if (cache == null) return 'Alarm cache: unreadable'; + if (cache!.isEmpty) return 'Alarm cache: none'; + return 'Alarm cache: ${cache!.join(', ')}'; + } + + Widget _buildCacheContent(ThemeData theme) { + if (cache == null) { + return Icon( + Icons.error_outline, + color: theme.colorScheme.error, + size: 16, + ); + } + if (cache!.isEmpty) { + return Icon( + Icons.check, + size: 16, + color: theme.colorScheme.onSurfaceVariant, + ); + } + return Text( + cache!.join(', '), + style: TextStyle(color: theme.colorScheme.onSurfaceVariant), + ); + } + + Widget? _keptIcons(Set kept) { + if (kept.isEmpty) return null; + return Row( + children: [ + for (final locker in SickLocker.all) + if (kept.contains(locker)) + Padding( + padding: const EdgeInsets.only(right: 4), + child: Icon(sickLockerIcons[locker], size: 16), + ), + ], + ); + } } diff --git a/phone_app/lib/screens/sick_days_screen.dart b/phone_app/lib/screens/sick_days_screen.dart index 7e2f639..8877043 100644 --- a/phone_app/lib/screens/sick_days_screen.dart +++ b/phone_app/lib/screens/sick_days_screen.dart @@ -9,6 +9,7 @@ import 'package:wake_alarm_sync/services/free_day_pool.dart'; import 'package:wake_alarm_sync/services/free_day_sync.dart'; import 'package:wake_alarm_sync/services/sick_day_pool.dart'; import 'package:wake_alarm_sync/services/sick_day_sync.dart'; +import 'package:wake_alarm_sync/ui/cues.dart'; import 'package:wake_alarm_sync/ui/theme.dart'; /// The phone's picker for the shared sick-day pool (`~/src/utils/sickdays`). @@ -68,7 +69,8 @@ class _SickDaysScreenState extends State { bool _refused = false; /// What the native ring-path cache holds, read back over the channel. - String _cache = ''; + List? _cache; + bool _cacheRead = false; late DateTime _month = DateTime(_today.year, _today.month); @@ -112,12 +114,8 @@ class _SickDaysScreenState extends State { } if (!mounted) return; setState(() { - _cache = - 'Alarm cache: ${switch (dates) { - null => 'unreadable', - [] => 'none', - final d => d.join(', '), - }}'; + _cache = dates; + _cacheRead = true; }); } @@ -189,23 +187,20 @@ class _SickDaysScreenState extends State { final marks = Theme.of(context).extension()!; return Scaffold( appBar: AppBar( - title: const Row( - children: [ - Icon(Icons.sick_outlined), - SizedBox(width: 8), - Text('Sick days'), - ], - ), + title: const Row(children: [Icon(Icons.sick_outlined)]), actions: [ IconButton( - icon: const Icon(Icons.sync), - tooltip: 'Sync now', + icon: const Icon(Icons.sync, semanticLabel: 'Sync now'), onPressed: _busy ? null : () => unawaited(_sync()), ), ], ), body: pool == null - ? Center(child: Text(_status.isEmpty ? 'Loading…' : _status)) + ? Center( + child: _status.isEmpty + ? const CircularProgressIndicator() + : StatusCue(semanticLabel: _status), + ) : SickDaysBody( upcoming: [ for (final day in pool.sickDays()) @@ -214,6 +209,7 @@ class _SickDaysScreenState extends State { keptOf: pool.keptLockers, status: _status, cache: _cache, + cacheRead: _cacheRead, refused: _refused, calendar: DayCalendar( month: _month, diff --git a/phone_app/lib/screens/stakes_card.dart b/phone_app/lib/screens/stakes_card.dart index 3a12fa6..6593dbf 100644 --- a/phone_app/lib/screens/stakes_card.dart +++ b/phone_app/lib/screens/stakes_card.dart @@ -2,12 +2,14 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:wake_alarm_sync/screens/escape_dialog.dart'; +import 'package:wake_alarm_sync/screens/stakes_marks.dart'; import 'package:wake_alarm_sync/services/alarm_policy.dart'; import 'package:wake_alarm_sync/services/enforcement_bridge.dart'; import 'package:wake_alarm_sync/services/enforcement_state.dart'; import 'package:wake_alarm_sync/services/escape_hatch.dart'; import 'package:wake_alarm_sync/services/today_stakes.dart'; -import 'package:wake_alarm_sync/ui/theme.dart'; +import 'package:wake_alarm_sync/ui/cues.dart'; +import 'package:wake_alarm_sync/ui/dashed_slot.dart'; /// Today's stakes in plain words, anything already in force, the streak, /// and the escape hatch while it lasts. @@ -61,6 +63,7 @@ class _StakesCardState extends State { EnforcementState? _state; AlarmTime? _next; String _message = ''; + bool _excused = false; bool _loaded = false; DateTime _now() => (widget.now ?? DateTime.now)(); @@ -102,6 +105,7 @@ class _StakesCardState extends State { if (reason == null) return; final ok = await _hatch.use(reason); if (!mounted) return; + _excused = ok; setState( () => _message = ok ? 'Excused — lockdown lifted, publishing to the PC.' @@ -116,12 +120,12 @@ class _StakesCardState extends State { final state = _state; final empty = widget.emptyText; if (state == null) { - return _loaded && empty != null ? Text(empty) : const SizedBox.shrink(); + return _loaded && empty != null + ? SpokenLabel(semanticLabel: empty, child: const DashedSlot()) + : const SizedBox.shrink(); } final theme = Theme.of(context); - final warning = theme.extension()?.warning; final view = describeStakes(now: _now(), state: state, nextAlarm: _next); - final body = theme.textTheme.bodyMedium; return Card( color: theme.colorScheme.surfaceContainerHigh, margin: EdgeInsets.zero, @@ -130,37 +134,25 @@ class _StakesCardState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text(view.title, style: theme.textTheme.titleMedium), - const SizedBox(height: 8), - for (final line in view.stakes) - Padding( - padding: const EdgeInsets.symmetric(vertical: 2), - child: Text(line, style: body), - ), - for (final line in view.pending) - Padding( - padding: const EdgeInsets.symmetric(vertical: 2), - child: Text(line, style: body?.copyWith(color: warning)), - ), - const SizedBox(height: 8), - Text( - view.streak, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), + StakesMarks(view: view), if (_message.isNotEmpty) ...[ const SizedBox(height: 8), - Text(_message, style: body), + StatusCue( + semanticLabel: _message, + kind: _excused ? CueKind.ok : CueKind.error, + ), ], if (_hatch.isOpen) ...[ const SizedBox(height: 8), Align( alignment: Alignment.centerLeft, - child: TextButton.icon( + child: IconButton( + key: const Key('stakes-escape'), onPressed: widget.busy ? null : () => unawaited(_escape()), - icon: const Icon(Icons.bug_report_outlined), - label: const Text('That was a bug'), + icon: const Icon( + Icons.bug_report_outlined, + semanticLabel: 'That was a bug', + ), ), ), ], diff --git a/phone_app/lib/screens/stakes_marks.dart b/phone_app/lib/screens/stakes_marks.dart new file mode 100644 index 0000000..d746ec4 --- /dev/null +++ b/phone_app/lib/screens/stakes_marks.dart @@ -0,0 +1,135 @@ +import 'package:flutter/material.dart'; +import 'package:wake_alarm_sync/services/today_stakes.dart'; +import 'package:wake_alarm_sync/ui/spoken_label.dart'; +import 'package:wake_alarm_sync/ui/theme.dart'; + +/// The stakes of [view] as glyph rows: times and dates are the only +/// characters painted; each row speaks its sentence from [StakesView]. +class StakesMarks extends StatelessWidget { + /// Creates the rows for [view]. + const StakesMarks({required this.view, super.key}); + + /// The stakes to draw. + final StakesView view; + + static String _dm(DateTime d) => + '${d.day.toString().padLeft(2, '0')}.' + '${d.month.toString().padLeft(2, '0')}'; + + static String _hm(int h, int m) => + '${h.toString().padLeft(2, '0')}:${m.toString().padLeft(2, '0')}'; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final warning = + theme.extension()?.warning ?? theme.colorScheme.error; + final kindIcon = switch (view.kind) { + StakesDayKind.free => Icons.event_available, + StakesDayKind.sick => Icons.sick_outlined, + StakesDayKind.stick => Icons.gavel_outlined, + StakesDayKind.carrot => Icons.emoji_events_outlined, + }; + final rows = [ + _row('title', view.title, [ + Icon(kindIcon, color: theme.colorScheme.primary), + Icon(view.isToday ? Icons.today : Icons.event, size: 18), + Text(_dm(view.day), style: theme.textTheme.titleMedium), + ]), + ]; + final words = view.stakes; + final dismissBy = view.dismissBy; + final lock = view.lockThrough; + final finishBy = view.finishBy; + final open = view.pcOpenUntil; + final ring = view.pcRingAt; + if (dismissBy != null && lock != null) { + rows.add( + _row('stick', words.first, [ + const Icon(Icons.alarm), + Text(hhmm(dismissBy)), + const Icon(Icons.arrow_forward, size: 16), + const Icon(Icons.lock_outline), + Text(_dm(lock)), + ]), + ); + } else if (finishBy != null && open != null) { + rows.add( + _row('carrot', words.first, [ + const Icon(Icons.flag_outlined), + Text(hhmm(finishBy)), + const Icon(Icons.arrow_forward, size: 16), + const Icon(Icons.computer), + Text(_hm(open.hour, open.minute)), + ]), + ); + } else if (view.kind != StakesDayKind.free && + view.kind != StakesDayKind.sick) { + rows.add( + _row('unarmed', words.first, const [Icon(Icons.alarm_off)]), + ); + } + if (ring != null) { + rows.add( + _row('pc-ring', words.last, [ + const Icon(Icons.computer), + const Icon(Icons.notifications_active_outlined), + Text(hhmm(ring)), + ]), + ); + } + rows + ..addAll(_pending(warning)) + ..add( + _row('streak', view.streak, [ + Icon( + Icons.local_fire_department_outlined, + color: theme.colorScheme.onSurfaceVariant, + ), + Text('${view.streakCount}'), + ]), + ); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: rows, + ); + } + + /// The rows for what is already in force, in `view.pending`'s order. + List _pending(Color warning) { + final spoken = view.pending.iterator; + String next() => spoken.moveNext() ? spoken.current : ''; + final locked = view.lockedThrough; + final style = TextStyle(color: warning); + return [ + if (view.escapedToday) + _row('escaped', next(), [ + Icon(Icons.bug_report_outlined, color: warning), + Icon(Icons.check, color: warning), + ]), + if (locked != null) + _row('locked', next(), [ + Icon(Icons.lock_outline, color: warning), + Text(_dm(locked), style: style), + ]), + if (view.missedToday) + _row('missed', next(), [ + Icon(Icons.close, color: warning), + Icon(Icons.computer, color: warning), + ]), + ]; + } + + static Widget _row(String what, String words, List marks) => Padding( + key: ValueKey('stakes-$what'), + padding: const EdgeInsets.symmetric(vertical: 4), + child: SpokenLabel( + semanticLabel: words, + child: Row( + children: [ + for (final mark in marks) ...[mark, const SizedBox(width: 8)], + ], + ), + ), + ); +} diff --git a/phone_app/lib/services/challenge.dart b/phone_app/lib/services/challenge.dart index 695d483..93fe6ae 100644 --- a/phone_app/lib/services/challenge.dart +++ b/phone_app/lib/services/challenge.dart @@ -178,7 +178,7 @@ class ChallengeGenerator { final backwards = word.split('').reversed.join(); return Challenge( kind: ChallengeKind.reverse, - display: 'Type this word backwards:\n$word', + display: word, answer: backwards, hint: 'Type the word backwards', ); diff --git a/phone_app/lib/services/session_notice.dart b/phone_app/lib/services/session_notice.dart index 7e5d031..8ccccfe 100644 --- a/phone_app/lib/services/session_notice.dart +++ b/phone_app/lib/services/session_notice.dart @@ -34,11 +34,20 @@ Future pointNativeAtLeg( ); } -/// The notification's one line: the leg to satisfy, or what happens instead. +/// The notification's one line: glyph + position count, with a running-clock +/// marker when the re-ring was spent. String noticeLabel(MorningSession session) { final leg = session.currentLeg!; + final glyphs = { + SessionLeg.bathroomIn: '🛁', + SessionLeg.showerDone: '🚿', + SessionLeg.dressed: '👕', + SessionLeg.atDesk: '🪑', + }; + final glyph = glyphs[leg] ?? '•'; + var text = '$glyph ${leg.index + 1}/${SessionLeg.values.length}'; if (session.deadlineRung.contains(leg)) { - return '${leg.label} — passes by itself soon'; + text += ' ⏳'; } - return leg.label; + return text; } diff --git a/phone_app/lib/services/stakes_view.dart b/phone_app/lib/services/stakes_view.dart new file mode 100644 index 0000000..b10794e --- /dev/null +++ b/phone_app/lib/services/stakes_view.dart @@ -0,0 +1,91 @@ +import 'package:wake_alarm_sync/services/stakes.dart'; + +/// Which kind of morning the stakes describe. +enum StakesDayKind { + /// A booked free day: nothing rings. + free, + + /// A marked sick day: nothing rings. + sick, + + /// A workday: a miss locks the apps. + stick, + + /// A weekend day: finishing in time opens the PC early. + carrot, +} + +/// What the stakes card shows: the facts, and the same facts in words. +/// +/// The card paints only the facts (glyphs, times, dates); the sentences are +/// its spoken labels and what the tests read for the wording. Built by +/// `describeStakes` in `today_stakes.dart`. +class StakesView { + /// Creates a view. + const StakesView({ + required this.title, + required this.stakes, + required this.pending, + required this.streak, + required this.kind, + required this.isToday, + required this.day, + required this.streakCount, + this.dismissBy, + this.lockThrough, + this.finishBy, + this.pcOpenUntil, + this.pcRingAt, + this.escapedToday = false, + this.lockedThrough, + this.missedToday = false, + }); + + /// The kind of the coming morning. + final StakesDayKind kind; + + /// Whether the coming morning is today (else tomorrow). + final bool isToday; + + /// The coming morning's date. + final DateTime day; + + /// Missed stick days in a row. + final int streakCount; + + /// Stick day: the dismiss deadline. Null when nothing is armed. + final DateTime? dismissBy; + + /// Stick day: the last day a miss locks the apps through. + final DateTime? lockThrough; + + /// Carrot day: the time the morning must be finished by. + final DateTime? finishBy; + + /// Carrot day: how long a finished morning keeps the PC open. + final ClockTime? pcOpenUntil; + + /// When the PC rings if the phone stays silent. Null when nothing rings. + final DateTime? pcRingAt; + + /// This morning was excused. + final bool escapedToday; + + /// Apps already locked through this day, when a lockdown is in force. + final DateTime? lockedThrough; + + /// This morning was missed (and not excused). + final bool missedToday; + + /// "Today · Thu 2 Oct — stick day" (spoken). + final String title; + + /// What the coming morning costs or earns (spoken, one per row). + final List stakes; + + /// A lockdown, penalty or escape already in force (spoken, one per row). + final List pending; + + /// The streak line (spoken). + final String streak; +} diff --git a/phone_app/lib/services/today_stakes.dart b/phone_app/lib/services/today_stakes.dart index 5c8ed32..1dfac5e 100644 --- a/phone_app/lib/services/today_stakes.dart +++ b/phone_app/lib/services/today_stakes.dart @@ -1,33 +1,13 @@ import 'package:wake_alarm_sync/services/alarm_ramp.dart'; import 'package:wake_alarm_sync/services/enforcement_state.dart'; import 'package:wake_alarm_sync/services/stakes.dart'; +import 'package:wake_alarm_sync/services/stakes_view.dart'; + +export 'package:wake_alarm_sync/services/stakes_view.dart'; /// An alarm time of day. typedef AlarmTime = ClockTime; -/// What the stakes card says, already in words. -class StakesView { - /// Creates a view. - const StakesView({ - required this.title, - required this.stakes, - required this.pending, - required this.streak, - }); - - /// "Today · Thu 2 Oct — stick day". - final String title; - - /// What the coming morning costs or earns. - final List stakes; - - /// A lockdown, penalty or escape already in force. - final List pending; - - /// The streak line. - final String streak; -} - /// The stakes of the next undecided morning, from the native [state]. /// /// That is today while today's alarm is still open (not dismissed, not @@ -59,6 +39,10 @@ StakesView describeStakes({ ? 'sick day' : (stick ? 'stick day' : 'carrot day'); final lines = []; + DateTime? dismissBy; + DateTime? lockThrough; + DateTime? finishBy; + DateTime? pcRingAt; if (alarm == null) { lines.add('No alarm armed yet — nothing at stake.'); } else if (free) { @@ -74,21 +58,26 @@ StakesView describeStakes({ final next = todayOpen ? "tomorrow's" : "${_weekday(_plusDays(day, 1))}'s"; + dismissBy = ringAt.add(stickGrace); + lockThrough = lock; lines.add( - 'Miss (no dismiss by ${hhmm(ringAt.add(stickGrace))}) → apps ' + 'Miss (no dismiss by ${hhmm(dismissBy)}) → apps ' 'locked through ${formatDay(lock)} + $next PC leniency gone', ); } else { + finishBy = carrotCutoff(ringAt); lines.add( - 'Finish the morning by ${hhmm(carrotCutoff(ringAt))} → PC open ' + 'Finish the morning by ${hhmm(finishBy)} → PC open ' 'until ${_hhmm(completedUntil.hour, completedUntil.minute)}', ); } + pcRingAt = ringAt.add(pcPhoneSilentAfter); lines.add( - 'PC rings at ${hhmm(ringAt.add(pcPhoneSilentAfter))} if the phone is ' + 'PC rings at ${hhmm(pcRingAt)} if the phone is ' 'silent (up to ${pcRingCap.inMinutes} min)', ); } + final locked = state.lockedThrough; return StakesView( title: '$when · ${formatDay(day)} — $kind', stakes: lines, @@ -96,6 +85,25 @@ StakesView describeStakes({ streak: 'Streak: ${state.streak} missed stick ' 'day${state.streak == 1 ? '' : 's'} in a row', + kind: free + ? StakesDayKind.free + : sick + ? StakesDayKind.sick + : (stick ? StakesDayKind.stick : StakesDayKind.carrot), + isToday: todayOpen, + day: day, + streakCount: state.streak, + dismissBy: dismissBy, + lockThrough: lockThrough, + finishBy: finishBy, + pcOpenUntil: finishBy == null ? null : completedUntil, + pcRingAt: pcRingAt, + escapedToday: state.escapedToday, + lockedThrough: + locked != null && !locked.isBefore(today) && !state.escapedToday + ? locked + : null, + missedToday: state.lastMiss == today && !state.escapedToday, ); } @@ -103,6 +111,16 @@ StakesView describeStakes({ /// or null when this ring has none (not a stick day, free, sick, already /// dismissed, excused or missed). String? ringStakesLine(EnforcementState state) { + final stakes = ringStakes(state); + if (stakes == null) return null; + return 'Stick day: no dismiss by ${hhmm(stakes.by)} → apps locked through ' + '${formatDay(stakes.lockThrough)}'; +} + +/// Returns the deadline and lockdown date for the ring stakes line, or null +/// when this ring has none (not a stick day, free, sick, already dismissed, +/// excused or missed). +({DateTime by, DateTime lockThrough})? ringStakes(EnforcementState state) { final stored = _stored(state); if (stored == null || !isWorkday(state.today) || @@ -114,8 +132,7 @@ String? ringStakesLine(EnforcementState state) { if (state.lastMiss == state.today) return null; final by = _at(state.today, stored).add(stickGrace); final lock = state.lockIfMissToday ?? _plusDays(state.today, 1); - return 'Stick day: no dismiss by ${hhmm(by)} → apps locked through ' - '${formatDay(lock)}'; + return (by: by, lockThrough: lock); } /// The carrot's cutoff for a morning whose alarm rings at [ringAt]: diff --git a/phone_app/lib/ui/cues.dart b/phone_app/lib/ui/cues.dart new file mode 100644 index 0000000..9d8f4ed --- /dev/null +++ b/phone_app/lib/ui/cues.dart @@ -0,0 +1,218 @@ +/// Text-free status cue widgets: enum classifier, semantic icon with haptic +/// feedback, and spoken-only label wrapper. +library; + +import 'dart:async' show unawaited; +import 'dart:math' show pi, sin; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:wake_alarm_sync/ui/theme.dart' show AppStatusColors; + +export 'package:wake_alarm_sync/ui/spoken_label.dart'; + +/// Semantic kind for a status cue; drives icon, colour and haptic. +enum CueKind { + /// Busy/loading state. + busy, + /// Success/connected state. + ok, + /// Error/failed state. + error, + /// Warning/caution state. + warning, + /// Informational state (default). + info, +} + +/// Classifies a status sentence into a [CueKind]. +/// +/// Uses the app's existing colour conventions (e.g. +/// `status.contains('Error')`). Returns `null` for empty string. +CueKind? cueKindOf(String status) { + if (status.isEmpty) return null; + if (status.endsWith('…') || status.endsWith('...')) return CueKind.busy; + + const errorKeywords = [ + 'Error', 'Could not', 'unavailable', 'refused', 'Not ', 'Unknown', + 'Too early', 'not earlier', 'Wrong', 'Failed', 'too short', + ]; + for (final kw in errorKeywords) { + if (status.contains(kw)) return CueKind.error; + } + + const okKeywords = [ + '✓', 'Connected', 'registered', 'matched', 'Captured', 'Synced', + 'Armed', 'Done', 'Correct', 'Dismissed', 'Excused', + ]; + for (final kw in okKeywords) { + if (status.contains(kw)) return CueKind.ok; + } + + if (status.contains('Connect a sync backend') || + status.contains('Open Settings')) { + return CueKind.warning; + } + return CueKind.info; +} + +/// A semantic status icon with haptic feedback and a shake animation on error. +/// +/// Paints no text. Icon and colour derived from [kind] (defaulting to +/// [cueKindOf] of [semanticLabel]). Renders [SizedBox.shrink] when [kind] +/// resolves to `null` (empty label). +class StatusCue extends StatefulWidget { + /// Creates a status cue. + /// + /// [semanticLabel] is the spoken label (also used to infer [kind] when + /// not provided). [kind] overrides inference. [icon] overrides the default + /// icon for the kind. [size] controls the icon/spinner dimension. + const StatusCue({ + required this.semanticLabel, + this.kind, + this.icon, + this.size = 28, + super.key, + }); + + /// The spoken label; also used to infer the cue kind when [kind] is null. + final String semanticLabel; + + /// Explicit kind override; defaults to [cueKindOf(semanticLabel)]. + final CueKind? kind; + + /// Optional icon override. + final IconData? icon; + + /// Icon / spinner dimension. + final double size; + + /// The resolved kind (explicit or inferred). + CueKind? get resolvedKind => kind ?? cueKindOf(semanticLabel); + + @override + State createState() => _StatusCueState(); +} + +class _StatusCueState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _shakeCtrl; + late final Animation _shakeAnim; + CueKind? _prevKind; + bool _shown = false; + + @override + void initState() { + super.initState(); + _shakeCtrl = AnimationController( + duration: const Duration(milliseconds: 300), + vsync: this, + ); + _shakeAnim = Tween(begin: 0, end: 1).animate( + CurvedAnimation(parent: _shakeCtrl, curve: Curves.easeOut), + ); + _prevKind = widget.resolvedKind; + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_shown) { + _shown = true; + _maybeTriggerHaptic(_prevKind); + } + } + + @override + void didUpdateWidget(covariant StatusCue oldWidget) { + super.didUpdateWidget(oldWidget); + final newKind = widget.resolvedKind; + final labelChanged = oldWidget.semanticLabel != widget.semanticLabel; + if (newKind != _prevKind || labelChanged) { + _maybeTriggerHaptic(newKind); + _prevKind = newKind; + } + } + + void _maybeTriggerHaptic(CueKind? kind) { + if (kind == null) return; + final disableAnim = MediaQuery.maybeDisableAnimationsOf(context) ?? false; + switch (kind) { + case CueKind.error: + unawaited(HapticFeedback.heavyImpact()); + if (!disableAnim) _shakeCtrl.forward(from: 0); + case CueKind.ok: + unawaited(HapticFeedback.lightImpact()); + case CueKind.busy: + case CueKind.warning: + case CueKind.info: + } + } + + @override + void dispose() { + _shakeCtrl.dispose(); + super.dispose(); + } + + Widget _buildSemantics(Widget child) { + return Semantics( + liveRegion: true, + label: widget.semanticLabel, + child: ExcludeSemantics(child: child), + ); + } + + @override + Widget build(BuildContext context) { + final kind = widget.resolvedKind; + if (kind == null) return const SizedBox.shrink(); + + final theme = Theme.of(context); + final statusColors = theme.extension(); + final colorScheme = theme.colorScheme; + + final iconData = widget.icon ?? + switch (kind) { + CueKind.ok => Icons.check_circle, + CueKind.error => Icons.error_outline, + CueKind.warning => Icons.warning_amber_rounded, + CueKind.info => Icons.info_outline, + CueKind.busy => Icons.sync, + }; + + final color = switch (kind) { + CueKind.ok => statusColors?.success ?? Colors.green, + CueKind.error => colorScheme.error, + CueKind.warning => statusColors?.warning ?? Colors.amber, + CueKind.info => colorScheme.onSurfaceVariant, + CueKind.busy => colorScheme.primary, + }; + + late final Widget child; + switch (kind) { + case CueKind.busy: + child = const SizedBox.square( + dimension: 28, + child: CircularProgressIndicator(strokeWidth: 3), + ); + case CueKind.ok: + case CueKind.error: + case CueKind.warning: + case CueKind.info: + child = Icon(iconData, size: widget.size, color: color); + } + + if (kind == CueKind.error) { + return AnimatedBuilder( + animation: _shakeAnim, + builder: (context, _) => Transform.translate( + offset: Offset(sin(_shakeAnim.value * 3 * 2 * pi) * 6, 0), + child: _buildSemantics(child), + ), + ); + } + return _buildSemantics(child); + } +} diff --git a/phone_app/lib/ui/dashed_slot.dart b/phone_app/lib/ui/dashed_slot.dart new file mode 100644 index 0000000..b5b7efd --- /dev/null +++ b/phone_app/lib/ui/dashed_slot.dart @@ -0,0 +1,146 @@ +import 'dart:math' show pi; + +import 'package:flutter/material.dart'; +import 'package:wake_alarm_sync/ui/spoken_label.dart'; + +/// A dashed outline slot for an empty/unregistered value. +/// Paints no text; spoken label is provided via [SpokenLabel]. +class DashedSlot extends StatelessWidget { + /// Creates a dashed slot. + const DashedSlot({super.key}); + + @override + Widget build(BuildContext context) { + final outlineColor = Theme.of(context).colorScheme.outline; + return SpokenLabel( + semanticLabel: 'none registered', + child: CustomPaint( + painter: _DashedSlotPainter(outlineColor: outlineColor), + child: const SizedBox(height: 40), + ), + ); + } +} + +class _DashedSlotPainter extends CustomPainter { + const _DashedSlotPainter({required this.outlineColor}); + + final Color outlineColor; + static const _dashWidth = 6.0; + static const _dashGap = 4.0; + static const _radius = 8.0; + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = outlineColor + ..style = PaintingStyle.stroke + ..strokeWidth = 2; + + // Top edge + _drawDashedLine( + canvas, + paint, + const Offset(_radius, 0), + Offset(size.width - _radius, 0), + ); + // Top-right arc + _drawDashedArc( + canvas, + paint, + Offset(size.width - _radius, _radius), + _radius, + -pi / 2, + pi / 2, + ); + // Right edge + _drawDashedLine( + canvas, + paint, + Offset(size.width, _radius), + Offset(size.width, size.height - _radius), + ); + // Bottom-right arc + _drawDashedArc( + canvas, + paint, + Offset(size.width - _radius, size.height - _radius), + _radius, + 0, + pi / 2, + ); + // Bottom edge + _drawDashedLine( + canvas, + paint, + Offset(size.width - _radius, size.height), + Offset(_radius, size.height), + ); + // Bottom-left arc + _drawDashedArc( + canvas, + paint, + Offset(_radius, size.height - _radius), + _radius, + pi / 2, + pi / 2, + ); + // Left edge + _drawDashedLine( + canvas, + paint, + Offset(0, size.height - _radius), + const Offset(0, _radius), + ); + // Top-left arc + _drawDashedArc( + canvas, + paint, + const Offset(_radius, _radius), + _radius, + pi, + pi / 2, + ); + } + + void _drawDashedLine(Canvas canvas, Paint paint, Offset start, Offset end) { + final delta = end - start; + final length = delta.distance; + if (length <= 0) return; + final unit = delta / length; + var pos = 0.0; + while (pos < length) { + final dashEnd = (pos + _dashWidth < length) ? pos + _dashWidth : length; + canvas.drawLine(start + unit * pos, start + unit * dashEnd, paint); + pos += _dashWidth + _dashGap; + } + } + + void _drawDashedArc( + Canvas canvas, + Paint paint, + Offset center, + double radius, + double startAngle, + double sweepAngle, + ) { + final arcLength = radius * sweepAngle.abs(); + var pos = 0.0; + while (pos < arcLength) { + final dashEnd = (pos + _dashWidth < arcLength) + ? pos + _dashWidth + : arcLength; + canvas.drawArc( + Rect.fromCircle(center: center, radius: radius), + startAngle + (sweepAngle > 0 ? pos : -pos) / radius, + (dashEnd - pos) / radius * (sweepAngle > 0 ? 1 : -1), + false, + paint, + ); + pos += _dashWidth + _dashGap; + } + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} diff --git a/phone_app/lib/ui/spoken_label.dart b/phone_app/lib/ui/spoken_label.dart new file mode 100644 index 0000000..9bd5e95 --- /dev/null +++ b/phone_app/lib/ui/spoken_label.dart @@ -0,0 +1,34 @@ +/// A wrapper that provides a spoken label for accessibility without any +/// visible text (no Tooltip). +/// +/// Use for icon-only controls whose wording must remain only for TalkBack. +library; + +import 'package:flutter/material.dart'; + +/// A wrapper that provides a spoken label for accessibility without any +/// visible text (no Tooltip). +/// +/// Use for icon-only controls whose wording must remain only for TalkBack. +class SpokenLabel extends StatelessWidget { + /// Creates a spoken label wrapper. + const SpokenLabel({ + required this.semanticLabel, + required this.child, + super.key, + }); + + /// The accessibility label. + final String semanticLabel; + + /// The child widget to wrap. + final Widget child; + + @override + Widget build(BuildContext context) { + return Semantics( + label: semanticLabel, + child: ExcludeSemantics(child: child), + ); + } +} diff --git a/phone_app/pubspec.lock b/phone_app/pubspec.lock index 6366756..bfbd908 100644 --- a/phone_app/pubspec.lock +++ b/phone_app/pubspec.lock @@ -5,10 +5,10 @@ packages: dependency: transitive description: name: android_file_picker - sha256: "868183c959e754e915954d8074c20cfa4cfdb4684b35ecc08378437be1a542d8" + sha256: "04c1306eda42c23ecbf449c909f4fa05c5e66fc48cf5f994d8d08b9d45e5396b" url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.1.5" android_intent_plus: dependency: "direct main" description: @@ -101,11 +101,11 @@ packages: dependency: "direct main" description: path: crdt_sync_dart - ref: "crdt_sync_dart-v0.12.0" - resolved-ref: "59b33949e4a130bb76c77e7434ddf4bbf8cde3a3" + ref: "crdt_sync_dart-v0.12.1" + resolved-ref: "6641d85f0550ca60433fc8e0d13a3dafdbb664d6" url: "https://github.com/kuhyx/utils" source: git - version: "0.12.0" + version: "0.12.1" cross_file: dependency: transitive description: @@ -134,11 +134,11 @@ packages: dependency: "direct main" description: path: data_transfer_flutter - ref: "data_transfer_flutter-v0.2.1" - resolved-ref: d4fca83c9f2ed33b3315cd838b9ca532ae8e559c + ref: "data_transfer_flutter-v0.2.2" + resolved-ref: eef7af4c86837ee82891493587ff3456297e5dea url: "https://github.com/kuhyx/utils" source: git - version: "0.2.1" + version: "0.2.2" dbus: dependency: transitive description: @@ -183,10 +183,10 @@ packages: dependency: transitive description: name: file_picker - sha256: c64036857780d7125f011d543f89eedcb5df61da473668bace4eee9ab875284d + sha256: "079dc46b753abbddb7f77cd87e6ed9ecc62902580707514568321bdf8e050a86" url: "https://pub.dev" source: hosted - version: "13.3.2" + version: "13.3.3" file_picker_darwin: dependency: transitive description: @@ -199,10 +199,10 @@ packages: dependency: transitive description: name: file_picker_linux - sha256: c6562fc7d426db2fb85ea66fe2627953d2709932df5584ceaacf0aa5e39cf4da + sha256: "9215ee8c672508cdd9a325202669f849e1baac883658626b3abed5d7777e6a4b" url: "https://pub.dev" source: hosted - version: "2.0.2" + version: "2.0.4" file_picker_platform_interface: dependency: transitive description: @@ -215,10 +215,10 @@ packages: dependency: transitive description: name: file_picker_web - sha256: a2288008280aabe371aff029466b3405e60838e0a1d714767488c59b926c8b8c + sha256: "50749a6da2984fc29609e102ad4d80fd1d07e6895150f8e4922086167d9d45d8" url: "https://pub.dev" source: hosted - version: "4.2.0" + version: "4.2.1" file_selector: dependency: transitive description: @@ -366,11 +366,11 @@ packages: dependency: "direct main" description: path: github_device_auth - ref: "github_device_auth-v0.3.2" - resolved-ref: "4ccaba1bb3e495ef72d70c6a7e7d7ec98d755792" + ref: "github_device_auth-v0.4.0" + resolved-ref: "3084080d744c348a217727e26a6be838e7d5a4ff" url: "https://github.com/kuhyx/utils" source: git - version: "0.3.2" + version: "0.4.0" google_account_token: dependency: "direct main" description: @@ -733,11 +733,11 @@ packages: dependency: "direct main" description: path: sync_settings_ui - ref: "sync_settings_ui-v0.6.1" - resolved-ref: ebedbf4b27fa332f7b8e6d80117d1c409bf3a7ad + ref: "sync_settings_ui-v0.7.0" + resolved-ref: "3084080d744c348a217727e26a6be838e7d5a4ff" url: "https://github.com/kuhyx/utils" source: git - version: "0.6.1" + version: "0.7.0" term_glyph: dependency: transitive description: @@ -878,10 +878,10 @@ packages: dependency: transitive description: name: windows_file_picker - sha256: "18452aa293de3b6961c6d05b0e71b4be7bb90c67635abdd3aa5c4e75dd875072" + sha256: cd1be37ada0abcfaa55bddb7410e8515844e35cd77669c35d9a28a15722299d8 url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "2.0.2" xdg_directories: dependency: transitive description: diff --git a/phone_app/pubspec.yaml b/phone_app/pubspec.yaml index db979ce..c6255e7 100644 --- a/phone_app/pubspec.yaml +++ b/phone_app/pubspec.yaml @@ -12,7 +12,7 @@ dependencies: crdt_sync: git: url: https://github.com/kuhyx/utils - ref: crdt_sync_dart-v0.12.0 + ref: crdt_sync_dart-v0.12.1 path: crdt_sync_dart cupertino_icons: 2.0.0 @@ -20,7 +20,7 @@ dependencies: data_transfer_flutter: git: url: https://github.com/kuhyx/utils - ref: data_transfer_flutter-v0.2.1 + ref: data_transfer_flutter-v0.2.2 path: data_transfer_flutter flutter: sdk: flutter @@ -31,7 +31,7 @@ dependencies: github_device_auth: git: url: https://github.com/kuhyx/utils - ref: github_device_auth-v0.3.2 + ref: github_device_auth-v0.4.0 path: github_device_auth google_account_token: git: @@ -49,7 +49,7 @@ dependencies: sync_settings_ui: git: url: https://github.com/kuhyx/utils - ref: sync_settings_ui-v0.6.1 + ref: sync_settings_ui-v0.7.0 path: sync_settings_ui url_launcher: 6.3.3 uuid: 4.6.0 diff --git a/phone_app/test/free_days_screen_test.dart b/phone_app/test/free_days_screen_test.dart index c2a4e1c..98c8b7d 100644 --- a/phone_app/test/free_days_screen_test.dart +++ b/phone_app/test/free_days_screen_test.dart @@ -1,6 +1,5 @@ import 'dart:io'; -import 'package:crdt_sync/crdt_sync.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -10,20 +9,17 @@ import 'package:wake_alarm_sync/services/free_day_pool.dart'; import 'package:wake_alarm_sync/services/free_day_sync.dart'; import 'package:wake_alarm_sync/services/sick_day_pool.dart'; import 'package:wake_alarm_sync/services/sick_day_sync.dart'; +import 'package:wake_alarm_sync/ui/cues.dart'; +import 'package:wake_alarm_sync/ui/dashed_slot.dart'; import 'package:wake_alarm_sync/ui/theme.dart'; -class _MemPersistence implements LogPersistence { - String? text; - - @override - Future read() async => text; - - @override - Future write(String value) async => text = value; -} +import 'fake_day_pool_store.dart'; final _today = DateTime(2026, 9, 11); +String statusOf(WidgetTester tester) => + tester.widget(find.byType(StatusCue).first).semanticLabel; + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -37,7 +33,7 @@ void main() { .setMockMethodCallHandler(channel, (call) async => null); scheduler = AlarmScheduler(channel: channel); pool = await FreeDayPool.open( - persistence: _MemPersistence(), + persistence: MemPersistence(), deviceId: 'phone', ); syncs = 0; @@ -63,7 +59,7 @@ void main() { scheduler: scheduler, ), openSickPool: () => - SickDayPool.open(persistence: _MemPersistence(), deviceId: 'phone'), + SickDayPool.open(persistence: MemPersistence(), deviceId: 'phone'), sickSyncer: SickDaySync( openClient: () async => null, scheduler: scheduler, @@ -83,14 +79,13 @@ void main() { await tester.pumpAndSettle(); expect(syncs, 1); - expect(find.text('33 of 35'), findsOneWidget); - expect(find.text('free days left in 2026'), findsOneWidget); - expect(find.text('Sun 2026-09-20'), findsOneWidget); + expect(find.text('33/35'), findsOneWidget); + expect(find.text('20.09.2026'), findsOneWidget); expect(find.text('travelling'), findsOneWidget); expect(find.textContaining('2026-09-10'), findsNothing); expect( - find.text('Not synced — connect Firebase in Settings.'), - findsOneWidget, + statusOf(tester), + contains('Not synced — connect Firebase in Settings.'), ); }); @@ -112,13 +107,12 @@ void main() { await tester.pumpWidget(app()); await tester.pumpAndSettle(); - await tester.ensureVisible(find.text('Release')); - await tester.tap(find.text('Release')); + await tester.tap(find.byKey(const ValueKey('release-2026-09-20'))); await tester.pumpAndSettle(); expect(pool.isFree(DateTime(2026, 9, 20)), isFalse); - expect(find.text('35 of 35'), findsOneWidget); - expect(find.textContaining('Tap a date above'), findsOneWidget); + expect(find.text('35/35'), findsOneWidget); + expect(find.byType(DashedSlot), findsOneWidget); expect(syncs, 2); }); @@ -127,7 +121,7 @@ void main() { await tester.pumpWidget(app()); await tester.pumpAndSettle(); - expect(find.text('September 2026'), findsOneWidget); + expect(find.text('09/2026'), findsOneWidget); expect(find.text('Take a day'), findsNothing); }); @@ -140,8 +134,8 @@ void main() { expect(pool.isFree(DateTime(2026, 9, 12)), isTrue); expect(pool.reasonFor(DateTime(2026, 9, 12)), ''); - expect(find.text('34 of 35'), findsOneWidget); - expect(find.text('Sat 2026-09-12'), findsOneWidget); + expect(find.text('34/35'), findsOneWidget); + expect(find.text('12.09.2026'), findsOneWidget); expect(syncs, 2); }); @@ -152,12 +146,12 @@ void main() { phoneSize(tester); await tester.pumpWidget(app()); await tester.pumpAndSettle(); - expect(find.text('34 of 35'), findsOneWidget); + expect(find.text('34/35'), findsOneWidget); await tapDay(tester, DateTime(2026, 9, 20)); expect(pool.isFree(DateTime(2026, 9, 20)), isFalse); - expect(find.text('35 of 35'), findsOneWidget); + expect(find.text('35/35'), findsOneWidget); expect(syncs, 2); }); @@ -182,29 +176,29 @@ void main() { await tester.pumpWidget(app()); await tester.pumpAndSettle(); - Future press(String tooltip, int times) async { + Future press(IconData icon, int times) async { for (var i = 0; i < times; i++) { - await tester.tap(find.byTooltip(tooltip)); + await tester.tap(find.byIcon(icon)); await tester.pumpAndSettle(); } } - await press('Next month', 1); - expect(find.text('October 2026'), findsOneWidget); + await press(Icons.chevron_right, 1); + expect(find.text('10/2026'), findsOneWidget); await tapDay(tester, DateTime(2026, 10, 2)); expect(pool.isFree(DateTime(2026, 10, 2)), isTrue); // Back to January of this year, and no further. - await press('Previous month', 9); - expect(find.text('January 2026'), findsOneWidget); - await press('Previous month', 1); - expect(find.text('January 2026'), findsOneWidget); + await press(Icons.chevron_left, 9); + expect(find.text('01/2026'), findsOneWidget); + await press(Icons.chevron_left, 1); + expect(find.text('01/2026'), findsOneWidget); // Forward to December of next year, and no further. - await press('Next month', 23); - expect(find.text('December 2027'), findsOneWidget); - await press('Next month', 1); - expect(find.text('December 2027'), findsOneWidget); + await press(Icons.chevron_right, 23); + expect(find.text('12/2027'), findsOneWidget); + await press(Icons.chevron_right, 1); + expect(find.text('12/2027'), findsOneWidget); }); testWidgets('a refused booking shows the reason and does not sync', ( @@ -217,7 +211,7 @@ void main() { phoneSize(tester); await tester.pumpWidget(app()); await tester.pumpAndSettle(); - expect(find.text('0 of 35'), findsOneWidget); + expect(find.text('0/35'), findsOneWidget); await tapDay(tester, DateTime(2026, 9, 12)); @@ -225,7 +219,7 @@ void main() { // The 35 booked days make the page long; back to the status line. await tester.drag(find.byType(ListView), const Offset(0, 3000)); await tester.pumpAndSettle(); - expect(find.textContaining('No free days left in 2026'), findsOneWidget); + expect(statusOf(tester), contains('No free days left in 2026')); expect(syncs, 1); }); @@ -244,7 +238,7 @@ void main() { ), ); await tester.pumpAndSettle(); - expect(find.textContaining('Error: pool unavailable'), findsOneWidget); + expect(statusOf(tester), contains('Error: pool unavailable')); expect(find.text('Take a day'), findsNothing); }); } diff --git a/phone_app/test/free_days_sick_conflict_test.dart b/phone_app/test/free_days_sick_conflict_test.dart index 646363a..c006ab4 100644 --- a/phone_app/test/free_days_sick_conflict_test.dart +++ b/phone_app/test/free_days_sick_conflict_test.dart @@ -9,12 +9,16 @@ import 'package:wake_alarm_sync/services/free_day_pool.dart'; import 'package:wake_alarm_sync/services/free_day_sync.dart'; import 'package:wake_alarm_sync/services/sick_day_pool.dart'; import 'package:wake_alarm_sync/services/sick_day_sync.dart'; +import 'package:wake_alarm_sync/ui/cues.dart'; import 'package:wake_alarm_sync/ui/theme.dart'; import 'fake_day_pool_store.dart'; final _today = DateTime(2026, 10, 16); +String statusOf(WidgetTester tester) => + tester.widget(find.byType(StatusCue).first).semanticLabel; + /// The free-day picker's half of "a date is never in both pools". void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -87,8 +91,8 @@ void main() { expect(free.isFree(DateTime(2026, 10, 20)), isFalse); expect( - find.text('2026-10-20 is a sick day — clear it in Sick days first.'), - findsOneWidget, + statusOf(tester), + contains('2026-10-20 is a sick day — clear it in Sick days first.'), ); // A normal day still books. await tapDay(tester, DateTime(2026, 10, 21)); @@ -104,6 +108,6 @@ void main() { ); await tapDay(tester, DateTime(2026, 10, 21)); expect(free.isFree(DateTime(2026, 10, 21)), isFalse); - expect(find.textContaining('Sick-day pool unavailable'), findsOneWidget); + expect(statusOf(tester), contains('Sick-day pool unavailable')); }); } diff --git a/phone_app/test/home_fit_test.dart b/phone_app/test/home_fit_test.dart index 72eeee2..df9afcd 100644 --- a/phone_app/test/home_fit_test.dart +++ b/phone_app/test/home_fit_test.dart @@ -36,11 +36,10 @@ void main() { theme: buildAppTheme(), home: Scaffold( appBar: AppBar( - title: const Text('Wake Alarm Sync'), actions: const [ - Icon(Icons.event_available), - Icon(Icons.sick_outlined), - Icon(Icons.settings), + Icon(Icons.event_available, semanticLabel: 'Free days'), + Icon(Icons.sick_outlined, semanticLabel: 'Sick days'), + Icon(Icons.settings, semanticLabel: 'Settings'), ], ), body: HomeBody( @@ -91,7 +90,7 @@ void main() { expect(tester.takeException(), isNull); expectNoVerticalScroll(tester); const bottomChrome = 2400 / 2.625 - 63 / 2.625; - final button = find.text('Wake me earlier tomorrow'); + final button = find.byIcon(Icons.arrow_upward); expect(tester.getRect(button).bottom, lessThan(bottomChrome)); await tester.tap(button); expect(picked, 1); diff --git a/phone_app/test/home_history_card_test.dart b/phone_app/test/home_history_card_test.dart index c7f4602..323484e 100644 --- a/phone_app/test/home_history_card_test.dart +++ b/phone_app/test/home_history_card_test.dart @@ -6,6 +6,8 @@ import 'package:wake_alarm_sync/services/morning_session.dart'; import 'package:wake_alarm_sync/services/session_rules.dart'; import 'package:wake_alarm_sync/services/session_store.dart'; import 'package:wake_alarm_sync/services/session_tag_settings.dart'; +import 'package:wake_alarm_sync/ui/dashed_slot.dart'; +import 'package:wake_alarm_sync/ui/spoken_label.dart'; MorningSession _session( DateTime at, { @@ -66,16 +68,23 @@ void main() { testWidgets('is absent until a morning has finished', (tester) async { await tester.pumpWidget(const MaterialApp(home: HomeHistoryCard())); await tester.pump(); - expect(find.text('Last mornings'), findsNothing); + expect(find.byType(Row), findsNothing); + expect(find.byType(DashedSlot), findsNothing); }); - testWidgets('says so when no morning has finished', (tester) async { + testWidgets('shows an empty slot when no morning has finished', ( + tester, + ) async { await tester.pumpWidget( const MaterialApp(home: HomeHistoryCard(emptyText: 'None yet.')), ); - expect(find.text('None yet.'), findsNothing); + expect(find.byType(DashedSlot), findsNothing); await tester.pump(); - expect(find.text('None yet.'), findsOneWidget); + expect(find.byType(DashedSlot), findsOneWidget); + expect( + tester.widget(find.byType(SpokenLabel).first).semanticLabel, + 'None yet.', + ); }); testWidgets('lists the last seven with weight deltas', (tester) async { @@ -83,14 +92,22 @@ void main() { for (var day = 1; day <= 9; day++) { await store.archive(_session(DateTime(2026, 9, day, 8), kg: 70.0 + day)); } - await tester.pumpWidget(const MaterialApp(home: HomeHistoryCard())); - await tester.pump(); - expect(find.text('Last mornings'), findsOneWidget); - expect( - find.textContaining('09-09 ✓ 0 re-rings · 79.0 kg (+1.0)'), - findsOne, + // As the home tile shows it: a scrolling bottom sheet. + await tester.pumpWidget( + const MaterialApp( + home: Scaffold(body: SingleChildScrollView(child: HomeHistoryCard())), + ), ); - expect(find.textContaining('09-03'), findsOneWidget); - expect(find.textContaining('09-02'), findsNothing); + await tester.pump(); + final rows = tester + .widgetList(find.byType(SpokenLabel)) + .map((w) => w.semanticLabel) + .toList(); + expect(rows, hasLength(kHistoryShown)); + expect(rows.first, '09-09 ✓ 0 re-rings · 79.0 kg (+1.0)'); + expect(find.text('79.0'), findsOneWidget); + expect(find.text(' (+1.0)'), findsWidgets); + expect(find.text('09-03'), findsOneWidget); + expect(find.text('09-02'), findsNothing); }); } diff --git a/phone_app/test/home_location_screen_test.dart b/phone_app/test/home_location_screen_test.dart index b2285cb..9ce1b00 100644 --- a/phone_app/test/home_location_screen_test.dart +++ b/phone_app/test/home_location_screen_test.dart @@ -5,6 +5,8 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:wake_alarm_sync/screens/home_location_screen.dart'; import 'package:wake_alarm_sync/services/alarm_scheduler.dart'; import 'package:wake_alarm_sync/services/home_location_settings.dart'; +import 'package:wake_alarm_sync/ui/cues.dart'; +import 'package:wake_alarm_sync/ui/dashed_slot.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -22,16 +24,13 @@ void main() { }); Future pumpScreen(WidgetTester tester) async { - await tester.pumpWidget( - const MaterialApp(home: HomeLocationScreen()), - ); + await tester.pumpWidget(const MaterialApp(home: HomeLocationScreen())); await tester.pump(); } testWidgets('a fresh install shows nothing captured', (tester) async { await pumpScreen(tester); - expect(find.text('not captured'), findsNWidgets(2)); - expect(find.text('none'), findsOneWidget); + expect(find.byType(DashedSlot), findsNWidgets(3)); }); testWidgets('capture stores what the platform reported', (tester) async { @@ -43,10 +42,13 @@ void main() { 'reason': 'ok', }; await pumpScreen(tester); - await tester.tap(find.text('Capture this place as home')); + await tester.tap(find.byIcon(Icons.my_location)); await tester.pumpAndSettle(); - expect(find.textContaining('Captured Wi-Fi'), findsOneWidget); + expect( + tester.widget(find.byType(StatusCue)).semanticLabel, + contains('Captured Wi-Fi'), + ); final home = await HomeLocationSettings.load(); expect(home.ssid, 'Orange_Home'); expect(home.bssids, ['cc:00:f1:ae:d6:c4']); @@ -58,10 +60,13 @@ void main() { ) async { presence = {'ssid': 'Orange_Home', 'reason': 'ok'}; await pumpScreen(tester); - await tester.tap(find.text('Capture this place as home')); + await tester.tap(find.byIcon(Icons.my_location)); await tester.pumpAndSettle(); - expect(find.textContaining('GPS fallback is unset'), findsOneWidget); + expect( + tester.widget(find.byType(StatusCue)).semanticLabel, + contains('GPS fallback is unset'), + ); expect((await HomeLocationSettings.load()).hasCoordinate, isFalse); }); @@ -70,20 +75,26 @@ void main() { ) async { presence = {'reason': 'no-permission'}; await pumpScreen(tester); - await tester.tap(find.text('Capture this place as home')); + await tester.tap(find.byIcon(Icons.my_location)); await tester.pumpAndSettle(); - expect(find.textContaining('Grant location permission'), findsOneWidget); + expect( + tester.widget(find.byType(StatusCue)).semanticLabel, + contains('Grant location permission'), + ); expect((await HomeLocationSettings.load()).isConfigured, isFalse); }); testWidgets('forget home returns to the strict sequence', (tester) async { await HomeLocationSettings.capture(ssid: 'Orange_Home'); await pumpScreen(tester); - await tester.tap(find.text('Forget home')); + await tester.tap(find.byIcon(Icons.delete_outline)); await tester.pumpAndSettle(); - expect(find.textContaining('full sequence now applies'), findsOneWidget); + expect( + tester.widget(find.byType(StatusCue)).semanticLabel, + contains('full sequence now applies'), + ); expect((await HomeLocationSettings.load()).isConfigured, isFalse); }); } diff --git a/phone_app/test/ring_away_test.dart b/phone_app/test/ring_away_test.dart index befb40f..a4b7710 100644 --- a/phone_app/test/ring_away_test.dart +++ b/phone_app/test/ring_away_test.dart @@ -10,10 +10,15 @@ import 'package:wake_alarm_sync/services/challenge.dart'; import 'package:wake_alarm_sync/services/dismiss_gate.dart'; import 'package:wake_alarm_sync/services/home_location_settings.dart'; import 'package:wake_alarm_sync/services/room_proof_settings.dart'; +import 'package:wake_alarm_sync/ui/cues.dart'; const String _tag1 = '04a2b3c4'; const String _tag2 = '04ffee11'; +/// The ring's status sentence, now spoken only (StatusCue.semanticLabel). +String statusOf(WidgetTester tester) => + tester.widget(find.byType(StatusCue)).semanticLabel; + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -63,8 +68,8 @@ void main() { presence = {'ssid': 'Orange_Home', 'reason': 'ok'}; await pumpRing(tester); - expect(find.text('Tap tag 1'), findsOneWidget); - expect(find.text('Tap tag 2'), findsOneWidget); + expect(find.byKey(const ValueKey('ring-row-tag1')), findsOneWidget); + expect(find.byKey(const ValueKey('ring-row-tag2')), findsOneWidget); }); testWidgets('away it skips the tags, keeps the walk, and asks 3', ( @@ -74,20 +79,23 @@ void main() { presence = {'ssid': 'Hotel_Guest', 'reason': 'ok'}; await pumpRing(tester); - expect(find.textContaining('Away from home'), findsWidgets); + expect(find.byKey(const Key('ring-bypass')), findsWidgets); // The persistent line, not the status that the step poll overwrites. - expect(find.textContaining('the tags are elsewhere'), findsOneWidget); - expect(find.textContaining('Tags unavailable'), findsNothing); + expect(statusOf(tester), contains('Away from home')); + expect(statusOf(tester), isNot(contains('Tags unavailable'))); expect(find.text('Tap tag 1'), findsNothing); expect(find.text('Tap tag 2'), findsNothing); // The walk is never skipped: away is where the tags are, not the bed. - expect(find.text('Walk 25 steps'), findsOneWidget); + expect( + find.byKey(const ValueKey('ring-row-steps')), + findsOneWidget, + ); steps = 500; elapsed = const Duration(minutes: 3); await tester.pump(kStepPollInterval); await tester.pump(); - expect(find.text('Question 1 of 3'), findsOneWidget); + expect(find.text('1/3'), findsOneWidget); }); testWidgets('the five-minute timer does not bump an away morning to 6', ( @@ -101,13 +109,13 @@ void main() { elapsed = const Duration(minutes: 3); await tester.pump(kStepPollInterval); await tester.pump(); - expect(find.text('Question 1 of 3'), findsOneWidget); + expect(find.text('1/3'), findsOneWidget); // An away morning routinely outlasts the bypass timer. await tester.pump(DismissGate.bypassAfter); await tester.pump(); - expect(find.text('Question 1 of 3'), findsOneWidget); - expect(find.text('Question 1 of 6'), findsNothing); + expect(find.text('1/3'), findsOneWidget); + expect(find.text('1/6'), findsNothing); }); testWidgets('unreadable presence is away, per the chosen fail mode', ( @@ -117,7 +125,7 @@ void main() { presence = {'reason': 'no-permission'}; await pumpRing(tester); - expect(find.textContaining('location permission denied'), findsOneWidget); + expect(statusOf(tester), contains('location permission denied')); expect(find.text('Tap tag 1'), findsNothing); }); @@ -139,6 +147,6 @@ void main() { elapsed = const Duration(minutes: 3); await tester.pump(kStepPollInterval); await tester.pump(); - expect(find.text('Question 1 of 3'), findsOneWidget); + expect(find.text('1/3'), findsOneWidget); }); } diff --git a/phone_app/test/ring_bypass_test.dart b/phone_app/test/ring_bypass_test.dart index 2d37db1..f93e9e0 100644 --- a/phone_app/test/ring_bypass_test.dart +++ b/phone_app/test/ring_bypass_test.dart @@ -9,10 +9,15 @@ import 'package:wake_alarm_sync/services/alarm_scheduler.dart'; import 'package:wake_alarm_sync/services/challenge.dart'; import 'package:wake_alarm_sync/services/dismiss_gate.dart'; import 'package:wake_alarm_sync/services/room_proof_settings.dart'; +import 'package:wake_alarm_sync/ui/cues.dart'; const String _tag1 = '04a2b3c4'; const String _tag2 = '04ffee11'; +/// The ring's status sentence, now spoken only (StatusCue.semanticLabel). +String statusOf(WidgetTester tester) => + tester.widget(find.byType(StatusCue)).semanticLabel; + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -69,8 +74,8 @@ void main() { testWidgets('it is not offered before the timer elapses', (tester) async { await pumpRing(tester); await tester.pump(DismissGate.bypassAfter - const Duration(seconds: 1)); - expect(find.textContaining('Tags unavailable'), findsNothing); - expect(find.text('Tap tag 1'), findsOneWidget); + expect(find.byKey(const Key('ring-bypass')), findsNothing); + expect(find.byKey(const ValueKey('ring-row-tag1')), findsOneWidget); }); testWidgets('after the timer it skips the tags but still needs the walk', ( @@ -80,17 +85,20 @@ void main() { await tester.pump(DismissGate.bypassAfter); await tester.pump(); - expect(find.textContaining('Tags unavailable'), findsOneWidget); + expect(find.byKey(const Key('ring-bypass')), findsOneWidget); // A hardware bypass must not borrow the away wording. - expect(find.textContaining('Away from home'), findsNothing); - expect(find.text('Tap tag 1'), findsNothing); - expect(find.text('Walk 25 steps'), findsOneWidget); - expect(find.text('Submit'), findsNothing); + expect(find.byKey(const Key('ring-bypass')), findsOneWidget); + expect(find.byKey(const ValueKey('ring-row-tag1')), findsNothing); + expect( + find.byKey(const ValueKey('ring-row-steps')), + findsOneWidget, + ); + expect(find.byKey(const Key('ring-submit')), findsNothing); steps = 500; elapsed = const Duration(minutes: 6); await pollSteps(tester); - expect(find.text('Question 1 of 6'), findsOneWidget); + expect(find.text('1/6'), findsOneWidget); }); testWidgets('under bypass the walk still needs elapsed time', (tester) async { @@ -104,7 +112,7 @@ void main() { steps = 500; elapsed = const Duration(seconds: 5); await pollSteps(tester); - expect(find.textContaining('Keep moving'), findsOneWidget); + expect(statusOf(tester), contains('Keep moving')); expect(find.text('Submit'), findsNothing); }); @@ -116,15 +124,15 @@ void main() { MaterialApp(home: RingScreen(clock: () => elapsed)), ); await tester.pump(); - expect(find.textContaining('Register two NFC tags'), findsOneWidget); - expect(find.textContaining('Tags unavailable'), findsOneWidget); + expect(find.byKey(const Key('ring-bypass')), findsOneWidget); + expect(find.byIcon(Icons.block), findsOneWidget); }); testWidgets('no NFC hardware bypasses immediately', (tester) async { hasNfc = false; mockChannel(); await pumpRing(tester); - expect(find.textContaining('NFC is off'), findsOneWidget); + expect(statusOf(tester), contains('NFC is off')); expect(find.text('Tap tag 1'), findsNothing); }); } diff --git a/phone_app/test/ring_route_test.dart b/phone_app/test/ring_route_test.dart index 172a087..28a7f02 100644 --- a/phone_app/test/ring_route_test.dart +++ b/phone_app/test/ring_route_test.dart @@ -10,10 +10,15 @@ import 'package:wake_alarm_sync/services/alarm_scheduler.dart'; import 'package:wake_alarm_sync/services/challenge.dart'; import 'package:wake_alarm_sync/services/dismiss_gate.dart'; import 'package:wake_alarm_sync/services/room_proof_settings.dart'; +import 'package:wake_alarm_sync/ui/cues.dart'; const String _tag1 = '04a2b3c4'; const String _tag2 = '04ffee11'; +/// The ring's status sentence, now spoken only (StatusCue.semanticLabel). +String statusOf(WidgetTester tester) => + tester.widget(find.byType(StatusCue)).semanticLabel; + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -97,11 +102,14 @@ void main() { testWidgets('it opens on the checklist, not the challenge', (tester) async { await pumpRing(tester); - expect(find.text('Wake up'), findsOneWidget); - expect(find.text('Tap tag 1'), findsOneWidget); - expect(find.text('Walk 25 steps'), findsOneWidget); - expect(find.text('Tap tag 2'), findsOneWidget); - expect(find.text('Submit'), findsNothing); + expect(find.text('Wake up'), findsNothing); + expect(find.byKey(const ValueKey('ring-row-tag1')), findsOneWidget); + expect( + find.byKey(const ValueKey('ring-row-steps')), + findsOneWidget, + ); + expect(find.byKey(const ValueKey('ring-row-tag2')), findsOneWidget); + expect(find.byKey(const Key('ring-submit')), findsNothing); }); testWidgets('tag 2 tapped first does nothing; tag 1 then works', ( @@ -109,10 +117,10 @@ void main() { ) async { await pumpRing(tester); await tapTag(tester, _tag2); - expect(find.textContaining('Not tag 1'), findsOneWidget); + expect(statusOf(tester), contains('Not tag 1')); await tapTag(tester, _tag1); - expect(find.textContaining('Tag 1 matched'), findsOneWidget); + expect(statusOf(tester), contains('Tag 1 matched')); }); testWidgets('an unknown tag does not un-latch a passed stage', ( @@ -128,7 +136,7 @@ void main() { steps = 500; elapsed = const Duration(minutes: 3); await pollSteps(tester); - expect(find.text('Tap tag 2'), findsOneWidget); + expect(find.byKey(const ValueKey('ring-row-tag2')), findsOneWidget); }); testWidgets('steps do not count before tag 1', (tester) async { @@ -137,9 +145,9 @@ void main() { elapsed = const Duration(minutes: 3); await pollSteps(tester); // Still on tag 1 — walking first must not skip the first location. - expect(find.text('Submit'), findsNothing); + expect(find.byKey(const Key('ring-submit')), findsNothing); await tapTag(tester, _tag2); - expect(find.textContaining('Not tag 1'), findsOneWidget); + expect(statusOf(tester), contains('Not tag 1')); }); testWidgets('after tag 1 the banked steps pass the walk at once', ( @@ -154,9 +162,9 @@ void main() { steps = 500; elapsed = const Duration(seconds: 5); await pollSteps(tester); - expect(find.textContaining('Keep moving'), findsNothing); - expect(find.textContaining('Walked far enough'), findsOneWidget); - expect(find.text('Tap tag 2'), findsOneWidget); + expect(statusOf(tester), isNot(contains('Keep moving'))); + expect(statusOf(tester), contains('Walked far enough')); + expect(find.byKey(const ValueKey('ring-row-tag2')), findsOneWidget); }); testWidgets('the full sequence dismisses: tag 1, walk, tag 2, 3 answers', ( @@ -170,11 +178,11 @@ void main() { await pollSteps(tester); await tapTag(tester, _tag2); - expect(find.text('Question 1 of 3'), findsOneWidget); + expect(find.text('1/3'), findsOneWidget); for (var i = 1; i <= DismissGate.challengesNormal; i++) { - expect(find.text('Question $i of 3'), findsOneWidget); + expect(find.text('$i/3'), findsOneWidget); await tester.enterText(find.byType(TextField), expected.next().answer); - await tester.tap(find.text('Submit')); + await tester.tap(find.byKey(const Key('ring-submit'))); await tester.pump(); } @@ -198,16 +206,16 @@ void main() { await tapTag(tester, _tag2); await tester.enterText(find.byType(TextField), expected.next().answer); - await tester.tap(find.text('Submit')); + await tester.tap(find.byKey(const Key('ring-submit'))); await tester.pump(); - expect(find.text('Question 2 of 3'), findsOneWidget); + expect(find.text('2/3'), findsOneWidget); expected.next(); // the screen regenerated after the correct answer await tester.enterText(find.byType(TextField), 'definitely wrong'); - await tester.tap(find.text('Submit')); + await tester.tap(find.byKey(const Key('ring-submit'))); await tester.pump(); expect( - find.text('Question 2 of 3'), + find.text('2/3'), findsOneWidget, reason: 'cumulative: a wrong answer must not reset the count', ); diff --git a/phone_app/test/ring_session_handoff_test.dart b/phone_app/test/ring_session_handoff_test.dart index fb0bf35..9120b87 100644 --- a/phone_app/test/ring_session_handoff_test.dart +++ b/phone_app/test/ring_session_handoff_test.dart @@ -88,7 +88,7 @@ void main() { await tapTag(tester, _tag2); for (var i = 0; i < 3; i++) { await tester.enterText(find.byType(TextField), answers.next().answer); - await tester.tap(find.text('Submit')); + await tester.tap(find.byKey(const Key('ring-submit'))); await tester.pump(); } await tester.pumpAndSettle(); @@ -144,12 +144,14 @@ void main() { await tester.tap(find.widgetWithText(ChoiceChip, '4')); await tester.pump(); await tester.enterText(find.byType(TextField), '72'); - await tester.tap(find.text('Log weight')); + await tester.tap(find.byKey(const Key('session-log-weight'))); await tester.pump(); await tapTag(tester, 'bath'); await tester.pump(); - expect(find.text('Weigh in — 72.0 kg · energy 4/5'), findsOneWidget); - expect(find.textContaining('tag counts in'), findsOneWidget); + expect(find.text('72.0'), findsOneWidget); + expect(find.text('4/5'), findsOneWidget); + // The shower floor: the second bathroom tap does not count yet. + expect(find.byIcon(Icons.hourglass_top), findsOneWidget); }); testWidgets('away from home the dismiss still just closes', (tester) async { @@ -162,7 +164,7 @@ void main() { await tester.pump(); for (var i = 0; i < 3; i++) { await tester.enterText(find.byType(TextField), answers.next().answer); - await tester.tap(find.text('Submit')); + await tester.tap(find.byKey(const Key('ring-submit'))); await tester.pump(); } await tester.pumpAndSettle(); diff --git a/phone_app/test/ring_stakes_test.dart b/phone_app/test/ring_stakes_test.dart index 22b48c3..d552b56 100644 --- a/phone_app/test/ring_stakes_test.dart +++ b/phone_app/test/ring_stakes_test.dart @@ -4,6 +4,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:wake_alarm_sync/screens/ring_screen.dart'; import 'package:wake_alarm_sync/screens/ring_stakes_line.dart'; import 'package:wake_alarm_sync/services/ring_start_recorder.dart'; +import 'package:wake_alarm_sync/ui/cues.dart'; import 'fake_enforcement_channel.dart'; @@ -40,12 +41,12 @@ void main() { ) async { native.state = nativeState(); await pumpLine(tester); - expect( - find.text( - 'Stick day: no dismiss by 07:15 → apps locked through Fri 2 Oct', - ), - findsOneWidget, - ); + final cue = tester.widget(find.byType(SpokenLabel)); + const expected = + 'Stick day: no dismiss by 07:15 → apps locked through Fri 2 Oct'; + expect(cue.semanticLabel, expected); + expect(find.text('07:15'), findsOneWidget); + expect(find.text('02.10'), findsOneWidget); }); testWidgets('nothing once today was dismissed', (tester) async { @@ -74,7 +75,7 @@ void main() { await tester.pumpWidget(MaterialApp(home: RingScreen(ringStart: recorder))); await tester.pump(); expect(recorder.calls, 1); - expect(find.textContaining('Stick day:'), findsOneWidget); + expect(find.byType(StatusCue), findsOneWidget); // The ring screen's own timers (bypass, step poll) outlive the test. await tester.pumpWidget(const SizedBox.shrink()); }); diff --git a/phone_app/test/room_proof_screen_test.dart b/phone_app/test/room_proof_screen_test.dart index 7fcd18f..28f15a7 100644 --- a/phone_app/test/room_proof_screen_test.dart +++ b/phone_app/test/room_proof_screen_test.dart @@ -5,6 +5,8 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:wake_alarm_sync/screens/room_proof_screen.dart'; import 'package:wake_alarm_sync/services/alarm_scheduler.dart'; import 'package:wake_alarm_sync/services/room_proof_settings.dart'; +import 'package:wake_alarm_sync/ui/cues.dart'; +import 'package:wake_alarm_sync/ui/dashed_slot.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -31,37 +33,37 @@ void main() { ) async { await tester.pumpWidget(const MaterialApp(home: RoomProofScreen())); await tester.pumpAndSettle(); - expect(find.textContaining('none registered'), findsNWidgets(2)); - expect(find.text('Register tag 1'), findsOneWidget); - expect(find.text('Register tag 2'), findsOneWidget); + expect(find.byType(DashedSlot), findsNWidgets(2)); + expect(find.byKey(const ValueKey('register-0')), findsOneWidget); + expect(find.byKey(const ValueKey('register-1')), findsOneWidget); }); testWidgets('starting a scan puts the reader into waiting state', ( tester, ) async { await tester.pumpWidget(const MaterialApp(home: RoomProofScreen())); - await tester.pumpAndSettle(); - await tester.tap(find.text('Register tag 1')); - await tester.pumpAndSettle(); + await tester.pump(); + await tester.tap(find.byKey(const ValueKey('register-0'))); + await tester.pump(); expect(calls, contains('startTagRegistration')); - expect(find.textContaining('Tap tag 1'), findsOneWidget); + final cue = tester.widget(find.byType(StatusCue)); + expect(cue.semanticLabel, contains('Tap tag 1')); }); testWidgets('only the scanning row is disabled while waiting', ( tester, ) async { await tester.pumpWidget(const MaterialApp(home: RoomProofScreen())); - await tester.pumpAndSettle(); - await tester.tap(find.text('Register tag 2')); - await tester.pumpAndSettle(); + await tester.pump(); + await tester.tap(find.byKey(const ValueKey('register-1'))); + await tester.pump(); + + final busyCue = tester.widget(find.byType(StatusCue)); + expect(busyCue.resolvedKind, CueKind.busy); - expect(find.text('Waiting for tag…'), findsOneWidget); final other = tester.widget( - find.ancestor( - of: find.text('Register tag 1'), - matching: find.byType(FilledButton), - ), + find.byKey(const ValueKey('register-0')), ); expect(other.onPressed, isNull, reason: 'one scan at a time'); }); @@ -71,11 +73,12 @@ void main() { ) async { nfcStarts = false; await tester.pumpWidget(const MaterialApp(home: RoomProofScreen())); - await tester.pumpAndSettle(); - await tester.tap(find.text('Register tag 1')); - await tester.pumpAndSettle(); + await tester.pump(); + await tester.tap(find.byKey(const ValueKey('register-0'))); + await tester.pump(); - expect(find.textContaining('unavailable'), findsOneWidget); + final cue = tester.widget(find.byType(StatusCue)); + expect(cue.semanticLabel, contains('unavailable')); }); testWidgets('each registered tag is shown against its own slot', ( @@ -84,9 +87,25 @@ void main() { await RoomProofSettings.saveNfcTagAt(0, '04a2b3c4'); await RoomProofSettings.saveNfcTagAt(1, '04ffee11'); await tester.pumpWidget(const MaterialApp(home: RoomProofScreen())); - await tester.pumpAndSettle(); - expect(find.text('Tag 1: 04a2b3c4'), findsOneWidget); - expect(find.text('Tag 2: 04ffee11'), findsOneWidget); - expect(find.text('Register tag 1 again'), findsOneWidget); + await tester.pump(); + expect(find.text('04a2b3c4'), findsOneWidget); + expect(find.text('04ffee11'), findsOneWidget); + expect(find.byKey(const ValueKey('register-0')), findsOneWidget); + // Verify key exists; "again" is part of the semantic label in the icon. + final registerBtn = tester.widget( + find.byKey(const ValueKey('register-0')), + ); + expect(registerBtn.onPressed, isNotNull); + }); + + testWidgets('registered status shows the confirmation message', ( + tester, + ) async { + await RoomProofSettings.saveNfcTagAt(0, 'abc'); + await tester.pumpWidget(const MaterialApp(home: RoomProofScreen())); + await tester.pump(); + + final cue = tester.widget(find.byType(StatusCue)); + expect(cue.semanticLabel, 'Tag 1 registered.'); }); } diff --git a/phone_app/test/services/session_legs_fixture_test.dart b/phone_app/test/services/session_legs_fixture_test.dart index 3187990..b9cce35 100644 --- a/phone_app/test/services/session_legs_fixture_test.dart +++ b/phone_app/test/services/session_legs_fixture_test.dart @@ -13,7 +13,8 @@ void main() { final raw = File( '../wake_alarm/tests/fixtures/session_legs.json', ).readAsStringSync(); - final legs = (jsonDecode(raw) as Map)['legs']! as List; + final legs = ((jsonDecode(raw) as Map)['legs']! as List) + .cast>(); expect( <(String, String)>[for (final l in SessionLeg.values) (l.name, l.label)], <(String, String)>[ diff --git a/phone_app/test/services/session_notice_test.dart b/phone_app/test/services/session_notice_test.dart index ded2441..ab5ea2a 100644 --- a/phone_app/test/services/session_notice_test.dart +++ b/phone_app/test/services/session_notice_test.dart @@ -31,13 +31,13 @@ void main() { scheduler = AlarmScheduler(channel: channel); }); - test('the label is the live leg', () { - expect(noticeLabel(fresh()), 'Tap the bathroom tag'); + test('the label is the live leg glyph + position', () { + expect(noticeLabel(fresh()), '🛁 1/4'); }); - test('a leg whose re-ring is spent says it will pass by itself', () { + test('a leg whose re-ring is spent appends a running-clock marker', () { final s = fresh()..deadlineRung.add(SessionLeg.bathroomIn); - expect(noticeLabel(s), 'Tap the bathroom tag — passes by itself soon'); + expect(noticeLabel(s), '🛁 1/4 ⏳'); }); test('a finished session drops the deadline and posts no notice', () async { @@ -64,7 +64,7 @@ void main() { expect(calls[0].arguments, {'atMillis': deadline}); expect(calls[1].arguments, { 'date': '2026-09-17', - 'label': 'Tap the bathroom tag', + 'label': '🛁 1/4', 'deadlineMillis': deadline, }); }); diff --git a/phone_app/test/services/today_stakes_test.dart b/phone_app/test/services/today_stakes_test.dart index 6c13b6c..c77255f 100644 --- a/phone_app/test/services/today_stakes_test.dart +++ b/phone_app/test/services/today_stakes_test.dart @@ -171,6 +171,30 @@ void main() { }); }); + group('ringStakes', () { + test('an open stick-day ring returns deadline and lockdown', () { + final stakes = ringStakes(_state()); + expect(stakes, isNotNull); + expect(stakes!.by, DateTime(2026, 10, 1, 7, 15)); + expect(stakes.lockThrough, DateTime(2026, 10, 2)); + }); + + test('a missing native lock date falls back to the next day', () { + final stakes = ringStakes(_state(lockIfMissToday: null)); + expect(stakes, isNotNull); + expect(stakes!.lockThrough, DateTime(2026, 10, 2)); + }); + + test('nothing on a carrot, free, settled or alarm-less day', () { + expect(ringStakes(_state(today: '2026-10-02')), isNull); + expect(ringStakes(_state(freeToday: true)), isNull); + expect(ringStakes(_state(dismissedToday: true)), isNull); + expect(ringStakes(_state(escapedToday: true)), isNull); + expect(ringStakes(_state(lastMiss: '2026-10-01')), isNull); + expect(ringStakes(_state(alarmHour: null)), isNull); + }); + }); + group('carrotCutoff', () { test('is A + 90 min before the cap, the cap after it', () { expect( diff --git a/phone_app/test/session_screen_catch_up_test.dart b/phone_app/test/session_screen_catch_up_test.dart index 50b71b1..f5834fa 100644 --- a/phone_app/test/session_screen_catch_up_test.dart +++ b/phone_app/test/session_screen_catch_up_test.dart @@ -69,7 +69,7 @@ void main() { await tester.tap(find.widgetWithText(ChoiceChip, '3')); await tester.pump(); await tester.enterText(find.byType(TextField), kg); - await tester.tap(find.text('Log weight')); + await tester.tap(find.byKey(const Key('session-log-weight'))); await tester.pump(); } diff --git a/phone_app/test/session_screen_checkin_test.dart b/phone_app/test/session_screen_checkin_test.dart index aebbb52..5a241b5 100644 --- a/phone_app/test/session_screen_checkin_test.dart +++ b/phone_app/test/session_screen_checkin_test.dart @@ -8,6 +8,11 @@ import 'package:wake_alarm_sync/screens/session_screen.dart'; import 'package:wake_alarm_sync/services/alarm_scheduler.dart'; import 'package:wake_alarm_sync/services/session_store.dart'; import 'package:wake_alarm_sync/services/session_tag_settings.dart'; +import 'package:wake_alarm_sync/ui/cues.dart'; + +/// The session's status sentence, now spoken only. +String statusOf(WidgetTester tester) => + tester.widget(find.byType(StatusCue)).semanticLabel; /// The check-in half of the session screen (energy + weight) and the two /// publishes: split from `session_screen_test.dart` at the 250-line cap. @@ -75,7 +80,7 @@ void main() { Future submit(WidgetTester tester, String kg) async { await tester.enterText(find.byType(TextField), kg); - await tester.tap(find.text('Log weight')); + await tester.tap(find.byKey(const Key('session-log-weight'))); await tester.pump(); } @@ -92,7 +97,7 @@ void main() { ) async { await pumpSession(tester); await submit(tester, '72'); - expect(find.textContaining('Tap your energy'), findsOneWidget); + expect(statusOf(tester), contains('Tap your energy')); expect(find.byType(TextField), findsOneWidget); expect((await SessionStore().loadCurrent())?.pendingWeightKg, isNull); }); @@ -102,7 +107,7 @@ void main() { await tester.tap(find.widgetWithText(ChoiceChip, '5')); await tester.pump(); await submit(tester, '72'); - expect(find.textContaining('now tap the bathroom tag'), findsOneWidget); + expect(find.byKey(const ValueKey('session-row-weigh')), findsOneWidget); final session = await SessionStore().loadCurrent(); expect(session?.energy, 5); expect(session?.pendingWeightKg, 72); @@ -113,13 +118,13 @@ void main() { await tester.tap(find.widgetWithText(ChoiceChip, '3')); await tester.pump(); await submit(tester, 'x'); - expect(find.textContaining('Enter your weight'), findsOneWidget); + expect(statusOf(tester), contains('Enter your weight')); await submit(tester, '1'); - expect(find.textContaining('not a body weight'), findsOneWidget); + expect(statusOf(tester), contains('not a body weight')); expect(find.byType(TextField), findsOneWidget); await tap('mystery'); await tester.pump(); - expect(find.text('Unknown tag'), findsOneWidget); + expect(statusOf(tester), 'Unknown tag'); }); testWidgets('the finish publish waits for a start publish in flight', ( diff --git a/phone_app/test/session_screen_relaunch_test.dart b/phone_app/test/session_screen_relaunch_test.dart index 522f8bc..c42fa8f 100644 --- a/phone_app/test/session_screen_relaunch_test.dart +++ b/phone_app/test/session_screen_relaunch_test.dart @@ -52,13 +52,11 @@ void main() { ..onTag(SessionTagSlot.bathroom, at); await SessionStore().save(session); await pumpCold(tester); - expect(find.text('Weigh in — 72.0 kg · energy 3/5'), findsOneWidget); - expect(find.textContaining('tag counts in'), findsOneWidget); + expect(find.text('72.0'), findsOneWidget); + expect(find.text('3/5'), findsOneWidget); + expect(find.textContaining('04:00'), findsOneWidget); final notice = calls.lastWhere((c) => c.method == 'updateSessionNotice'); - expect( - (notice.arguments as Map)['label'], - 'Shower, then tap the bathroom tag again', - ); + expect((notice.arguments as Map)['label'], '🚿 2/4'); expect(calls.map((c) => c.method), isNot(contains('closeRingScreen'))); }); diff --git a/phone_app/test/session_screen_test.dart b/phone_app/test/session_screen_test.dart index c69ff43..37275b9 100644 --- a/phone_app/test/session_screen_test.dart +++ b/phone_app/test/session_screen_test.dart @@ -8,6 +8,11 @@ import 'package:wake_alarm_sync/services/morning_session.dart'; import 'package:wake_alarm_sync/services/session_rules.dart'; import 'package:wake_alarm_sync/services/session_store.dart'; import 'package:wake_alarm_sync/services/session_tag_settings.dart'; +import 'package:wake_alarm_sync/ui/cues.dart'; + +/// The session's status sentence, now spoken only. +String statusOf(WidgetTester tester) => + tester.widget(find.byType(StatusCue)).semanticLabel; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -83,7 +88,7 @@ void main() { await tester.tap(find.widgetWithText(ChoiceChip, '3')); await tester.pump(); await tester.enterText(find.byType(TextField), kg); - await tester.tap(find.text('Log weight')); + await tester.tap(find.byKey(const Key('session-log-weight'))); await tester.pump(); } @@ -91,9 +96,7 @@ void main() { tester, ) async { await pumpSession(tester); - expect(find.text('Stay up'), findsOneWidget); - expect(find.text('Weigh in'), findsOneWidget); - expect(find.text('Alarm returns in 10:00'), findsOneWidget); + expect(find.byKey(const ValueKey('session-row-weigh')), findsOneWidget); final deadline = now.add(MorningSession.legWindow).millisecondsSinceEpoch; final arm = calls.firstWhere((c) => c.method == 'armReRing'); expect(arm.arguments, {'atMillis': deadline}); @@ -101,7 +104,7 @@ void main() { final notice = calls.firstWhere((c) => c.method == 'updateSessionNotice'); expect(notice.arguments, { 'date': '2026-09-17', - 'label': 'Tap the bathroom tag', + 'label': '🛁 1/4', 'deadlineMillis': deadline, }); expect((await SessionStore().loadCurrent())?.date, '2026-09-17'); @@ -116,7 +119,7 @@ void main() { .where((c) => c.method == 'updateSessionNotice') .map((c) => (c.arguments as Map)['label']) .toList(); - expect(labels.last, 'Shower, then tap the bathroom tag again'); + expect(labels.last, '🚿 2/4'); expect(methods(), isNot(contains('beginMotionWatch'))); }); @@ -125,22 +128,22 @@ void main() { ) async { await pumpSession(tester); await logWeight(tester, '72,4'); - expect(find.textContaining('now tap the bathroom tag'), findsOneWidget); + expect(find.byKey(const ValueKey('session-row-weigh')), findsOneWidget); expect(find.byType(TextField), findsNothing); await tap('desk'); await tester.pump(); - expect(find.text('Not the bathroom tag'), findsOneWidget); + expect(statusOf(tester), 'Not the bathroom tag'); await tap('bath'); await tester.pump(); - expect(find.text('Weigh in — 72.4 kg · energy 3/5'), findsOneWidget); - expect(find.textContaining('tag counts in 08:00'), findsOneWidget); + expect(find.text('72.4'), findsOneWidget); + expect(find.text('3/5'), findsOneWidget); await tap('bath'); await tester.pump(); await tester.pump(); - expect(find.textContaining('Too early'), findsOneWidget); + expect(statusOf(tester), startsWith('Too early')); now = now.add(const Duration(minutes: 9)); await tick(tester); - expect(find.textContaining('Alarm returns in'), findsOneWidget); + expect(find.text('09:00'), findsOneWidget); for (final id in ['bath', 'ward', 'desk']) { await tap(id); } @@ -210,14 +213,16 @@ void main() { )..recordReRing(now.subtract(const Duration(minutes: 1))); await SessionStore().save(session); await pumpSession(tester); - expect(find.text('Re-rings: 1/3'), findsOneWidget); + expect(find.text('1/3'), findsOneWidget); expect( calls.firstWhere((c) => c.method == 'armReRing').arguments, {'atMillis': null}, ); await tick(tester); - expect(find.textContaining('Skipped'), findsOneWidget); - expect(find.textContaining('(skipped by the clock)'), findsOneWidget); + expect( + find.byKey(const ValueKey('session-row-bathroomIn')), + findsOneWidget, + ); }); testWidgets('a big jump must be typed twice', (tester) async { @@ -231,9 +236,9 @@ void main() { ); await pumpSession(tester); await logWeight(tester, '75'); - expect(find.textContaining('type it again'), findsOneWidget); + expect(statusOf(tester), contains('type it again')); await logWeight(tester, '75'); - expect(find.textContaining('now tap the bathroom tag'), findsOneWidget); + expect(statusOf(tester), contains('now tap the bathroom tag within 2 min')); expect(find.byType(TextField), findsNothing); }); } diff --git a/phone_app/test/session_tags_screen_test.dart b/phone_app/test/session_tags_screen_test.dart index d0e1333..2d926e6 100644 --- a/phone_app/test/session_tags_screen_test.dart +++ b/phone_app/test/session_tags_screen_test.dart @@ -5,6 +5,8 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:wake_alarm_sync/screens/session_tags_screen.dart'; import 'package:wake_alarm_sync/services/alarm_scheduler.dart'; import 'package:wake_alarm_sync/services/session_tag_settings.dart'; +import 'package:wake_alarm_sync/ui/cues.dart'; +import 'package:wake_alarm_sync/ui/dashed_slot.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -45,20 +47,29 @@ void main() { ) async { await tester.pumpWidget(const MaterialApp(home: SessionTagsScreen())); await tester.pump(); - expect(find.textContaining('Bathroom (scale + shower): none'), findsOne); - expect(find.textContaining('Wardrobe: none'), findsOneWidget); - expect(find.textContaining('Desk: none'), findsOneWidget); + expect(find.byType(DashedSlot), findsNWidgets(3)); }); testWidgets('a scan registers the tag in its slot', (tester) async { await tester.pumpWidget(const MaterialApp(home: SessionTagsScreen())); await tester.pump(); - await tester.tap(find.text('Register wardrobe tag')); + await tester.tap(find.byKey(const ValueKey('register-wardrobe'))); await tester.pump(); - expect(find.text('Waiting for tag…'), findsOneWidget); + + // Busy indicator in the button + final registerBtn = tester.widget( + find.byKey(const ValueKey('register-wardrobe')), + ); + expect(registerBtn.child, isA()); + + expect(find.byType(StatusCue), findsOneWidget); + final busyCue = tester.widget(find.byType(StatusCue)); + expect(busyCue.resolvedKind, CueKind.busy); + await tapTag(tester, 'w1'); - expect(find.textContaining('Wardrobe: w1'), findsOneWidget); - expect(find.text('The wardrobe tag is registered.'), findsOneWidget); + expect(find.text('w1'), findsOneWidget); + final okCue = tester.widget(find.byType(StatusCue)); + expect(okCue.semanticLabel, 'The wardrobe tag is registered.'); expect(calls, contains('stopTagRegistration')); expect( (await SessionTagSettings.load()).tagAt(SessionTagSlot.wardrobe), @@ -70,10 +81,11 @@ void main() { await SessionTagSettings.saveTagAt(SessionTagSlot.bathroom, 'b1'); await tester.pumpWidget(const MaterialApp(home: SessionTagsScreen())); await tester.pump(); - await tester.tap(find.text('Register desk tag')); + await tester.tap(find.byKey(const ValueKey('register-desk'))); await tester.pump(); await tapTag(tester, 'b1'); - expect(find.textContaining('already the bathroom tag'), findsOneWidget); + final errCue = tester.widget(find.byType(StatusCue)); + expect(errCue.semanticLabel, contains('already the bathroom tag')); expect( (await SessionTagSettings.load()).tagAt(SessionTagSlot.desk), isNull, @@ -84,18 +96,20 @@ void main() { await SessionTagSettings.saveTagAt(SessionTagSlot.desk, 'd1'); await tester.pumpWidget(const MaterialApp(home: SessionTagsScreen())); await tester.pump(); - await tester.tap(find.text('Register desk tag again')); + await tester.tap(find.byKey(const ValueKey('register-desk'))); await tester.pump(); await tapTag(tester, 'd1'); - expect(find.text('The desk tag is registered.'), findsOneWidget); + final okCue = tester.widget(find.byType(StatusCue)); + expect(okCue.semanticLabel, 'The desk tag is registered.'); }); testWidgets('says so when NFC is off', (tester) async { nfcStarts = false; await tester.pumpWidget(const MaterialApp(home: SessionTagsScreen())); await tester.pump(); - await tester.tap(find.text('Register bathroom tag')); + await tester.tap(find.byKey(const ValueKey('register-bathroom'))); await tester.pump(); - expect(find.textContaining('NFC is unavailable'), findsOneWidget); + final errCue = tester.widget(find.byType(StatusCue)); + expect(errCue.semanticLabel, contains('NFC is unavailable')); }); } diff --git a/phone_app/test/settings_screen_test.dart b/phone_app/test/settings_screen_test.dart index ebdd02b..0546ce9 100644 --- a/phone_app/test/settings_screen_test.dart +++ b/phone_app/test/settings_screen_test.dart @@ -2,21 +2,22 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:wake_alarm_sync/screens/github_mirror_screen.dart'; import 'package:wake_alarm_sync/screens/settings_screen.dart'; +import 'package:wake_alarm_sync/ui/theme.dart'; void main() { testWidgets('shows the two sync links and navigates to each', ( tester, ) async { await tester.pumpWidget( - const MaterialApp(home: SettingsScreen()), + MaterialApp(theme: buildAppTheme(), home: const SettingsScreen()), ); - expect(find.text('Sync settings'), findsOneWidget); - expect(find.text('Advanced sync (GitHub)'), findsOneWidget); - expect(find.text('Room proof'), findsOneWidget); - expect(find.text('Home location'), findsOneWidget); + expect(find.byKey(const Key('settings-sync')), findsOneWidget); + expect(find.byKey(const Key('settings-github')), findsOneWidget); + expect(find.byKey(const Key('settings-room-proof')), findsOneWidget); + expect(find.byKey(const Key('settings-home')), findsOneWidget); - await tester.tap(find.text('Advanced sync (GitHub)')); + await tester.tap(find.byKey(const Key('settings-github'))); await tester.pumpAndSettle(); expect(find.byType(GitHubMirrorScreen), findsOneWidget); @@ -29,6 +30,7 @@ void main() { var imported = 0; await tester.pumpWidget( MaterialApp( + theme: buildAppTheme(), home: SettingsScreen( onExport: () async => exported++, onImport: () async { @@ -39,12 +41,12 @@ void main() { ), ); - await tester.tap(find.bySemanticsLabel('Export data')); + await tester.tap(find.byKey(const Key('settings-export'))); await tester.pump(); expect(exported, 1); expect(find.byIcon(Icons.check_circle), findsOneWidget); - await tester.tap(find.bySemanticsLabel('Import data')); + await tester.tap(find.byKey(const Key('settings-import'))); await tester.pumpAndSettle(const Duration(seconds: 5)); expect(imported, 1); expect(find.byIcon(Icons.error_outline), findsOneWidget); @@ -54,6 +56,7 @@ void main() { var imported = 0; await tester.pumpWidget( MaterialApp( + theme: buildAppTheme(), home: SettingsScreen( onImport: () async { imported++; @@ -63,7 +66,7 @@ void main() { ), ); - await tester.tap(find.bySemanticsLabel('Import data')); + await tester.tap(find.byKey(const Key('settings-import'))); await tester.pump(); expect(imported, 1); expect(find.byIcon(Icons.check_circle), findsOneWidget); @@ -71,10 +74,13 @@ void main() { testWidgets('a cancelled import shows no feedback', (tester) async { await tester.pumpWidget( - MaterialApp(home: SettingsScreen(onImport: () async => false)), + MaterialApp( + theme: buildAppTheme(), + home: SettingsScreen(onImport: () async => false), + ), ); - await tester.tap(find.bySemanticsLabel('Import data')); + await tester.tap(find.byKey(const Key('settings-import'))); await tester.pump(); expect(find.byType(SnackBar), findsNothing); }); @@ -84,6 +90,7 @@ void main() { ) async { await tester.pumpWidget( MaterialApp( + theme: buildAppTheme(), home: SettingsScreen( onExport: () async {}, onImport: () async => throw const FormatException('bad file'), @@ -91,9 +98,9 @@ void main() { ), ); - await tester.tap(find.bySemanticsLabel('Export data')); + await tester.tap(find.byKey(const Key('settings-export'))); await tester.pump(); - await tester.tap(find.bySemanticsLabel('Import data')); + await tester.tap(find.byKey(const Key('settings-import'))); await tester.pump(); await tester.pump(const Duration(milliseconds: 600)); expect(find.byIcon(Icons.error_outline), findsOneWidget); diff --git a/phone_app/test/sick_day_sheet_test.dart b/phone_app/test/sick_day_sheet_test.dart index bcaff96..ce5a0b2 100644 --- a/phone_app/test/sick_day_sheet_test.dart +++ b/phone_app/test/sick_day_sheet_test.dart @@ -10,6 +10,8 @@ import 'package:wake_alarm_sync/services/free_day_sync.dart'; import 'package:wake_alarm_sync/services/sick_day_keep.dart'; import 'package:wake_alarm_sync/services/sick_day_pool.dart'; import 'package:wake_alarm_sync/services/sick_day_sync.dart'; +import 'package:wake_alarm_sync/ui/cues.dart'; +import 'package:wake_alarm_sync/ui/dashed_slot.dart'; import 'package:wake_alarm_sync/ui/theme.dart'; import 'fake_day_pool_store.dart'; @@ -17,8 +19,14 @@ import 'fake_day_pool_store.dart'; /// The per-locker sheet on the sick-day calendar (sickdays 0.2.0 keep). final _today = DateTime(2026, 10, 16); final _day = DateTime(2026, 10, 20); + +Finder iconLabelled(String label) => + find.byWidgetPredicate((w) => w is Icon && w.semanticLabel == label); bool _never(DateTime _) => false; +String statusOf(WidgetTester tester) => + tester.widget(find.byType(StatusCue).first).semanticLabel; + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -102,23 +110,24 @@ void main() { await open(tester); await tap(tester, cell(_day)); - expect(find.text('Mark sick: Tue 2026-10-20'), findsOneWidget); + expect(find.text('20.10.2026'), findsOneWidget); + expect(find.byKey(const ValueKey('sick-mark')), findsOneWidget); for (final locker in SickLocker.all) { expect(isOn(tester, locker), isFalse, reason: locker); } - for (final label in [ - 'Workout lock', - 'LeetCode', - 'Book', - 'Diet', - 'Wake alarm', + for (final icon in [ + Icons.fitness_center, + Icons.code, + Icons.menu_book, + Icons.restaurant, + Icons.alarm, ]) { - expect(find.text(label), findsOneWidget); + expect(find.byIcon(icon), findsOneWidget); } - expect(find.text('Clear sick day'), findsNothing); + expect(find.byKey(const ValueKey('sick-clear')), findsNothing); expect(sick.isSick(_day), isFalse, reason: 'nothing until confirmed'); - await tap(tester, find.text('Mark sick')); + await tap(tester, find.byKey(const ValueKey('sick-mark'))); expect(sick.isSick(_day), isTrue); expect(sick.keptLockers(_day), isEmpty); expect(caches.last, ['2026-10-20'], reason: 'the alarm stands down'); @@ -132,15 +141,14 @@ void main() { await tap(tester, cell(_day)); await tap(tester, toggle(SickLocker.alarm)); await tap(tester, toggle(SickLocker.leetcode)); - await tap(tester, find.text('Mark sick')); + await tap(tester, find.byKey(const ValueKey('sick-mark'))); expect(sick.keptLockers(_day), {SickLocker.alarm, SickLocker.leetcode}); expect(caches.last, isEmpty); - expect(find.text('Alarm cache: none'), findsOneWidget); + expect(iconLabelled('Alarm cache: none'), findsOneWidget); final box = tester.widget(cell(_day)); expect(box.isMarked, isTrue, reason: 'still a sick day'); expect(box.badge, isTrue, reason: 'kept lockers are badged'); - expect(find.text('Kept: LeetCode, Wake alarm'), findsOneWidget); }); testWidgets('a sick day without kept lockers has no badge', (tester) async { @@ -154,12 +162,14 @@ void main() { await open(tester); await tap(tester, cell(_day)); - expect(find.text('Sick day: Tue 2026-10-20'), findsOneWidget); + // In the list and in the sheet heading; save means it is already sick. + expect(find.text('20.10.2026'), findsWidgets); + expect(find.byKey(const Key('sick-save')), findsOneWidget); expect(isOn(tester, SickLocker.diet), isTrue); expect(isOn(tester, SickLocker.alarm), isFalse); - expect(find.text('Save'), findsOneWidget); - expect(find.text('Clear sick day'), findsOneWidget); - expect(find.text('Mark sick'), findsNothing); + expect(find.byKey(const ValueKey('sick-save')), findsOneWidget); + expect(find.byKey(const ValueKey('sick-clear')), findsOneWidget); + expect(find.byKey(const ValueKey('sick-mark')), findsNothing); }); testWidgets('Save edits keep only, never state', (tester) async { @@ -168,7 +178,7 @@ void main() { await open(tester); await tap(tester, cell(_day)); await tap(tester, toggle(SickLocker.alarm)); - await tap(tester, find.text('Save')); + await tap(tester, find.byKey(const ValueKey('sick-save'))); final fields = sick.snapshot()['2026-10-20']!.fields; expect(fields['state'], state, reason: 'value and HLC untouched'); @@ -183,7 +193,7 @@ void main() { final before = sick.snapshot()['2026-10-20']; await open(tester); await tap(tester, cell(_day)); - await tap(tester, find.text('Save')); + await tap(tester, find.byKey(const ValueKey('sick-save'))); expect(sick.snapshot()['2026-10-20'], before); expect(syncs, 1); }); @@ -192,11 +202,11 @@ void main() { await sick.add(_day, isFree: _never, now: _today, keep: ['alarm']); await open(tester); await tap(tester, cell(_day)); - await tap(tester, find.text('Clear sick day')); + await tap(tester, find.byKey(const ValueKey('sick-clear'))); expect(sick.isSick(_day), isFalse); expect(sick.snapshot()['2026-10-20']!.fields['keep']!.$1, isEmpty); - expect(find.text('None ahead.'), findsOneWidget); + expect(find.byType(DashedSlot), findsOneWidget); expect(syncs, 2); }); diff --git a/phone_app/test/sick_days_screen_test.dart b/phone_app/test/sick_days_screen_test.dart index 7065765..59eed72 100644 --- a/phone_app/test/sick_days_screen_test.dart +++ b/phone_app/test/sick_days_screen_test.dart @@ -10,12 +10,20 @@ import 'package:wake_alarm_sync/services/free_day_pool.dart'; import 'package:wake_alarm_sync/services/free_day_sync.dart'; import 'package:wake_alarm_sync/services/sick_day_pool.dart'; import 'package:wake_alarm_sync/services/sick_day_sync.dart'; +import 'package:wake_alarm_sync/ui/cues.dart'; +import 'package:wake_alarm_sync/ui/dashed_slot.dart'; import 'package:wake_alarm_sync/ui/theme.dart'; import 'fake_day_pool_store.dart'; final _today = DateTime(2026, 10, 16); +Finder iconLabelled(String label) => + find.byWidgetPredicate((w) => w is Icon && w.semanticLabel == label); + +String statusOf(WidgetTester tester) => + tester.widget(find.byType(StatusCue).first).semanticLabel; + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -95,10 +103,10 @@ void main() { Future viaSheet( WidgetTester tester, DateTime day, - String button, + String buttonKey, ) async { await tapDay(tester, day); - await tester.tap(find.text(button)); + await tester.tap(find.byKey(Key(buttonKey))); await tester.pumpAndSettle(); } @@ -110,10 +118,10 @@ void main() { testWidgets('every day starts normal; syncs on open', (tester) async { await open(tester); - expect(find.text('Sick days'), findsOneWidget); - expect(find.byIcon(Icons.sick_outlined), findsOneWidget); - expect(find.text('October 2026'), findsOneWidget); - expect(find.text('None ahead.'), findsOneWidget); + expect(find.byType(SickDaysScreen), findsOneWidget); + expect(find.byIcon(Icons.sick_outlined), findsWidgets); + expect(find.text('10/2026'), findsOneWidget); + expect(find.byType(DashedSlot), findsOneWidget); expect(syncs, 1); expect(caches.last, isEmpty); }); @@ -121,13 +129,13 @@ void main() { testWidgets('today can be marked sick and cleared again', (tester) async { await open(tester); - await viaSheet(tester, _today, 'Mark sick'); + await viaSheet(tester, _today, 'sick-mark'); expect(sick.isSick(_today), isTrue); - expect(find.text('Fri 2026-10-16'), findsOneWidget); + expect(find.text('16.10.2026'), findsOneWidget); expect(caches.last, ['2026-10-16'], reason: 'native cache rewritten'); expect(syncs, 2); - await viaSheet(tester, _today, 'Clear sick day'); + await viaSheet(tester, _today, 'sick-clear'); expect(sick.isSick(_today), isFalse); expect(caches.last, isEmpty); expect(syncs, 3); @@ -135,21 +143,21 @@ void main() { testWidgets('the native cache is read back after a mark', (tester) async { await open(tester); - expect(find.text('Alarm cache: none'), findsOneWidget); - await viaSheet(tester, DateTime(2026, 10, 18), 'Mark sick'); - await viaSheet(tester, DateTime(2026, 10, 17), 'Mark sick'); - expect(find.text('Alarm cache: 2026-10-17, 2026-10-18'), findsOneWidget); + expect(iconLabelled('Alarm cache: none'), findsOneWidget); + await viaSheet(tester, DateTime(2026, 10, 18), 'sick-mark'); + await viaSheet(tester, DateTime(2026, 10, 17), 'sick-mark'); + expect(iconLabelled('Alarm cache: 2026-10-17, 2026-10-18'), findsOneWidget); }); testWidgets('an unreadable native cache never reads as none', (tester) async { cacheUnreadable = true; await open(tester); - expect(find.text('Alarm cache: unreadable'), findsOneWidget); + expect(iconLabelled('Alarm cache: unreadable'), findsOneWidget); }); testWidgets('a future day is marked in the sick colour', (tester) async { await open(tester); - await viaSheet(tester, DateTime(2026, 10, 20), 'Mark sick'); + await viaSheet(tester, DateTime(2026, 10, 20), 'sick-mark'); expect(sick.isSick(DateTime(2026, 10, 20)), isTrue); final box = tester.widget(cell(DateTime(2026, 10, 20))); expect(box.markColor, DayMarkColors.dark.sick); @@ -175,8 +183,8 @@ void main() { await tester.drag(find.byType(ListView), const Offset(0, 3000)); await tester.pumpAndSettle(); expect( - find.text('2026-10-20 is a free day — release it in Free days first.'), - findsOneWidget, + statusOf(tester), + contains('2026-10-20 is a free day — release it in Free days first.'), ); expect(syncs, 1); }); @@ -190,7 +198,7 @@ void main() { expect(sick.isSick(DateTime(2026, 10, 20)), isFalse); await tester.drag(find.byType(ListView), const Offset(0, 3000)); await tester.pumpAndSettle(); - expect(find.textContaining('Free-day pool unavailable'), findsOneWidget); + expect(statusOf(tester), contains('Free-day pool unavailable')); }); testWidgets('month arrows reach January and the end of next year', ( @@ -198,16 +206,16 @@ void main() { ) async { await open(tester); for (var i = 0; i < 9; i++) { - await tester.tap(find.byTooltip('Previous month')); + await tester.tap(find.byIcon(Icons.chevron_left)); await tester.pumpAndSettle(); } - expect(find.text('January 2026'), findsOneWidget); + expect(find.text('01/2026'), findsOneWidget); for (var i = 0; i < 23; i++) { - await tester.tap(find.byTooltip('Next month')); + await tester.tap(find.byIcon(Icons.chevron_right)); await tester.pumpAndSettle(); } - expect(find.text('December 2027'), findsOneWidget); - await tester.tap(find.byTooltip('Sync now')); + expect(find.text('12/2027'), findsOneWidget); + await tester.tap(find.byIcon(Icons.sync)); await tester.pumpAndSettle(); expect(syncs, 2); }); @@ -223,6 +231,6 @@ void main() { ), ); await tester.pumpAndSettle(); - expect(find.textContaining('Error: pool unavailable'), findsOneWidget); + expect(statusOf(tester), contains('Error: pool unavailable')); }); } diff --git a/phone_app/test/stakes_card_test.dart b/phone_app/test/stakes_card_test.dart index ee9baa6..13132cd 100644 --- a/phone_app/test/stakes_card_test.dart +++ b/phone_app/test/stakes_card_test.dart @@ -5,10 +5,24 @@ import 'package:wake_alarm_sync/screens/home_body.dart'; import 'package:wake_alarm_sync/screens/stakes_card.dart'; import 'package:wake_alarm_sync/services/escape_log.dart'; import 'package:wake_alarm_sync/services/stakes.dart'; +import 'package:wake_alarm_sync/ui/cues.dart'; +import 'package:wake_alarm_sync/ui/dashed_slot.dart'; import 'package:wake_alarm_sync/ui/theme.dart'; import 'fake_enforcement_channel.dart'; +/// Every row's spoken sentence: the card paints glyphs, the words are here. +List spoken(WidgetTester tester) => tester + .widgetList(find.byType(SpokenLabel)) + .map((w) => w.semanticLabel) + .toList(); + +/// The escape outcome, now a StatusCue. +String cueWords(WidgetTester tester) => + tester.widget(find.byType(StatusCue)).semanticLabel; + +final Finder escapeButton = find.byKey(const Key('stakes-escape')); + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -54,12 +68,18 @@ void main() { lastMiss: '2026-09-30', ); await pumpCard(tester); - expect(find.text('Today · Thu 1 Oct — stick day'), findsOneWidget); - expect(find.textContaining('no dismiss by 07:15'), findsOneWidget); - expect(find.textContaining('PC rings at 07:02'), findsOneWidget); - expect(find.text('Apps locked through Fri 2 Oct.'), findsOneWidget); - expect(find.text('Streak: 1 missed stick day in a row'), findsOneWidget); - expect(find.text('That was a bug'), findsOneWidget); + final words = spoken(tester); + expect(words, contains('Today · Thu 1 Oct — stick day')); + expect(words.where((w) => w.contains('no dismiss by 07:15')), hasLength(1)); + expect(words.where((w) => w.contains('PC rings at 07:02')), hasLength(1)); + expect(words, contains('Apps locked through Fri 2 Oct.')); + expect(words, contains('Streak: 1 missed stick day in a row')); + // Painted: the gavel day, the deadline, the lock date and the streak. + expect(find.byIcon(Icons.gavel_outlined), findsOneWidget); + expect(find.text('07:15'), findsOneWidget); + expect(find.text('02.10'), findsWidgets); + expect(find.text('1'), findsOneWidget); + expect(escapeButton, findsOneWidget); }); testWidgets('absent when the native side answers nothing', (tester) async { @@ -83,26 +103,25 @@ void main() { ); await tester.pump(); // Friday's ramp with only the seed minutes earned: 07:57. - expect(find.textContaining('Finish the morning by 09:27'), findsOneWidget); - expect(find.textContaining('PC rings at 07:59'), findsOneWidget); + final words = spoken(tester); + expect(words.where((w) => w.contains('Finish the morning by 09:27')), [ + isNotEmpty, + ]); + expect(words.where((w) => w.contains('PC rings at 07:59')), hasLength(1)); + expect(find.text('09:27'), findsOneWidget); + expect(find.text('07:59'), findsOneWidget); }); testWidgets('the escape button is gone after the last day', (tester) async { native.state = nativeState(today: '2026-10-16'); await pumpCard(tester, now: DateTime(2026, 10, 16, 6)); - expect(find.text('That was a bug'), findsNothing); + expect(escapeButton, findsNothing); }); testWidgets('the escape button is disabled while syncing', (tester) async { native.state = nativeState(); await pumpCard(tester, busy: true); - final button = tester.widget( - find.ancestor( - of: find.text('That was a bug'), - matching: find.byWidgetPredicate((w) => w is TextButton), - ), - ); - expect(button.onPressed, isNull); + expect(tester.widget(escapeButton).onPressed, isNull); }); testWidgets('escaping needs a long reason, then lifts and publishes', ( @@ -123,39 +142,43 @@ void main() { ), ); await tester.pump(); - await tester.tap(find.text('That was a bug')); + await tester.tap(escapeButton); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); FilledButton confirm() => - tester.widget(find.byType(FilledButton)); + tester.widget(find.byKey(const Key('escape-confirm'))); expect(confirm().onPressed, isNull); await tester.enterText(find.byType(TextField), 'too short'); await tester.pump(); expect(confirm().onPressed, isNull); - expect(find.text('9 / $escapeReasonMinChars characters'), findsOneWidget); + expect(find.text('9/$escapeReasonMinChars'), findsOneWidget); const reason = 'The tag on the bathroom wall fell off overnight.'; await tester.enterText(find.byType(TextField), reason); await tester.pump(); expect(confirm().onPressed, isNotNull); - await tester.tap(find.text('Excuse this morning')); + await tester.tap(find.byKey(const Key('escape-confirm'))); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); expect(native.methods, contains('escapeToday')); expect((await EscapeLog.load()).single['reason'], reason); expect(escaped, 1); - expect(find.textContaining('Excused'), findsOneWidget); + expect(cueWords(tester), startsWith('Excused')); + expect( + tester.widget(find.byType(StatusCue)).resolvedKind, + CueKind.ok, + ); }); testWidgets('cancelling the dialog changes nothing', (tester) async { native.state = nativeState(); final escaped = await pumpCard(tester); - await tester.tap(find.text('That was a bug')); + await tester.tap(escapeButton); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); - await tester.tap(find.text('Cancel')); + await tester.tap(find.byKey(const Key('escape-cancel'))); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); expect(native.methods, isNot(contains('escapeToday'))); @@ -167,15 +190,19 @@ void main() { ..state = nativeState() ..escapeResult = false; await pumpCard(tester); - await tester.tap(find.text('That was a bug')); + await tester.tap(escapeButton); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); await tester.enterText(find.byType(TextField), 'y' * escapeReasonMinChars); await tester.pump(); - await tester.tap(find.text('Excuse this morning')); + await tester.tap(find.byKey(const Key('escape-confirm'))); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); - expect(find.textContaining('refused'), findsOneWidget); + expect(cueWords(tester), contains('refused')); + expect( + tester.widget(find.byType(StatusCue)).resolvedKind, + CueKind.error, + ); expect(await EscapeLog.load(), isEmpty); }); @@ -211,8 +238,9 @@ void main() { home: Scaffold(body: StakesCard(emptyText: 'No stakes.')), ), ); - expect(find.text('No stakes.'), findsNothing); + expect(find.byType(DashedSlot), findsNothing); await tester.pump(); - expect(find.text('No stakes.'), findsOneWidget); + expect(find.byType(DashedSlot), findsOneWidget); + expect(spoken(tester).first, 'No stakes.'); }); } diff --git a/phone_app/test/text_free_days_test.dart b/phone_app/test/text_free_days_test.dart new file mode 100644 index 0000000..34946bf --- /dev/null +++ b/phone_app/test/text_free_days_test.dart @@ -0,0 +1,143 @@ +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:wake_alarm_sync/screens/free_days_screen.dart'; +import 'package:wake_alarm_sync/screens/sick_day_sheet.dart'; +import 'package:wake_alarm_sync/screens/sick_days_screen.dart'; +import 'package:wake_alarm_sync/services/alarm_scheduler.dart'; +import 'package:wake_alarm_sync/services/free_day_pool.dart'; +import 'package:wake_alarm_sync/services/free_day_sync.dart'; +import 'package:wake_alarm_sync/services/sick_day_pool.dart'; +import 'package:wake_alarm_sync/services/sick_day_sync.dart'; +import 'package:wake_alarm_sync/ui/theme.dart'; + +import 'text_free_support.dart'; + +class _MemPersistence implements LogPersistence { + String? text; + + @override + Future read() async => text; + + @override + Future write(String value) async => text = value; +} + +final _today = DateTime(2026, 9, 11); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('settings-type screens — free/sick days', () { + testWidgets('FreeDaysScreen has no words on screen', (tester) async { + phoneView(tester); + const channel = MethodChannel(AlarmScheduler.channelName); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async => null); + final scheduler = AlarmScheduler(channel: channel); + final pool = await FreeDayPool.open( + persistence: _MemPersistence(), + deviceId: 'phone', + ); + await tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + home: FreeDaysScreen( + openPool: () async => pool, + syncer: FreeDaySync( + openClient: () async => null, + scheduler: scheduler, + ), + openSickPool: () => SickDayPool.open( + persistence: _MemPersistence(), + deviceId: 'phone', + ), + sickSyncer: SickDaySync( + openClient: () async => null, + scheduler: scheduler, + ), + now: () => _today, + ), + ), + ); + await tester.pumpAndSettle(); + expectNoWords(tester, 'FreeDaysScreen'); + }); + + testWidgets('SickDaysScreen has no words on screen', (tester) async { + phoneView(tester); + final caches = >[]; + const cacheUnreadable = false; + const channel = MethodChannel(AlarmScheduler.channelName); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'setSickDays') { + caches.add( + ((call.arguments as Map)['dates'] as List).cast(), + ); + } + if (call.method == 'getSickDays' && !cacheUnreadable) { + return caches.isEmpty ? [] : caches.last; + } + return null; + }); + final scheduler = AlarmScheduler(channel: channel); + final sick = await SickDayPool.open( + persistence: _MemPersistence(), + deviceId: 'phone', + ); + final free = await FreeDayPool.open( + persistence: _MemPersistence(), + deviceId: 'phone', + ); + await tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + home: SickDaysScreen( + openPool: () async => sick, + syncer: SickDaySync( + openClient: () async => null, + scheduler: scheduler, + ), + openFreePool: () async => free, + freeSyncer: FreeDaySync( + openClient: () async => null, + scheduler: scheduler, + ), + now: () => _today, + scheduler: scheduler, + ), + ), + ); + await tester.pumpAndSettle(); + expectNoWords(tester, 'SickDaysScreen'); + }); + + testWidgets('SickDaySheet has no words on screen', (tester) async { + phoneView(tester); + await tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + home: Scaffold( + body: Center( + child: IconButton( + key: const Key('open'), + onPressed: () => showSickDaySheet( + tester.element(find.byKey(const Key('open'))), + day: DateTime(2026, 11, 20), + isSick: false, + kept: const {}, + ), + icon: const Icon(Icons.open_in_new), + ), + ), + ), + ), + ); + await tester.tap(find.byKey(const Key('open'))); + await tester.pumpAndSettle(); + expectNoWords(tester, 'SickDaySheet'); + }); + }); +} diff --git a/phone_app/test/text_free_ring_test.dart b/phone_app/test/text_free_ring_test.dart new file mode 100644 index 0000000..7b6a4f9 --- /dev/null +++ b/phone_app/test/text_free_ring_test.dart @@ -0,0 +1,216 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:wake_alarm_sync/screens/ring_screen.dart'; +import 'package:wake_alarm_sync/screens/session_screen.dart'; +import 'package:wake_alarm_sync/services/alarm_scheduler.dart'; +import 'package:wake_alarm_sync/services/challenge.dart'; +import 'package:wake_alarm_sync/services/dismiss_gate.dart'; +import 'package:wake_alarm_sync/services/room_proof_settings.dart'; +import 'package:wake_alarm_sync/services/session_notice.dart'; +import 'package:wake_alarm_sync/services/session_rules.dart'; +import 'package:wake_alarm_sync/services/session_tag_settings.dart'; + +import 'text_free_support.dart'; + +const String _tag1 = '04a2b3c4'; +const String _tag2 = '04ffee11'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late List calls; + var steps = 0; + var hasNfc = true; + var elapsed = Duration.zero; + + void mockChannel() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel(AlarmScheduler.channelName), + (call) async { + calls.add(call.method); + if (call.method == 'stepCount') return steps; + if (call.method == 'hasNfc') return hasNfc; + return null; + }, + ); + } + + Future pollSteps(WidgetTester tester) async { + await tester.pump(kStepPollInterval); + await tester.pump(); + } + + setUp(() { + calls = []; + steps = 0; + hasNfc = true; + elapsed = Duration.zero; + SharedPreferences.setMockInitialValues({}); + mockChannel(); + }); + + Future pumpRing(WidgetTester tester) async { + await RoomProofSettings.saveNfcTagAt(0, _tag1); + await RoomProofSettings.saveNfcTagAt(1, _tag2); + await tester.pumpWidget( + MaterialApp( + home: RingScreen( + generator: ChallengeGenerator(random: Random(1)), + clock: () => elapsed, + ), + routes: { + kSessionRoute: (_) => const Scaffold(body: Text('SESSION')), + }, + ), + ); + await tester.pump(); + return ChallengeGenerator(random: Random(1)); + } + + group('ring screen — no words', () { + testWidgets('at tag 1 stage', (tester) async { + phoneView(tester); + await pumpRing(tester); + expectNoWords(tester, 'RingScreen-tag1'); + }); + + testWidgets('after bypass fires (still walk)', (tester) async { + phoneView(tester); + await pumpRing(tester); + await tester.pump(DismissGate.bypassAfter); + await tester.pump(); + expectNoWords(tester, 'RingScreen-bypass-walk'); + }); + + testWidgets('in bypass with steps reached — challenge stage', ( + tester, + ) async { + phoneView(tester); + await pumpRing(tester); + await tester.pump(DismissGate.bypassAfter); + await tester.pump(); + steps = 500; + elapsed = const Duration(minutes: 6); + await pollSteps(tester); + expectNoWords(tester, 'RingScreen-challenge-bypass'); + }); + + testWidgets('challenge after tag path — no words', ( + tester, + ) async { + phoneView(tester); + await pumpRing(tester); + // Simulate tag 1 tap + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + AlarmScheduler.channelName, + const StandardMethodCodec().encodeMethodCall( + const MethodCall('onNfcTag', _tag1), + ), + (_) {}, + ); + steps = 500; + elapsed = const Duration(minutes: 3); + await pollSteps(tester); + // Tag 2 tap to reach challenges + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + AlarmScheduler.channelName, + const StandardMethodCodec().encodeMethodCall( + const MethodCall('onNfcTag', _tag2), + ), + (_) {}, + ); + await tester.pump(); + expectNoWords(tester, 'RingScreen-challenge-tags'); + }); + + testWidgets('all four ChallengeKind variants via different seeds', ( + tester, + ) async { + phoneView(tester); + for (final seed in [1, 2, 3, 4]) { + await RoomProofSettings.saveNfcTagAt(0, _tag1); + await RoomProofSettings.saveNfcTagAt(1, _tag2); + await tester.pumpWidget( + MaterialApp( + home: RingScreen( + generator: ChallengeGenerator(random: Random(seed)), + clock: () => elapsed, + ), + routes: { + kSessionRoute: (_) => const Scaffold(body: Text('SESSION')), + }, + ), + ); + await tester.pump(); + await tester.pump(DismissGate.bypassAfter); + await tester.pump(); + steps = 500; + elapsed = const Duration(minutes: 6); + await pollSteps(tester); + expectNoWords(tester, 'RingScreen-seed-$seed'); + } + }); + }); + + group('session screen — no words', () { + testWidgets('right after session starts', (tester) async { + phoneView(tester); + late List calls; + late DateTime now; + void mockChannelFn() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel(AlarmScheduler.channelName), + (call) async { + calls.add(call); + if (call.method == 'hasNfc') return true; + return null; + }, + ); + } + + calls = []; + now = DateTime(2026, 9, 17, 8, 4); + SharedPreferences.setMockInitialValues({}); + mockChannelFn(); + await SessionTagSettings.replaceAll(['bath', 'ward', 'desk'], ''); + + await tester.pumpWidget( + MaterialApp( + routes: { + '/': (_) => + SessionScreen(clock: () => now, publish: () async => 'pushed'), + '/ring': (_) => const Scaffold(body: Text('RING')), + }, + ), + ); + await tester.pump(); + await tester.pump(); + expectNoWords(tester, 'SessionScreen-start'); + }); + }); + + group('notification line', () { + test('noticeLabel for an active session has no L words', () { + final session = SessionRules.start( + now: DateTime(2026, 9, 1, 8), + tags: const SessionTagSettings(), + nfcAvailable: false, + ); + final label = noticeLabel(session); + final wordRegex = RegExp(r'\p{L}{2,}', unicode: true); + final words = wordRegex + .allMatches(label) + .map((m) => m.group(0)!) + .toList(); + expect(words, isEmpty, reason: 'noticeLabel had words: $words'); + }); + }); +} diff --git a/phone_app/test/text_free_support.dart b/phone_app/test/text_free_support.dart new file mode 100644 index 0000000..2b6bb81 --- /dev/null +++ b/phone_app/test/text_free_support.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Every word painted on screen: text of each RenderParagraph (covers Text, +/// Text.rich, button/chip labels and InputDecoration labels/hints), minus +/// content subtrees. +List wordsOnScreen(WidgetTester tester) { + final contentElements = {}; + final allElements = tester.allElements; + + // First pass: collect all content-subtree elements + for (final element in allElements) { + if (element.widget.key case ValueKey( + value: final v, + ) when v.startsWith('content:')) { + _collectSubtree(element, contentElements); + } + if (element.widget is EditableText) { + contentElements.add(element); + } + } + + final words = []; + final wordRegex = RegExp(r'\p{L}{2,}', unicode: true); + final unitRegex = RegExp(r'(\d)\s?(kg|km|min|m|s|h)\b'); + + // Second pass: collect words from RenderParagraphs not in content subtrees + for (final element in allElements) { + if (contentElements.contains(element)) continue; + final renderObject = element.renderObject; + if (renderObject is RenderParagraph) { + final text = renderObject.text.toPlainText(); + // Strip unit symbols that follow a digit + final stripped = text.replaceAllMapped(unitRegex, (m) => m.group(1)!); + for (final match in wordRegex.allMatches(stripped)) { + words.add(match.group(0)!); + } + } + } + + return words; +} + +void _collectSubtree(Element element, Set out) { + out.add(element); + element.visitChildren((child) => _collectSubtree(child, out)); +} + +/// Fails with the list of words and the screen name. +void expectNoWords(WidgetTester tester, String screen) { + final words = wordsOnScreen(tester); + expect(words, isEmpty, reason: 'words on $screen: $words'); + expect(find.byType(Tooltip), findsNothing, reason: 'tooltip on $screen'); +} + +/// Pixel 6a portrait: 1080x2400 physical at 2.625. +void phoneView(WidgetTester tester) { + tester.view + ..physicalSize = const Size(1080, 2400) + ..devicePixelRatio = 2.625 + ..padding = const FakeViewPadding(top: 132, bottom: 63); + addTearDown(tester.view.reset); +} diff --git a/phone_app/test/text_free_sync_test.dart b/phone_app/test/text_free_sync_test.dart new file mode 100644 index 0000000..cc18afc --- /dev/null +++ b/phone_app/test/text_free_sync_test.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sync_settings_ui/sync_settings_ui.dart'; +import 'package:wake_alarm_sync/ui/theme.dart'; + +import 'text_free_support.dart'; + +/// The Settings sync row opens the shared `sync_settings_ui` screen; it is +/// part of what kuhy sees, so the textgate guard covers it too. +Widget _syncScreen({required bool googleAvailable}) => MaterialApp( + theme: buildAppTheme(), + home: SyncSettingsScreen( + accountLoader: () async => null, + accountSaver: (_) async {}, + accountClearer: () async {}, + sessionProbe: () async => false, + firebaseFactory: () async => null, + googleFirebaseFactory: () async => null, + googleAvailable: googleAvailable, + ), +); + +void main() { + for (final google in [true, false]) { + testWidgets('SyncSettingsScreen (google: $google) has no words', ( + tester, + ) async { + phoneView(tester); + await tester.pumpWidget(_syncScreen(googleAvailable: google)); + await tester.pumpAndSettle(); + expectNoWords(tester, 'SyncSettingsScreen'); + }); + } + + testWidgets('SyncSettingsScreen expanded password form has no words', ( + tester, + ) async { + phoneView(tester); + await tester.pumpWidget(_syncScreen(googleAvailable: false)); + await tester.pumpAndSettle(); + await tester.tap(find.byType(ExpansionTile)); + await tester.pumpAndSettle(); + expect(find.byType(TextField), findsWidgets); + expectNoWords(tester, 'SyncSettingsScreen-expanded'); + }); +} diff --git a/phone_app/test/text_free_test.dart b/phone_app/test/text_free_test.dart new file mode 100644 index 0000000..f363db7 --- /dev/null +++ b/phone_app/test/text_free_test.dart @@ -0,0 +1,207 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:wake_alarm_sync/screens/escape_dialog.dart'; +import 'package:wake_alarm_sync/screens/github_mirror_screen.dart'; +import 'package:wake_alarm_sync/screens/home_body.dart'; +import 'package:wake_alarm_sync/screens/home_history_card.dart'; +import 'package:wake_alarm_sync/screens/home_location_screen.dart'; +import 'package:wake_alarm_sync/screens/room_proof_screen.dart'; +import 'package:wake_alarm_sync/screens/session_tags_screen.dart'; +import 'package:wake_alarm_sync/screens/settings_screen.dart'; +import 'package:wake_alarm_sync/screens/stakes_card.dart'; +import 'package:wake_alarm_sync/services/alarm_scheduler.dart'; +import 'package:wake_alarm_sync/services/room_proof_settings.dart'; +import 'package:wake_alarm_sync/services/session_rules.dart'; +import 'package:wake_alarm_sync/services/session_store.dart'; +import 'package:wake_alarm_sync/services/session_tag_settings.dart'; +import 'package:wake_alarm_sync/ui/theme.dart'; + +import 'fake_enforcement_channel.dart'; +import 'text_free_support.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('settings-type screens', () { + testWidgets('SettingsScreen has no words on screen', (tester) async { + phoneView(tester); + SharedPreferences.setMockInitialValues({}); + await tester.pumpWidget(const MaterialApp(home: SettingsScreen())); + await tester.pumpAndSettle(); + expectNoWords(tester, 'SettingsScreen'); + }); + + testWidgets('HomeBody has no words on screen', (tester) async { + phoneView(tester); + FakeEnforcementChannel().install(); + await tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + home: Scaffold( + appBar: AppBar(actions: const [Icon(Icons.settings)]), + body: HomeBody( + time: '07:00', + baseline: '07:30', + isOverridden: true, + busy: false, + status: 'Synced to PC ✓', + configured: false, + onPickEarlier: () {}, + ), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expectNoWords(tester, 'HomeBody'); + }); + + testWidgets('RoomProofScreen fresh install has no words', (tester) async { + phoneView(tester); + SharedPreferences.setMockInitialValues({}); + const channel = MethodChannel(AlarmScheduler.channelName); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'startTagRegistration') return true; + return null; + }); + await tester.pumpWidget(const MaterialApp(home: RoomProofScreen())); + await tester.pumpAndSettle(); + expectNoWords(tester, 'RoomProofScreen-fresh'); + }); + + testWidgets('RoomProofScreen with tag registered has no words', ( + tester, + ) async { + phoneView(tester); + SharedPreferences.setMockInitialValues({}); + await RoomProofSettings.saveNfcTagAt(0, '04a2b3c4'); + const channel = MethodChannel(AlarmScheduler.channelName); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'startTagRegistration') return true; + return null; + }); + await tester.pumpWidget(const MaterialApp(home: RoomProofScreen())); + await tester.pumpAndSettle(); + expectNoWords(tester, 'RoomProofScreen-tagged'); + }); + + testWidgets('SessionTagsScreen has no words on screen', (tester) async { + phoneView(tester); + SharedPreferences.setMockInitialValues({}); + const channel = MethodChannel(AlarmScheduler.channelName); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'startTagRegistration') return true; + return null; + }); + await tester.pumpWidget(const MaterialApp(home: SessionTagsScreen())); + await tester.pump(); + await tester.pump(); + expectNoWords(tester, 'SessionTagsScreen'); + }); + + testWidgets('HomeLocationScreen has no words on screen', (tester) async { + phoneView(tester); + SharedPreferences.setMockInitialValues({}); + const channel = MethodChannel(AlarmScheduler.channelName); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async => null); + await tester.pumpWidget(const MaterialApp(home: HomeLocationScreen())); + await tester.pump(); + await tester.pump(); + expectNoWords(tester, 'HomeLocationScreen'); + }); + + testWidgets('GitHubMirrorScreen has no words on screen', (tester) async { + phoneView(tester); + SharedPreferences.setMockInitialValues({}); + const channel = MethodChannel(AlarmScheduler.channelName); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async => null); + await tester.pumpWidget( + MaterialApp(theme: buildAppTheme(), home: const GitHubMirrorScreen()), + ); + await tester.pumpAndSettle(); + expectNoWords(tester, 'GitHubMirrorScreen'); + }); + + testWidgets('EscapeDialog has no words on screen', (tester) async { + phoneView(tester); + SharedPreferences.setMockInitialValues({}); + await tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + home: Scaffold( + body: Center( + child: IconButton( + key: const Key('open'), + onPressed: () => showDialog( + context: tester.element(find.byKey(const Key('open'))), + barrierDismissible: false, + builder: (_) => const EscapeDialog(), + ), + icon: const Icon(Icons.open_in_new), + ), + ), + ), + ), + ); + await tester.tap(find.byKey(const Key('open'))); + await tester.pumpAndSettle(); + expectNoWords(tester, 'EscapeDialog'); + }); + + testWidgets('HomeHistoryCard has no words on screen', (tester) async { + phoneView(tester); + SharedPreferences.setMockInitialValues({}); + final store = SessionStore(); + for (var day = 1; day <= 9; day++) { + await store.archive( + SessionRules.start( + now: DateTime(2026, 9, day, 8), + tags: const SessionTagSettings(), + nfcAvailable: false, + )..weightKg = 70.0 + day, + ); + } + // As the home tile shows it: a scrolling bottom sheet. + await tester.pumpWidget( + const MaterialApp( + home: Scaffold(body: SingleChildScrollView(child: HomeHistoryCard())), + ), + ); + await tester.pump(); + expectNoWords(tester, 'HomeHistoryCard'); + }); + + testWidgets('StakesCard has no words on screen', (tester) async { + phoneView(tester); + SharedPreferences.setMockInitialValues({}); + FakeEnforcementChannel() + ..install() + ..state = nativeState(streak: 1, lockedThrough: '2026-10-02'); + final thursday = DateTime(2026, 10, 1, 6, 50); + var escaped = 0; + await tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + home: Scaffold( + body: SingleChildScrollView( + child: StakesCard( + now: () => thursday, + nextAlarm: () async => (hour: 6, minute: 59), + onEscaped: () => escaped++, + ), + ), + ), + ), + ); + await tester.pump(); + expectNoWords(tester, 'StakesCard'); + }); + }); +} diff --git a/phone_app/test/ui/cues_haptics_test.dart b/phone_app/test/ui/cues_haptics_test.dart new file mode 100644 index 0000000..42cedba --- /dev/null +++ b/phone_app/test/ui/cues_haptics_test.dart @@ -0,0 +1,162 @@ +/// Tests for cues.dart: haptic feedback and semantics behavior. +library; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:wake_alarm_sync/ui/cues.dart'; + +void main() { + group('StatusCue haptics', () { + testWidgets('error triggers heavyImpact haptic', (tester) async { + final calls = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + calls.add(call); + return null; + }, + ); + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold(body: StatusCue(semanticLabel: 'Error: x')), + ), + ); + + expect(calls, isNotEmpty); + final heavyCalls = calls.where( + (c) => + c.method == 'HapticFeedback.vibrate' && + c.arguments == 'HapticFeedbackType.heavyImpact', + ); + expect(heavyCalls, isNotEmpty); + }); + + testWidgets('ok triggers lightImpact on kind change', (tester) async { + final calls = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + calls.add(call); + return null; + }, + ); + + var currentKind = CueKind.info; + var currentLabel = 'Syncing'; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (context, setState) { + return Column( + children: [ + StatusCue(semanticLabel: currentLabel, kind: currentKind), + TextButton( + onPressed: () { + setState(() { + currentKind = CueKind.ok; + currentLabel = 'Synced ✓'; + }); + }, + child: const Text('Change'), + ), + ], + ); + }, + ), + ), + ), + ); + calls.clear(); + + await tester.tap(find.byType(TextButton)); + await tester.pump(); + + final lightCalls = calls.where( + (c) => + c.method == 'HapticFeedback.vibrate' && + c.arguments == 'HapticFeedbackType.lightImpact', + ); + expect(lightCalls, isNotEmpty); + }); + + void expectHeavyCalls(int expected, List calls) { + final count = calls + .where( + (c) => + c.method == 'HapticFeedback.vibrate' && + c.arguments == 'HapticFeedbackType.heavyImpact', + ) + .length; + expect(count, equals(expected)); + } + + testWidgets('haptic fires once on MediaQuery change with same error kind', ( + tester, + ) async { + final calls = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + calls.add(call); + return null; + }, + ); + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold(body: StatusCue(semanticLabel: 'Error: x')), + ), + ); + calls.clear(); + + // Pump again inside a MediaQuery with different viewInsets + await tester.pumpWidget( + const MaterialApp( + home: MediaQuery( + data: MediaQueryData(viewInsets: EdgeInsets.only(bottom: 300)), + child: Scaffold(body: StatusCue(semanticLabel: 'Error: x')), + ), + ), + ); + + // Should NOT fire again - same error kind, same label + expectHeavyCalls(1, calls); + }); + + testWidgets( + 'haptic fires again when semanticLabel changes with same error kind', + (tester) async { + final calls = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + calls.add(call); + return null; + }, + ); + + // Initial pump with error cue + await tester.pumpWidget( + const MaterialApp( + home: Scaffold(body: StatusCue(semanticLabel: 'Error: first')), + ), + ); + calls.clear(); + + // Pump with different semanticLabel but same error kind + await tester.pumpWidget( + const MaterialApp( + home: Scaffold(body: StatusCue(semanticLabel: 'Error: second')), + ), + ); + + // Should fire again because semanticLabel changed + expectHeavyCalls(1, calls); + }, + ); + }); +} diff --git a/phone_app/test/ui/cues_test.dart b/phone_app/test/ui/cues_test.dart new file mode 100644 index 0000000..75d5c83 --- /dev/null +++ b/phone_app/test/ui/cues_test.dart @@ -0,0 +1,149 @@ +/// Tests for cues.dart: CueKind classifier and StatusCue rendering. +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:wake_alarm_sync/ui/cues.dart'; + +void main() { + group('cueKindOf', () { + test('empty returns null', () => expect(cueKindOf(''), isNull)); + + test('busy ellipsis', () { + expect(cueKindOf('Syncing…'), equals(CueKind.busy)); + expect(cueKindOf('Syncing...'), equals(CueKind.busy)); + }); + + test('error keywords', () { + const cases = [ + 'Error: x', + 'Could not connect', + 'Service unavailable', + 'Connection refused', + 'Not found', + 'Unknown error', + 'Too early to dismiss', + 'not earlier than 07:00', + 'Wrong tag', + 'Failed to sync', + 'Walk too short', + ]; + for (final c in cases) { + expect(cueKindOf(c), equals(CueKind.error), reason: c); + } + }); + + test('ok keywords', () { + const cases = [ + 'Synced ✓', + 'Connected to GitHub', + 'Tag registered', + 'Tag matched', + 'Captured photo', + 'Synced', + 'Alarm Armed', + 'Done', + 'Correct answer', + 'Dismissed', + 'Excused day', + ]; + for (final c in cases) { + expect(cueKindOf(c), equals(CueKind.ok), reason: c); + } + }); + + test('warning keywords', () { + expect( + cueKindOf('Connect a sync backend in Settings first.'), + equals(CueKind.warning), + ); + expect(cueKindOf('Open Settings to connect'), equals(CueKind.warning)); + }); + + test('default info', () { + expect(cueKindOf('hello'), equals(CueKind.info)); + expect(cueKindOf('Syncing now'), equals(CueKind.info)); + }); + }); + + group('StatusCue', () { + testWidgets('renders no Text', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold(body: StatusCue(semanticLabel: 'Error: x')), + ), + ); + expect(find.byType(Text), findsNothing); + }); + + testWidgets('renders error icon for error kind', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold(body: StatusCue(semanticLabel: 'Error: x')), + ), + ); + expect(find.byIcon(Icons.error_outline), findsOneWidget); + }); + + testWidgets('renders CircularProgressIndicator for busy', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold(body: StatusCue(semanticLabel: 'Syncing…')), + ), + ); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('renders SizedBox.shrink for empty label', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold(body: StatusCue(semanticLabel: '')), + ), + ); + expect(find.byType(Icon), findsNothing); + expect(find.byType(CircularProgressIndicator), findsNothing); + }); + + testWidgets('semanticLabel is reachable via semantics', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: StatusCue(semanticLabel: 'Error: connection failed'), + ), + ), + ); + + final handle = tester.ensureSemantics(); + try { + final semanticsFinder = find.byWidgetPredicate( + (widget) => + widget is Semantics && + widget.properties.label?.contains('connection failed') == true, + ); + final semantics = tester.getSemantics(semanticsFinder); + expect(semantics.label, contains('connection failed')); + } finally { + handle.dispose(); + } + }); + + testWidgets('SpokenLabel wraps child with semantics label', (tester) async { + const label = 'Close button'; + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: SpokenLabel(semanticLabel: label, child: Icon(Icons.close)), + ), + ), + ); + + final handle = tester.ensureSemantics(); + try { + final semantics = tester.getSemantics(find.byType(SpokenLabel)); + expect(semantics.label, equals(label)); + } finally { + handle.dispose(); + } + }); + }); +} diff --git a/phone_app/test/widget_test.dart b/phone_app/test/widget_test.dart index c9bec64..2ec8d16 100644 --- a/phone_app/test/widget_test.dart +++ b/phone_app/test/widget_test.dart @@ -4,6 +4,6 @@ import 'package:wake_alarm_sync/main.dart'; void main() { testWidgets('App renders without crashing', (tester) async { await tester.pumpWidget(const WakeAlarmSyncApp()); - expect(find.text('Wake Alarm Sync'), findsOneWidget); + expect(find.bySemanticsLabel('Alarm is armed for'), findsOneWidget); }); } diff --git a/requirements.txt b/requirements.txt index 88f8fc0..43fd2b1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,7 @@ pre-commit==4.7.0 pylint==4.1.3 pytest==9.2.0 pytest-cov==7.1.0 -pytest-randomly==5.0.0 +pytest-randomly==5.1.0 pytest-sugar==1.1.1 pytest-xdist==3.8.0 ruff==0.17.4 diff --git a/wake_alarm/_session_strip.py b/wake_alarm/_session_strip.py index 0f9e8bc..a3d0314 100644 --- a/wake_alarm/_session_strip.py +++ b/wake_alarm/_session_strip.py @@ -40,13 +40,13 @@ from gatelock.log_integrity import verify_entry_hmac from wake_alarm._constants import MORNING_SESSION_FILE from wake_alarm._logging_setup import configure_logging +from wake_alarm._strip_pips import pips as _pips if TYPE_CHECKING: from subprocess import Popen _logger = logging.getLogger(__name__) -TITLE = "Morning session not finished" REFRESH_EVERY_SECONDS = 30.0 REFRESH_COMMAND = [ "systemctl", @@ -164,10 +164,9 @@ def _verdict(entry: dict[str, object] | None, now: datetime) -> StripContent | s return f"the exemption ended at {exempt_until.isoformat()}" lines: list[str] = [] step = entry.get("next_step") - if isinstance(step, str) and step: - lines.append(f"Next: {step}") - lines.append(f"Locks return at {exempt_until.astimezone(now.tzinfo):%H:%M}") - return StripContent(TITLE, tuple(lines)) + title = _pips(step) + lines.append(f"◷ {exempt_until.astimezone(now.tzinfo):%H:%M}") + return StripContent(title, tuple(lines)) def strip_content( diff --git a/wake_alarm/_strip_pips.py b/wake_alarm/_strip_pips.py new file mode 100644 index 0000000..c0c9f48 --- /dev/null +++ b/wake_alarm/_strip_pips.py @@ -0,0 +1,31 @@ +"""The corner strip's text-free progress row: one pip per morning-session leg. + +Split out of ``_session_strip.py`` (250-line cap). The strip shows no words +since 2026-11-10; ``next_step`` in ``morning_session.json`` is still the leg's +label (a data contract), so the pip row finds its place by looking that label +up in ``LEGS``. +""" + +from __future__ import annotations + +from wake_alarm._session_legs import LEGS + +DONE = "●" +NEXT = "◉" +LATER = "○" + + +def pips(step: object) -> str: + """Progress pips for ``step`` among the ``LEGS`` labels. + + One plain geometric character per leg (Tk renders no colour emoji): + filled for legs already passed, a bullseye for the next step, hollow + for later ones. ``""`` when ``step`` is not a known label. + """ + labels = [label for _, label in LEGS] + if not isinstance(step, str) or step not in labels: + return "" + idx = labels.index(step) + return " ".join( + DONE if i < idx else (NEXT if i == idx else LATER) for i in range(len(labels)) + ) diff --git a/wake_alarm/tests/test_session_strip.py b/wake_alarm/tests/test_session_strip.py index ef1ba97..9427e14 100644 --- a/wake_alarm/tests/test_session_strip.py +++ b/wake_alarm/tests/test_session_strip.py @@ -12,7 +12,8 @@ from gatelock.corner_strip import StripContent import pytest from wake_alarm import _session_strip -from wake_alarm.tests.test_strip_support import NOW, STEP, signed +from wake_alarm._session_legs import LEGS +from wake_alarm.tests.test_strip_support import NOW, signed if TYPE_CHECKING: from pathlib import Path @@ -20,25 +21,26 @@ if TYPE_CHECKING: # conftest replaces _popen for every test; the real one is captured at import. REAL_POPEN = _session_strip._popen +_STEP_LABELS = [label for _, label in LEGS] + class TestStripContent: - def test_in_progress_says_next_step_and_when_locks_return(self) -> None: + def test_in_progress_says_pips_and_when_locks_return(self) -> None: + # default signed() uses _STEP_LABELS[1], so first two pips are ● ◉ ○ ○ assert _session_strip.strip_content(signed(), NOW) == StripContent( - "Morning session not finished", - (f"Next: {STEP}", "Locks return at 08:45"), + "● ◉ ○ ○", + ("◷ 08:45",), ) - def test_next_step_null_drops_the_next_line(self) -> None: + def test_next_step_none_gives_empty_title(self) -> None: content = _session_strip.strip_content(signed(next_step=None), NOW) - assert content == StripContent( - "Morning session not finished", ("Locks return at 08:45",) - ) + assert content == StripContent("", ("◷ 08:45",)) def test_exempt_until_is_shown_in_local_time(self) -> None: utc = (NOW + timedelta(minutes=45)).astimezone(UTC).isoformat() content = _session_strip.strip_content(signed(exempt_until=utc), NOW) assert content is not None - assert content.lines[-1] == "Locks return at 08:45" + assert content.lines[-1] == "◷ 08:45" @pytest.mark.parametrize("outcome", ["completed", "failed", "none", "weird"]) def test_other_outcomes_show_nothing(self, outcome: str) -> None: @@ -69,10 +71,25 @@ class TestStripContent: assert _session_strip.strip_content(signed(exempt_until=until), NOW) is None @pytest.mark.parametrize("step", [5, ""]) - def test_unusable_next_step_is_left_out(self, step: object) -> None: + def test_unusable_next_step_gives_empty_title(self, step: object) -> None: content = _session_strip.strip_content(signed(next_step=step), NOW) assert content is not None - assert content.lines == ("Locks return at 08:45",) + assert content.title == "" + assert content.lines == ("◷ 08:45",) + + +class TestPips: + def test_first_leg(self) -> None: + assert _session_strip._pips(_STEP_LABELS[0]) == "◉ ○ ○ ○" + + def test_last_leg(self) -> None: + assert _session_strip._pips(_STEP_LABELS[-1]) == "● ● ● ◉" + + def test_unknown_label(self) -> None: + assert _session_strip._pips("bogus") == "" + + def test_none_returns_empty(self) -> None: + assert _session_strip._pips(None) == "" class TestReadEntry: diff --git a/wake_alarm/tests/test_session_strip_run.py b/wake_alarm/tests/test_session_strip_run.py index bd4cea8..325b981 100644 --- a/wake_alarm/tests/test_session_strip_run.py +++ b/wake_alarm/tests/test_session_strip_run.py @@ -14,7 +14,6 @@ import pytest from wake_alarm import _session_strip from wake_alarm.tests.test_strip_support import ( NOW, - STEP, Display, Procs, signed, @@ -135,8 +134,8 @@ class TestMain: assert _session_strip.main() == 0 assert display.built == [ StripContent( - "Morning session not finished", - (f"Next: {STEP}", "Locks return at 08:45"), + "● ◉ ○ ○", + ("◷ 08:45",), ) ] assert display.ticks == 3