Make wake-alarm text-free (textgate-rework)
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,free-days-interop) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / free-days-interop (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run

Todo: 364208fc
This commit is contained in:
Krzysztof Rudnicki 2026-11-11 12:45:10 +01:00
parent 89e6eed105
commit 52c3ff5f1e
75 changed files with 3319 additions and 887 deletions

View File

@ -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.

View File

@ -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)

View File

@ -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)

View File

@ -1,3 +1,4 @@
// phone-slot: not-shared-storage (path is per-package: Android/data/<package>/files)
import 'dart:async';
import 'dart:io';

View File

@ -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),

View File

@ -28,7 +28,12 @@ class EnergyPicker extends StatelessWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Text('Energy', style: Theme.of(context).textTheme.labelLarge),
const Row(
children: <Widget>[
Icon(Icons.bolt, semanticLabel: 'Energy'),
SizedBox(width: 8),
],
),
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,

View File

@ -34,50 +34,67 @@ class _EscapeDialogState extends State<EscapeDialog> {
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: <Widget>[
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: <Widget>[
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: <Widget>[
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'),
),
],
);

View File

@ -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<AppStatusColors>()!;
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<String>(
'content:reason-${FreeDayPool.toIso(entry.day)}',
),
child: Text(entry.reason),
),
trailing: IconButton(
key: ValueKey<String>(
'release-${FreeDayPool.toIso(entry.day)}',
),
icon: const Icon(Icons.event_busy, semanticLabel: 'Release'),
onPressed: busy ? null : () => onRelease(entry.day),
child: const Text('Release'),
),
),
],

View File

@ -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<FreeDaysScreen> {
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),

View File

@ -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<GitHubMirrorScreen> {
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<String>('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<String>('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": "<same 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),
],
),
),

View File

@ -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<int>(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<AppStatusColors>()!.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<AppStatusColors>()!.warning,
),
Icon(
Icons.cloud_off,
size: 28,
color: theme.extension<AppStatusColors>()!.warning,
semanticLabel: 'Open Settings to connect Firebase',
),
],
],

View File

@ -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<HomeHistoryCard> {
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: <Widget>[
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<String>('history-${_sessions[i].date}'),
session: _sessions[i],
previousWeight: _previousWeight(i),
),
),
],
@ -82,6 +85,67 @@ class _HomeHistoryCardState extends State<HomeHistoryCard> {
}
}
/// 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<AppStatusColors>();
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: <Widget>[
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) ...<Widget>[
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) ...<Widget>[
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

View File

@ -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<HomeLocationScreen> {
_ => '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: <Widget>[
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: <Widget>[
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<String>('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<String>('content:home-bssids'),
child: _home.bssids.isEmpty
? const DashedSlot()
: Text(_home.bssids.join('\n')),
),
),
_valueRow(
Icons.place,
'Coordinate',
KeyedSubtree(
key: const ValueKey<String>('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: <Widget>[
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),
],
),
);

View File

@ -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<HomePresenceLine> {
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),
],
),
),
);
}

View File

