mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-11 15:07:56 +01:00
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
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:
parent
89e6eed105
commit
52c3ff5f1e
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
// phone-slot: not-shared-storage (path is per-package: Android/data/<package>/files)
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@ -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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@ -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',
|
||||
),
|
||||
],
|
||||
],
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>(
|
||||
|
||||
@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@ -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)!)],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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')}';
|
||||
}
|
||||
|
||||
@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@ -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()),
|
||||
),
|
||||
|
||||
@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
135
phone_app/lib/screens/stakes_marks.dart
Normal file
135
phone_app/lib/screens/stakes_marks.dart
Normal 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)],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -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',
|
||||
);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
91
phone_app/lib/services/stakes_view.dart
Normal file
91
phone_app/lib/services/stakes_view.dart
Normal 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;
|
||||
}
|
||||
@ -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
218
phone_app/lib/ui/cues.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
146
phone_app/lib/ui/dashed_slot.dart
Normal file
146
phone_app/lib/ui/dashed_slot.dart
Normal 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;
|
||||
}
|
||||
34
phone_app/lib/ui/spoken_label.dart
Normal file
34
phone_app/lib/ui/spoken_label.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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'));
|
||||
});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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',
|
||||
);
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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());
|
||||
});
|
||||
|
||||
@ -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.');
|
||||
});
|
||||
}
|
||||
|
||||
@ -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)>[
|
||||
|
||||
@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
@ -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', (
|
||||
|
||||
@ -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')));
|
||||
});
|
||||
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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'));
|
||||
});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
|
||||
@ -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'));
|
||||
});
|
||||
}
|
||||
|
||||
@ -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.');
|
||||
});
|
||||
}
|
||||
|
||||
143
phone_app/test/text_free_days_test.dart
Normal file
143
phone_app/test/text_free_days_test.dart
Normal 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');
|
||||
});
|
||||
});
|
||||
}
|
||||
216
phone_app/test/text_free_ring_test.dart
Normal file
216
phone_app/test/text_free_ring_test.dart
Normal 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');
|
||||
});
|
||||
});
|
||||
}
|
||||
64
phone_app/test/text_free_support.dart
Normal file
64
phone_app/test/text_free_support.dart
Normal 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);
|
||||
}
|
||||
46
phone_app/test/text_free_sync_test.dart
Normal file
46
phone_app/test/text_free_sync_test.dart
Normal 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');
|
||||
});
|
||||
}
|
||||
207
phone_app/test/text_free_test.dart
Normal file
207
phone_app/test/text_free_test.dart
Normal 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');
|
||||
});
|
||||
});
|
||||
}
|
||||
162
phone_app/test/ui/cues_haptics_test.dart
Normal file
162
phone_app/test/ui/cues_haptics_test.dart
Normal 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);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
149
phone_app/test/ui/cues_test.dart
Normal file
149
phone_app/test/ui/cues_test.dart
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
31
wake_alarm/_strip_pips.py
Normal 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))
|
||||
)
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user