@ -114,7 +114,7 @@ class _HomeScreenState extends State<HomeScreen> {
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<HomeScreen> {
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<void>(
context,
MaterialPageRoute<void>(builder: (_) => const FreeDaysScreen()),
@ -188,8 +187,8 @@ class _HomeScreenState extends State<HomeScreen> {
icon: Icon(
Icons.sick_outlined,
color: Theme.of(context).colorScheme.onSurfaceVariant,
semanticLabel: 'Sick days',
),
tooltip: 'Sick days',
onPressed: () => Navigator.push<void>(
context,
MaterialPageRoute<void>(builder: (_) => const SickDaysScreen()),
@ -200,6 +199,7 @@ class _HomeScreenState extends State<HomeScreen> {
Icons.settings,
// Dimmed relative to the adjacent title text (rule 28).
color: Theme.of(context).colorScheme.onSurfaceVariant,
semanticLabel: 'Settings',
),
onPressed: () async {
await Navigator.push<void>(

View File

@ -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<String>('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<String>('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: <Widget>[
Icon(icon, color: theme.colorScheme.onSurfaceVariant),
Icon(icon, size: 32, semanticLabel: semanticLabel),
const SizedBox(height: 8),
Text(label, textAlign: TextAlign.center, maxLines: 1),
],
),
),

View File

@ -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: <Widget>[
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: <Widget>[
StatusCue(semanticLabel: status),
if (match != null) ...[const SizedBox(width: 8), Text(match.group(0)!)],
],
);
}
}

View File

@ -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: <Widget>[
Text(
'Question ${passed + 1} of $total',
style: theme.textTheme.labelLarge,
SpokenLabel(
semanticLabel: 'Question ${passed + 1} of $total',
child: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
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<String>('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

View File

@ -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: <Widget>[
@ -32,42 +35,98 @@ class RingChecklist extends StatelessWidget {
if (!(gate.bypassActive && stage != DismissStage.steps))
Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: <Widget>[
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<String>('ring-row-${stage.name}'),
child: Row(
children: <Widget>[
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: <Widget>[
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: <Widget>[
Icon(Icons.nfc, size: size, color: color),
Text(
'1',
style: TextStyle(fontSize: size, color: color),
),
],
);
case DismissStage.steps:
return Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
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: <Widget>[
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();
}
}
}

View File

@ -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<RingStakesLine> {
String? _line;
({DateTime by, DateTime lockThrough})? _stakes;
@override
void initState() {
@ -32,32 +34,53 @@ class _RingStakesLineState extends State<RingStakesLine> {
}
Future<void> _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<AppStatusColors>();
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<AppStatusColors>()?.warning,
child: SpokenLabel(
semanticLabel: line,
child: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
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')}';
}

View File

@ -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<RoomProofScreen> {
Future<void> _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<void> _startScan(int slot) async {
@ -89,18 +101,33 @@ class _RoomProofScreenState extends State<RoomProofScreen> {
final scanning = _scanningSlot == slot;
return Padding(
padding: const EdgeInsets.only(bottom: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Text('Tag ${slot + 1}: ${id ?? "none registered"}'),
const SizedBox(height: 8),
child: Row(
children: <Widget>[
CircleAvatar(radius: 14, child: Text('${slot + 1}')),
const SizedBox(width: 12),
Expanded(
child: id != null
? KeyedSubtree(
key: ValueKey<String>('content:tag-$slot'),
child: Text(id),
)
: const DashedSlot(),
),
const SizedBox(width: 12),
FilledButton(
key: ValueKey<String>('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<RoomProofScreen> {
@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: <Widget>[
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),
],
),
),

View File

@ -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: <Widget>[
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) ...<Widget>[
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) ...<Widget>[
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: <Widget>[
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: <Widget>[
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: <Widget>[
const Icon(Icons.alarm),
Text(_mmss(deadline.difference(now))),
],
);
}
final statusColors = theme.extension<AppStatusColors>();
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
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';
}
}

View File

@ -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 = <Widget>[
_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: <Widget>[
for (final (label, done, live) in rows)
Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: <Widget>[
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<String>('session-row-weigh'),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: <Widget>[
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) ...<Widget>[
Text('${session.weightKg ?? session.pendingWeightKg}'),
if (session.energy != null) ...<Widget>[
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<AppStatusColors>();
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<String>('session-row-${leg.name}'),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: <Widget>[
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;
}
}
}

View File

@ -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<SessionTagsScreen> {
});
}
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: <Widget>[
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<String>('content:tag-${slot.name}'),
child: Text(id),
)
: const DashedSlot(),
),
const SizedBox(width: 12),
FilledButton(
key: ValueKey<String>('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: <Widget>[
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),
],
),
),

View File

@ -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<AppStatusColors>();
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<void>(
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<void>(
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<void>(
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<void>(
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<void>(
MaterialPageRoute(builder: (_) => const GitHubMirrorScreen()),
),

View File

@ -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 = <String, String>{
SickLocker.workout: 'Workout lock',
@ -13,6 +15,15 @@ const sickLockerLabels = <String, String>{
SickLocker.alarm: 'Wake alarm',
};
/// Icons for each locker, matching [sickLockerLabels] order.
const sickLockerIcons = <String, IconData>{
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<String> kept) => [
for (final MapEntry(:key, :value) in sickLockerLabels.entries)
@ -116,7 +127,11 @@ class SickDaySheet extends StatefulWidget {
class _SickDaySheetState extends State<SickDaySheet> {
late final Set<String> _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<SickDaySheet> {
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<String>('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<SickDaySheet> {
const SizedBox(height: 16),
if (widget.isSick) ...[
FilledButton(
key: const ValueKey<String>('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<String>('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<String>('sick-mark'),
onPressed: () => _close(SickSheetAction.mark),
child: const Text('Mark sick'),
child: const Icon(Icons.sick, semanticLabel: 'Mark sick'),
),
],
),

View File

@ -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<String>? 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<AppStatusColors>()!;
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<String> 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),
),
],
);
}
}

View File

@ -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<SickDaysScreen> {
bool _refused = false;
/// What the native ring-path cache holds, read back over the channel.
String _cache = '';
List<String>? _cache;
bool _cacheRead = false;
late DateTime _month = DateTime(_today.year, _today.month);
@ -112,12 +114,8 @@ class _SickDaysScreenState extends State<SickDaysScreen> {
}
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<SickDaysScreen> {
final marks = Theme.of(context).extension<DayMarkColors>()!;
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<SickDaysScreen> {
keptOf: pool.keptLockers,
status: _status,
cache: _cache,
cacheRead: _cacheRead,
refused: _refused,
calendar: DayCalendar(
month: _month,

View File

@ -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<StakesCard> {
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<StakesCard> {
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<StakesCard> {
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<AppStatusColors>()?.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<StakesCard> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
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',
),
),
),
],

View File

@ -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<AppStatusColors>()?.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 = <Widget>[
_row('title', view.title, <Widget>[
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, <Widget>[
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, <Widget>[
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 <Widget>[Icon(Icons.alarm_off)]),
);
}
if (ring != null) {
rows.add(
_row('pc-ring', words.last, <Widget>[
const Icon(Icons.computer),
const Icon(Icons.notifications_active_outlined),
Text(hhmm(ring)),
]),
);
}
rows
..addAll(_pending(warning))
..add(
_row('streak', view.streak, <Widget>[
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<Widget> _pending(Color warning) {
final spoken = view.pending.iterator;
String next() => spoken.moveNext() ? spoken.current : '';
final locked = view.lockedThrough;
final style = TextStyle(color: warning);
return <Widget>[
if (view.escapedToday)
_row('escaped', next(), <Widget>[
Icon(Icons.bug_report_outlined, color: warning),
Icon(Icons.check, color: warning),
]),
if (locked != null)
_row('locked', next(), <Widget>[
Icon(Icons.lock_outline, color: warning),
Text(_dm(locked), style: style),
]),
if (view.missedToday)
_row('missed', next(), <Widget>[
Icon(Icons.close, color: warning),
Icon(Icons.computer, color: warning),
]),
];
}
static Widget _row(String what, String words, List<Widget> marks) => Padding(
key: ValueKey<String>('stakes-$what'),
padding: const EdgeInsets.symmetric(vertical: 4),
child: SpokenLabel(
semanticLabel: words,
child: Row(
children: <Widget>[
for (final mark in marks) ...<Widget>[mark, const SizedBox(width: 8)],
],
),
),
);
}

View File

@ -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',
);

View File

@ -34,11 +34,20 @@ Future<void> 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, String>{
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;
}

View File

@ -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<String> stakes;
/// A lockdown, penalty or escape already in force (spoken, one per row).
final List<String> pending;
/// The streak line (spoken).
final String streak;
}

View File

@ -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<String> stakes;
/// A lockdown, penalty or escape already in force.
final List<String> 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 = <String>[];
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]:

218
phone_app/lib/ui/cues.dart Normal file
View File

@ -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<StatusCue> createState() => _StatusCueState();
}
class _StatusCueState extends State<StatusCue>
with SingleTickerProviderStateMixin {
late final AnimationController _shakeCtrl;
late final Animation<double> _shakeAnim;
CueKind? _prevKind;
bool _shown = false;
@override
void initState() {
super.initState();
_shakeCtrl = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_shakeAnim = Tween<double>(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<AppStatusColors>();
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);
}
}

View File

@ -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;
}

View File

@ -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),
);
}
}

View File

@ -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:

View File

@ -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

View File

@ -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<String?> read() async => text;
@override
Future<void> write(String value) async => text = value;
}
import 'fake_day_pool_store.dart';
final _today = DateTime(2026, 9, 11);
String statusOf(WidgetTester tester) =>
tester.widget<StatusCue>(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<String>('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<void> press(String tooltip, int times) async {
Future<void> 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);
});
}

View File

@ -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<StatusCue>(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'));
});
}

View File

@ -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);

View File

@ -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<SpokenLabel>(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<SpokenLabel>(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);
});
}

View File

@ -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<void> 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<StatusCue>(find.byType(StatusCue)).semanticLabel,
contains('Captured Wi-Fi'),
);
final home = await HomeLocationSettings.load();
expect(home.ssid, 'Orange_Home');
expect(home.bssids, <String>['cc:00:f1:ae:d6:c4']);
@ -58,10 +60,13 @@ void main() {
) async {
presence = <String, Object?>{'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<StatusCue>(find.byType(StatusCue)).semanticLabel,
contains('GPS fallback is unset'),
);
expect((await HomeLocationSettings.load()).hasCoordinate, isFalse);
});
@ -70,20 +75,26 @@ void main() {
) async {
presence = <String, Object?>{'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<StatusCue>(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<StatusCue>(find.byType(StatusCue)).semanticLabel,
contains('full sequence now applies'),
);
expect((await HomeLocationSettings.load()).isConfigured, isFalse);
});
}

View File

@ -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<StatusCue>(find.byType(StatusCue)).semanticLabel;
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@ -63,8 +68,8 @@ void main() {
presence = <String, Object?>{'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<String>('ring-row-tag1')), findsOneWidget);
expect(find.byKey(const ValueKey<String>('ring-row-tag2')), findsOneWidget);
});
testWidgets('away it skips the tags, keeps the walk, and asks 3', (
@ -74,20 +79,23 @@ void main() {
presence = <String, Object?>{'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<String>('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 = <String, Object?>{'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);
});
}

View File

@ -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<StatusCue>(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<String>('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<String>('ring-row-tag1')), findsNothing);
expect(
find.byKey(const ValueKey<String>('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);
});
}

View File

@ -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<StatusCue>(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<String>('ring-row-tag1')), findsOneWidget);
expect(
find.byKey(const ValueKey<String>('ring-row-steps')),
findsOneWidget,
);
expect(find.byKey(const ValueKey<String>('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<String>('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<String>('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',
);

View File

@ -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();

View File

@ -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<SpokenLabel>(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());
});

View File

@ -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<String>('register-0')), findsOneWidget);
expect(find.byKey(const ValueKey<String>('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<String>('register-0')));
await tester.pump();
expect(calls, contains('startTagRegistration'));
expect(find.textContaining('Tap tag 1'), findsOneWidget);
final cue = tester.widget<StatusCue>(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<String>('register-1')));
await tester.pump();
final busyCue = tester.widget<StatusCue>(find.byType(StatusCue));
expect(busyCue.resolvedKind, CueKind.busy);
expect(find.text('Waiting for tag…'), findsOneWidget);
final other = tester.widget<FilledButton>(
find.ancestor(
of: find.text('Register tag 1'),
matching: find.byType(FilledButton),
),
find.byKey(const ValueKey<String>('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<String>('register-0')));
await tester.pump();
expect(find.textContaining('unavailable'), findsOneWidget);
final cue = tester.widget<StatusCue>(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<String>('register-0')), findsOneWidget);
// Verify key exists; "again" is part of the semantic label in the icon.
final registerBtn = tester.widget<FilledButton>(
find.byKey(const ValueKey<String>('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<StatusCue>(find.byType(StatusCue));
expect(cue.semanticLabel, 'Tag 1 registered.');
});
}

View File

@ -13,7 +13,8 @@ void main() {
final raw = File(
'../wake_alarm/tests/fixtures/session_legs.json',
).readAsStringSync();
final legs = (jsonDecode(raw) as Map<String, Object?>)['legs']! as List;
final legs = ((jsonDecode(raw) as Map<String, Object?>)['legs']! as List)
.cast<Map<String, dynamic>>();
expect(
<(String, String)>[for (final l in SessionLeg.values) (l.name, l.label)],
<(String, String)>[

View File

@ -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, <String, int?>{'atMillis': deadline});
expect(calls[1].arguments, <String, Object?>{
'date': '2026-09-17',
'label': 'Tap the bathroom tag',
'label': '🛁 1/4',
'deadlineMillis': deadline,
});
});

View File

@ -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(

View File

@ -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();
}

View File

@ -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<StatusCue>(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<void> 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', (

View File

@ -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<Object?, Object?>)['label'],
'Shower, then tap the bathroom tag again',
);
expect((notice.arguments as Map<Object?, Object?>)['label'], '🚿 2/4');
expect(calls.map((c) => c.method), isNot(contains('closeRingScreen')));
});

View File

@ -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<StatusCue>(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, <String, int?>{'atMillis': deadline});
@ -101,7 +104,7 @@ void main() {
final notice = calls.firstWhere((c) => c.method == 'updateSessionNotice');
expect(notice.arguments, <String, Object?>{
'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<Object?, Object?>)['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 <String>['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,
<String, int?>{'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);
});
}

View File

@ -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<String>('register-wardrobe')));
await tester.pump();
expect(find.text('Waiting for tag…'), findsOneWidget);
// Busy indicator in the button
final registerBtn = tester.widget<FilledButton>(
find.byKey(const ValueKey<String>('register-wardrobe')),
);
expect(registerBtn.child, isA<SizedBox>());
expect(find.byType(StatusCue), findsOneWidget);
final busyCue = tester.widget<StatusCue>(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<StatusCue>(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<String>('register-desk')));
await tester.pump();
await tapTag(tester, 'b1');
expect(find.textContaining('already the bathroom tag'), findsOneWidget);
final errCue = tester.widget<StatusCue>(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<String>('register-desk')));
await tester.pump();
await tapTag(tester, 'd1');
expect(find.text('The desk tag is registered.'), findsOneWidget);
final okCue = tester.widget<StatusCue>(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<String>('register-bathroom')));
await tester.pump();
expect(find.textContaining('NFC is unavailable'), findsOneWidget);
final errCue = tester.widget<StatusCue>(find.byType(StatusCue));
expect(errCue.semanticLabel, contains('NFC is unavailable'));
});
}

View File

@ -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);

View File

@ -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<StatusCue>(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<String>('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<String>('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<String>('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<String>('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<DayCell>(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<String>('sick-save')), findsOneWidget);
expect(find.byKey(const ValueKey<String>('sick-clear')), findsOneWidget);
expect(find.byKey(const ValueKey<String>('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<String>('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<String>('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<String>('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);
});

View File

@ -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<StatusCue>(find.byType(StatusCue).first).semanticLabel;
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@ -95,10 +103,10 @@ void main() {
Future<void> 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<DayCell>(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'));
});
}

View File

@ -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<String> spoken(WidgetTester tester) => tester
.widgetList<SpokenLabel>(find.byType(SpokenLabel))
.map((w) => w.semanticLabel)
.toList();
/// The escape outcome, now a StatusCue.
String cueWords(WidgetTester tester) =>
tester.widget<StatusCue>(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<TextButton>(
find.ancestor(
of: find.text('That was a bug'),
matching: find.byWidgetPredicate((w) => w is TextButton),
),
);
expect(button.onPressed, isNull);
expect(tester.widget<IconButton>(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<FilledButton>(find.byType(FilledButton));
tester.widget<FilledButton>(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<StatusCue>(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<StatusCue>(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.');
});
}

View File

@ -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<String?> read() async => text;
@override
Future<void> 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 = <List<String>>[];
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<String>(),
);
}
if (call.method == 'getSickDays' && !cacheUnreadable) {
return caches.isEmpty ? <String>[] : 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 <String>{},
),
icon: const Icon(Icons.open_in_new),
),
),
),
),
);
await tester.tap(find.byKey(const Key('open')));
await tester.pumpAndSettle();
expectNoWords(tester, 'SickDaySheet');
});
});
}

View File

@ -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<String> 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<void> pollSteps(WidgetTester tester) async {
await tester.pump(kStepPollInterval);
await tester.pump();
}
setUp(() {
calls = <String>[];
steps = 0;
hasNfc = true;
elapsed = Duration.zero;
SharedPreferences.setMockInitialValues(<String, Object>{});
mockChannel();
});
Future<ChallengeGenerator> 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: <String, WidgetBuilder>{
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: <String, WidgetBuilder>{
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<MethodCall> 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 = <MethodCall>[];
now = DateTime(2026, 9, 17, 8, 4);
SharedPreferences.setMockInitialValues(<String, Object>{});
mockChannelFn();
await SessionTagSettings.replaceAll(<String>['bath', 'ward', 'desk'], '');
await tester.pumpWidget(
MaterialApp(
routes: <String, WidgetBuilder>{
'/': (_) =>
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');
});
});
}

View File

@ -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<String> wordsOnScreen(WidgetTester tester) {
final contentElements = <Element>{};
final allElements = tester.allElements;
// First pass: collect all content-subtree elements
for (final element in allElements) {
if (element.widget.key case ValueKey<String>(
value: final v,
) when v.startsWith('content:')) {
_collectSubtree(element, contentElements);
}
if (element.widget is EditableText) {
contentElements.add(element);
}
}
final words = <String>[];
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<Element> 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);
}

View File

@ -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');
});
}

View File

@ -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(<String, Object>{});
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(<String, Object>{});
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(<String, Object>{});
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(<String, Object>{});
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(<String, Object>{});
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(<String, Object>{});
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(<String, Object>{});
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: Scaffold(
body: Center(
child: IconButton(
key: const Key('open'),
onPressed: () => showDialog<String>(
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(<String, Object>{});
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(<String, Object>{});
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');
});
});
}

View File

@ -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 = <MethodCall>[];
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 = <MethodCall>[];
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<MethodCall> 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 = <MethodCall>[];
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 = <MethodCall>[];
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);
},
);
});
}

View File

@ -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();
}
});
});
}

View File

@ -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);
});
}

View File

@ -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

View File

@ -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(

31
wake_alarm/_strip_pips.py Normal file
View File

@ -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))
)

View File

@ -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:

View File

@ -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