Compare commits

...

10 Commits

Author SHA1 Message Date
9250db4cc1 E2E-encrypt the published alarm record
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,free-days-interop) (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / free-days-interop (push) Has been cancelled
release-apk / release (APK) (push) Has been cancelled
repo-contract / repo contract (push) Has been cancelled
The phone pushes alarm.json sealed under wake-alarm-sync/devices-e2e
(Argon2id key from the shared crdt-sync passphrase, keystore + lock
icon in Settings). The PC reads it through open_encrypted_client on
both Firebase and the GitHub mirror (now via GitHubSyncClient); no or
wrong passphrase reads as nothing published, never plaintext. The old
plaintext prefix is copied once per process, never deleted.

crdt-sync 0.11.0, crdt_sync_dart 0.14.1, crdt_sync_flutter 0.8.1,
sync_settings_ui 0.8.2, freedays 0.4.2, sickdays 0.5.3. install.sh
now fails if crdt_sync/pynacl/argon2 do not import.

Todo: fda71ed1

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 10:30:27 +01:00
9dbc4547ee Add deploy script and CLAUDE.md deploy line
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
repo-contract / repo contract (push) Waiting to run
PC half: editable install + timers + trigger dry-run health check.
Phone: release APK via phone_deploy, health = installed base.apk
matches the built one. install.sh stays manual (interactive sudo).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 09:33:04 +01:00
3b7f561452 Run the .fvmrc Flutter in repo scripts
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
The free-day hook ran the system flutter (3.47.9). On a fresh
checkout its pub get rewrote pubspec.lock to its own test_api, so
pre-commit failed with "files were modified". Hooks and
test_changed.sh now put the pinned fvm SDK on PATH.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 09:10:57 +01:00
8e6fa90c80 Bump Flutter to 3.50.0 via material_ui
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
Flutter 3.50 deprecates package:flutter/material.dart, so the app
moves to material_ui 1.11.0 together with sync_settings_ui 0.8.0
and github_device_auth 0.5.0 (crdt_sync 0.13.1 rides along).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 09:06:16 +01:00
6cb51ad0f2 Bump sickdays to 0.5.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 08:46:24 +01:00
4dbde2b525 Record dropped hardening residue items
Item 13, two-tag and locked-keyguard on-device runs dropped by kuhy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 08:28:00 +01:00
28327d322a Make wake-alarm text-free (textgate-rework) — res…
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
2026-11-11 14:27:52 +01:00
6c248e712e Bump gatelock to 0.13.0
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
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-11 13:00:19 +01:00
52c3ff5f1e Make wake-alarm text-free (textgate-rework)
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,free-days-interop) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / free-days-interop (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Todo: 364208fc
2026-11-11 12:45:10 +01:00
89e6eed105 Bump sync_settings_ui to 0.6.1
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
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-08 18:28:23 +01:00
116 changed files with 4512 additions and 1244 deletions

2
.fvmrc
View File

@ -1,3 +1,3 @@
{
"flutter": "3.47.9"
"flutter": "3.50.0"
}

View File

@ -71,7 +71,7 @@ jobs:
- uses: kuhyx/utils/flutter-setup@flutter-setup-v1
with:
flutter-version: 3.47.9
flutter-version: 3.50.0
working-directory: phone_app
- name: Dart writes, Python replays, both must agree

View File

@ -87,7 +87,7 @@ jobs:
- uses: subosito/flutter-action@v2
with:
flutter-version: 3.47.9
flutter-version: 3.50.0
channel: stable
cache: true

View File

@ -107,9 +107,9 @@ repos:
# the manifest becomes an unresolvable hook environment rather
# than merely an old one.
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.12.0#subdirectory=gatelock
- crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync
- freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays
- sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.4.0#subdirectory=sickdays
- crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.11.0#subdirectory=crdt-sync
- freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.2#subdirectory=freedays
- sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.5.3#subdirectory=sickdays
# Test suites intentionally use patterns (protected-access, magic-value
# comparisons) that don't belong in the source-code 10/10 gate.
exclude: ^(\.venv/)|(^|/)(tests/|conftest\.py)

View File

@ -100,9 +100,16 @@ systemd-sleep hook: a suspended or just-booted machine still fires.
`_sync.py` is the only reader.
- Source of truth: a `crdt_sync` log written by `phone_app/` under
`wake-alarm-sync/devices/<device-id>/alarm.json`, at
`wake-alarm-sync/devices-e2e/<device-id>/alarm.json`, at
`.alarm.fields.alarm_hour[0]` / `.alarm_minute[0]`. Same logical path on both
backends, so only the transport differs, never the parse.
- **End-to-end encrypted** (since 2026-11-12): fields are sealed with a key
derived from the shared passphrase (`~/.config/crdt-sync/password` on the
PC, the keystore + lock icon in Settings on the phone). Both readers go
through `open_encrypted_client`, which copies the old plaintext
`wake-alarm-sync/devices` over once per process and never deletes it. No
or wrong passphrase reads as "nothing published" (cache, then 08:00) —
never plaintext. diet_guard reads the same prefix for weigh-ins.
- **The Firebase reader discovers device directories; it is not pinned to
`devices/phone/`.** The phone's device id is migrating from the fixed
constant `phone` to a per-install uuid, and a pinned reader would silently
@ -128,9 +135,9 @@ systemd-sleep hook: a suspended or just-booted machine still fires.
**404, not 403**.
- `crdt_sync` is imported at module scope but **guarded by
`try/except ImportError`**: the service runs `/usr/bin/python`, not a venv, so
a dependency that failed to install must degrade the read to the GitHub path
rather than crash the per-minute trigger. The GitHub path stays stdlib-only
for that reason. Verify with `/usr/bin/python3 -c "import crdt_sync"`, not the
a dependency that failed to install must degrade the read to the cache
rather than crash the per-minute trigger. Both backends need it now (the
GitHub mirror is read through `GitHubSyncClient`, encrypted too). Verify with `/usr/bin/python3 -c "import crdt_sync"`, not the
dev venv.
- Resolution chain in `get_alarm()`: fresh cache (< 5 min) → live fetch → stale
cache → 08:00. The cache bounds remote reads to ~288/day
@ -220,6 +227,7 @@ and the root ring unit's `systemctl start|stop` lines live there.
- lint: `pre-commit run ruff --all-files && cd phone_app && flutter analyze --fatal-infos --fatal-warnings`
- coverage: `python -m pytest -q wake_alarm/tests/ --cov=wake_alarm --cov-branch --cov-report=xml --cov-fail-under=100`
- coverage-gaps: `coverage-gaps coverage.xml`
- deploy: `git -C ~/src/wake-alarm pull --ff-only && ~/src/wake-alarm/scripts/deploy.sh` (editable install under /usr/bin/python3 + timers and `_trigger --dry-run` health check, then the release APK onto the Pixel via phone_deploy, health = installed base.apk equals the built one; `--build-only` for board sessions, `--check` for the health check alone; unit or root-file changes still need `bash install.sh`)
Other: full lint gate `pre-commit run --all-files`; watch live
`journalctl --user -fu wake-alarm-trigger.service`; `bash install.sh` /

View File

@ -131,7 +131,8 @@ it as not the morning's ring.
### Escape hatch (until 2026-10-15 inclusive)
Home screen button "That was a bug". Needs a typed reason ≥ 40 characters.
Bug-glyph button on the stakes card (spoken label "That was a bug"; the
app is text-free since 2026-11-10). Needs a typed reason ≥ 40 characters.
It lifts the phone lockdown, clears the streak, cancels today's PC penalty,
stops a ringing PC alarm, and writes everything into the incident report.
Unlimited uses — every use is listed at the top of that day's report.
@ -207,6 +208,9 @@ PC (`events.jsonl` `event`): `fallback_tick` (only on decision change),
next-alarm, headless `ring_started` on a heads-up ring, boot-path miss
(phone off before the alarm) → lockdown through tomorrow, escape hatch.
Not yet on the Pixel: the real focus-owner reaction and Firebase publish.
- Item 13 (black screen): dropped by kuhy 2026-11-12
- Two-tag dismiss on device: dropped by kuhy 2026-11-12
- Locked-keyguard dismiss on device: dropped by kuhy 2026-11-12
## Evidence for Claude

View File

@ -132,6 +132,10 @@ echo "[1/4] Installing wake_alarm for /usr/bin/python..."
echo " Installed. Verifying import..."
/usr/bin/python3 -c "import wake_alarm; import gatelock"
echo " wake_alarm and gatelock import under /usr/bin/python."
# The alarm is end-to-end encrypted: without crdt_sync (pynacl, argon2-cffi)
# the trigger can only fall back to the cache and 08:00. Fail the install.
/usr/bin/python3 -c "import crdt_sync, nacl, argon2"
echo " crdt_sync (E2E) imports under /usr/bin/python."
echo "[2/4] Installing systemd user units..."
mkdir -p "$SYSTEMD_USER_DIR"

38
keep.md Normal file
View File

@ -0,0 +1,38 @@
# Text-free keep list
Every `textgate scan` hit that stays on purpose. `scripts/check_textgate_keep.sh`
runs the scan and fails on any hit no row below covers. Rework rationale and the
per-screen decisions: `~/data/wake-alarm_notext/keep.md` (2026-11-10 rework).
## Scan exceptions
First cell: a file, or a directory ending in `/`. Second cell: the exact text,
or `*` for any text. The checker parses only this table.
| Path | Text | Reason |
|---|---|---|
| `docs/explanations/` | * | Generated by the `explain-diff` skill (`docs/explanations/index.html`). Explanation pages for the repo, not app UI; a text-free explanation defeats its purpose. |
## Kept but not flagged by the scan
Rows copied from the rework's keep list. The scan does not report these today;
they are listed so the next rework does not delete them.
### OS-level labels: the app name only
| Where | Text | Reason |
|---|---|---|
| `phone_app/lib/ui/app_name.dart` `kAppName` | `WakeAlarm` | `MaterialApp.title`: the task-switcher name, a proper noun |
| `phone_app/android/app/src/main/AndroidManifest.xml` | `android:label="WakeAlarm"` | launcher label, a proper noun |
| `phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AppName.kt` `APP_NAME` | `WakeAlarm` | name of the ring and session notification channels |
### Content: data the program only displays
| Where | Content | Reason |
|---|---|---|
| `room_proof_screen.dart`, `session_tags_screen.dart` | registered NFC tag ids | authored identifiers read off the tag |
| `ring_challenge.dart` `challenge.display` | digits, operators, sequences, the reversed word | the thing you must read to dismiss; always visible |
| `home_location_screen.dart` | captured SSID, BSSIDs, coordinates | read from the radio |
| all screens | clock times, numeral dates, counts, kg, `N/M` | numbers |
| `escape_dialog.dart` | the excuse text kuhy types | typed value |
| `alarm_publisher.dart` two `publish()` failure strings | spoken labels, two distinct cue kinds | CLAUDE.md: keep both messages |

View File

@ -105,10 +105,9 @@ class AlarmService : Service() {
// IMPORTANCE_HIGH is required for a full-screen intent to be honoured.
val channel = NotificationChannel(
CHANNEL_ID,
"Wake alarm",
APP_NAME,
NotificationManager.IMPORTANCE_HIGH,
).apply {
description = "The ringing wake alarm"
// The service owns sound and vibration, so the channel must not add
// its own — a channel sound would play on the notification stream
// and be silenced by DND, unlike STREAM_ALARM.
@ -129,8 +128,6 @@ class AlarmService : Service() {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
return Notification.Builder(this, CHANNEL_ID)
.setContentTitle("Wake alarm")
.setContentText("Tap to dismiss")
.setSmallIcon(android.R.drawable.ic_lock_idle_alarm)
.setCategory(Notification.CATEGORY_ALARM)
.setOngoing(true)

View File

@ -0,0 +1,9 @@
package com.kuhy.wake_alarm_sync
/**
* The app's only visible word, used as every notification channel's name.
*
* Matches `android:label` in AndroidManifest.xml and Dart's `kAppName`, so the
* notification settings name the app the way the launcher does.
*/
const val APP_NAME = "WakeAlarm"

View File

@ -15,7 +15,7 @@ import java.time.LocalDate
*
* Two jobs, one record. The launcher starts [MainActivity], and it has to
* know *before any Dart runs* whether to step aside into the session screen;
* the notification needs the leg text with no engine running behind it.
* the notification needs the leg glyph with no engine running behind it.
* Dart rewrites this cache on every leg change (`updateSessionNotice`) and
* `finishSession` clears it, so it is only ever as stale as the last change.
*
@ -50,8 +50,7 @@ object SessionNotice {
.apply()
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.createNotificationChannel(
NotificationChannel(CHANNEL_ID, "Morning session", NotificationManager.IMPORTANCE_LOW)
.apply { description = "The live morning checklist" },
NotificationChannel(CHANNEL_ID, APP_NAME, NotificationManager.IMPORTANCE_LOW),
)
manager.notify(NOTIFICATION_ID, build(context, label, deadlineMillis))
}
@ -87,7 +86,6 @@ object SessionNotice {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val builder = Notification.Builder(context, CHANNEL_ID)
.setContentTitle("Morning session")
.setContentText(label)
.setSmallIcon(android.R.drawable.ic_lock_idle_alarm)
.setCategory(Notification.CATEGORY_REMINDER)

View File

@ -1,4 +1,4 @@
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/screens/home_screen.dart';
import 'package:wake_alarm_sync/screens/ring_screen.dart';
@ -7,6 +7,7 @@ import 'package:wake_alarm_sync/services/alarm_policy.dart';
import 'package:wake_alarm_sync/services/alarm_scheduler.dart';
import 'package:wake_alarm_sync/services/day_pool_refresh.dart';
import 'package:wake_alarm_sync/services/headless_ring_start.dart';
import 'package:wake_alarm_sync/ui/app_name.dart';
import 'package:wake_alarm_sync/ui/theme.dart';
/// Route the Kotlin `AlarmActivity` hands to the engine as its initial
@ -63,7 +64,7 @@ class WakeAlarmSyncApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Wake Alarm Sync',
title: kAppName,
theme: buildAppTheme(),
navigatorKey: navigatorKey,
// AlarmActivity sets its initial route to kRingRoute, so the ring screen

View File

@ -1,4 +1,4 @@
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/main.dart';
import 'package:wake_alarm_sync/services/alarm_scheduler.dart';

View File

@ -1,4 +1,4 @@
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/main.dart';
import 'package:wake_alarm_sync/services/alarm_scheduler.dart';

View File

@ -1,4 +1,4 @@
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/main.dart';
import 'package:wake_alarm_sync/probe/file_driven_scheduler.dart';

View File

@ -1,4 +1,4 @@
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/screens/room_proof_screen.dart';

View File

@ -1,7 +1,8 @@
// phone-slot: not-shared-storage (path is per-package: Android/data/<package>/files)
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:wake_alarm_sync/services/alarm_scheduler.dart';

View File

@ -1,4 +1,4 @@
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/screens/day_cell.dart';
import 'package:wake_alarm_sync/services/alarm_ramp.dart';
import 'package:wake_alarm_sync/services/free_day_pool.dart';
@ -74,15 +74,24 @@ class DayCalendar extends StatelessWidget {
/// Latest month the forward arrow reaches; null means no limit.
final DateTime? lastMonth;
static const _labels = ['M', 'T', 'W', 'T', 'F', 'S', 'S'];
static const _months = [
'January', 'February', 'March', 'April', 'May', 'June', 'July', //
'August', 'September', 'October', 'November', 'December',
];
bool _before(DateTime a, DateTime b) =>
a.year < b.year || (a.year == b.year && a.month < b.month);
Widget _weekdayDot(BuildContext context, int index) {
final theme = Theme.of(context);
final isWeekend = index == 5 || index == 6;
return Container(
width: isWeekend ? 8 : 6,
height: isWeekend ? 8 : 6,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isWeekend
? theme.colorScheme.primary
: theme.colorScheme.onSurfaceVariant,
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@ -97,22 +106,26 @@ class DayCalendar extends StatelessWidget {
Row(
children: [
IconButton(
icon: const Icon(Icons.chevron_left),
tooltip: 'Previous month',
icon: const Icon(
Icons.chevron_left,
semanticLabel: 'Previous month',
),
onPressed: canBack
? () => onMonth(DateTime(month.year, month.month - 1))
: null,
),
Expanded(
child: Text(
'${_months[month.month - 1]} ${month.year}',
'${month.month.toString().padLeft(2, '0')}/${month.year}',
textAlign: TextAlign.center,
style: theme.textTheme.titleMedium,
),
),
IconButton(
icon: const Icon(Icons.chevron_right),
tooltip: 'Next month',
icon: const Icon(
Icons.chevron_right,
semanticLabel: 'Next month',
),
onPressed: canForward
? () => onMonth(DateTime(month.year, month.month + 1))
: null,
@ -121,17 +134,8 @@ class DayCalendar extends StatelessWidget {
),
Row(
children: [
for (final label in _labels)
Expanded(
child: Center(
child: Text(
label,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
),
for (var i = 0; i < 7; i++)
Expanded(child: Center(child: _weekdayDot(context, i))),
],
),
const SizedBox(height: 4),

View File

@ -1,4 +1,4 @@
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';
Widget _dot(Color color) => Container(
key: const ValueKey<String>('day-badge'),

View File

@ -1,4 +1,4 @@
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/services/weight_entry.dart';
/// Five chips, 1 to 5: the morning's energy, tapped next to the weigh-in.
@ -28,7 +28,12 @@ class EnergyPicker extends StatelessWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Text('Energy', style: Theme.of(context).textTheme.labelLarge),
const Row(
children: <Widget>[
Icon(Icons.bolt, semanticLabel: 'Energy'),
SizedBox(width: 8),
],
),
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,

View File

@ -1,4 +1,4 @@
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/services/stakes.dart';
/// Asks why this morning should be excused; the reason, or null if
@ -34,50 +34,67 @@ class _EscapeDialogState extends State<EscapeDialog> {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final ready = _length >= escapeReasonMinChars;
const reasonWords = 'What went wrong?';
return AlertDialog(
// The keyboard covers half a small screen; scroll instead of overflow.
scrollable: true,
title: const Text('That was a bug'),
icon: const Icon(
Icons.bug_report_outlined,
semanticLabel: 'That was a bug',
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
const Text(
'Lifts the app lockdown, clears the streak, cancels today’s PC '
'penalty and stops a ringing PC. Every use is listed in the '
'incident report.',
),
const SizedBox(height: 16),
TextField(
controller: _reason,
autofocus: true,
maxLines: 4,
minLines: 2,
decoration: const InputDecoration(
labelText: 'What went wrong?',
border: OutlineInputBorder(),
MergeSemantics(
child: Semantics(
label: reasonWords,
child: TextField(
controller: _reason,
autofocus: true,
maxLines: 4,
minLines: 2,
decoration: const InputDecoration(
prefixIcon: Icon(Icons.edit_note),
border: OutlineInputBorder(),
),
onChanged: (_) => setState(() {}),
),
),
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 8),
Text(
'$_length / $escapeReasonMinChars characters',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
Row(
children: <Widget>[
Expanded(
child: LinearProgressIndicator(
value: (_length / escapeReasonMinChars)
.clamp(0, 1)
.toDouble(),
),
),
const SizedBox(width: 8),
Text(
'$_length/$escapeReasonMinChars',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
],
),
actions: <Widget>[
TextButton(
IconButton(
key: const Key('escape-cancel'),
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
icon: const Icon(Icons.close, semanticLabel: 'Cancel'),
),
FilledButton(
key: const Key('escape-confirm'),
onPressed: ready
? () => Navigator.of(context).pop(_reason.text.trim())
: null,
child: const Text('Excuse this morning'),
child: const Icon(Icons.check, semanticLabel: 'Excuse this morning'),
),
],
);

View File

@ -1,7 +1,8 @@
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/screens/day_calendar.dart';
import 'package:wake_alarm_sync/services/free_day_pool.dart';
import 'package:wake_alarm_sync/ui/theme.dart';
import 'package:wake_alarm_sync/ui/cues.dart';
import 'package:wake_alarm_sync/ui/dashed_slot.dart';
/// One upcoming free day, as the list shows it.
typedef UpcomingFreeDay = ({DateTime day, String reason});
@ -45,70 +46,78 @@ class FreeDaysBody extends StatelessWidget {
/// Called when the user gives a day back.
final void Function(DateTime day) onRelease;
static const _weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
String _label(DateTime day) =>
'${_weekdays[day.weekday - 1]} ${FreeDayPool.toIso(day)}';
String _toDdMmYyyy(DateTime day) {
final d = day.day.toString().padLeft(2, '0');
final m = day.month.toString().padLeft(2, '0');
return '$d.$m.${day.year}';
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final statusColors = theme.extension<AppStatusColors>()!;
return SafeArea(
child: ListView(
padding: const EdgeInsets.all(16),
children: [
Text(
'$remaining of ${FreeDayPool.annualBudget}',
style: theme.textTheme.displayMedium?.copyWith(
fontWeight: FontWeight.bold,
),
Row(
children: [
const Icon(Icons.event_available),
const SizedBox(width: 8),
Text(
'$remaining/${FreeDayPool.annualBudget}',
style: theme.textTheme.displayMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
Text(
'free days left in $year',
'$year',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Text(
'On a free day nothing locks, nothing rings and nothing asks. '
'Every app, at once.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
if (status.isNotEmpty) ...[
const SizedBox(height: 16),
Text(
status,
maxLines: 4,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: status.contains('Error')
? theme.colorScheme.error
: statusColors.success,
),
),
StatusCue(semanticLabel: status),
],
const SizedBox(height: 16),
calendar,
const SizedBox(height: 24),
Text('Booked ahead', style: theme.textTheme.titleMedium),
Row(
children: [
Icon(
Icons.upcoming,
color: theme.colorScheme.onSurfaceVariant,
semanticLabel: 'Booked ahead',
),
],
),
const SizedBox(height: 8),
if (upcoming.isEmpty)
Text(
'None. Tap a date above to book one.',
style: TextStyle(color: theme.colorScheme.onSurfaceVariant),
const SpokenLabel(
semanticLabel: 'None. Tap a date above to book one.',
child: DashedSlot(),
),
for (final entry in upcoming)
ListTile(
contentPadding: EdgeInsets.zero,
title: Text(_label(entry.day)),
subtitle: entry.reason.isEmpty ? null : Text(entry.reason),
trailing: TextButton(
title: Text(_toDdMmYyyy(entry.day)),
subtitle: entry.reason.isEmpty
? null
: KeyedSubtree(
key: ValueKey<String>(
'content:reason-${FreeDayPool.toIso(entry.day)}',
),
child: Text(entry.reason),
),
trailing: IconButton(
key: ValueKey<String>(
'release-${FreeDayPool.toIso(entry.day)}',
),
icon: const Icon(Icons.event_busy, semanticLabel: 'Release'),
onPressed: busy ? null : () => onRelease(entry.day),
child: const Text('Release'),
),
),
],

View File

@ -1,12 +1,13 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/screens/day_calendar.dart';
import 'package:wake_alarm_sync/screens/free_days_body.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/cues.dart';
/// The one picker for the shared free-day pool.
///
@ -173,17 +174,20 @@ class _FreeDaysScreenState extends State<FreeDaysScreen> {
final pool = _pool;
return Scaffold(
appBar: AppBar(
title: const Text('Free days'),
// title removed
actions: [
IconButton(
icon: const Icon(Icons.sync),
tooltip: 'Sync now',
icon: const Icon(Icons.sync, semanticLabel: 'Sync now'),
onPressed: _busy ? null : () => unawaited(_sync()),
),
],
),
body: pool == null
? Center(child: Text(_status.isEmpty ? 'Loading…' : _status))
? Center(
child: _status.isEmpty
? const CircularProgressIndicator()
: StatusCue(semanticLabel: _status),
)
: FreeDaysBody(
year: _today.year,
remaining: pool.remaining(_today.year),

View File

@ -1,8 +1,9 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:github_device_auth/github_device_auth.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/services/sync_settings.dart';
import 'package:wake_alarm_sync/ui/cues.dart';
/// The GitHub mirror screen: cutover-only sync transport, not a peer of
/// Firebase.
@ -86,62 +87,44 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
Widget build(BuildContext context) {
final isConnected = _settings.isConfigured;
return Scaffold(
appBar: AppBar(title: const Text('Advanced sync (GitHub)')),
appBar: AppBar(),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Align(
alignment: Alignment.centerLeft,
child: Text(
isConnected
? 'Connected to GitHub.'
: 'Not connected to GitHub.',
style: TextStyle(
fontSize: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
Icon(
isConnected ? Icons.link : Icons.link_off,
color: isConnected
? Theme.of(context).colorScheme.tertiary
: Theme.of(context).colorScheme.onSurfaceVariant,
size: 32,
semanticLabel: isConnected
? 'Connected to GitHub.'
: 'Not connected to GitHub.',
),
const SizedBox(height: 12),
if (!isConnected)
ElevatedButton.icon(
ElevatedButton(
key: const ValueKey<String>('github-connect'),
onPressed: _busy ? null : _connectGitHub,
icon: const Icon(Icons.link),
label: const Text('Connect GitHub'),
child: const Icon(
Icons.link,
semanticLabel: 'Connect GitHub',
),
)
else
OutlinedButton.icon(
OutlinedButton(
key: const ValueKey<String>('github-disconnect'),
onPressed: _busy ? null : _disconnect,
icon: const Icon(Icons.link_off),
label: const Text('Disconnect GitHub'),
),
const SizedBox(height: 16),
Text(
'PC config — the PC reads Firebase first and falls back '
'to GitHub. For the GitHub fallback, create:\n'
'~/.config/wake_alarm/sync.json\n\n'
'{ "token": "<same token>" }\n\n'
'The token needs "repo" scope so the PC can read '
'kuhyx/syncs.',
style: TextStyle(
fontSize: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
if (_status.isNotEmpty) ...[
const SizedBox(height: 16),
Text(
_status,
style: TextStyle(
color: _status.startsWith('Error')
? Theme.of(context).colorScheme.error
: null,
child: const Icon(
Icons.link_off,
semanticLabel: 'Disconnect GitHub',
),
),
],
const SizedBox(height: 16),
StatusCue(semanticLabel: _status),
],
),
),

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.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,41 +92,44 @@ 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),
],
HomeTiles(
@ -126,12 +139,11 @@ class HomeBody extends StatelessWidget {
),
if (!configured) ...[
const SizedBox(height: 16),
Text(
'Open Settings (gear icon) to connect Firebase.',
textAlign: TextAlign.center,
style: TextStyle(
color: theme.extension<AppStatusColors>()!.warning,
),
Icon(
Icons.cloud_off,
size: 28,
color: theme.extension<AppStatusColors>()!.warning,
semanticLabel: 'Open Settings to connect Firebase',
),
],
],

View File

@ -1,9 +1,12 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/services/morning_session.dart';
import 'package:wake_alarm_sync/services/session_store.dart';
import 'package:wake_alarm_sync/services/weight_entry.dart';
import 'package:wake_alarm_sync/ui/dashed_slot.dart';
import 'package:wake_alarm_sync/ui/spoken_label.dart';
import 'package:wake_alarm_sync/ui/theme.dart';
/// How many mornings the home screen shows.
const int kHistoryShown = 7;
@ -52,20 +55,20 @@ class _HomeHistoryCardState extends State<HomeHistoryCard> {
Widget build(BuildContext context) {
if (_sessions.isEmpty) {
final empty = widget.emptyText;
return _loaded && empty != null ? Text(empty) : const SizedBox.shrink();
return _loaded && empty != null
? SpokenLabel(semanticLabel: empty, child: const DashedSlot())
: const SizedBox.shrink();
}
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Text('Last mornings', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
for (var i = 0; i < _sessions.length; i++)
Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Text(
describeMorning(_sessions[i], previousWeight: _previousWeight(i)),
style: theme.textTheme.bodyMedium,
padding: const EdgeInsets.symmetric(vertical: 4),
child: _MorningRow(
key: ValueKey<String>('history-${_sessions[i].date}'),
session: _sessions[i],
previousWeight: _previousWeight(i),
),
),
],
@ -82,6 +85,67 @@ class _HomeHistoryCardState extends State<HomeHistoryCard> {
}
}
/// One morning as glyphs and numbers; [describeMorning] is its spoken form.
class _MorningRow extends StatelessWidget {
const _MorningRow({
required this.session,
required this.previousWeight,
super.key,
});
final MorningSession session;
final double? previousWeight;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final status = theme.extension<AppStatusColors>();
final muted = theme.colorScheme.onSurfaceVariant;
final (IconData outcome, Color colour) = switch (session.outcome) {
SessionOutcome.completed => (
Icons.check_circle,
status?.success ?? Colors.green,
),
SessionOutcome.failed => (Icons.cancel, theme.colorScheme.error),
SessionOutcome.active => (Icons.more_horiz, muted),
};
final kg = session.weightKg;
final previous = previousWeight;
final energy = session.energy;
return SpokenLabel(
semanticLabel: describeMorning(session, previousWeight: previous),
// Wrap, not Row: a long weight delta must not overflow a narrow sheet.
child: Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
children: <Widget>[
Text(session.date.substring(5)),
const SizedBox(width: 8),
Icon(outcome, color: colour, size: 20),
const SizedBox(width: 12),
Icon(Icons.replay, size: 16, color: muted),
Text('${session.reRings}'),
if (kg != null) ...<Widget>[
const SizedBox(width: 12),
Icon(Icons.monitor_weight_outlined, size: 16, color: muted),
Text('$kg'),
if (previous != null) Text(' (${_signed(kg - previous)})'),
],
if (energy != null) ...<Widget>[
const SizedBox(width: 12),
Icon(Icons.bolt, size: 16, color: muted),
Text('$energy/$maxEnergy'),
],
],
),
);
}
}
String _signed(double delta) {
final sign = delta > 0 ? '+' : (delta < 0 ? '−' : '±');
return '$sign${delta.abs().toStringAsFixed(1)}';
}
/// One line for [session]: `09-16 ✓ 0 re-rings · 72.4 kg (−0.3) · energy 3/5`.
String describeMorning(MorningSession session, {double? previousWeight}) {
final mark = switch (session.outcome) {
@ -95,9 +159,7 @@ String describeMorning(MorningSession session, {double? previousWeight}) {
if (kg != null) {
weight = ' · $kg kg';
if (previousWeight != null) {
final delta = kg - previousWeight;
final sign = delta > 0 ? '+' : (delta < 0 ? '−' : '±');
weight += ' ($sign${delta.abs().toStringAsFixed(1)})';
weight += ' (${_signed(kg - previousWeight)})';
}
}
final energy = session.energy == null

View File

@ -1,8 +1,10 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/services/alarm_scheduler.dart';
import 'package:wake_alarm_sync/services/home_location_settings.dart';
import 'package:wake_alarm_sync/ui/cues.dart';
import 'package:wake_alarm_sync/ui/dashed_slot.dart';
/// Registers the Wi-Fi and coordinate that mean "home".
///
@ -111,53 +113,95 @@ class _HomeLocationScreenState extends State<HomeLocationScreen> {
_ => 'Nothing readable here.',
};
Widget _valueRow(IconData icon, String semanticLabel, Widget value) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Icon(icon, semanticLabel: semanticLabel, size: 24),
const SizedBox(width: 12),
Expanded(child: value),
],
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(title: const Text('Home location')),
appBar: AppBar(),
body: ListView(
padding: const EdgeInsets.all(16),
children: <Widget>[
Text(
'Away from home the two NFC tags are unreachable, so they are '
'skipped and the alarm asks the usual questions instead. The '
'walk is always required.',
style: theme.textTheme.bodyMedium,
_valueRow(
Icons.wifi,
'Home Wi-Fi',
KeyedSubtree(
key: const ValueKey<String>('content:home-ssid'),
child: _home.ssid != null
? Text(_home.ssid!)
: const DashedSlot(),
),
),
const SizedBox(height: 20),
Text('Home Wi-Fi', style: theme.textTheme.titleMedium),
Text(_home.ssid ?? 'not captured'),
const SizedBox(height: 12),
Text('Access points', style: theme.textTheme.titleMedium),
Text(_home.bssids.isEmpty ? 'none' : _home.bssids.join('\n')),
const SizedBox(height: 12),
Text('Coordinate', style: theme.textTheme.titleMedium),
Text(
_home.hasCoordinate
? '${_home.lat!.toStringAsFixed(5)}, '
_valueRow(
Icons.router,
'Access points',
KeyedSubtree(
key: const ValueKey<String>('content:home-bssids'),
child: _home.bssids.isEmpty
? const DashedSlot()
: Text(_home.bssids.join('\n')),
),
),
_valueRow(
Icons.place,
'Coordinate',
KeyedSubtree(
key: const ValueKey<String>('content:home-coord'),
child: _home.hasCoordinate
? Text(
'${_home.lat!.toStringAsFixed(5)}, '
'${_home.lon!.toStringAsFixed(5)} '
'(±${_home.radiusMeters.round()} m)'
: 'not captured',
'(±${_home.radiusMeters.round()} m)',
)
: const DashedSlot(),
),
),
const SizedBox(height: 24),
FilledButton(
onPressed: _busy ? null : _capture,
child: const Text('Capture this place as home'),
),
const SizedBox(height: 8),
Text(
'Press once on each Wi-Fi band (2.4 GHz and 5 GHz) so a band '
'switch overnight is not mistaken for travelling.',
style: theme.textTheme.bodySmall,
Row(
children: <Widget>[
FilledButton(
onPressed: _busy ? null : _capture,
child: const Icon(
Icons.my_location,
semanticLabel: 'Capture this place as home',
),
),
const SizedBox(width: 12),
Icon(
Icons.network_wifi_2_bar,
color: Theme.of(context).colorScheme.onSurfaceVariant,
semanticLabel: 'Press once on each Wi-Fi band, 2.4 and 5 GHz',
),
const SizedBox(width: 8),
Icon(
Icons.network_wifi,
color: Theme.of(context).colorScheme.onSurfaceVariant,
semanticLabel: 'Press once on each Wi-Fi band, 2.4 and 5 GHz',
),
],
),
const SizedBox(height: 16),
TextButton(
onPressed: _home.isConfigured ? _clear : null,
child: const Text('Forget home'),
child: const Icon(
Icons.delete_outline,
semanticLabel: 'Forget home',
),
),
const SizedBox(height: 16),
Text(_status, style: theme.textTheme.bodyMedium),
StatusCue(semanticLabel: _status),
],
),
);

View File

@ -1,7 +1,8 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/services/home_presence.dart';
import 'package:wake_alarm_sync/ui/cues.dart';
/// One line saying what tonight's alarm will demand, and why.
///
@ -52,14 +53,26 @@ class _HomePresenceLineState extends State<HomePresenceLine> {
final verdict = _verdict;
if (verdict == null) return const SizedBox.shrink();
final atHome = verdict.state == PresenceState.home;
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.only(top: 12),
child: Text(
atHome
child: SpokenLabel(
semanticLabel: atHome
? 'Tonight: tags required — ${verdict.detail}'
: 'Tonight: tags skipped — ${verdict.detail}',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Icon(
atHome ? Icons.nfc : Icons.directions_walk,
size: 18,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Icon(atHome ? Icons.home : Icons.explore, size: 18),
],
),
),
);
}

View File

@ -1,7 +1,7 @@
import 'dart:async';
import 'dart:developer' as developer;
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/screens/free_days_screen.dart';
import 'package:wake_alarm_sync/screens/home_body.dart';
@ -114,7 +114,7 @@ class _HomeScreenState extends State<HomeScreen> {
final picked = await showTimePicker(
context: context,
initialTime: TimeOfDay(hour: _hour, minute: _minute),
helpText: 'Earlier than $current only',
helpText: '',
);
if (picked == null) return;
final accepted = await AlarmPolicy.setEarlier(picked.hour, picked.minute);
@ -171,14 +171,13 @@ class _HomeScreenState extends State<HomeScreen> {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Wake Alarm Sync'),
actions: [
IconButton(
icon: Icon(
Icons.event_available,
color: Theme.of(context).colorScheme.onSurfaceVariant,
semanticLabel: 'Free days',
),
tooltip: 'Free days',
onPressed: () => Navigator.push<void>(
context,
MaterialPageRoute<void>(builder: (_) => const FreeDaysScreen()),
@ -188,8 +187,8 @@ class _HomeScreenState extends State<HomeScreen> {
icon: Icon(
Icons.sick_outlined,
color: Theme.of(context).colorScheme.onSurfaceVariant,
semanticLabel: 'Sick days',
),
tooltip: 'Sick days',
onPressed: () => Navigator.push<void>(
context,
MaterialPageRoute<void>(builder: (_) => const SickDaysScreen()),
@ -200,6 +199,7 @@ class _HomeScreenState extends State<HomeScreen> {
Icons.settings,
// Dimmed relative to the adjacent title text (rule 28).
color: Theme.of(context).colorScheme.onSurfaceVariant,
semanticLabel: 'Settings',
),
onPressed: () async {
await Navigator.push<void>(

View File

@ -1,4 +1,4 @@
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/screens/home_history_card.dart';
import 'package:wake_alarm_sync/screens/stakes_card.dart';
@ -34,7 +34,7 @@ class HomeTiles extends StatelessWidget {
child: _Tile(
key: const Key('home-tile-stakes'),
icon: Icons.gavel_outlined,
label: "Today's stakes",
semanticLabel: "Today's stakes",
sheet: StakesCard(
key: ValueKey<String>('stakes-$syncEpoch'),
busy: busy,
@ -48,7 +48,7 @@ class HomeTiles extends StatelessWidget {
child: _Tile(
key: const Key('home-tile-history'),
icon: Icons.history,
label: 'Last mornings',
semanticLabel: 'Last mornings',
sheet: HomeHistoryCard(
key: ValueKey<String>('history-$syncEpoch'),
emptyText: 'No finished morning yet.',
@ -63,13 +63,13 @@ class HomeTiles extends StatelessWidget {
class _Tile extends StatelessWidget {
const _Tile({
required this.icon,
required this.label,
required this.semanticLabel,
required this.sheet,
super.key,
});
final IconData icon;
final String label;
final String semanticLabel;
final Widget sheet;
@override
@ -95,9 +95,8 @@ class _Tile extends StatelessWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Icon(icon, color: theme.colorScheme.onSurfaceVariant),
Icon(icon, size: 32, semanticLabel: semanticLabel),
const SizedBox(height: 8),
Text(label, textAlign: TextAlign.center, maxLines: 1),
],
),
),

View File

@ -1,9 +1,10 @@
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/screens/ring_challenge.dart';
import 'package:wake_alarm_sync/screens/ring_checklist.dart';
import 'package:wake_alarm_sync/screens/ring_stakes_line.dart';
import 'package:wake_alarm_sync/services/challenge.dart';
import 'package:wake_alarm_sync/services/dismiss_gate.dart';
import 'package:wake_alarm_sync/ui/cues.dart';
/// The ring screen's body: the checklist, or the live challenge, plus status.
///
@ -38,7 +39,6 @@ class RingBody extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final inChallenges = gate.stage == DismissStage.challenges;
return PopScope(
// The alarm cannot be escaped with back; dismissing is the only way out.
@ -50,7 +50,6 @@ class RingBody extends StatelessWidget {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text('Wake up', style: theme.textTheme.headlineMedium),
const SizedBox(height: 16),
const RingStakesLine(),
const SizedBox(height: 8),
@ -65,7 +64,7 @@ class RingBody extends StatelessWidget {
else
RingChecklist(gate: gate),
const SizedBox(height: 16),
Text(status, style: theme.textTheme.bodyMedium),
_StatusRow(status: status),
],
),
),
@ -74,3 +73,21 @@ class RingBody extends StatelessWidget {
);
}
}
class _StatusRow extends StatelessWidget {
const _StatusRow({required this.status});
final String status;
@override
Widget build(BuildContext context) {
final match = RegExp(r'\d+(/\d+)?').firstMatch(status);
return Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
StatusCue(semanticLabel: status),
if (match != null) ...[const SizedBox(width: 8), Text(match.group(0)!)],
],
);
}
}

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/services/challenge.dart';
import 'package:wake_alarm_sync/services/dismiss_gate.dart';
import 'package:wake_alarm_sync/ui/spoken_label.dart';
/// The question half of the ring screen, shown once the walk is proven.
class RingChallenge extends StatelessWidget {
@ -32,37 +33,99 @@ class RingChallenge extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
const answerWords = 'Answer';
return Column(
children: <Widget>[
Text(
'Question ${passed + 1} of $total',
style: theme.textTheme.labelLarge,
SpokenLabel(
semanticLabel: 'Question ${passed + 1} of $total',
child: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Text('${passed + 1}/$total', style: theme.textTheme.labelLarge),
const SizedBox(width: 8),
Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(total, (i) {
final filled = i < passed;
final current = i == passed;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Icon(
filled
? Icons.circle
: current
? Icons.circle_outlined
: Icons.circle_outlined,
size: 8,
color: filled
? colorScheme.primary
: current
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
);
}),
),
],
),
),
const SizedBox(height: 8),
Text(challenge.hint, style: theme.textTheme.bodyLarge),
SpokenLabel(
semanticLabel: challenge.hint,
child: Icon(
_kindIcon(challenge.kind),
size: 40,
color: colorScheme.primary,
),
),
const SizedBox(height: 12),
Text(
// Always visible: a challenge that hides itself is unsolvable when
// the user reaches the screen having walked in from another room.
challenge.display,
textAlign: TextAlign.center,
style: theme.textTheme.displaySmall,
KeyedSubtree(
key: const ValueKey<String>('content:challenge'),
child: Text(
challenge.display,
textAlign: TextAlign.center,
style: theme.textTheme.displaySmall,
),
),
const SizedBox(height: 16),
TextField(
controller: controller,
autofocus: true,
textCapitalization: TextCapitalization.characters,
decoration: const InputDecoration(labelText: 'Answer'),
onSubmitted: (_) => onSubmit(),
MergeSemantics(
child: Semantics(
label: answerWords,
child: TextField(
controller: controller,
autofocus: true,
textCapitalization: TextCapitalization.characters,
decoration: const InputDecoration(prefixIcon: Icon(Icons.edit)),
onSubmitted: (_) => onSubmit(),
),
),
),
const SizedBox(height: 16),
FilledButton(onPressed: onSubmit, child: const Text('Submit')),
FilledButton(
key: const Key('ring-submit'),
style: FilledButton.styleFrom(minimumSize: const Size(160, 48)),
onPressed: onSubmit,
child: const Icon(Icons.check, semanticLabel: 'Submit'),
),
],
);
}
}
IconData _kindIcon(ChallengeKind kind) {
switch (kind) {
case ChallengeKind.math:
return Icons.calculate_outlined;
case ChallengeKind.sort:
return Icons.trending_up;
case ChallengeKind.sequence:
return Icons.more_horiz;
case ChallengeKind.reverse:
return Icons.swap_horiz;
}
}
/// Logs [challenge] so a hardware test can be scripted.
///
/// Debug aid only: the answer is already on screen for whoever is holding

View File

@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/services/dismiss_gate.dart';
import 'package:wake_alarm_sync/services/room_proof.dart';
import 'package:wake_alarm_sync/ui/spoken_label.dart';
/// The three-step progress list shown while the room proof is incomplete.
///
@ -25,6 +27,7 @@ class RingChecklist extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
@ -32,42 +35,98 @@ class RingChecklist extends StatelessWidget {
if (!(gate.bypassActive && stage != DismissStage.steps))
Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: <Widget>[
Icon(
_isDone(stage)
? Icons.check_circle
: Icons.radio_button_unchecked,
color: _isDone(stage)
? theme.colorScheme.primary
: theme.disabledColor,
),
const SizedBox(width: 12),
Text(
label,
style: gate.stage == stage
? theme.textTheme.titleMedium
: theme.textTheme.bodyMedium,
),
],
child: SpokenLabel(
semanticLabel: label,
key: ValueKey<String>('ring-row-${stage.name}'),
child: Row(
children: <Widget>[
Icon(
_isDone(stage)
? Icons.check_circle
: Icons.radio_button_unchecked,
color: _isDone(stage)
? colorScheme.primary
: theme.disabledColor,
size: gate.stage == stage ? 32 : 24,
),
const SizedBox(width: 12),
_stageGlyphs(stage, gate.stage == stage, colorScheme),
],
),
),
),
if (gate.bypassActive)
Padding(
padding: const EdgeInsets.only(top: 12),
child: Text(
// This line persists; the status below it is overwritten by the
// live step count within seconds, so the reason the tags went
// away has to live here or the user never sees it.
gate.awayActive
child: SpokenLabel(
semanticLabel: gate.awayActive
? 'Away from home — the tags are elsewhere. Walk, then '
'answer ${gate.challengesRequired} questions.'
: 'Tags unavailable — walk, then answer '
'${gate.challengesRequired} questions instead.',
style: theme.textTheme.bodyMedium,
key: const Key('ring-bypass'),
child: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Icon(
gate.awayActive ? Icons.explore : Icons.block,
color: colorScheme.error,
),
const Icon(Icons.arrow_forward, size: 16),
const Icon(Icons.directions_walk),
const Icon(Icons.arrow_forward, size: 16),
const Icon(Icons.quiz_outlined),
Text('${gate.challengesRequired}'),
],
),
),
),
],
);
}
Widget _stageGlyphs(DismissStage stage, bool current, ColorScheme cs) {
final primary = cs.primary;
final muted = cs.onSurfaceVariant;
final color = current ? primary : muted;
final size = current ? 32.0 : 24.0;
switch (stage) {
case DismissStage.tag1:
return Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Icon(Icons.nfc, size: size, color: color),
Text(
'1',
style: TextStyle(fontSize: size, color: color),
),
],
);
case DismissStage.steps:
return Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Icon(Icons.directions_walk, size: size, color: color),
Text(
'${RoomProof.requiredSteps}',
style: TextStyle(fontSize: size, color: color),
),
],
);
case DismissStage.tag2:
return Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Icon(Icons.nfc, size: size, color: color),
Text(
'2',
style: TextStyle(fontSize: size, color: color),
),
],
);
case DismissStage.challenges:
case DismissStage.done:
return const SizedBox.shrink();
}
}
}

View File

@ -1,7 +1,7 @@
import 'dart:async';
import 'dart:developer' as developer;
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/screens/ring_body.dart';
import 'package:wake_alarm_sync/screens/ring_challenge.dart';
import 'package:wake_alarm_sync/services/alarm_policy.dart';

View File

@ -1,8 +1,10 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/services/enforcement_bridge.dart';
import 'package:wake_alarm_sync/services/today_stakes.dart';
import 'package:wake_alarm_sync/ui/cues.dart';
import 'package:wake_alarm_sync/ui/spoken_label.dart';
import 'package:wake_alarm_sync/ui/theme.dart';
/// The ring screen's one line: what not dismissing costs today.
@ -23,7 +25,7 @@ class RingStakesLine extends StatefulWidget {
}
class _RingStakesLineState extends State<RingStakesLine> {
String? _line;
({DateTime by, DateTime lockThrough})? _stakes;
@override
void initState() {
@ -32,32 +34,53 @@ class _RingStakesLineState extends State<RingStakesLine> {
}
Future<void> _load() async {
String? line;
({DateTime by, DateTime lockThrough})? stakes;
try {
final state = await (widget.bridge ?? EnforcementBridge()).state();
line = state == null ? null : ringStakesLine(state);
stakes = state == null ? null : ringStakes(state);
} on Exception {
line = null;
stakes = null;
}
if (!mounted || line == null) return;
setState(() => _line = line);
if (!mounted || stakes == null) return;
setState(() => _stakes = stakes);
}
@override
Widget build(BuildContext context) {
final line = _line;
if (line == null) return const SizedBox.shrink();
final stakes = _stakes;
if (stakes == null) return const SizedBox.shrink();
final theme = Theme.of(context);
final statusColors = theme.extension<AppStatusColors>();
final warningColor = statusColors?.warning ?? Colors.amber;
final line =
'Stick day: no dismiss by ${hhmm(stakes.by)} → apps locked through '
'${formatDay(stakes.lockThrough)}';
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Text(
line,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
// Null-safe: a missing theme extension must not break a ring.
color: theme.extension<AppStatusColors>()?.warning,
child: SpokenLabel(
semanticLabel: line,
child: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Icon(Icons.gavel_outlined, color: warningColor),
const SizedBox(width: 4),
Text(hhmm(stakes.by), style: TextStyle(color: warningColor)),
const SizedBox(width: 4),
const Icon(Icons.arrow_forward, size: 16),
const SizedBox(width: 4),
Icon(Icons.lock_outline, color: warningColor),
const SizedBox(width: 4),
Text(
_formatLockDate(stakes.lockThrough),
style: TextStyle(color: warningColor),
),
],
),
),
);
}
String _formatLockDate(DateTime d) =>
'${d.day.toString().padLeft(2, '0')}'
'.${d.month.toString().padLeft(2, '0')}';
}

View File

@ -1,8 +1,10 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.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,
child: Row(
children: <Widget>[
Text('Tag ${slot + 1}: ${id ?? "none registered"}'),
const SizedBox(height: 8),
CircleAvatar(radius: 14, child: Text('${slot + 1}')),
const SizedBox(width: 12),
Expanded(
child: id != null
? KeyedSubtree(
key: ValueKey<String>('content:tag-$slot'),
child: Text(id),
)
: const DashedSlot(),
),
const SizedBox(width: 12),
FilledButton(
key: ValueKey<String>('register-$slot'),
onPressed: _scanningSlot != null ? null : () => _startScan(slot),
child: Text(
scanning
? 'Waiting for tag…'
: 'Register tag ${slot + 1}${id == null ? "" : " again"}',
),
child: scanning
? const SizedBox.square(
dimension: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Icon(
id == null ? Icons.add : Icons.refresh,
semanticLabel: id == null
? 'Register tag ${slot + 1}'
: 'Register tag ${slot + 1} again',
),
),
],
),
@ -110,20 +137,15 @@ class _RoomProofScreenState extends State<RoomProofScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Room proof')),
appBar: AppBar(),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
const Text(
'Put the two tags in different rooms. Dismissing the alarm '
'means tapping tag 1, walking, then tapping tag 2.',
),
const SizedBox(height: 24),
for (var i = 0; i < RoomProofSettings.requiredTagCount; i++)
_slotRow(i),
Text(_status),
StatusCue(semanticLabel: _status),
],
),
),

View File

@ -1,7 +1,11 @@
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/screens/energy_picker.dart';
import 'package:wake_alarm_sync/screens/session_checklist.dart';
import 'package:wake_alarm_sync/services/morning_session.dart';
import 'package:wake_alarm_sync/ui/cues.dart';
import 'package:wake_alarm_sync/ui/theme.dart';
const _weightSemantics = 'Weight (kg)';
/// The session screen's body: the checklist, the check-in, the clocks.
///
@ -49,8 +53,9 @@ class SessionBody extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final leg = session.currentLeg;
final reRingWords =
'Re-rings: ${session.reRings}/${MorningSession.maxReRings}';
return Scaffold(
body: SafeArea(
child: SingleChildScrollView(
@ -58,58 +63,54 @@ class SessionBody extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Text('Stay up', style: theme.textTheme.headlineMedium),
const SizedBox(height: 4),
Text(
'The alarm is off. It comes back if a step below is missed. '
'Leaving the app is fine — the notification brings you back.',
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 24),
SessionChecklist(session: session, now: now),
const SizedBox(height: 24),
if (_awaitingWeight) ...<Widget>[
EnergyPicker(selected: energy, onSelect: onSelectEnergy),
const SizedBox(height: 12),
TextField(
controller: controller,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
MergeSemantics(
child: Semantics(
label: _weightSemantics,
child: TextField(
controller: controller,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
decoration: const InputDecoration(
prefixIcon: Icon(Icons.monitor_weight_outlined),
border: OutlineInputBorder(),
),
onSubmitted: (_) => onSubmitWeight(),
),
),
decoration: const InputDecoration(
labelText: 'Weight (kg)',
border: OutlineInputBorder(),
),
onSubmitted: (_) => onSubmitWeight(),
),
const SizedBox(height: 12),
FilledButton(
key: const Key('session-log-weight'),
onPressed: onSubmitWeight,
child: const Text('Log weight'),
child: const Icon(Icons.check, semanticLabel: 'Log weight'),
),
const SizedBox(height: 16),
],
if (leg != null)
Text(
_deadlineLine(leg),
textAlign: TextAlign.center,
style: theme.textTheme.titleMedium,
if (leg != null) ...<Widget>[
SpokenLabel(
semanticLabel: _deadlineWords(leg),
child: _DeadlineRow(leg: leg, session: session, now: now),
),
const SizedBox(height: 8),
],
SpokenLabel(
semanticLabel: reRingWords,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Icon(Icons.replay, size: 16),
Text('${session.reRings}/${MorningSession.maxReRings}'),
],
),
const SizedBox(height: 8),
Text(
'Re-rings: ${session.reRings}/${MorningSession.maxReRings}',
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall,
),
const SizedBox(height: 16),
if (status.isNotEmpty)
Text(
status,
textAlign: TextAlign.center,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyLarge,
),
Center(child: StatusCue(semanticLabel: status)),
],
),
),
@ -117,10 +118,10 @@ class SessionBody extends StatelessWidget {
);
}
String _deadlineLine(SessionLeg leg) {
String _deadlineWords(SessionLeg leg) {
final soonest = session.earliest(leg);
if (soonest != null && now.isBefore(soonest)) {
return 'Shower — tag counts in ${_mmss(soonest.difference(now))}';
return 'Shower tag counts in ${_mmss(soonest.difference(now))}';
}
final deadline = session.deadline(leg);
if (now.isBefore(deadline)) {
@ -135,3 +136,54 @@ class SessionBody extends StatelessWidget {
return '$m:$s';
}
}
class _DeadlineRow extends StatelessWidget {
const _DeadlineRow({
required this.leg,
required this.session,
required this.now,
});
final SessionLeg leg;
final MorningSession session;
final DateTime now;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final soonest = session.earliest(leg);
if (soonest != null && now.isBefore(soonest)) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Icon(Icons.shower),
const Icon(Icons.hourglass_top),
Text(_mmss(soonest.difference(now))),
],
);
}
final deadline = session.deadline(leg);
if (now.isBefore(deadline)) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Icon(Icons.alarm),
Text(_mmss(deadline.difference(now))),
],
);
}
final statusColors = theme.extension<AppStatusColors>();
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Icon(Icons.alarm_off, color: statusColors?.warning ?? Colors.amber),
],
);
}
static String _mmss(Duration d) {
final m = d.inMinutes.toString().padLeft(2, '0');
final s = (d.inSeconds % 60).toString().padLeft(2, '0');
return '$m:$s';
}
}

View File

@ -1,4 +1,4 @@
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/services/morning_session.dart';
import 'package:wake_alarm_sync/services/session_rules.dart';
import 'package:wake_alarm_sync/services/weight_entry.dart';

View File

@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/services/morning_session.dart';
import 'package:wake_alarm_sync/services/weight_entry.dart';
import 'package:wake_alarm_sync/ui/spoken_label.dart';
import 'package:wake_alarm_sync/ui/theme.dart';
/// The session's progress list: the weigh-in, then every leg with a tag.
///
@ -23,50 +25,142 @@ class SessionChecklist extends StatelessWidget {
final theme = Theme.of(context);
final weighed = session.weightKg != null || session.pendingWeightKg != null;
final current = session.currentLeg;
final rows = <(String, bool, bool)>[
(
'Weigh in${_kg(session)}',
weighed,
!weighed && current == SessionLeg.bathroomIn,
final rows = <Widget>[
_WeighInRow(
session: session,
weighed: weighed,
current: current,
theme: theme,
),
for (final leg in SessionLeg.values)
if (session.passedHow[leg] != LegPass.skipped)
(
_label(leg),
session.passedAt.containsKey(leg),
leg == current && (leg != SessionLeg.bathroomIn || weighed),
_LegRow(
session: session,
leg: leg,
current: current,
theme: theme,
weighed: weighed,
),
];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
for (final (label, done, live) in rows)
Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: <Widget>[
Icon(
done ? Icons.check_circle : Icons.radio_button_unchecked,
color: done ? theme.colorScheme.primary : theme.disabledColor,
),
const SizedBox(width: 12),
Expanded(
child: Text(
label,
style: live
? theme.textTheme.titleMedium
: theme.textTheme.bodyMedium,
),
),
],
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: rows);
}
}
class _WeighInRow extends StatelessWidget {
const _WeighInRow({
required this.session,
required this.weighed,
required this.current,
required this.theme,
});
final MorningSession session;
final bool weighed;
final SessionLeg? current;
final ThemeData theme;
@override
Widget build(BuildContext context) {
final live = !weighed && current == SessionLeg.bathroomIn;
return SpokenLabel(
semanticLabel: weighed
? session.energy == null
? 'Weight logged'
: 'Weigh in — ${session.weightKg ?? session.pendingWeightKg} kg · energy ${session.energy}/$maxEnergy'
: 'Weigh in',
key: const ValueKey<String>('session-row-weigh'),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: <Widget>[
Icon(
weighed ? Icons.check_circle : Icons.radio_button_unchecked,
color: weighed ? theme.colorScheme.primary : theme.disabledColor,
),
),
],
const SizedBox(width: 12),
Icon(
Icons.monitor_weight_outlined,
size: 32,
color: live ? theme.colorScheme.primary : null,
),
const SizedBox(width: 12),
if (weighed) ...<Widget>[
Text('${session.weightKg ?? session.pendingWeightKg}'),
if (session.energy != null) ...<Widget>[
const SizedBox(width: 8),
const Icon(Icons.bolt, size: 18),
Text('${session.energy}/$maxEnergy'),
],
],
],
),
),
);
}
}
class _LegRow extends StatelessWidget {
const _LegRow({
required this.session,
required this.leg,
required this.current,
required this.theme,
required this.weighed,
});
final MorningSession session;
final SessionLeg leg;
final SessionLeg? current;
final ThemeData theme;
final bool weighed;
@override
Widget build(BuildContext context) {
final done = session.passedAt.containsKey(leg);
final how = session.passedHow[leg];
final live = leg == current && (leg != SessionLeg.bathroomIn || weighed);
final bypassed = how == LegPass.bypassed;
final statusColors = theme.extension<AppStatusColors>();
final warningColor = statusColors?.warning ?? Colors.amber;
IconData frontIcon;
if (done) {
frontIcon = bypassed ? Icons.timer_off : Icons.check_circle;
} else {
frontIcon = Icons.radio_button_unchecked;
}
Color frontColor;
if (done) {
frontColor = bypassed ? warningColor : theme.colorScheme.primary;
} else {
frontColor = theme.disabledColor;
}
return SpokenLabel(
semanticLabel: how == null ? leg.label : _semanticLabel(leg, how),
key: ValueKey<String>('session-row-${leg.name}'),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: <Widget>[
Icon(frontIcon, color: frontColor, size: live ? 32 : 24),
const SizedBox(width: 12),
_legIcon(
leg,
size: live ? 32 : 24,
color: live ? theme.colorScheme.primary : null,
),
const SizedBox(width: 12),
const Icon(Icons.nfc, size: 18),
],
),
),
);
}
String _label(SessionLeg leg) {
final how = session.passedHow[leg];
String _semanticLabel(SessionLeg leg, LegPass how) {
if (how == LegPass.bypassed) return '${leg.label} (skipped by the clock)';
if (leg == SessionLeg.bathroomIn && !session.bathroomTagRequired) {
return 'Weight logged';
@ -74,12 +168,20 @@ class SessionChecklist extends StatelessWidget {
return leg.label;
}
static String _kg(MorningSession session) {
final kg = session.weightKg ?? session.pendingWeightKg;
if (kg == null) return '';
final energy = session.energy;
return energy == null
? ' — $kg kg'
: ' — $kg kg · energy $energy/$maxEnergy';
Widget _legIcon(SessionLeg leg, {double size = 24, Color? color}) {
return Icon(_legIconData(leg), size: size, color: color);
}
IconData _legIconData(SessionLeg leg) {
switch (leg) {
case SessionLeg.bathroomIn:
return Icons.bathroom_outlined;
case SessionLeg.showerDone:
return Icons.shower;
case SessionLeg.dressed:
return Icons.checkroom;
case SessionLeg.atDesk:
return Icons.desk;
}
}
}

View File

@ -1,6 +1,6 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/main.dart';
import 'package:wake_alarm_sync/screens/session_body.dart';
import 'package:wake_alarm_sync/screens/session_checkin.dart';

View File

@ -1,8 +1,10 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.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,67 @@ class _SessionTagsScreenState extends State<SessionTagsScreen> {
});
}
IconData _iconForSlot(SessionTagSlot slot) => switch (slot) {
SessionTagSlot.bathroom => Icons.shower,
SessionTagSlot.wardrobe => Icons.checkroom,
SessionTagSlot.desk => Icons.desk,
};
String _semanticLabelForSlot(SessionTagSlot slot) => switch (slot) {
SessionTagSlot.bathroom => 'Bathroom (scale + shower)',
SessionTagSlot.wardrobe => 'Wardrobe',
SessionTagSlot.desk => 'Desk',
};
Widget _slotRow(SessionTagSlot slot) {
final id = _settings.tagAt(slot);
final scanning = _scanning == slot;
return Padding(
padding: const EdgeInsets.only(bottom: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
child: Row(
children: <Widget>[
Text('${_title(slot)}: ${id ?? "none registered"}'),
const SizedBox(height: 8),
Icon(_iconForSlot(slot), semanticLabel: _semanticLabelForSlot(slot)),
const SizedBox(width: 12),
Expanded(
child: id != null
? KeyedSubtree(
key: ValueKey<String>('content:tag-${slot.name}'),
child: Text(id),
)
: const DashedSlot(),
),
const SizedBox(width: 12),
FilledButton(
key: ValueKey<String>('register-${slot.name}'),
onPressed: _scanning != null ? null : () => _startScan(slot),
child: Text(
scanning
? 'Waiting for tag…'
: 'Register ${slot.name} tag${id == null ? "" : " again"}',
),
child: scanning
? const SizedBox.square(
dimension: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Icon(
id == null ? Icons.add : Icons.refresh,
semanticLabel: id == null
? 'Register ${slot.name} tag'
: 'Register ${slot.name} tag again',
),
),
],
),
);
}
static String _title(SessionTagSlot slot) => switch (slot) {
SessionTagSlot.bathroom => 'Bathroom (scale + shower)',
SessionTagSlot.wardrobe => 'Wardrobe',
SessionTagSlot.desk => 'Desk',
};
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Morning session tags')),
appBar: AppBar(),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
const Text(
'After the alarm is dismissed: weigh in and tap the bathroom '
'tag, shower and tap it again, tap the wardrobe tag dressed, '
'then the desk tag. A slot left empty is skipped.',
),
const SizedBox(height: 24),
for (final slot in SessionTagSlot.values) _slotRow(slot),
Text(_status),
StatusCue(semanticLabel: _status),
],
),
),

View File

@ -1,16 +1,20 @@
import 'dart:developer' as developer;
import 'package:flutter/material.dart';
import 'package:crdt_sync_flutter/crdt_sync_flutter.dart'
show E2ePassphraseButton;
import 'package:material_ui/material_ui.dart';
import 'package:sync_settings_ui/sync_settings_ui.dart';
import 'package:wake_alarm_sync/screens/github_mirror_screen.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/services/alarm_sync_e2e.dart';
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,10 +76,21 @@ 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: [
// End-to-end encryption passphrase: a closed lock while the last
// sync found no usable key, so nothing reaches the PC until set.
ValueListenableBuilder<bool>(
valueListenable: alarmSyncLocked,
builder: (_, locked, _) => E2ePassphraseButton(
key: const Key('settings-e2e'),
locked: locked,
),
),
IconButton(
key: const Key('settings-export'),
icon: const Icon(Icons.upload_file, semanticLabel: 'Export data'),
@ -98,9 +113,12 @@ 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 +135,57 @@ class SettingsScreen extends StatelessWidget {
),
),
ListTile(
key: const Key('settings-room-proof'),
contentPadding: EdgeInsets.zero,
title: const Text('Room proof'),
subtitle: const Text('Two NFC tags, at two locations'),
leading: const Icon(Icons.nfc, semanticLabel: 'Room proof'),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.of(context).push<void>(
MaterialPageRoute(builder: (_) => const RoomProofScreen()),
),
),
ListTile(
key: const Key('settings-session-tags'),
contentPadding: EdgeInsets.zero,
title: const Text('Morning session tags'),
subtitle: const Text('Bathroom, wardrobe, desk — after the ring'),
leading: const Icon(
Icons.checklist,
semanticLabel: 'Morning session tags',
),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.of(context).push<void>(
MaterialPageRoute(builder: (_) => const SessionTagsScreen()),
),
),
ListTile(
key: const Key('settings-home'),
contentPadding: EdgeInsets.zero,
title: const Text('Home location'),
subtitle: const Text('The Wi-Fi and place that mean you are home'),
leading: const Icon(
Icons.home_outlined,
semanticLabel: 'Home location',
),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.of(context).push<void>(
MaterialPageRoute(builder: (_) => const HomeLocationScreen()),
),
),
ListTile(
key: const Key('settings-github'),
contentPadding: EdgeInsets.zero,
title: const Text('Advanced sync (GitHub)'),
subtitle: const Text('Cutover mirror — not recommended'),
trailing: const Icon(Icons.chevron_right),
leading: Icon(
Icons.code,
color: colors.onSurfaceVariant.withValues(alpha: 0.6),
semanticLabel: 'Advanced sync (GitHub)',
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.warning_amber_rounded,
color: statusColors?.warning ?? colors.error,
semanticLabel: 'Cutover mirror, not recommended',
),
const Icon(Icons.chevron_right),
],
),
onTap: () => Navigator.of(context).push<void>(
MaterialPageRoute(builder: (_) => const GitHubMirrorScreen()),
),

View File

@ -1,9 +1,11 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/services/free_day_pool.dart';
import 'package:wake_alarm_sync/services/sick_day_keep.dart';
import 'package:wake_alarm_sync/services/sick_day_pool.dart';
export 'package:wake_alarm_sync/screens/sick_day_sheet.dart';
/// What each locker is called on screen, in `sickdays.LOCKERS` order.
const sickLockerLabels = <String, String>{
SickLocker.workout: 'Workout lock',
@ -13,6 +15,15 @@ const sickLockerLabels = <String, String>{
SickLocker.alarm: 'Wake alarm',
};
/// Icons for each locker, matching [sickLockerLabels] order.
const sickLockerIcons = <String, IconData>{
SickLocker.workout: Icons.fitness_center,
SickLocker.leetcode: Icons.code,
SickLocker.book: Icons.menu_book,
SickLocker.diet: Icons.restaurant,
SickLocker.alarm: Icons.alarm,
};
/// The kept lockers of a day as one line, in [sickLockerLabels] order.
String keptLabel(Set<String> kept) => [
for (final MapEntry(:key, :value) in sickLockerLabels.entries)
@ -116,7 +127,11 @@ class SickDaySheet extends StatefulWidget {
class _SickDaySheetState extends State<SickDaySheet> {
late final Set<String> _keep = {...widget.kept};
static const _weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
String _toDdMmYyyy(DateTime day) {
final d = day.day.toString().padLeft(2, '0');
final m = day.month.toString().padLeft(2, '0');
return '$d.$m.${day.year}';
}
void _close(SickSheetAction action) => Navigator.of(
context,
@ -132,26 +147,19 @@ class _SickDaySheetState extends State<SickDaySheet> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'${widget.isSick ? 'Sick day' : 'Mark sick'}: '
'${_weekdays[day.weekday - 1]} ${FreeDayPool.toIso(day)}',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
'Off = stands down for the day. Switch on any locker that '
'should still apply; it then behaves exactly as on a normal '
'day.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
Row(
children: [
const Icon(Icons.sick_outlined),
const SizedBox(width: 8),
Text(_toDdMmYyyy(day), style: theme.textTheme.titleMedium),
],
),
const SizedBox(height: 8),
for (final MapEntry(:key, :value) in sickLockerLabels.entries)
SwitchListTile(
key: ValueKey<String>('keep-$key'),
contentPadding: EdgeInsets.zero,
title: Text(value),
title: Icon(sickLockerIcons[key], semanticLabel: value),
value: _keep.contains(key),
onChanged: (on) =>
setState(() => on ? _keep.add(key) : _keep.remove(key)),
@ -159,21 +167,27 @@ class _SickDaySheetState extends State<SickDaySheet> {
const SizedBox(height: 16),
if (widget.isSick) ...[
FilledButton(
key: const ValueKey<String>('sick-save'),
onPressed: () => _close(SickSheetAction.save),
child: const Text('Save'),
child: const Icon(Icons.check, semanticLabel: 'Save'),
),
const SizedBox(height: 8),
TextButton(
style: TextButton.styleFrom(
foregroundColor: theme.colorScheme.error,
),
key: const ValueKey<String>('sick-clear'),
onPressed: () => _close(SickSheetAction.clear),
child: const Text('Clear sick day'),
child: const Icon(
Icons.delete_outline,
semanticLabel: 'Clear sick day',
),
),
] else
FilledButton(
key: const ValueKey<String>('sick-mark'),
onPressed: () => _close(SickSheetAction.mark),
child: const Text('Mark sick'),
child: const Icon(Icons.sick, semanticLabel: 'Mark sick'),
),
],
),

View File

@ -1,8 +1,9 @@
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.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,112 @@ class SickDaysBody extends StatelessWidget {
/// The status line; may be a raw `Error: …`.
final String status;
/// The native ring-path cache, read back (`Alarm cache: …`).
final String cache;
/// The native ring-path cache, read back (list of ISO
/// dates or null for unreadable).
final List<String>? cache;
/// Whether the cache has been read at least once.
final bool cacheRead;
/// Whether [status] is a refused tap, drawn like an error.
final bool refused;
static const _weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
String _label(DateTime day) =>
'${_weekdays[day.weekday - 1]} ${FreeDayPool.toIso(day)}';
String _toDdMmYyyy(DateTime day) {
final d = day.day.toString().padLeft(2, '0');
final m = day.month.toString().padLeft(2, '0');
return '$d.$m.${day.year}';
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final muted = theme.colorScheme.onSurfaceVariant;
final statusColors = theme.extension<AppStatusColors>()!;
final failed = refused || status.contains('Error');
return SafeArea(
child: ListView(
padding: const EdgeInsets.all(16),
children: [
Text(
'Tap a day to mark it sick and choose which lockers still '
'apply; tap a sick day to change them or clear it. Today or '
'later only. No limit and no reason needed.',
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 8),
Text(
'On a sick day every locker left off stands down: no alarm, no '
'lock, and work left undone is dropped rather than carried '
'forward. A locker switched on behaves as on a normal day; a dot '
'marks a sick day that keeps one.',
style: theme.textTheme.bodySmall?.copyWith(color: muted),
),
if (status.isNotEmpty) ...[
const SizedBox(height: 16),
Text(
status,
maxLines: 4,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: failed ? theme.colorScheme.error : statusColors.success,
),
StatusCue(
semanticLabel: status,
kind: failed ? CueKind.error : null,
),
],
if (cache.isNotEmpty) ...[
if (cacheRead) ...[
const SizedBox(height: 8),
Text(cache, style: TextStyle(color: muted)),
Row(
children: [
Icon(
Icons.memory,
size: 16,
semanticLabel: _cacheSemanticLabel,
),
const SizedBox(width: 8),
_buildCacheContent(theme),
],
),
],
const SizedBox(height: 16),
calendar,
const SizedBox(height: 24),
Text('Marked sick', style: theme.textTheme.titleMedium),
const Row(
children: [Icon(Icons.sick_outlined, semanticLabel: 'Marked sick')],
),
const SizedBox(height: 8),
if (upcoming.isEmpty)
Text('None ahead.', style: TextStyle(color: muted)),
const SpokenLabel(
semanticLabel: 'None ahead.',
child: DashedSlot(),
),
for (final day in upcoming)
ListTile(
contentPadding: EdgeInsets.zero,
title: Text(_label(day)),
subtitle: switch (keptLabel(keptOf(day))) {
'' => null,
final kept => Text('Kept: $kept'),
},
title: Text(_toDdMmYyyy(day)),
subtitle: _keptIcons(keptOf(day)),
),
],
),
);
}
String get _cacheSemanticLabel {
if (cache == null) return 'Alarm cache: unreadable';
if (cache!.isEmpty) return 'Alarm cache: none';
return 'Alarm cache: ${cache!.join(', ')}';
}
Widget _buildCacheContent(ThemeData theme) {
if (cache == null) {
return Icon(
Icons.error_outline,
color: theme.colorScheme.error,
size: 16,
);
}
if (cache!.isEmpty) {
return Icon(
Icons.check,
size: 16,
color: theme.colorScheme.onSurfaceVariant,
);
}
return Text(
cache!.join(', '),
style: TextStyle(color: theme.colorScheme.onSurfaceVariant),
);
}
Widget? _keptIcons(Set<String> kept) {
if (kept.isEmpty) return null;
return Row(
children: [
for (final locker in SickLocker.all)
if (kept.contains(locker))
Padding(
padding: const EdgeInsets.only(right: 4),
child: Icon(sickLockerIcons[locker], size: 16),
),
],
);
}
}

View File

@ -1,6 +1,6 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.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/screens/sick_days_body.dart';
@ -9,6 +9,7 @@ import 'package:wake_alarm_sync/services/free_day_pool.dart';
import 'package:wake_alarm_sync/services/free_day_sync.dart';
import 'package:wake_alarm_sync/services/sick_day_pool.dart';
import 'package:wake_alarm_sync/services/sick_day_sync.dart';
import 'package:wake_alarm_sync/ui/cues.dart';
import 'package:wake_alarm_sync/ui/theme.dart';
/// The phone's picker for the shared sick-day pool (`~/src/utils/sickdays`).
@ -68,7 +69,8 @@ class _SickDaysScreenState extends State<SickDaysScreen> {
bool _refused = false;
/// What the native ring-path cache holds, read back over the channel.
String _cache = '';
List<String>? _cache;
bool _cacheRead = false;
late DateTime _month = DateTime(_today.year, _today.month);
@ -112,12 +114,8 @@ class _SickDaysScreenState extends State<SickDaysScreen> {
}
if (!mounted) return;
setState(() {
_cache =
'Alarm cache: ${switch (dates) {
null => 'unreadable',
[] => 'none',
final d => d.join(', '),
}}';
_cache = dates;
_cacheRead = true;
});
}
@ -189,23 +187,20 @@ class _SickDaysScreenState extends State<SickDaysScreen> {
final marks = Theme.of(context).extension<DayMarkColors>()!;
return Scaffold(
appBar: AppBar(
title: const Row(
children: [
Icon(Icons.sick_outlined),
SizedBox(width: 8),
Text('Sick days'),
],
),
title: const Row(children: [Icon(Icons.sick_outlined)]),
actions: [
IconButton(
icon: const Icon(Icons.sync),
tooltip: 'Sync now',
icon: const Icon(Icons.sync, semanticLabel: 'Sync now'),
onPressed: _busy ? null : () => unawaited(_sync()),
),
],
),
body: pool == null
? Center(child: Text(_status.isEmpty ? 'Loading…' : _status))
? Center(
child: _status.isEmpty
? const CircularProgressIndicator()
: StatusCue(semanticLabel: _status),
)
: SickDaysBody(
upcoming: [
for (final day in pool.sickDays())
@ -214,6 +209,7 @@ class _SickDaysScreenState extends State<SickDaysScreen> {
keptOf: pool.keptLockers,
status: _status,
cache: _cache,
cacheRead: _cacheRead,
refused: _refused,
calendar: DayCalendar(
month: _month,

View File

@ -1,13 +1,15 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/screens/escape_dialog.dart';
import 'package:wake_alarm_sync/screens/stakes_marks.dart';
import 'package:wake_alarm_sync/services/alarm_policy.dart';
import 'package:wake_alarm_sync/services/enforcement_bridge.dart';
import 'package:wake_alarm_sync/services/enforcement_state.dart';
import 'package:wake_alarm_sync/services/escape_hatch.dart';
import 'package:wake_alarm_sync/services/today_stakes.dart';
import 'package:wake_alarm_sync/ui/theme.dart';
import 'package:wake_alarm_sync/ui/cues.dart';
import 'package:wake_alarm_sync/ui/dashed_slot.dart';
/// Today's stakes in plain words, anything already in force, the streak,
/// and the escape hatch while it lasts.
@ -61,6 +63,7 @@ class _StakesCardState extends State<StakesCard> {
EnforcementState? _state;
AlarmTime? _next;
String _message = '';
bool _excused = false;
bool _loaded = false;
DateTime _now() => (widget.now ?? DateTime.now)();
@ -102,6 +105,7 @@ class _StakesCardState extends State<StakesCard> {
if (reason == null) return;
final ok = await _hatch.use(reason);
if (!mounted) return;
_excused = ok;
setState(
() => _message = ok
? 'Excused — lockdown lifted, publishing to the PC.'
@ -116,12 +120,12 @@ class _StakesCardState extends State<StakesCard> {
final state = _state;
final empty = widget.emptyText;
if (state == null) {
return _loaded && empty != null ? Text(empty) : const SizedBox.shrink();
return _loaded && empty != null
? SpokenLabel(semanticLabel: empty, child: const DashedSlot())
: const SizedBox.shrink();
}
final theme = Theme.of(context);
final warning = theme.extension<AppStatusColors>()?.warning;
final view = describeStakes(now: _now(), state: state, nextAlarm: _next);
final body = theme.textTheme.bodyMedium;
return Card(
color: theme.colorScheme.surfaceContainerHigh,
margin: EdgeInsets.zero,
@ -130,37 +134,25 @@ class _StakesCardState extends State<StakesCard> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Text(view.title, style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
for (final line in view.stakes)
Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Text(line, style: body),
),
for (final line in view.pending)
Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Text(line, style: body?.copyWith(color: warning)),
),
const SizedBox(height: 8),
Text(
view.streak,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
StakesMarks(view: view),
if (_message.isNotEmpty) ...[
const SizedBox(height: 8),
Text(_message, style: body),
StatusCue(
semanticLabel: _message,
kind: _excused ? CueKind.ok : CueKind.error,
),
],
if (_hatch.isOpen) ...[
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
child: IconButton(
key: const Key('stakes-escape'),
onPressed: widget.busy ? null : () => unawaited(_escape()),
icon: const Icon(Icons.bug_report_outlined),
label: const Text('That was a bug'),
icon: const Icon(
Icons.bug_report_outlined,
semanticLabel: 'That was a bug',
),
),
),
],

View File

@ -0,0 +1,135 @@
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/services/today_stakes.dart';
import 'package:wake_alarm_sync/ui/spoken_label.dart';
import 'package:wake_alarm_sync/ui/theme.dart';
/// The stakes of [view] as glyph rows: times and dates are the only
/// characters painted; each row speaks its sentence from [StakesView].
class StakesMarks extends StatelessWidget {
/// Creates the rows for [view].
const StakesMarks({required this.view, super.key});
/// The stakes to draw.
final StakesView view;
static String _dm(DateTime d) =>
'${d.day.toString().padLeft(2, '0')}.'
'${d.month.toString().padLeft(2, '0')}';
static String _hm(int h, int m) =>
'${h.toString().padLeft(2, '0')}:${m.toString().padLeft(2, '0')}';
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final warning =
theme.extension<AppStatusColors>()?.warning ?? theme.colorScheme.error;
final kindIcon = switch (view.kind) {
StakesDayKind.free => Icons.event_available,
StakesDayKind.sick => Icons.sick_outlined,
StakesDayKind.stick => Icons.gavel_outlined,
StakesDayKind.carrot => Icons.emoji_events_outlined,
};
final rows = <Widget>[
_row('title', view.title, <Widget>[
Icon(kindIcon, color: theme.colorScheme.primary),
Icon(view.isToday ? Icons.today : Icons.event, size: 18),
Text(_dm(view.day), style: theme.textTheme.titleMedium),
]),
];
final words = view.stakes;
final dismissBy = view.dismissBy;
final lock = view.lockThrough;
final finishBy = view.finishBy;
final open = view.pcOpenUntil;
final ring = view.pcRingAt;
if (dismissBy != null && lock != null) {
rows.add(
_row('stick', words.first, <Widget>[
const Icon(Icons.alarm),
Text(hhmm(dismissBy)),
const Icon(Icons.arrow_forward, size: 16),
const Icon(Icons.lock_outline),
Text(_dm(lock)),
]),
);
} else if (finishBy != null && open != null) {
rows.add(
_row('carrot', words.first, <Widget>[
const Icon(Icons.flag_outlined),
Text(hhmm(finishBy)),
const Icon(Icons.arrow_forward, size: 16),
const Icon(Icons.computer),
Text(_hm(open.hour, open.minute)),
]),
);
} else if (view.kind != StakesDayKind.free &&
view.kind != StakesDayKind.sick) {
rows.add(
_row('unarmed', words.first, const <Widget>[Icon(Icons.alarm_off)]),
);
}
if (ring != null) {
rows.add(
_row('pc-ring', words.last, <Widget>[
const Icon(Icons.computer),
const Icon(Icons.notifications_active_outlined),
Text(hhmm(ring)),
]),
);
}
rows
..addAll(_pending(warning))
..add(
_row('streak', view.streak, <Widget>[
Icon(
Icons.local_fire_department_outlined,
color: theme.colorScheme.onSurfaceVariant,
),
Text('${view.streakCount}'),
]),
);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: rows,
);
}
/// The rows for what is already in force, in `view.pending`'s order.
List<Widget> _pending(Color warning) {
final spoken = view.pending.iterator;
String next() => spoken.moveNext() ? spoken.current : '';
final locked = view.lockedThrough;
final style = TextStyle(color: warning);
return <Widget>[
if (view.escapedToday)
_row('escaped', next(), <Widget>[
Icon(Icons.bug_report_outlined, color: warning),
Icon(Icons.check, color: warning),
]),
if (locked != null)
_row('locked', next(), <Widget>[
Icon(Icons.lock_outline, color: warning),
Text(_dm(locked), style: style),
]),
if (view.missedToday)
_row('missed', next(), <Widget>[
Icon(Icons.close, color: warning),
Icon(Icons.computer, color: warning),
]),
];
}
static Widget _row(String what, String words, List<Widget> marks) => Padding(
key: ValueKey<String>('stakes-$what'),
padding: const EdgeInsets.symmetric(vertical: 4),
child: SpokenLabel(
semanticLabel: words,
child: Row(
children: <Widget>[
for (final mark in marks) ...<Widget>[mark, const SizedBox(width: 8)],
],
),
),
);
}

View File

@ -0,0 +1,59 @@
/// End-to-end encryption of the alarm record this app publishes.
///
/// The record (alarm time, tags, home location, morning sessions, weigh-ins,
/// enforcement fields) is sealed with a key derived from the shared
/// crdt-sync passphrase in the OS keystore, under
/// `wake-alarm-sync/devices-e2e`; Firebase and the GitHub mirror only see
/// the record id and clocks. The PC (`wake_alarm/_sync_transport.py`) and
/// diet_guard (its weigh-in ingest) read the same layout.
///
/// Fail closed: without a usable key nothing is pulled or pushed. The old
/// plaintext prefix is copied over on open and never written or deleted.
library;
import 'package:crdt_sync/crdt_sync.dart';
import 'package:crdt_sync_flutter/crdt_sync_flutter.dart'
show openEncryptedStoreFromKeystore;
import 'package:flutter/foundation.dart';
/// The plaintext prefix every pre-E2E build wrote; only the copy reads it.
const legacyAlarmPrefix = 'wake-alarm-sync/devices';
/// Where the alarm record now lives, and where its revisions go.
final E2eLayout alarmLayout = E2eLayout.of(legacyAlarmPrefix);
/// Argon2id cost for a header this device creates; only tests lower it.
@visibleForTesting
Argon2Params e2eArgon2Params = Argon2Params.recommended;
/// Whether the last open found no usable key, for the lock button.
final ValueNotifier<bool> alarmSyncLocked = ValueNotifier<bool>(false);
/// Thrown instead of syncing when end-to-end encryption has no usable key.
class SyncLockedException implements Exception {
/// Creates the exception for [reason].
const SyncLockedException(this.reason);
/// Why the store is locked.
final E2eLockReason reason;
@override
String toString() => 'sync locked (${reason.name})';
}
/// The encrypted view of [backend], the outermost store (above the mirror).
///
/// Throws [SyncLockedException] when locked, so no caller can fall through
/// to a plaintext push.
Future<RemoteStore> openAlarmStore(RemoteStore backend) async {
final state = await openEncryptedStoreFromKeystore(
store: backend,
oldPrefix: legacyAlarmPrefix,
params: e2eArgon2Params,
);
alarmSyncLocked.value = state is! E2eReady;
if (state is E2eReady) return state.store;
throw SyncLockedException(
state is E2eLocked ? state.reason : E2eLockReason.badHeader,
);
}

View File

@ -7,6 +7,7 @@ import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:uuid/uuid.dart';
import 'package:wake_alarm_sync/services/alarm_sync_e2e.dart';
import 'package:wake_alarm_sync/services/firebase_backend.dart';
/// The alarm time this device holds, as read from or written to the store.
@ -17,7 +18,8 @@ typedef Alarm = ({int hour, int minute});
///
/// The phone is the **sole writer**: it stores the chosen alarm locally in a
/// [LogStore] (one HLC-stamped [Record], id [_recordId]) and pushes that log
/// to `wake-alarm-sync/devices/<deviceId>/alarm.json` — Firebase first,
/// to `wake-alarm-sync/devices-e2e/<deviceId>/alarm.json`, end-to-end
/// encrypted (`alarm_sync_e2e.dart`) — Firebase first,
/// mirrored to the private `kuhyx/syncs` repo during the cutover (see
/// [syncBackend]). The path is deliberately identical on both backends, so
/// only the transport differs.
@ -162,11 +164,17 @@ class AlarmSyncService {
/// The pull is what brings the tags and home location back to a fresh
/// install. The alarm time itself is unaffected: [setLocal] runs first on
/// every publish, so the local time is always the newest and wins.
///
/// Everything goes through the end-to-end encrypted view of [client]
/// (`alarm_sync_e2e.dart`); a locked device throws [SyncLockedException]
/// before reading or writing anything.
Future<void> pushWith(RemoteStore client) async {
final sealed = await openAlarmStore(client);
final merged = await syncLog(
client: client,
client: sealed,
deviceId: deviceId,
pathPrefix: 'wake-alarm-sync/devices',
pathPrefix: alarmLayout.prefix,
revsPath: alarmLayout.revsPath,
filename: 'alarm.json',
localLog: _store.snapshot(),
encode: logToJson,

View File

@ -178,7 +178,7 @@ class ChallengeGenerator {
final backwards = word.split('').reversed.join();
return Challenge(
kind: ChallengeKind.reverse,
display: 'Type this word backwards:\n$word',
display: word,
answer: backwards,
hint: 'Type the word backwards',
);

View File

@ -115,9 +115,8 @@ class HomePresence {
);
}
static String _readableDistance(double metres) => metres >= 1000
? '${(metres / 1000).round()} km'
: '${metres.round()} m';
static String _readableDistance(double metres) =>
metres >= 1000 ? '${(metres / 1000).round()} km' : '${metres.round()} m';
static String _reasonText(String? reason) => switch (reason) {
'no-permission' => 'location permission denied',

View File

@ -76,9 +76,5 @@ Future<RingStartup> resolveRingStartup({
: 'NFC is off or unavailable.',
);
}
return RingStartup(
settings: settings,
skip: RingSkip.none,
status: '',
);
return RingStartup(settings: settings, skip: RingSkip.none, status: '');
}

View File

@ -34,11 +34,20 @@ Future<void> pointNativeAtLeg(
);
}
/// The notification's one line: the leg to satisfy, or what happens instead.
/// The notification's one line: glyph + position count, with a running-clock
/// marker when the re-ring was spent.
String noticeLabel(MorningSession session) {
final leg = session.currentLeg!;
final glyphs = <SessionLeg, String>{
SessionLeg.bathroomIn: '🛁',
SessionLeg.showerDone: '🚿',
SessionLeg.dressed: '👕',
SessionLeg.atDesk: '🪑',
};
final glyph = glyphs[leg] ?? '•';
var text = '$glyph ${leg.index + 1}/${SessionLeg.values.length}';
if (session.deadlineRung.contains(leg)) {
return '${leg.label} — passes by itself soon';
text += ' ⏳';
}
return leg.label;
return text;
}

View File

@ -0,0 +1,91 @@
import 'package:wake_alarm_sync/services/stakes.dart';
/// Which kind of morning the stakes describe.
enum StakesDayKind {
/// A booked free day: nothing rings.
free,
/// A marked sick day: nothing rings.
sick,
/// A workday: a miss locks the apps.
stick,
/// A weekend day: finishing in time opens the PC early.
carrot,
}
/// What the stakes card shows: the facts, and the same facts in words.
///
/// The card paints only the facts (glyphs, times, dates); the sentences are
/// its spoken labels and what the tests read for the wording. Built by
/// `describeStakes` in `today_stakes.dart`.
class StakesView {
/// Creates a view.
const StakesView({
required this.title,
required this.stakes,
required this.pending,
required this.streak,
required this.kind,
required this.isToday,
required this.day,
required this.streakCount,
this.dismissBy,
this.lockThrough,
this.finishBy,
this.pcOpenUntil,
this.pcRingAt,
this.escapedToday = false,
this.lockedThrough,
this.missedToday = false,
});
/// The kind of the coming morning.
final StakesDayKind kind;
/// Whether the coming morning is today (else tomorrow).
final bool isToday;
/// The coming morning's date.
final DateTime day;
/// Missed stick days in a row.
final int streakCount;
/// Stick day: the dismiss deadline. Null when nothing is armed.
final DateTime? dismissBy;
/// Stick day: the last day a miss locks the apps through.
final DateTime? lockThrough;
/// Carrot day: the time the morning must be finished by.
final DateTime? finishBy;
/// Carrot day: how long a finished morning keeps the PC open.
final ClockTime? pcOpenUntil;
/// When the PC rings if the phone stays silent. Null when nothing rings.
final DateTime? pcRingAt;
/// This morning was excused.
final bool escapedToday;
/// Apps already locked through this day, when a lockdown is in force.
final DateTime? lockedThrough;
/// This morning was missed (and not excused).
final bool missedToday;
/// "Today · Thu 2 Oct — stick day" (spoken).
final String title;
/// What the coming morning costs or earns (spoken, one per row).
final List<String> stakes;
/// A lockdown, penalty or escape already in force (spoken, one per row).
final List<String> pending;
/// The streak line (spoken).
final String streak;
}

View File

@ -1,33 +1,13 @@
import 'package:wake_alarm_sync/services/alarm_ramp.dart';
import 'package:wake_alarm_sync/services/enforcement_state.dart';
import 'package:wake_alarm_sync/services/stakes.dart';
import 'package:wake_alarm_sync/services/stakes_view.dart';
export 'package:wake_alarm_sync/services/stakes_view.dart';
/// An alarm time of day.
typedef AlarmTime = ClockTime;
/// What the stakes card says, already in words.
class StakesView {
/// Creates a view.
const StakesView({
required this.title,
required this.stakes,
required this.pending,
required this.streak,
});
/// "Today · Thu 2 Oct — stick day".
final String title;
/// What the coming morning costs or earns.
final List<String> stakes;
/// A lockdown, penalty or escape already in force.
final List<String> pending;
/// The streak line.
final String streak;
}
/// The stakes of the next undecided morning, from the native [state].
///
/// That is today while today's alarm is still open (not dismissed, not
@ -59,6 +39,10 @@ StakesView describeStakes({
? 'sick day'
: (stick ? 'stick day' : 'carrot day');
final lines = <String>[];
DateTime? dismissBy;
DateTime? lockThrough;
DateTime? finishBy;
DateTime? pcRingAt;
if (alarm == null) {
lines.add('No alarm armed yet — nothing at stake.');
} else if (free) {
@ -74,21 +58,26 @@ StakesView describeStakes({
final next = todayOpen
? "tomorrow's"
: "${_weekday(_plusDays(day, 1))}'s";
dismissBy = ringAt.add(stickGrace);
lockThrough = lock;
lines.add(
'Miss (no dismiss by ${hhmm(ringAt.add(stickGrace))}) → apps '
'Miss (no dismiss by ${hhmm(dismissBy)}) → apps '
'locked through ${formatDay(lock)} + $next PC leniency gone',
);
} else {
finishBy = carrotCutoff(ringAt);
lines.add(
'Finish the morning by ${hhmm(carrotCutoff(ringAt))} → PC open '
'Finish the morning by ${hhmm(finishBy)} → PC open '
'until ${_hhmm(completedUntil.hour, completedUntil.minute)}',
);
}
pcRingAt = ringAt.add(pcPhoneSilentAfter);
lines.add(
'PC rings at ${hhmm(ringAt.add(pcPhoneSilentAfter))} if the phone is '
'PC rings at ${hhmm(pcRingAt)} if the phone is '
'silent (up to ${pcRingCap.inMinutes} min)',
);
}
final locked = state.lockedThrough;
return StakesView(
title: '$when · ${formatDay(day)} — $kind',
stakes: lines,
@ -96,6 +85,25 @@ StakesView describeStakes({
streak:
'Streak: ${state.streak} missed stick '
'day${state.streak == 1 ? '' : 's'} in a row',
kind: free
? StakesDayKind.free
: sick
? StakesDayKind.sick
: (stick ? StakesDayKind.stick : StakesDayKind.carrot),
isToday: todayOpen,
day: day,
streakCount: state.streak,
dismissBy: dismissBy,
lockThrough: lockThrough,
finishBy: finishBy,
pcOpenUntil: finishBy == null ? null : completedUntil,
pcRingAt: pcRingAt,
escapedToday: state.escapedToday,
lockedThrough:
locked != null && !locked.isBefore(today) && !state.escapedToday
? locked
: null,
missedToday: state.lastMiss == today && !state.escapedToday,
);
}
@ -103,6 +111,16 @@ StakesView describeStakes({
/// or null when this ring has none (not a stick day, free, sick, already
/// dismissed, excused or missed).
String? ringStakesLine(EnforcementState state) {
final stakes = ringStakes(state);
if (stakes == null) return null;
return 'Stick day: no dismiss by ${hhmm(stakes.by)} → apps locked through '
'${formatDay(stakes.lockThrough)}';
}
/// Returns the deadline and lockdown date for the ring stakes line, or null
/// when this ring has none (not a stick day, free, sick, already dismissed,
/// excused or missed).
({DateTime by, DateTime lockThrough})? ringStakes(EnforcementState state) {
final stored = _stored(state);
if (stored == null ||
!isWorkday(state.today) ||
@ -114,8 +132,7 @@ String? ringStakesLine(EnforcementState state) {
if (state.lastMiss == state.today) return null;
final by = _at(state.today, stored).add(stickGrace);
final lock = state.lockIfMissToday ?? _plusDays(state.today, 1);
return 'Stick day: no dismiss by ${hhmm(by)} → apps locked through '
'${formatDay(lock)}';
return (by: by, lockThrough: lock);
}
/// The carrot's cutoff for a morning whose alarm rings at [ringAt]:

View File

@ -0,0 +1,6 @@
/// The app's only visible word: the proper noun the OS task switcher shows.
///
/// Matches `android:label` in AndroidManifest.xml and the Kotlin `APP_NAME`
/// (notification channels), so the launcher, the task switcher and the
/// notification settings all name the app the same way.
const String kAppName = 'WakeAlarm';

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

@ -0,0 +1,241 @@
/// 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/services.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/ui/theme.dart' show AppStatusColors;
export 'package:wake_alarm_sync/ui/spoken_label.dart';
/// Semantic kind for a status cue; drives icon, colour and haptic.
enum CueKind {
/// Busy/loading state.
busy,
/// Success/connected state.
ok,
/// Error/failed state.
error,
/// Warning/caution state.
warning,
/// Informational state (default).
info,
}
/// Classifies a status sentence into a [CueKind].
///
/// Uses the app's existing colour conventions (e.g.
/// `status.contains('Error')`). Returns `null` for empty string.
CueKind? cueKindOf(String status) {
if (status.isEmpty) return null;
if (status.endsWith('…') || status.endsWith('...')) return CueKind.busy;
const errorKeywords = [
'Error',
'Could not',
'unavailable',
'refused',
'Not ',
'Unknown',
'Too early',
'not earlier',
'Wrong',
'Failed',
'too short',
];
for (final kw in errorKeywords) {
if (status.contains(kw)) return CueKind.error;
}
const okKeywords = [
'✓',
'Connected',
'registered',
'matched',
'Captured',
'Synced',
'Armed',
'Done',
'Correct',
'Dismissed',
'Excused',
];
for (final kw in okKeywords) {
if (status.contains(kw)) return CueKind.ok;
}
if (status.contains('Connect a sync backend') ||
status.contains('Open Settings')) {
return CueKind.warning;
}
return CueKind.info;
}
/// A semantic status icon with haptic feedback and a shake animation on error.
///
/// Paints no text. Icon and colour derived from [kind] (defaulting to
/// [cueKindOf] of [semanticLabel]). Renders [SizedBox.shrink] when [kind]
/// resolves to `null` (empty label).
class StatusCue extends StatefulWidget {
/// Creates a status cue.
///
/// [semanticLabel] is the spoken label (also used to infer [kind] when
/// not provided). [kind] overrides inference. [icon] overrides the default
/// icon for the kind. [size] controls the icon/spinner dimension.
const StatusCue({
required this.semanticLabel,
this.kind,
this.icon,
this.size = 28,
super.key,
});
/// The spoken label; also used to infer the cue kind when [kind] is null.
final String semanticLabel;
/// Explicit kind override; defaults to [cueKindOf(semanticLabel)].
final CueKind? kind;
/// Optional icon override.
final IconData? icon;
/// Icon / spinner dimension.
final double size;
/// The resolved kind (explicit or inferred).
CueKind? get resolvedKind => kind ?? cueKindOf(semanticLabel);
@override
State<StatusCue> createState() => _StatusCueState();
}
class _StatusCueState extends State<StatusCue>
with SingleTickerProviderStateMixin {
late final AnimationController _shakeCtrl;
late final Animation<double> _shakeAnim;
CueKind? _prevKind;
bool _shown = false;
@override
void initState() {
super.initState();
_shakeCtrl = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_shakeAnim = Tween<double>(
begin: 0,
end: 1,
).animate(CurvedAnimation(parent: _shakeCtrl, curve: Curves.easeOut));
_prevKind = widget.resolvedKind;
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (!_shown) {
_shown = true;
_maybeTriggerHaptic(_prevKind);
}
}
@override
void didUpdateWidget(covariant StatusCue oldWidget) {
super.didUpdateWidget(oldWidget);
final newKind = widget.resolvedKind;
final labelChanged = oldWidget.semanticLabel != widget.semanticLabel;
if (newKind != _prevKind || labelChanged) {
_maybeTriggerHaptic(newKind);
_prevKind = newKind;
}
}
void _maybeTriggerHaptic(CueKind? kind) {
if (kind == null) return;
final disableAnim = MediaQuery.maybeDisableAnimationsOf(context) ?? false;
switch (kind) {
case CueKind.error:
unawaited(HapticFeedback.heavyImpact());
if (!disableAnim) _shakeCtrl.forward(from: 0);
case CueKind.ok:
unawaited(HapticFeedback.lightImpact());
case CueKind.busy:
case CueKind.warning:
case CueKind.info:
}
}
@override
void dispose() {
_shakeCtrl.dispose();
super.dispose();
}
Widget _buildSemantics(Widget child) {
return Semantics(
liveRegion: true,
label: widget.semanticLabel,
child: ExcludeSemantics(child: child),
);
}
@override
Widget build(BuildContext context) {
final kind = widget.resolvedKind;
if (kind == null) return const SizedBox.shrink();
final theme = Theme.of(context);
final statusColors = theme.extension<AppStatusColors>();
final colorScheme = theme.colorScheme;
final iconData =
widget.icon ??
switch (kind) {
CueKind.ok => Icons.check_circle,
CueKind.error => Icons.error_outline,
CueKind.warning => Icons.warning_amber_rounded,
CueKind.info => Icons.info_outline,
CueKind.busy => Icons.sync,
};
final color = switch (kind) {
CueKind.ok => statusColors?.success ?? Colors.green,
CueKind.error => colorScheme.error,
CueKind.warning => statusColors?.warning ?? Colors.amber,
CueKind.info => colorScheme.onSurfaceVariant,
CueKind.busy => colorScheme.primary,
};
late final Widget child;
switch (kind) {
case CueKind.busy:
child = const SizedBox.square(
dimension: 28,
child: CircularProgressIndicator(strokeWidth: 3),
);
case CueKind.ok:
case CueKind.error:
case CueKind.warning:
case CueKind.info:
child = Icon(iconData, size: widget.size, color: color);
}
if (kind == CueKind.error) {
return AnimatedBuilder(
animation: _shakeAnim,
builder: (context, _) => Transform.translate(
offset: Offset(sin(_shakeAnim.value * 3 * 2 * pi) * 6, 0),
child: _buildSemantics(child),
),
);
}
return _buildSemantics(child);
}
}

View File

@ -0,0 +1,146 @@
import 'dart:math' show pi;
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/ui/spoken_label.dart';
/// A dashed outline slot for an empty/unregistered value.
/// Paints no text; spoken label is provided via [SpokenLabel].
class DashedSlot extends StatelessWidget {
/// Creates a dashed slot.
const DashedSlot({super.key});
@override
Widget build(BuildContext context) {
final outlineColor = Theme.of(context).colorScheme.outline;
return SpokenLabel(
semanticLabel: 'none registered',
child: CustomPaint(
painter: _DashedSlotPainter(outlineColor: outlineColor),
child: const SizedBox(height: 40),
),
);
}
}
class _DashedSlotPainter extends CustomPainter {
const _DashedSlotPainter({required this.outlineColor});
final Color outlineColor;
static const _dashWidth = 6.0;
static const _dashGap = 4.0;
static const _radius = 8.0;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = outlineColor
..style = PaintingStyle.stroke
..strokeWidth = 2;
// Top edge
_drawDashedLine(
canvas,
paint,
const Offset(_radius, 0),
Offset(size.width - _radius, 0),
);
// Top-right arc
_drawDashedArc(
canvas,
paint,
Offset(size.width - _radius, _radius),
_radius,
-pi / 2,
pi / 2,
);
// Right edge
_drawDashedLine(
canvas,
paint,
Offset(size.width, _radius),
Offset(size.width, size.height - _radius),
);
// Bottom-right arc
_drawDashedArc(
canvas,
paint,
Offset(size.width - _radius, size.height - _radius),
_radius,
0,
pi / 2,
);
// Bottom edge
_drawDashedLine(
canvas,
paint,
Offset(size.width - _radius, size.height),
Offset(_radius, size.height),
);
// Bottom-left arc
_drawDashedArc(
canvas,
paint,
Offset(_radius, size.height - _radius),
_radius,
pi / 2,
pi / 2,
);
// Left edge
_drawDashedLine(
canvas,
paint,
Offset(0, size.height - _radius),
const Offset(0, _radius),
);
// Top-left arc
_drawDashedArc(
canvas,
paint,
const Offset(_radius, _radius),
_radius,
pi,
pi / 2,
);
}
void _drawDashedLine(Canvas canvas, Paint paint, Offset start, Offset end) {
final delta = end - start;
final length = delta.distance;
if (length <= 0) return;
final unit = delta / length;
var pos = 0.0;
while (pos < length) {
final dashEnd = (pos + _dashWidth < length) ? pos + _dashWidth : length;
canvas.drawLine(start + unit * pos, start + unit * dashEnd, paint);
pos += _dashWidth + _dashGap;
}
}
void _drawDashedArc(
Canvas canvas,
Paint paint,
Offset center,
double radius,
double startAngle,
double sweepAngle,
) {
final arcLength = radius * sweepAngle.abs();
var pos = 0.0;
while (pos < arcLength) {
final dashEnd = (pos + _dashWidth < arcLength)
? pos + _dashWidth
: arcLength;
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
startAngle + (sweepAngle > 0 ? pos : -pos) / radius,
(dashEnd - pos) / radius * (sweepAngle > 0 ? 1 : -1),
false,
paint,
);
pos += _dashWidth + _dashGap;
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

View File

@ -0,0 +1,34 @@
/// A wrapper that provides a spoken label for accessibility without any
/// visible text (no Tooltip).
///
/// Use for icon-only controls whose wording must remain only for TalkBack.
library;
import 'package:material_ui/material_ui.dart';
/// A wrapper that provides a spoken label for accessibility without any
/// visible text (no Tooltip).
///
/// Use for icon-only controls whose wording must remain only for TalkBack.
class SpokenLabel extends StatelessWidget {
/// Creates a spoken label wrapper.
const SpokenLabel({
required this.semanticLabel,
required this.child,
super.key,
});
/// The accessibility label.
final String semanticLabel;
/// The child widget to wrap.
final Widget child;
@override
Widget build(BuildContext context) {
return Semantics(
label: semanticLabel,
child: ExcludeSemantics(child: child),
);
}
}

View File

@ -6,7 +6,7 @@
/// hand-picked, not algorithmically derived from one seed color.
library;
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';
/// Builds the app's single dark `ThemeData` from the shared token set.
ThemeData buildAppTheme() {

View File

@ -5,10 +5,10 @@ packages:
dependency: transitive
description:
name: android_file_picker
sha256: "03d3dd3cb578ab26b1e528aeea27bf6b976942f5a24e3da82d270369b8e161a8"
sha256: "04c1306eda42c23ecbf449c909f4fa05c5e66fc48cf5f994d8d08b9d45e5396b"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
version: "2.1.5"
android_intent_plus:
dependency: "direct main"
description:
@ -77,10 +77,10 @@ packages:
dependency: transitive
description:
name: clock
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e
url: "https://pub.dev"
source: hosted
version: "1.1.2"
version: "1.1.3"
code_assets:
dependency: transitive
description:
@ -101,11 +101,20 @@ packages:
dependency: "direct main"
description:
path: crdt_sync_dart
ref: "crdt_sync_dart-v0.12.0"
resolved-ref: "59b33949e4a130bb76c77e7434ddf4bbf8cde3a3"
ref: "crdt_sync_dart-v0.14.1"
resolved-ref: edd0f7a7882cb1d3904932ba94faa282d08165df
url: "https://github.com/kuhyx/utils"
source: git
version: "0.12.0"
version: "0.14.1"
crdt_sync_flutter:
dependency: "direct main"
description:
path: crdt_sync_flutter
ref: "crdt_sync_flutter-v0.8.1"
resolved-ref: "3225af8365481677d61a8cabec0c0ad4d0407567"
url: "https://github.com/kuhyx/utils"
source: git
version: "0.8.1"
cross_file:
dependency: transitive
description:
@ -122,6 +131,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.8"
cryptography:
dependency: transitive
description:
name: cryptography
sha256: "3eda3029d34ec9095a27a198ac9785630fe525c0eb6a49f3d575272f8e792ef0"
url: "https://pub.dev"
source: hosted
version: "2.9.0"
cupertino_icons:
dependency: "direct main"
description:
@ -130,15 +147,23 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.0"
cupertino_ui:
dependency: transitive
description:
name: cupertino_ui
sha256: "369e39a1c5c030a6212589c3da1809920b1c2cfade7b02aade7695bfa4b88c3a"
url: "https://pub.dev"
source: hosted
version: "1.1.6"
data_transfer_flutter:
dependency: "direct main"
description:
path: data_transfer_flutter
ref: "data_transfer_flutter-v0.2.0"
resolved-ref: bbb7f0f0c0cd891518418472955c8a2ac6cbca4d
ref: "data_transfer_flutter-v0.2.2"
resolved-ref: eef7af4c86837ee82891493587ff3456297e5dea
url: "https://github.com/kuhyx/utils"
source: git
version: "0.2.0"
version: "0.2.2"
dbus:
dependency: transitive
description:
@ -183,26 +208,26 @@ packages:
dependency: transitive
description:
name: file_picker
sha256: "02f2945a7f29535c735f6b283c3c5553810566d3052d730429aec78fe9d06592"
sha256: "079dc46b753abbddb7f77cd87e6ed9ecc62902580707514568321bdf8e050a86"
url: "https://pub.dev"
source: hosted
version: "13.3.1"
version: "13.3.3"
file_picker_darwin:
dependency: transitive
description:
name: file_picker_darwin
sha256: b845af092ca7a3cff0e768029f835157d371a8341750f4158a7ad358143b0e54
sha256: daeb0219f6279c73eb9c63ae9a566beef04ae13d59b8feba0be2a7f3b258d3d4
url: "https://pub.dev"
source: hosted
version: "2.4.1"
version: "2.4.2"
file_picker_linux:
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 +240,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:
@ -304,6 +329,11 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.14.4"
flutter_localizations:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
flutter_secure_storage:
dependency: "direct main"
description:
@ -366,11 +396,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.5.0"
resolved-ref: "0ef5461805e8f574de5174c940775e5245d675b3"
url: "https://github.com/kuhyx/utils"
source: git
version: "0.3.2"
version: "0.5.0"
google_account_token:
dependency: "direct main"
description:
@ -412,6 +442,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.9.1"
intl:
dependency: transitive
description:
name: intl
sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867"
url: "https://pub.dev"
source: hosted
version: "0.20.3"
jni:
dependency: transitive
description:
@ -460,6 +498,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.2"
listen:
dependency: transitive
description:
name: listen
sha256: "47501a08016a43fcad79252439d723f50f14f88fa7bfd8a177e0a417e5c9e1f2"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
logging:
dependency: transitive
description:
@ -484,6 +530,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.13.0"
material_ui:
dependency: "direct main"
description:
name: material_ui
sha256: "474c135fd46b56ff1d35ca982155e8863d28fbc6464d1192acf4ee53a738680e"
url: "https://pub.dev"
source: hosted
version: "1.11.0"
meta:
dependency: transitive
description:
@ -709,10 +763,10 @@ packages:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490"
url: "https://pub.dev"
source: hosted
version: "1.12.1"
version: "1.12.2"
stream_channel:
dependency: transitive
description:
@ -733,11 +787,11 @@ packages:
dependency: "direct main"
description:
path: sync_settings_ui
ref: "sync_settings_ui-v0.6.0"
resolved-ref: "3121639732fac85f7e25f3df4e081a228d63aa74"
ref: "sync_settings_ui-v0.8.2"
resolved-ref: "3225af8365481677d61a8cabec0c0ad4d0407567"
url: "https://github.com/kuhyx/utils"
source: git
version: "0.6.0"
version: "0.8.2"
term_glyph:
dependency: transitive
description:
@ -750,10 +804,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
sha256: "0a10344e901e5b2e63819567951cb6a06673ed6b84f40462188ff5a0c41f371f"
url: "https://pub.dev"
source: hosted
version: "0.7.12"
version: "0.7.14"
typed_data:
dependency: transitive
description:
@ -838,10 +892,10 @@ packages:
dependency: transitive
description:
name: vector_math
sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47
sha256: fa49e1674cb84acb900141714e6c6e1f9ae099827c24d141430ff6819db4e1bc
url: "https://pub.dev"
source: hosted
version: "2.4.2"
version: "2.4.4"
very_good_analysis:
dependency: "direct dev"
description:
@ -878,10 +932,10 @@ packages:
dependency: transitive
description:
name: windows_file_picker
sha256: "18452aa293de3b6961c6d05b0e71b4be7bb90c67635abdd3aa5c4e75dd875072"
sha256: cd1be37ada0abcfaa55bddb7410e8515844e35cd77669c35d9a28a15722299d8
url: "https://pub.dev"
source: hosted
version: "2.0.1"
version: "2.0.2"
xdg_directories:
dependency: transitive
description:

View File

@ -12,15 +12,21 @@ dependencies:
crdt_sync:
git:
url: https://github.com/kuhyx/utils
ref: crdt_sync_dart-v0.12.0
ref: crdt_sync_dart-v0.14.1
path: crdt_sync_dart
# Keystore-backed end-to-end encryption open + the passphrase lock button.
crdt_sync_flutter:
git:
url: https://github.com/kuhyx/utils
ref: crdt_sync_flutter-v0.8.1
path: crdt_sync_flutter
cupertino_icons: 2.0.0
# Shared JSON export/import plumbing (Save-as, picker, envelope).
data_transfer_flutter:
git:
url: https://github.com/kuhyx/utils
ref: data_transfer_flutter-v0.2.0
ref: data_transfer_flutter-v0.2.2
path: data_transfer_flutter
flutter:
sdk: flutter
@ -31,7 +37,7 @@ dependencies:
github_device_auth:
git:
url: https://github.com/kuhyx/utils
ref: github_device_auth-v0.3.2
ref: github_device_auth-v0.5.0
path: github_device_auth
google_account_token:
git:
@ -39,6 +45,7 @@ dependencies:
ref: google_account_token-v0.1.0
path: google_account_token
http: 1.6.0
material_ui: 1.11.0
path: 1.9.1
path_provider: 2.1.6
shared_preferences: 2.5.6
@ -49,7 +56,7 @@ dependencies:
sync_settings_ui:
git:
url: https://github.com/kuhyx/utils
ref: sync_settings_ui-v0.6.0
ref: sync_settings_ui-v0.8.2
path: sync_settings_ui
url_launcher: 6.3.3
uuid: 4.6.0

View File

@ -23,6 +23,8 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
APP_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
readonly SCRIPT_DIR APP_DIR
# shellcheck source=../../../scripts/pinned_flutter.sh
source "$APP_DIR/../scripts/pinned_flutter.sh"
readonly SICKDAYS_PYTHON="${SICKDAYS_PYTHON:-$HOME/.local/share/sickdays/venv/bin/python}"
TEMP_DIR=""
@ -34,6 +36,7 @@ cleanup() {
trap cleanup EXIT
validate_requirements() {
use_pinned_flutter "$APP_DIR/.."
if [[ ! -x "$SICKDAYS_PYTHON" ]]; then
echo "Error: no sickdays python at $SICKDAYS_PYTHON (set SICKDAYS_PYTHON)" >&2
exit 1

View File

@ -0,0 +1,92 @@
/// Shared end-to-end encryption plumbing for the sync tests.
library;
import 'dart:convert';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart' as http_testing;
import 'package:wake_alarm_sync/services/alarm_sync_e2e.dart';
import 'fake_secure_storage.dart';
/// The passphrase every test device shares.
const testPassphrase = 'test-passphrase';
/// A keystore holding [testPassphrase], cheap Argon2id, a fresh copy memo.
void installE2eTestStorage({Map<String, String>? initial}) {
e2eArgon2Params = const Argon2Params(
memoryKiB: 8,
iterations: 1,
parallelism: 1,
);
resetE2eCopyMemo();
installFakeSecureStorage(
initial: {'crdt_sync_e2e_passphrase': testPassphrase, ...?initial},
);
}
/// A GitHub Contents API stand-in that stores what is PUT and lists
/// directories from it, so the encryption header reads back.
class MemGitHub {
/// Starts holding [files] (repo path -> text).
MemGitHub([Map<String, String>? files]) : files = {...?files};
/// Repo path -> text.
final Map<String, String> files;
/// Every PUT path, in order.
final List<String> putPaths = [];
/// The mock HTTP client.
late final http.Client httpClient = http_testing.MockClient(_handle);
/// A [GitHubClient] over [httpClient].
GitHubClient client() =>
GitHubClient(owner: 'o', repo: 'r', token: 't', httpClient: httpClient);
/// The decrypted text at [path], read like another device would.
Future<String?> opened(String path) async {
final state = await openEncryptedStore(
store: client(),
oldPrefix: legacyAlarmPrefix,
readPassphrase: () async => testPassphrase,
);
return await (state as E2eReady).store.getFileText(path);
}
Future<http.Response> _handle(http.Request request) async {
final path = request.url.path;
if (!path.contains('/contents/')) return http.Response('{}', 200);
final key = path.split('/contents/').last;
if (request.method == 'PUT') {
final body = jsonDecode(request.body) as Map<String, dynamic>;
files[key] = utf8.decode(base64.decode(body['content'] as String));
putPaths.add(key);
return http.Response('{}', 200);
}
final text = files[key];
if (text != null) {
return http.Response(
jsonEncode({'content': base64.encode(utf8.encode(text)), 'sha': 's'}),
200,
);
}
final names = {
for (final k in files.keys)
if (k.startsWith('$key/')) k.substring(key.length + 1).split('/')[0],
};
if (names.isEmpty) return http.Response('{}', 404);
return http.Response(
jsonEncode([
for (final n in names)
{
'type': files.containsKey('$key/$n') ? 'file' : 'dir',
'name': n,
'path': '$key/$n',
},
]),
200,
);
}
}

View File

@ -1,29 +1,25 @@
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';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/screens/free_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/cues.dart';
import 'package:wake_alarm_sync/ui/dashed_slot.dart';
import 'package:wake_alarm_sync/ui/theme.dart';
class _MemPersistence implements LogPersistence {
String? text;
@override
Future<String?> read() async => text;
@override
Future<void> write(String value) async => text = value;
}
import 'fake_day_pool_store.dart';
final _today = DateTime(2026, 9, 11);
String statusOf(WidgetTester tester) =>
tester.widget<StatusCue>(find.byType(StatusCue).first).semanticLabel;
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@ -37,7 +33,7 @@ void main() {
.setMockMethodCallHandler(channel, (call) async => null);
scheduler = AlarmScheduler(channel: channel);
pool = await FreeDayPool.open(
persistence: _MemPersistence(),
persistence: MemPersistence(),
deviceId: 'phone',
);
syncs = 0;
@ -63,7 +59,7 @@ void main() {
scheduler: scheduler,
),
openSickPool: () =>
SickDayPool.open(persistence: _MemPersistence(), deviceId: 'phone'),
SickDayPool.open(persistence: MemPersistence(), deviceId: 'phone'),
sickSyncer: SickDaySync(
openClient: () async => null,
scheduler: scheduler,
@ -83,14 +79,13 @@ void main() {
await tester.pumpAndSettle();
expect(syncs, 1);
expect(find.text('33 of 35'), findsOneWidget);
expect(find.text('free days left in 2026'), findsOneWidget);
expect(find.text('Sun 2026-09-20'), findsOneWidget);
expect(find.text('33/35'), findsOneWidget);
expect(find.text('20.09.2026'), findsOneWidget);
expect(find.text('travelling'), findsOneWidget);
expect(find.textContaining('2026-09-10'), findsNothing);
expect(
find.text('Not synced — connect Firebase in Settings.'),
findsOneWidget,
statusOf(tester),
contains('Not synced — connect Firebase in Settings.'),
);
});
@ -112,13 +107,12 @@ void main() {
await tester.pumpWidget(app());
await tester.pumpAndSettle();
await tester.ensureVisible(find.text('Release'));
await tester.tap(find.text('Release'));
await tester.tap(find.byKey(const ValueKey<String>('release-2026-09-20')));
await tester.pumpAndSettle();
expect(pool.isFree(DateTime(2026, 9, 20)), isFalse);
expect(find.text('35 of 35'), findsOneWidget);
expect(find.textContaining('Tap a date above'), findsOneWidget);
expect(find.text('35/35'), findsOneWidget);
expect(find.byType(DashedSlot), findsOneWidget);
expect(syncs, 2);
});
@ -127,7 +121,7 @@ void main() {
await tester.pumpWidget(app());
await tester.pumpAndSettle();
expect(find.text('September 2026'), findsOneWidget);
expect(find.text('09/2026'), findsOneWidget);
expect(find.text('Take a day'), findsNothing);
});
@ -140,8 +134,8 @@ void main() {
expect(pool.isFree(DateTime(2026, 9, 12)), isTrue);
expect(pool.reasonFor(DateTime(2026, 9, 12)), '');
expect(find.text('34 of 35'), findsOneWidget);
expect(find.text('Sat 2026-09-12'), findsOneWidget);
expect(find.text('34/35'), findsOneWidget);
expect(find.text('12.09.2026'), findsOneWidget);
expect(syncs, 2);
});
@ -152,12 +146,12 @@ void main() {
phoneSize(tester);
await tester.pumpWidget(app());
await tester.pumpAndSettle();
expect(find.text('34 of 35'), findsOneWidget);
expect(find.text('34/35'), findsOneWidget);
await tapDay(tester, DateTime(2026, 9, 20));
expect(pool.isFree(DateTime(2026, 9, 20)), isFalse);
expect(find.text('35 of 35'), findsOneWidget);
expect(find.text('35/35'), findsOneWidget);
expect(syncs, 2);
});
@ -182,29 +176,29 @@ void main() {
await tester.pumpWidget(app());
await tester.pumpAndSettle();
Future<void> press(String tooltip, int times) async {
Future<void> press(IconData icon, int times) async {
for (var i = 0; i < times; i++) {
await tester.tap(find.byTooltip(tooltip));
await tester.tap(find.byIcon(icon));
await tester.pumpAndSettle();
}
}
await press('Next month', 1);
expect(find.text('October 2026'), findsOneWidget);
await press(Icons.chevron_right, 1);
expect(find.text('10/2026'), findsOneWidget);
await tapDay(tester, DateTime(2026, 10, 2));
expect(pool.isFree(DateTime(2026, 10, 2)), isTrue);
// Back to January of this year, and no further.
await press('Previous month', 9);
expect(find.text('January 2026'), findsOneWidget);
await press('Previous month', 1);
expect(find.text('January 2026'), findsOneWidget);
await press(Icons.chevron_left, 9);
expect(find.text('01/2026'), findsOneWidget);
await press(Icons.chevron_left, 1);
expect(find.text('01/2026'), findsOneWidget);
// Forward to December of next year, and no further.
await press('Next month', 23);
expect(find.text('December 2027'), findsOneWidget);
await press('Next month', 1);
expect(find.text('December 2027'), findsOneWidget);
await press(Icons.chevron_right, 23);
expect(find.text('12/2027'), findsOneWidget);
await press(Icons.chevron_right, 1);
expect(find.text('12/2027'), findsOneWidget);
});
testWidgets('a refused booking shows the reason and does not sync', (
@ -217,7 +211,7 @@ void main() {
phoneSize(tester);
await tester.pumpWidget(app());
await tester.pumpAndSettle();
expect(find.text('0 of 35'), findsOneWidget);
expect(find.text('0/35'), findsOneWidget);
await tapDay(tester, DateTime(2026, 9, 12));
@ -225,7 +219,7 @@ void main() {
// The 35 booked days make the page long; back to the status line.
await tester.drag(find.byType(ListView), const Offset(0, 3000));
await tester.pumpAndSettle();
expect(find.textContaining('No free days left in 2026'), findsOneWidget);
expect(statusOf(tester), contains('No free days left in 2026'));
expect(syncs, 1);
});
@ -244,7 +238,7 @@ void main() {
),
);
await tester.pumpAndSettle();
expect(find.textContaining('Error: pool unavailable'), findsOneWidget);
expect(statusOf(tester), contains('Error: pool unavailable'));
expect(find.text('Take a day'), findsNothing);
});
}

View File

@ -1,20 +1,24 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/screens/free_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/cues.dart';
import 'package:wake_alarm_sync/ui/theme.dart';
import 'fake_day_pool_store.dart';
final _today = DateTime(2026, 10, 16);
String statusOf(WidgetTester tester) =>
tester.widget<StatusCue>(find.byType(StatusCue).first).semanticLabel;
/// The free-day picker's half of "a date is never in both pools".
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@ -87,8 +91,8 @@ void main() {
expect(free.isFree(DateTime(2026, 10, 20)), isFalse);
expect(
find.text('2026-10-20 is a sick day — clear it in Sick days first.'),
findsOneWidget,
statusOf(tester),
contains('2026-10-20 is a sick day — clear it in Sick days first.'),
);
// A normal day still books.
await tapDay(tester, DateTime(2026, 10, 21));
@ -104,6 +108,6 @@ void main() {
);
await tapDay(tester, DateTime(2026, 10, 21));
expect(free.isFree(DateTime(2026, 10, 21)), isFalse);
expect(find.textContaining('Sick-day pool unavailable'), findsOneWidget);
expect(statusOf(tester), contains('Sick-day pool unavailable'));
});
}

View File

@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_ui/material_ui.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:wake_alarm_sync/screens/home_body.dart';
import 'package:wake_alarm_sync/screens/home_history_card.dart';
@ -36,11 +36,10 @@ void main() {
theme: buildAppTheme(),
home: Scaffold(
appBar: AppBar(
title: const Text('Wake Alarm Sync'),
actions: const [
Icon(Icons.event_available),
Icon(Icons.sick_outlined),
Icon(Icons.settings),
Icon(Icons.event_available, semanticLabel: 'Free days'),
Icon(Icons.sick_outlined, semanticLabel: 'Sick days'),
Icon(Icons.settings, semanticLabel: 'Settings'),
],
),
body: HomeBody(
@ -91,7 +90,7 @@ void main() {
expect(tester.takeException(), isNull);
expectNoVerticalScroll(tester);
const bottomChrome = 2400 / 2.625 - 63 / 2.625;
final button = find.text('Wake me earlier tomorrow');
final button = find.byIcon(Icons.arrow_upward);
expect(tester.getRect(button).bottom, lessThan(bottomChrome));
await tester.tap(button);
expect(picked, 1);

View File

@ -1,11 +1,13 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_ui/material_ui.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:wake_alarm_sync/screens/home_history_card.dart';
import 'package:wake_alarm_sync/services/morning_session.dart';
import 'package:wake_alarm_sync/services/session_rules.dart';
import 'package:wake_alarm_sync/services/session_store.dart';
import 'package:wake_alarm_sync/services/session_tag_settings.dart';
import 'package:wake_alarm_sync/ui/dashed_slot.dart';
import 'package:wake_alarm_sync/ui/spoken_label.dart';
MorningSession _session(
DateTime at, {
@ -66,16 +68,23 @@ void main() {
testWidgets('is absent until a morning has finished', (tester) async {
await tester.pumpWidget(const MaterialApp(home: HomeHistoryCard()));
await tester.pump();
expect(find.text('Last mornings'), findsNothing);
expect(find.byType(Row), findsNothing);
expect(find.byType(DashedSlot), findsNothing);
});
testWidgets('says so when no morning has finished', (tester) async {
testWidgets('shows an empty slot when no morning has finished', (
tester,
) async {
await tester.pumpWidget(
const MaterialApp(home: HomeHistoryCard(emptyText: 'None yet.')),
);
expect(find.text('None yet.'), findsNothing);
expect(find.byType(DashedSlot), findsNothing);
await tester.pump();
expect(find.text('None yet.'), findsOneWidget);
expect(find.byType(DashedSlot), findsOneWidget);
expect(
tester.widget<SpokenLabel>(find.byType(SpokenLabel).first).semanticLabel,
'None yet.',
);
});
testWidgets('lists the last seven with weight deltas', (tester) async {
@ -83,14 +92,22 @@ void main() {
for (var day = 1; day <= 9; day++) {
await store.archive(_session(DateTime(2026, 9, day, 8), kg: 70.0 + day));
}
await tester.pumpWidget(const MaterialApp(home: HomeHistoryCard()));
await tester.pump();
expect(find.text('Last mornings'), findsOneWidget);
expect(
find.textContaining('09-09 ✓ 0 re-rings · 79.0 kg (+1.0)'),
findsOne,
// As the home tile shows it: a scrolling bottom sheet.
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(body: SingleChildScrollView(child: HomeHistoryCard())),
),
);
expect(find.textContaining('09-03'), findsOneWidget);
expect(find.textContaining('09-02'), findsNothing);
await tester.pump();
final rows = tester
.widgetList<SpokenLabel>(find.byType(SpokenLabel))
.map((w) => w.semanticLabel)
.toList();
expect(rows, hasLength(kHistoryShown));
expect(rows.first, '09-09 ✓ 0 re-rings · 79.0 kg (+1.0)');
expect(find.text('79.0'), findsOneWidget);
expect(find.text(' (+1.0)'), findsWidgets);
expect(find.text('09-03'), findsOneWidget);
expect(find.text('09-02'), findsNothing);
});
}

View File

@ -1,10 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_ui/material_ui.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:wake_alarm_sync/screens/home_location_screen.dart';
import 'package:wake_alarm_sync/services/alarm_scheduler.dart';
import 'package:wake_alarm_sync/services/home_location_settings.dart';
import 'package:wake_alarm_sync/ui/cues.dart';
import 'package:wake_alarm_sync/ui/dashed_slot.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@ -22,16 +24,13 @@ void main() {
});
Future<void> pumpScreen(WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(home: HomeLocationScreen()),
);
await tester.pumpWidget(const MaterialApp(home: HomeLocationScreen()));
await tester.pump();
}
testWidgets('a fresh install shows nothing captured', (tester) async {
await pumpScreen(tester);
expect(find.text('not captured'), findsNWidgets(2));
expect(find.text('none'), findsOneWidget);
expect(find.byType(DashedSlot), findsNWidgets(3));
});
testWidgets('capture stores what the platform reported', (tester) async {
@ -43,10 +42,13 @@ void main() {
'reason': 'ok',
};
await pumpScreen(tester);
await tester.tap(find.text('Capture this place as home'));
await tester.tap(find.byIcon(Icons.my_location));
await tester.pumpAndSettle();
expect(find.textContaining('Captured Wi-Fi'), findsOneWidget);
expect(
tester.widget<StatusCue>(find.byType(StatusCue)).semanticLabel,
contains('Captured Wi-Fi'),
);
final home = await HomeLocationSettings.load();
expect(home.ssid, 'Orange_Home');
expect(home.bssids, <String>['cc:00:f1:ae:d6:c4']);
@ -58,10 +60,13 @@ void main() {
) async {
presence = <String, Object?>{'ssid': 'Orange_Home', 'reason': 'ok'};
await pumpScreen(tester);
await tester.tap(find.text('Capture this place as home'));
await tester.tap(find.byIcon(Icons.my_location));
await tester.pumpAndSettle();
expect(find.textContaining('GPS fallback is unset'), findsOneWidget);
expect(
tester.widget<StatusCue>(find.byType(StatusCue)).semanticLabel,
contains('GPS fallback is unset'),
);
expect((await HomeLocationSettings.load()).hasCoordinate, isFalse);
});
@ -70,20 +75,26 @@ void main() {
) async {
presence = <String, Object?>{'reason': 'no-permission'};
await pumpScreen(tester);
await tester.tap(find.text('Capture this place as home'));
await tester.tap(find.byIcon(Icons.my_location));
await tester.pumpAndSettle();
expect(find.textContaining('Grant location permission'), findsOneWidget);
expect(
tester.widget<StatusCue>(find.byType(StatusCue)).semanticLabel,
contains('Grant location permission'),
);
expect((await HomeLocationSettings.load()).isConfigured, isFalse);
});
testWidgets('forget home returns to the strict sequence', (tester) async {
await HomeLocationSettings.capture(ssid: 'Orange_Home');
await pumpScreen(tester);
await tester.tap(find.text('Forget home'));
await tester.tap(find.byIcon(Icons.delete_outline));
await tester.pumpAndSettle();
expect(find.textContaining('full sequence now applies'), findsOneWidget);
expect(
tester.widget<StatusCue>(find.byType(StatusCue)).semanticLabel,
contains('full sequence now applies'),
);
expect((await HomeLocationSettings.load()).isConfigured, isFalse);
});
}

View File

@ -1,8 +1,8 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_ui/material_ui.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:wake_alarm_sync/screens/ring_screen.dart';
import 'package:wake_alarm_sync/services/alarm_scheduler.dart';
@ -10,10 +10,15 @@ import 'package:wake_alarm_sync/services/challenge.dart';
import 'package:wake_alarm_sync/services/dismiss_gate.dart';
import 'package:wake_alarm_sync/services/home_location_settings.dart';
import 'package:wake_alarm_sync/services/room_proof_settings.dart';
import 'package:wake_alarm_sync/ui/cues.dart';
const String _tag1 = '04a2b3c4';
const String _tag2 = '04ffee11';
/// The ring's status sentence, now spoken only (StatusCue.semanticLabel).
String statusOf(WidgetTester tester) =>
tester.widget<StatusCue>(find.byType(StatusCue)).semanticLabel;
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@ -63,8 +68,8 @@ void main() {
presence = <String, Object?>{'ssid': 'Orange_Home', 'reason': 'ok'};
await pumpRing(tester);
expect(find.text('Tap tag 1'), findsOneWidget);
expect(find.text('Tap tag 2'), findsOneWidget);
expect(find.byKey(const ValueKey<String>('ring-row-tag1')), findsOneWidget);
expect(find.byKey(const ValueKey<String>('ring-row-tag2')), findsOneWidget);
});
testWidgets('away it skips the tags, keeps the walk, and asks 3', (
@ -74,20 +79,23 @@ void main() {
presence = <String, Object?>{'ssid': 'Hotel_Guest', 'reason': 'ok'};
await pumpRing(tester);
expect(find.textContaining('Away from home'), findsWidgets);
expect(find.byKey(const Key('ring-bypass')), findsWidgets);
// The persistent line, not the status that the step poll overwrites.
expect(find.textContaining('the tags are elsewhere'), findsOneWidget);
expect(find.textContaining('Tags unavailable'), findsNothing);
expect(statusOf(tester), contains('Away from home'));
expect(statusOf(tester), isNot(contains('Tags unavailable')));
expect(find.text('Tap tag 1'), findsNothing);
expect(find.text('Tap tag 2'), findsNothing);
// The walk is never skipped: away is where the tags are, not the bed.
expect(find.text('Walk 25 steps'), findsOneWidget);
expect(
find.byKey(const ValueKey<String>('ring-row-steps')),
findsOneWidget,
);
steps = 500;
elapsed = const Duration(minutes: 3);
await tester.pump(kStepPollInterval);
await tester.pump();
expect(find.text('Question 1 of 3'), findsOneWidget);
expect(find.text('1/3'), findsOneWidget);
});
testWidgets('the five-minute timer does not bump an away morning to 6', (
@ -101,13 +109,13 @@ void main() {
elapsed = const Duration(minutes: 3);
await tester.pump(kStepPollInterval);
await tester.pump();
expect(find.text('Question 1 of 3'), findsOneWidget);
expect(find.text('1/3'), findsOneWidget);
// An away morning routinely outlasts the bypass timer.
await tester.pump(DismissGate.bypassAfter);
await tester.pump();
expect(find.text('Question 1 of 3'), findsOneWidget);
expect(find.text('Question 1 of 6'), findsNothing);
expect(find.text('1/3'), findsOneWidget);
expect(find.text('1/6'), findsNothing);
});
testWidgets('unreadable presence is away, per the chosen fail mode', (
@ -117,7 +125,7 @@ void main() {
presence = <String, Object?>{'reason': 'no-permission'};
await pumpRing(tester);
expect(find.textContaining('location permission denied'), findsOneWidget);
expect(statusOf(tester), contains('location permission denied'));
expect(find.text('Tap tag 1'), findsNothing);
});
@ -139,6 +147,6 @@ void main() {
elapsed = const Duration(minutes: 3);
await tester.pump(kStepPollInterval);
await tester.pump();
expect(find.text('Question 1 of 3'), findsOneWidget);
expect(find.text('1/3'), findsOneWidget);
});
}

View File

@ -1,18 +1,23 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_ui/material_ui.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:wake_alarm_sync/screens/ring_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/ui/cues.dart';
const String _tag1 = '04a2b3c4';
const String _tag2 = '04ffee11';
/// The ring's status sentence, now spoken only (StatusCue.semanticLabel).
String statusOf(WidgetTester tester) =>
tester.widget<StatusCue>(find.byType(StatusCue)).semanticLabel;
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@ -69,8 +74,8 @@ void main() {
testWidgets('it is not offered before the timer elapses', (tester) async {
await pumpRing(tester);
await tester.pump(DismissGate.bypassAfter - const Duration(seconds: 1));
expect(find.textContaining('Tags unavailable'), findsNothing);
expect(find.text('Tap tag 1'), findsOneWidget);
expect(find.byKey(const Key('ring-bypass')), findsNothing);
expect(find.byKey(const ValueKey<String>('ring-row-tag1')), findsOneWidget);
});
testWidgets('after the timer it skips the tags but still needs the walk', (
@ -80,17 +85,20 @@ void main() {
await tester.pump(DismissGate.bypassAfter);
await tester.pump();
expect(find.textContaining('Tags unavailable'), findsOneWidget);
expect(find.byKey(const Key('ring-bypass')), findsOneWidget);
// A hardware bypass must not borrow the away wording.
expect(find.textContaining('Away from home'), findsNothing);
expect(find.text('Tap tag 1'), findsNothing);
expect(find.text('Walk 25 steps'), findsOneWidget);
expect(find.text('Submit'), findsNothing);
expect(find.byKey(const Key('ring-bypass')), findsOneWidget);
expect(find.byKey(const ValueKey<String>('ring-row-tag1')), findsNothing);
expect(
find.byKey(const ValueKey<String>('ring-row-steps')),
findsOneWidget,
);
expect(find.byKey(const Key('ring-submit')), findsNothing);
steps = 500;
elapsed = const Duration(minutes: 6);
await pollSteps(tester);
expect(find.text('Question 1 of 6'), findsOneWidget);
expect(find.text('1/6'), findsOneWidget);
});
testWidgets('under bypass the walk still needs elapsed time', (tester) async {
@ -104,7 +112,7 @@ void main() {
steps = 500;
elapsed = const Duration(seconds: 5);
await pollSteps(tester);
expect(find.textContaining('Keep moving'), findsOneWidget);
expect(statusOf(tester), contains('Keep moving'));
expect(find.text('Submit'), findsNothing);
});
@ -116,15 +124,15 @@ void main() {
MaterialApp(home: RingScreen(clock: () => elapsed)),
);
await tester.pump();
expect(find.textContaining('Register two NFC tags'), findsOneWidget);
expect(find.textContaining('Tags unavailable'), findsOneWidget);
expect(find.byKey(const Key('ring-bypass')), findsOneWidget);
expect(find.byIcon(Icons.block), findsOneWidget);
});
testWidgets('no NFC hardware bypasses immediately', (tester) async {
hasNfc = false;
mockChannel();
await pumpRing(tester);
expect(find.textContaining('NFC is off'), findsOneWidget);
expect(statusOf(tester), contains('NFC is off'));
expect(find.text('Tap tag 1'), findsNothing);
});
}

View File

@ -1,8 +1,8 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_ui/material_ui.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:wake_alarm_sync/main.dart';
import 'package:wake_alarm_sync/screens/ring_screen.dart';
@ -10,10 +10,15 @@ import 'package:wake_alarm_sync/services/alarm_scheduler.dart';
import 'package:wake_alarm_sync/services/challenge.dart';
import 'package:wake_alarm_sync/services/dismiss_gate.dart';
import 'package:wake_alarm_sync/services/room_proof_settings.dart';
import 'package:wake_alarm_sync/ui/cues.dart';
const String _tag1 = '04a2b3c4';
const String _tag2 = '04ffee11';
/// The ring's status sentence, now spoken only (StatusCue.semanticLabel).
String statusOf(WidgetTester tester) =>
tester.widget<StatusCue>(find.byType(StatusCue)).semanticLabel;
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@ -97,11 +102,14 @@ void main() {
testWidgets('it opens on the checklist, not the challenge', (tester) async {
await pumpRing(tester);
expect(find.text('Wake up'), findsOneWidget);
expect(find.text('Tap tag 1'), findsOneWidget);
expect(find.text('Walk 25 steps'), findsOneWidget);
expect(find.text('Tap tag 2'), findsOneWidget);
expect(find.text('Submit'), findsNothing);
expect(find.text('Wake up'), findsNothing);
expect(find.byKey(const ValueKey<String>('ring-row-tag1')), findsOneWidget);
expect(
find.byKey(const ValueKey<String>('ring-row-steps')),
findsOneWidget,
);
expect(find.byKey(const ValueKey<String>('ring-row-tag2')), findsOneWidget);
expect(find.byKey(const Key('ring-submit')), findsNothing);
});
testWidgets('tag 2 tapped first does nothing; tag 1 then works', (
@ -109,10 +117,10 @@ void main() {
) async {
await pumpRing(tester);
await tapTag(tester, _tag2);
expect(find.textContaining('Not tag 1'), findsOneWidget);
expect(statusOf(tester), contains('Not tag 1'));
await tapTag(tester, _tag1);
expect(find.textContaining('Tag 1 matched'), findsOneWidget);
expect(statusOf(tester), contains('Tag 1 matched'));
});
testWidgets('an unknown tag does not un-latch a passed stage', (
@ -128,7 +136,7 @@ void main() {
steps = 500;
elapsed = const Duration(minutes: 3);
await pollSteps(tester);
expect(find.text('Tap tag 2'), findsOneWidget);
expect(find.byKey(const ValueKey<String>('ring-row-tag2')), findsOneWidget);
});
testWidgets('steps do not count before tag 1', (tester) async {
@ -137,9 +145,9 @@ void main() {
elapsed = const Duration(minutes: 3);
await pollSteps(tester);
// Still on tag 1 — walking first must not skip the first location.
expect(find.text('Submit'), findsNothing);
expect(find.byKey(const Key('ring-submit')), findsNothing);
await tapTag(tester, _tag2);
expect(find.textContaining('Not tag 1'), findsOneWidget);
expect(statusOf(tester), contains('Not tag 1'));
});
testWidgets('after tag 1 the banked steps pass the walk at once', (
@ -154,9 +162,9 @@ void main() {
steps = 500;
elapsed = const Duration(seconds: 5);
await pollSteps(tester);
expect(find.textContaining('Keep moving'), findsNothing);
expect(find.textContaining('Walked far enough'), findsOneWidget);
expect(find.text('Tap tag 2'), findsOneWidget);
expect(statusOf(tester), isNot(contains('Keep moving')));
expect(statusOf(tester), contains('Walked far enough'));
expect(find.byKey(const ValueKey<String>('ring-row-tag2')), findsOneWidget);
});
testWidgets('the full sequence dismisses: tag 1, walk, tag 2, 3 answers', (
@ -170,11 +178,11 @@ void main() {
await pollSteps(tester);
await tapTag(tester, _tag2);
expect(find.text('Question 1 of 3'), findsOneWidget);
expect(find.text('1/3'), findsOneWidget);
for (var i = 1; i <= DismissGate.challengesNormal; i++) {
expect(find.text('Question $i of 3'), findsOneWidget);
expect(find.text('$i/3'), findsOneWidget);
await tester.enterText(find.byType(TextField), expected.next().answer);
await tester.tap(find.text('Submit'));
await tester.tap(find.byKey(const Key('ring-submit')));
await tester.pump();
}
@ -198,16 +206,16 @@ void main() {
await tapTag(tester, _tag2);
await tester.enterText(find.byType(TextField), expected.next().answer);
await tester.tap(find.text('Submit'));
await tester.tap(find.byKey(const Key('ring-submit')));
await tester.pump();
expect(find.text('Question 2 of 3'), findsOneWidget);
expect(find.text('2/3'), findsOneWidget);
expected.next(); // the screen regenerated after the correct answer
await tester.enterText(find.byType(TextField), 'definitely wrong');
await tester.tap(find.text('Submit'));
await tester.tap(find.byKey(const Key('ring-submit')));
await tester.pump();
expect(
find.text('Question 2 of 3'),
find.text('2/3'),
findsOneWidget,
reason: 'cumulative: a wrong answer must not reset the count',
);

View File

@ -1,8 +1,8 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_ui/material_ui.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';
@ -88,7 +88,7 @@ void main() {
await tapTag(tester, _tag2);
for (var i = 0; i < 3; i++) {
await tester.enterText(find.byType(TextField), answers.next().answer);
await tester.tap(find.text('Submit'));
await tester.tap(find.byKey(const Key('ring-submit')));
await tester.pump();
}
await tester.pumpAndSettle();
@ -144,12 +144,14 @@ void main() {
await tester.tap(find.widgetWithText(ChoiceChip, '4'));
await tester.pump();
await tester.enterText(find.byType(TextField), '72');
await tester.tap(find.text('Log weight'));
await tester.tap(find.byKey(const Key('session-log-weight')));
await tester.pump();
await tapTag(tester, 'bath');
await tester.pump();
expect(find.text('Weigh in — 72.0 kg · energy 4/5'), findsOneWidget);
expect(find.textContaining('tag counts in'), findsOneWidget);
expect(find.text('72.0'), findsOneWidget);
expect(find.text('4/5'), findsOneWidget);
// The shower floor: the second bathroom tap does not count yet.
expect(find.byIcon(Icons.hourglass_top), findsOneWidget);
});
testWidgets('away from home the dismiss still just closes', (tester) async {
@ -162,7 +164,7 @@ void main() {
await tester.pump();
for (var i = 0; i < 3; i++) {
await tester.enterText(find.byType(TextField), answers.next().answer);
await tester.tap(find.text('Submit'));
await tester.tap(find.byKey(const Key('ring-submit')));
await tester.pump();
}
await tester.pumpAndSettle();

View File

@ -1,9 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_ui/material_ui.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:wake_alarm_sync/screens/ring_screen.dart';
import 'package:wake_alarm_sync/screens/ring_stakes_line.dart';
import 'package:wake_alarm_sync/services/ring_start_recorder.dart';
import 'package:wake_alarm_sync/ui/cues.dart';
import 'fake_enforcement_channel.dart';
@ -40,12 +41,12 @@ void main() {
) async {
native.state = nativeState();
await pumpLine(tester);
expect(
find.text(
'Stick day: no dismiss by 07:15 → apps locked through Fri 2 Oct',
),
findsOneWidget,
);
final cue = tester.widget<SpokenLabel>(find.byType(SpokenLabel));
const expected =
'Stick day: no dismiss by 07:15 → apps locked through Fri 2 Oct';
expect(cue.semanticLabel, expected);
expect(find.text('07:15'), findsOneWidget);
expect(find.text('02.10'), findsOneWidget);
});
testWidgets('nothing once today was dismissed', (tester) async {
@ -74,7 +75,7 @@ void main() {
await tester.pumpWidget(MaterialApp(home: RingScreen(ringStart: recorder)));
await tester.pump();
expect(recorder.calls, 1);
expect(find.textContaining('Stick day:'), findsOneWidget);
expect(find.byType(StatusCue), findsOneWidget);
// The ring screen's own timers (bypass, step poll) outlive the test.
await tester.pumpWidget(const SizedBox.shrink());
});

View File

@ -1,10 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_ui/material_ui.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:wake_alarm_sync/screens/room_proof_screen.dart';
import 'package:wake_alarm_sync/services/alarm_scheduler.dart';
import 'package:wake_alarm_sync/services/room_proof_settings.dart';
import 'package:wake_alarm_sync/ui/cues.dart';
import 'package:wake_alarm_sync/ui/dashed_slot.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@ -31,37 +33,37 @@ void main() {
) async {
await tester.pumpWidget(const MaterialApp(home: RoomProofScreen()));
await tester.pumpAndSettle();
expect(find.textContaining('none registered'), findsNWidgets(2));
expect(find.text('Register tag 1'), findsOneWidget);
expect(find.text('Register tag 2'), findsOneWidget);
expect(find.byType(DashedSlot), findsNWidgets(2));
expect(find.byKey(const ValueKey<String>('register-0')), findsOneWidget);
expect(find.byKey(const ValueKey<String>('register-1')), findsOneWidget);
});
testWidgets('starting a scan puts the reader into waiting state', (
tester,
) async {
await tester.pumpWidget(const MaterialApp(home: RoomProofScreen()));
await tester.pumpAndSettle();
await tester.tap(find.text('Register tag 1'));
await tester.pumpAndSettle();
await tester.pump();
await tester.tap(find.byKey(const ValueKey<String>('register-0')));
await tester.pump();
expect(calls, contains('startTagRegistration'));
expect(find.textContaining('Tap tag 1'), findsOneWidget);
final cue = tester.widget<StatusCue>(find.byType(StatusCue));
expect(cue.semanticLabel, contains('Tap tag 1'));
});
testWidgets('only the scanning row is disabled while waiting', (
tester,
) async {
await tester.pumpWidget(const MaterialApp(home: RoomProofScreen()));
await tester.pumpAndSettle();
await tester.tap(find.text('Register tag 2'));
await tester.pumpAndSettle();
await tester.pump();
await tester.tap(find.byKey(const ValueKey<String>('register-1')));
await tester.pump();
final busyCue = tester.widget<StatusCue>(find.byType(StatusCue));
expect(busyCue.resolvedKind, CueKind.busy);
expect(find.text('Waiting for tag…'), findsOneWidget);
final other = tester.widget<FilledButton>(
find.ancestor(
of: find.text('Register tag 1'),
matching: find.byType(FilledButton),
),
find.byKey(const ValueKey<String>('register-0')),
);
expect(other.onPressed, isNull, reason: 'one scan at a time');
});
@ -71,11 +73,12 @@ void main() {
) async {
nfcStarts = false;
await tester.pumpWidget(const MaterialApp(home: RoomProofScreen()));
await tester.pumpAndSettle();
await tester.tap(find.text('Register tag 1'));
await tester.pumpAndSettle();
await tester.pump();
await tester.tap(find.byKey(const ValueKey<String>('register-0')));
await tester.pump();
expect(find.textContaining('unavailable'), findsOneWidget);
final cue = tester.widget<StatusCue>(find.byType(StatusCue));
expect(cue.semanticLabel, contains('unavailable'));
});
testWidgets('each registered tag is shown against its own slot', (
@ -84,9 +87,25 @@ void main() {
await RoomProofSettings.saveNfcTagAt(0, '04a2b3c4');
await RoomProofSettings.saveNfcTagAt(1, '04ffee11');
await tester.pumpWidget(const MaterialApp(home: RoomProofScreen()));
await tester.pumpAndSettle();
expect(find.text('Tag 1: 04a2b3c4'), findsOneWidget);
expect(find.text('Tag 2: 04ffee11'), findsOneWidget);
expect(find.text('Register tag 1 again'), findsOneWidget);
await tester.pump();
expect(find.text('04a2b3c4'), findsOneWidget);
expect(find.text('04ffee11'), findsOneWidget);
expect(find.byKey(const ValueKey<String>('register-0')), findsOneWidget);
// Verify key exists; "again" is part of the semantic label in the icon.
final registerBtn = tester.widget<FilledButton>(
find.byKey(const ValueKey<String>('register-0')),
);
expect(registerBtn.onPressed, isNotNull);
});
testWidgets('registered status shows the confirmation message', (
tester,
) async {
await RoomProofSettings.saveNfcTagAt(0, 'abc');
await tester.pumpWidget(const MaterialApp(home: RoomProofScreen()));
await tester.pump();
final cue = tester.widget<StatusCue>(find.byType(StatusCue));
expect(cue.semanticLabel, 'Tag 1 registered.');
});
}

View File

@ -2,13 +2,14 @@ import 'dart:convert';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart' as http_testing;
import 'package:wake_alarm_sync/services/alarm_sync_service.dart';
import '../e2e_support.dart';
const _deviceId = 'this-install';
const _peerId = 'previous-install';
const _prefix = 'wake-alarm-sync/devices';
const _old = 'wake-alarm-sync/devices';
const _prefix = 'wake-alarm-sync/devices-e2e';
class _MemPersistence implements LogPersistence {
String? text;
@ -20,45 +21,6 @@ class _MemPersistence implements LogPersistence {
Future<void> write(String value) async => text = value;
}
http.Response _json(Object body, [int status = 200]) =>
http.Response(jsonEncode(body), status);
/// Mock GitHub Contents API with one peer directory holding [peerLog].
///
/// The directory GET answers the listing `syncLog` walks, the peer's file GET
/// answers the pull, and every PUT is recorded so the merged push can be
/// inspected.
({http.Client client, List<Map<String, dynamic>> pushes}) _mockRemote({
Map<String, dynamic>? peerLog,
}) {
final pushes = <Map<String, dynamic>>[];
final client = http_testing.MockClient((request) async {
final path = request.url.path;
if (!path.contains('/contents/')) return _json({});
final key = path.split('/contents/').last;
if (request.method == 'PUT') {
final body = jsonDecode(request.body) as Map<String, dynamic>;
final text = utf8.decode(base64.decode(body['content'] as String));
pushes.add(jsonDecode(text) as Map<String, dynamic>);
return _json({});
}
if (key == _prefix) {
return _json([
if (peerLog != null) {'name': _peerId, 'type': 'dir'},
{'name': _deviceId, 'type': 'dir'},
]);
}
if (key == '$_prefix/$_peerId/alarm.json' && peerLog != null) {
return _json({
'content': base64.encode(utf8.encode(jsonEncode(peerLog))),
'encoding': 'base64',
});
}
return _json({}, 404);
});
return (client: client, pushes: pushes);
}
Map<String, dynamic> _peerRecord(Map<String, Object?> fields, String hlc) => {
'alarm': {
'id': 'alarm',
@ -74,6 +36,8 @@ void main() {
late AlarmSyncService service;
setUp(() async {
TestWidgetsFlutterBinding.ensureInitialized();
installE2eTestStorage();
service = await AlarmSyncService.open(
persistence: _MemPersistence(),
deviceId: _deviceId,
@ -101,20 +65,18 @@ void main() {
test('pushWith pulls a previous install and takes its newer field', () async {
await service.setLocal(8, 0);
final (:client, :pushes) = _mockRemote(
peerLog: _peerRecord({
'alarm_hour': 7,
'alarm_minute': 0,
'nfc_tag_ids': ['x', 'y'],
}, '2026-09-11T10:26:18.254Z-0000-$_peerId'),
);
final github = GitHubClient(
owner: 'o',
repo: 'r',
token: 't',
httpClient: client,
);
// The previous install never updated: its record sits in plaintext on
// the old prefix and reaches this one through the one-time E2E copy.
final remote = MemGitHub({
'$_old/$_peerId/alarm.json': jsonEncode(
_peerRecord({
'alarm_hour': 7,
'alarm_minute': 0,
'nfc_tag_ids': ['x', 'y'],
}, '2026-09-11T10:26:18.254Z-0000-$_peerId'),
),
});
final github = remote.client();
await service.pushWith(github);
// The peer's tags are merged in; the peer's older alarm loses to the
@ -122,25 +84,23 @@ void main() {
expect(service.field('nfc_tag_ids')?.$1, ['x', 'y']);
expect(service.currentAlarm, (hour: 8, minute: 0));
// And the merged result is what was pushed, under this install's path.
expect(pushes, hasLength(1));
final fields = (pushes.single['alarm'] as Map)['fields'] as Map;
const path = '$_prefix/$_deviceId/alarm.json';
expect(remote.files[path], isNot(contains('nfc_tag_ids')));
final pushed = jsonDecode((await remote.opened(path))!) as Map;
final fields = (pushed['alarm'] as Map)['fields'] as Map;
expect((fields['nfc_tag_ids'] as List).first, ['x', 'y']);
expect((fields['alarm_hour'] as List).first, 8);
// The old plaintext node is copied, never rewritten.
expect(remote.putPaths.where((p) => p.startsWith('$_old/')), isEmpty);
});
test('pushWith with no peers pushes the local log unchanged', () async {
await service.setLocal(8, 0);
final (:client, :pushes) = _mockRemote();
final github = GitHubClient(
owner: 'o',
repo: 'r',
token: 't',
httpClient: client,
);
final remote = MemGitHub();
await service.pushWith(github);
await service.pushWith(remote.client());
expect(pushes, hasLength(1));
expect(remote.putPaths, contains('$_prefix/$_deviceId/alarm.json'));
expect(service.field('nfc_tag_ids'), isNull);
});
}

View File

@ -6,13 +6,13 @@ import 'package:http/http.dart' as http;
import 'package:http/testing.dart' as http_testing;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:uuid/uuid.dart';
import 'package:wake_alarm_sync/services/alarm_sync_e2e.dart';
import 'package:wake_alarm_sync/services/alarm_sync_service.dart';
const _deviceId = 'test-device-uuid';
const _alarmPath = 'wake-alarm-sync/devices/$_deviceId/alarm.json';
import '../e2e_support.dart';
http.Response _response(int statusCode, [Object? jsonBody]) =>
http.Response(jsonEncode(jsonBody ?? {}), statusCode);
const _deviceId = 'test-device-uuid';
const _alarmPath = 'wake-alarm-sync/devices-e2e/$_deviceId/alarm.json';
/// In-memory persistence so the store never touches the real documents dir.
class _MemPersistence implements LogPersistence {
@ -25,39 +25,12 @@ class _MemPersistence implements LogPersistence {
Future<void> write(String value) async => text = value;
}
class _PutCall {
_PutCall(this.path, this.body);
final String path;
final Map<String, dynamic> body;
}
/// Mock GitHub Contents API: GET `.../contents/<key>` returns
/// [contentResponses]`[key]` (404 if absent), the bare repo-existence GET
/// always succeeds, and every PUT is recorded. Mirrors the workout_app test.
({http.Client httpClient, List<_PutCall> putCalls}) _mockGitHub({
Map<String, http.Response> contentResponses = const {},
}) {
final putCalls = <_PutCall>[];
final client = http_testing.MockClient((request) async {
final path = request.url.path;
if (!path.contains('/contents/')) return _response(200);
final key = path.split('/contents/').last;
if (request.method == 'PUT') {
putCalls.add(
_PutCall(key, jsonDecode(request.body) as Map<String, dynamic>),
);
return _response(200);
}
return contentResponses[key] ?? _response(404);
});
return (httpClient: client, putCalls: putCalls);
}
Map<String, dynamic> _decodePush(_PutCall call) =>
jsonDecode(utf8.decode(base64.decode(call.body['content'] as String)))
as Map<String, dynamic>;
void main() {
setUp(() {
TestWidgetsFlutterBinding.ensureInitialized();
installE2eTestStorage();
});
test('currentAlarm is null before anything is set', () async {
final service = await AlarmSyncService.open(
persistence: _MemPersistence(),
@ -89,19 +62,42 @@ void main() {
deviceId: _deviceId,
);
await service.setLocal(7, 15);
final (:httpClient, :putCalls) = _mockGitHub();
final remote = MemGitHub();
await service.push('tok', httpClient: httpClient);
await service.push('tok', httpClient: remote.httpClient);
expect(putCalls, hasLength(1));
expect(putCalls.single.path, _alarmPath);
final pushed = _decodePush(putCalls.single);
final fields = (pushed['alarm'] as Map<String, dynamic>)['fields']
as Map<String, dynamic>;
expect(remote.putPaths, contains(_alarmPath));
expect(remote.files[_alarmPath], isNot(contains('alarm_hour')));
final pushed =
jsonDecode((await remote.opened(_alarmPath))!) as Map<String, dynamic>;
final fields =
(pushed['alarm'] as Map<String, dynamic>)['fields']
as Map<String, dynamic>;
expect((fields['alarm_hour'] as List).first, 7);
expect((fields['alarm_minute'] as List).first, 15);
});
test('a device with no passphrase pushes nothing', () async {
installE2eTestStorage(initial: {'crdt_sync_e2e_passphrase': ''});
final service = await AlarmSyncService.open(
persistence: _MemPersistence(),
deviceId: _deviceId,
);
await service.setLocal(7, 15);
final remote = MemGitHub();
await expectLater(
service.push('tok', httpClient: remote.httpClient),
throwsA(isA<SyncLockedException>()),
);
expect(remote.putPaths, isEmpty);
expect(alarmSyncLocked.value, isTrue);
expect(
const SyncLockedException(E2eLockReason.wrongKey).toString(),
contains('wrongKey'),
);
});
test('push is a network round-trip that swallows a server error', () async {
final service = await AlarmSyncService.open(
persistence: _MemPersistence(),
@ -160,19 +156,18 @@ void main() {
deviceId: 'uuid-abc',
);
await service.setLocal(6, 30);
final paths = <String>[];
final client = http_testing.MockClient((request) async {
paths.add(request.url.path);
return _response(200);
});
final remote = MemGitHub();
await service.push('tok', httpClient: client);
await service.push('tok', httpClient: remote.httpClient);
expect(paths.any((path) => path.contains('devices/uuid-abc/')), isTrue);
final paths = remote.putPaths;
expect(
paths.any((path) => path.contains('devices-e2e/uuid-abc/')),
isTrue,
);
expect(
paths.any(
(path) =>
path.contains('devices/${AlarmSyncService.legacyDeviceId}/'),
(path) => path.contains('/${AlarmSyncService.legacyDeviceId}/'),
),
isFalse,
);

View File

@ -72,8 +72,7 @@ void main() {
final c = ChallengeGenerator(random: _FixedRandom(<int>[2, 3, 4])).math();
expect(c.display, contains('*'));
final parts = RegExp(r'(\d+) \* (\d+)').firstMatch(c.display)!;
final product =
int.parse(parts.group(1)!) * int.parse(parts.group(2)!);
final product = int.parse(parts.group(1)!) * int.parse(parts.group(2)!);
expect(c.answer, '$product');
});

View File

@ -122,8 +122,7 @@ void main() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel(AlarmScheduler.channelName),
(call) async =>
call.method == 'readPresence' ? reading() : null,
(call) async => call.method == 'readPresence' ? reading() : null,
);
}

View File

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

View File

@ -31,13 +31,13 @@ void main() {
scheduler = AlarmScheduler(channel: channel);
});
test('the label is the live leg', () {
expect(noticeLabel(fresh()), 'Tap the bathroom tag');
test('the label is the live leg glyph + position', () {
expect(noticeLabel(fresh()), '🛁 1/4');
});
test('a leg whose re-ring is spent says it will pass by itself', () {
test('a leg whose re-ring is spent appends a running-clock marker', () {
final s = fresh()..deadlineRung.add(SessionLeg.bathroomIn);
expect(noticeLabel(s), 'Tap the bathroom tag — passes by itself soon');
expect(noticeLabel(s), '🛁 1/4 ⏳');
});
test('a finished session drops the deadline and posts no notice', () async {
@ -64,7 +64,7 @@ void main() {
expect(calls[0].arguments, <String, int?>{'atMillis': deadline});
expect(calls[1].arguments, <String, Object?>{
'date': '2026-09-17',
'label': 'Tap the bathroom tag',
'label': '🛁 1/4',
'deadlineMillis': deadline,
});
});

View File

@ -171,6 +171,30 @@ void main() {
});
});
group('ringStakes', () {
test('an open stick-day ring returns deadline and lockdown', () {
final stakes = ringStakes(_state());
expect(stakes, isNotNull);
expect(stakes!.by, DateTime(2026, 10, 1, 7, 15));
expect(stakes.lockThrough, DateTime(2026, 10, 2));
});
test('a missing native lock date falls back to the next day', () {
final stakes = ringStakes(_state(lockIfMissToday: null));
expect(stakes, isNotNull);
expect(stakes!.lockThrough, DateTime(2026, 10, 2));
});
test('nothing on a carrot, free, settled or alarm-less day', () {
expect(ringStakes(_state(today: '2026-10-02')), isNull);
expect(ringStakes(_state(freeToday: true)), isNull);
expect(ringStakes(_state(dismissedToday: true)), isNull);
expect(ringStakes(_state(escapedToday: true)), isNull);
expect(ringStakes(_state(lastMiss: '2026-10-01')), isNull);
expect(ringStakes(_state(alarmHour: null)), isNull);
});
});
group('carrotCutoff', () {
test('is A + 90 min before the cap, the cap after it', () {
expect(

View File

@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_ui/material_ui.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:wake_alarm_sync/screens/session_screen.dart';
import 'package:wake_alarm_sync/services/alarm_scheduler.dart';
@ -69,7 +69,7 @@ void main() {
await tester.tap(find.widgetWithText(ChoiceChip, '3'));
await tester.pump();
await tester.enterText(find.byType(TextField), kg);
await tester.tap(find.text('Log weight'));
await tester.tap(find.byKey(const Key('session-log-weight')));
await tester.pump();
}

View File

@ -1,13 +1,18 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_ui/material_ui.dart';
import 'package:shared_preferences/shared_preferences.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/session_store.dart';
import 'package:wake_alarm_sync/services/session_tag_settings.dart';
import 'package:wake_alarm_sync/ui/cues.dart';
/// The session's status sentence, now spoken only.
String statusOf(WidgetTester tester) =>
tester.widget<StatusCue>(find.byType(StatusCue)).semanticLabel;
/// The check-in half of the session screen (energy + weight) and the two
/// publishes: split from `session_screen_test.dart` at the 250-line cap.
@ -75,7 +80,7 @@ void main() {
Future<void> submit(WidgetTester tester, String kg) async {
await tester.enterText(find.byType(TextField), kg);
await tester.tap(find.text('Log weight'));
await tester.tap(find.byKey(const Key('session-log-weight')));
await tester.pump();
}
@ -92,7 +97,7 @@ void main() {
) async {
await pumpSession(tester);
await submit(tester, '72');
expect(find.textContaining('Tap your energy'), findsOneWidget);
expect(statusOf(tester), contains('Tap your energy'));
expect(find.byType(TextField), findsOneWidget);
expect((await SessionStore().loadCurrent())?.pendingWeightKg, isNull);
});
@ -102,7 +107,7 @@ void main() {
await tester.tap(find.widgetWithText(ChoiceChip, '5'));
await tester.pump();
await submit(tester, '72');
expect(find.textContaining('now tap the bathroom tag'), findsOneWidget);
expect(find.byKey(const ValueKey('session-row-weigh')), findsOneWidget);
final session = await SessionStore().loadCurrent();
expect(session?.energy, 5);
expect(session?.pendingWeightKg, 72);
@ -113,13 +118,13 @@ void main() {
await tester.tap(find.widgetWithText(ChoiceChip, '3'));
await tester.pump();
await submit(tester, 'x');
expect(find.textContaining('Enter your weight'), findsOneWidget);
expect(statusOf(tester), contains('Enter your weight'));
await submit(tester, '1');
expect(find.textContaining('not a body weight'), findsOneWidget);
expect(statusOf(tester), contains('not a body weight'));
expect(find.byType(TextField), findsOneWidget);
await tap('mystery');
await tester.pump();
expect(find.text('Unknown tag'), findsOneWidget);
expect(statusOf(tester), 'Unknown tag');
});
testWidgets('the finish publish waits for a start publish in flight', (

View File

@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_ui/material_ui.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:wake_alarm_sync/screens/session_screen.dart';
import 'package:wake_alarm_sync/services/alarm_scheduler.dart';
@ -52,13 +52,11 @@ void main() {
..onTag(SessionTagSlot.bathroom, at);
await SessionStore().save(session);
await pumpCold(tester);
expect(find.text('Weigh in — 72.0 kg · energy 3/5'), findsOneWidget);
expect(find.textContaining('tag counts in'), findsOneWidget);
expect(find.text('72.0'), findsOneWidget);
expect(find.text('3/5'), findsOneWidget);
expect(find.textContaining('04:00'), findsOneWidget);
final notice = calls.lastWhere((c) => c.method == 'updateSessionNotice');
expect(
(notice.arguments as Map<Object?, Object?>)['label'],
'Shower, then tap the bathroom tag again',
);
expect((notice.arguments as Map<Object?, Object?>)['label'], '🚿 2/4');
expect(calls.map((c) => c.method), isNot(contains('closeRingScreen')));
});

View File

@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_ui/material_ui.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:wake_alarm_sync/screens/session_screen.dart';
import 'package:wake_alarm_sync/services/alarm_scheduler.dart';
@ -8,6 +8,11 @@ import 'package:wake_alarm_sync/services/morning_session.dart';
import 'package:wake_alarm_sync/services/session_rules.dart';
import 'package:wake_alarm_sync/services/session_store.dart';
import 'package:wake_alarm_sync/services/session_tag_settings.dart';
import 'package:wake_alarm_sync/ui/cues.dart';
/// The session's status sentence, now spoken only.
String statusOf(WidgetTester tester) =>
tester.widget<StatusCue>(find.byType(StatusCue)).semanticLabel;
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@ -83,7 +88,7 @@ void main() {
await tester.tap(find.widgetWithText(ChoiceChip, '3'));
await tester.pump();
await tester.enterText(find.byType(TextField), kg);
await tester.tap(find.text('Log weight'));
await tester.tap(find.byKey(const Key('session-log-weight')));
await tester.pump();
}
@ -91,9 +96,7 @@ void main() {
tester,
) async {
await pumpSession(tester);
expect(find.text('Stay up'), findsOneWidget);
expect(find.text('Weigh in'), findsOneWidget);
expect(find.text('Alarm returns in 10:00'), findsOneWidget);
expect(find.byKey(const ValueKey('session-row-weigh')), findsOneWidget);
final deadline = now.add(MorningSession.legWindow).millisecondsSinceEpoch;
final arm = calls.firstWhere((c) => c.method == 'armReRing');
expect(arm.arguments, <String, int?>{'atMillis': deadline});
@ -101,7 +104,7 @@ void main() {
final notice = calls.firstWhere((c) => c.method == 'updateSessionNotice');
expect(notice.arguments, <String, Object?>{
'date': '2026-09-17',
'label': 'Tap the bathroom tag',
'label': '🛁 1/4',
'deadlineMillis': deadline,
});
expect((await SessionStore().loadCurrent())?.date, '2026-09-17');
@ -116,7 +119,7 @@ void main() {
.where((c) => c.method == 'updateSessionNotice')
.map((c) => (c.arguments as Map<Object?, Object?>)['label'])
.toList();
expect(labels.last, 'Shower, then tap the bathroom tag again');
expect(labels.last, '🚿 2/4');
expect(methods(), isNot(contains('beginMotionWatch')));
});
@ -125,22 +128,22 @@ void main() {
) async {
await pumpSession(tester);
await logWeight(tester, '72,4');
expect(find.textContaining('now tap the bathroom tag'), findsOneWidget);
expect(find.byKey(const ValueKey('session-row-weigh')), findsOneWidget);
expect(find.byType(TextField), findsNothing);
await tap('desk');
await tester.pump();
expect(find.text('Not the bathroom tag'), findsOneWidget);
expect(statusOf(tester), 'Not the bathroom tag');
await tap('bath');
await tester.pump();
expect(find.text('Weigh in — 72.4 kg · energy 3/5'), findsOneWidget);
expect(find.textContaining('tag counts in 08:00'), findsOneWidget);
expect(find.text('72.4'), findsOneWidget);
expect(find.text('3/5'), findsOneWidget);
await tap('bath');
await tester.pump();
await tester.pump();
expect(find.textContaining('Too early'), findsOneWidget);
expect(statusOf(tester), startsWith('Too early'));
now = now.add(const Duration(minutes: 9));
await tick(tester);
expect(find.textContaining('Alarm returns in'), findsOneWidget);
expect(find.text('09:00'), findsOneWidget);
for (final id in <String>['bath', 'ward', 'desk']) {
await tap(id);
}
@ -210,14 +213,16 @@ void main() {
)..recordReRing(now.subtract(const Duration(minutes: 1)));
await SessionStore().save(session);
await pumpSession(tester);
expect(find.text('Re-rings: 1/3'), findsOneWidget);
expect(find.text('1/3'), findsOneWidget);
expect(
calls.firstWhere((c) => c.method == 'armReRing').arguments,
<String, int?>{'atMillis': null},
);
await tick(tester);
expect(find.textContaining('Skipped'), findsOneWidget);
expect(find.textContaining('(skipped by the clock)'), findsOneWidget);
expect(
find.byKey(const ValueKey('session-row-bathroomIn')),
findsOneWidget,
);
});
testWidgets('a big jump must be typed twice', (tester) async {
@ -231,9 +236,9 @@ void main() {
);
await pumpSession(tester);
await logWeight(tester, '75');
expect(find.textContaining('type it again'), findsOneWidget);
expect(statusOf(tester), contains('type it again'));
await logWeight(tester, '75');
expect(find.textContaining('now tap the bathroom tag'), findsOneWidget);
expect(statusOf(tester), contains('now tap the bathroom tag within 2 min'));
expect(find.byType(TextField), findsNothing);
});
}

View File

@ -1,10 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_ui/material_ui.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:wake_alarm_sync/screens/session_tags_screen.dart';
import 'package:wake_alarm_sync/services/alarm_scheduler.dart';
import 'package:wake_alarm_sync/services/session_tag_settings.dart';
import 'package:wake_alarm_sync/ui/cues.dart';
import 'package:wake_alarm_sync/ui/dashed_slot.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@ -45,20 +47,29 @@ void main() {
) async {
await tester.pumpWidget(const MaterialApp(home: SessionTagsScreen()));
await tester.pump();
expect(find.textContaining('Bathroom (scale + shower): none'), findsOne);
expect(find.textContaining('Wardrobe: none'), findsOneWidget);
expect(find.textContaining('Desk: none'), findsOneWidget);
expect(find.byType(DashedSlot), findsNWidgets(3));
});
testWidgets('a scan registers the tag in its slot', (tester) async {
await tester.pumpWidget(const MaterialApp(home: SessionTagsScreen()));
await tester.pump();
await tester.tap(find.text('Register wardrobe tag'));
await tester.tap(find.byKey(const ValueKey<String>('register-wardrobe')));
await tester.pump();
expect(find.text('Waiting for tag…'), findsOneWidget);
// Busy indicator in the button
final registerBtn = tester.widget<FilledButton>(
find.byKey(const ValueKey<String>('register-wardrobe')),
);
expect(registerBtn.child, isA<SizedBox>());
expect(find.byType(StatusCue), findsOneWidget);
final busyCue = tester.widget<StatusCue>(find.byType(StatusCue));
expect(busyCue.resolvedKind, CueKind.busy);
await tapTag(tester, 'w1');
expect(find.textContaining('Wardrobe: w1'), findsOneWidget);
expect(find.text('The wardrobe tag is registered.'), findsOneWidget);
expect(find.text('w1'), findsOneWidget);
final okCue = tester.widget<StatusCue>(find.byType(StatusCue));
expect(okCue.semanticLabel, 'The wardrobe tag is registered.');
expect(calls, contains('stopTagRegistration'));
expect(
(await SessionTagSettings.load()).tagAt(SessionTagSlot.wardrobe),
@ -70,10 +81,11 @@ void main() {
await SessionTagSettings.saveTagAt(SessionTagSlot.bathroom, 'b1');
await tester.pumpWidget(const MaterialApp(home: SessionTagsScreen()));
await tester.pump();
await tester.tap(find.text('Register desk tag'));
await tester.tap(find.byKey(const ValueKey<String>('register-desk')));
await tester.pump();
await tapTag(tester, 'b1');
expect(find.textContaining('already the bathroom tag'), findsOneWidget);
final errCue = tester.widget<StatusCue>(find.byType(StatusCue));
expect(errCue.semanticLabel, contains('already the bathroom tag'));
expect(
(await SessionTagSettings.load()).tagAt(SessionTagSlot.desk),
isNull,
@ -84,18 +96,20 @@ void main() {
await SessionTagSettings.saveTagAt(SessionTagSlot.desk, 'd1');
await tester.pumpWidget(const MaterialApp(home: SessionTagsScreen()));
await tester.pump();
await tester.tap(find.text('Register desk tag again'));
await tester.tap(find.byKey(const ValueKey<String>('register-desk')));
await tester.pump();
await tapTag(tester, 'd1');
expect(find.text('The desk tag is registered.'), findsOneWidget);
final okCue = tester.widget<StatusCue>(find.byType(StatusCue));
expect(okCue.semanticLabel, 'The desk tag is registered.');
});
testWidgets('says so when NFC is off', (tester) async {
nfcStarts = false;
await tester.pumpWidget(const MaterialApp(home: SessionTagsScreen()));
await tester.pump();
await tester.tap(find.text('Register bathroom tag'));
await tester.tap(find.byKey(const ValueKey<String>('register-bathroom')));
await tester.pump();
expect(find.textContaining('NFC is unavailable'), findsOneWidget);
final errCue = tester.widget<StatusCue>(find.byType(StatusCue));
expect(errCue.semanticLabel, contains('NFC is unavailable'));
});
}

View File

@ -1,22 +1,21 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_ui/material_ui.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 {
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 +28,7 @@ void main() {
var imported = 0;
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: SettingsScreen(
onExport: () async => exported++,
onImport: () async {
@ -39,12 +39,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 +54,7 @@ void main() {
var imported = 0;
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: SettingsScreen(
onImport: () async {
imported++;
@ -63,7 +64,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 +72,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 +88,7 @@ void main() {
) async {
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: SettingsScreen(
onExport: () async {},
onImport: () async => throw const FormatException('bad file'),
@ -91,9 +96,9 @@ void main() {
),
);
await tester.tap(find.bySemanticsLabel('Export data'));
await tester.tap(find.byKey(const Key('settings-export')));
await tester.pump();
await tester.tap(find.bySemanticsLabel('Import data'));
await tester.tap(find.byKey(const Key('settings-import')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 600));
expect(find.byIcon(Icons.error_outline), findsOneWidget);

View File

@ -1,7 +1,7 @@
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:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/screens/day_calendar.dart';
import 'package:wake_alarm_sync/screens/sick_days_screen.dart';
import 'package:wake_alarm_sync/services/alarm_scheduler.dart';
@ -10,6 +10,8 @@ import 'package:wake_alarm_sync/services/free_day_sync.dart';
import 'package:wake_alarm_sync/services/sick_day_keep.dart';
import 'package:wake_alarm_sync/services/sick_day_pool.dart';
import 'package:wake_alarm_sync/services/sick_day_sync.dart';
import 'package:wake_alarm_sync/ui/cues.dart';
import 'package:wake_alarm_sync/ui/dashed_slot.dart';
import 'package:wake_alarm_sync/ui/theme.dart';
import 'fake_day_pool_store.dart';
@ -17,8 +19,14 @@ import 'fake_day_pool_store.dart';
/// The per-locker sheet on the sick-day calendar (sickdays 0.2.0 keep).
final _today = DateTime(2026, 10, 16);
final _day = DateTime(2026, 10, 20);
Finder iconLabelled(String label) =>
find.byWidgetPredicate((w) => w is Icon && w.semanticLabel == label);
bool _never(DateTime _) => false;
String statusOf(WidgetTester tester) =>
tester.widget<StatusCue>(find.byType(StatusCue).first).semanticLabel;
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@ -102,23 +110,24 @@ void main() {
await open(tester);
await tap(tester, cell(_day));
expect(find.text('Mark sick: Tue 2026-10-20'), findsOneWidget);
expect(find.text('20.10.2026'), findsOneWidget);
expect(find.byKey(const ValueKey<String>('sick-mark')), findsOneWidget);
for (final locker in SickLocker.all) {
expect(isOn(tester, locker), isFalse, reason: locker);
}
for (final label in [
'Workout lock',
'LeetCode',
'Book',
'Diet',
'Wake alarm',
for (final icon in [
Icons.fitness_center,
Icons.code,
Icons.menu_book,
Icons.restaurant,
Icons.alarm,
]) {
expect(find.text(label), findsOneWidget);
expect(find.byIcon(icon), findsOneWidget);
}
expect(find.text('Clear sick day'), findsNothing);
expect(find.byKey(const ValueKey<String>('sick-clear')), findsNothing);
expect(sick.isSick(_day), isFalse, reason: 'nothing until confirmed');
await tap(tester, find.text('Mark sick'));
await tap(tester, find.byKey(const ValueKey<String>('sick-mark')));
expect(sick.isSick(_day), isTrue);
expect(sick.keptLockers(_day), isEmpty);
expect(caches.last, ['2026-10-20'], reason: 'the alarm stands down');
@ -132,15 +141,14 @@ void main() {
await tap(tester, cell(_day));
await tap(tester, toggle(SickLocker.alarm));
await tap(tester, toggle(SickLocker.leetcode));
await tap(tester, find.text('Mark sick'));
await tap(tester, find.byKey(const ValueKey<String>('sick-mark')));
expect(sick.keptLockers(_day), {SickLocker.alarm, SickLocker.leetcode});
expect(caches.last, isEmpty);
expect(find.text('Alarm cache: none'), findsOneWidget);
expect(iconLabelled('Alarm cache: none'), findsOneWidget);
final box = tester.widget<DayCell>(cell(_day));
expect(box.isMarked, isTrue, reason: 'still a sick day');
expect(box.badge, isTrue, reason: 'kept lockers are badged');
expect(find.text('Kept: LeetCode, Wake alarm'), findsOneWidget);
});
testWidgets('a sick day without kept lockers has no badge', (tester) async {
@ -154,12 +162,14 @@ void main() {
await open(tester);
await tap(tester, cell(_day));
expect(find.text('Sick day: Tue 2026-10-20'), findsOneWidget);
// In the list and in the sheet heading; save means it is already sick.
expect(find.text('20.10.2026'), findsWidgets);
expect(find.byKey(const Key('sick-save')), findsOneWidget);
expect(isOn(tester, SickLocker.diet), isTrue);
expect(isOn(tester, SickLocker.alarm), isFalse);
expect(find.text('Save'), findsOneWidget);
expect(find.text('Clear sick day'), findsOneWidget);
expect(find.text('Mark sick'), findsNothing);
expect(find.byKey(const ValueKey<String>('sick-save')), findsOneWidget);
expect(find.byKey(const ValueKey<String>('sick-clear')), findsOneWidget);
expect(find.byKey(const ValueKey<String>('sick-mark')), findsNothing);
});
testWidgets('Save edits keep only, never state', (tester) async {
@ -168,7 +178,7 @@ void main() {
await open(tester);
await tap(tester, cell(_day));
await tap(tester, toggle(SickLocker.alarm));
await tap(tester, find.text('Save'));
await tap(tester, find.byKey(const ValueKey<String>('sick-save')));
final fields = sick.snapshot()['2026-10-20']!.fields;
expect(fields['state'], state, reason: 'value and HLC untouched');
@ -183,7 +193,7 @@ void main() {
final before = sick.snapshot()['2026-10-20'];
await open(tester);
await tap(tester, cell(_day));
await tap(tester, find.text('Save'));
await tap(tester, find.byKey(const ValueKey<String>('sick-save')));
expect(sick.snapshot()['2026-10-20'], before);
expect(syncs, 1);
});
@ -192,11 +202,11 @@ void main() {
await sick.add(_day, isFree: _never, now: _today, keep: ['alarm']);
await open(tester);
await tap(tester, cell(_day));
await tap(tester, find.text('Clear sick day'));
await tap(tester, find.byKey(const ValueKey<String>('sick-clear')));
expect(sick.isSick(_day), isFalse);
expect(sick.snapshot()['2026-10-20']!.fields['keep']!.$1, isEmpty);
expect(find.text('None ahead.'), findsOneWidget);
expect(find.byType(DashedSlot), findsOneWidget);
expect(syncs, 2);
});

View File

@ -1,8 +1,8 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/screens/day_calendar.dart';
import 'package:wake_alarm_sync/screens/sick_days_screen.dart';
import 'package:wake_alarm_sync/services/alarm_scheduler.dart';
@ -10,12 +10,20 @@ import 'package:wake_alarm_sync/services/free_day_pool.dart';
import 'package:wake_alarm_sync/services/free_day_sync.dart';
import 'package:wake_alarm_sync/services/sick_day_pool.dart';
import 'package:wake_alarm_sync/services/sick_day_sync.dart';
import 'package:wake_alarm_sync/ui/cues.dart';
import 'package:wake_alarm_sync/ui/dashed_slot.dart';
import 'package:wake_alarm_sync/ui/theme.dart';
import 'fake_day_pool_store.dart';
final _today = DateTime(2026, 10, 16);
Finder iconLabelled(String label) =>
find.byWidgetPredicate((w) => w is Icon && w.semanticLabel == label);
String statusOf(WidgetTester tester) =>
tester.widget<StatusCue>(find.byType(StatusCue).first).semanticLabel;
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@ -95,10 +103,10 @@ void main() {
Future<void> viaSheet(
WidgetTester tester,
DateTime day,
String button,
String buttonKey,
) async {
await tapDay(tester, day);
await tester.tap(find.text(button));
await tester.tap(find.byKey(Key(buttonKey)));
await tester.pumpAndSettle();
}
@ -110,10 +118,10 @@ void main() {
testWidgets('every day starts normal; syncs on open', (tester) async {
await open(tester);
expect(find.text('Sick days'), findsOneWidget);
expect(find.byIcon(Icons.sick_outlined), findsOneWidget);
expect(find.text('October 2026'), findsOneWidget);
expect(find.text('None ahead.'), findsOneWidget);
expect(find.byType(SickDaysScreen), findsOneWidget);
expect(find.byIcon(Icons.sick_outlined), findsWidgets);
expect(find.text('10/2026'), findsOneWidget);
expect(find.byType(DashedSlot), findsOneWidget);
expect(syncs, 1);
expect(caches.last, isEmpty);
});
@ -121,13 +129,13 @@ void main() {
testWidgets('today can be marked sick and cleared again', (tester) async {
await open(tester);
await viaSheet(tester, _today, 'Mark sick');
await viaSheet(tester, _today, 'sick-mark');
expect(sick.isSick(_today), isTrue);
expect(find.text('Fri 2026-10-16'), findsOneWidget);
expect(find.text('16.10.2026'), findsOneWidget);
expect(caches.last, ['2026-10-16'], reason: 'native cache rewritten');
expect(syncs, 2);
await viaSheet(tester, _today, 'Clear sick day');
await viaSheet(tester, _today, 'sick-clear');
expect(sick.isSick(_today), isFalse);
expect(caches.last, isEmpty);
expect(syncs, 3);
@ -135,21 +143,21 @@ void main() {
testWidgets('the native cache is read back after a mark', (tester) async {
await open(tester);
expect(find.text('Alarm cache: none'), findsOneWidget);
await viaSheet(tester, DateTime(2026, 10, 18), 'Mark sick');
await viaSheet(tester, DateTime(2026, 10, 17), 'Mark sick');
expect(find.text('Alarm cache: 2026-10-17, 2026-10-18'), findsOneWidget);
expect(iconLabelled('Alarm cache: none'), findsOneWidget);
await viaSheet(tester, DateTime(2026, 10, 18), 'sick-mark');
await viaSheet(tester, DateTime(2026, 10, 17), 'sick-mark');
expect(iconLabelled('Alarm cache: 2026-10-17, 2026-10-18'), findsOneWidget);
});
testWidgets('an unreadable native cache never reads as none', (tester) async {
cacheUnreadable = true;
await open(tester);
expect(find.text('Alarm cache: unreadable'), findsOneWidget);
expect(iconLabelled('Alarm cache: unreadable'), findsOneWidget);
});
testWidgets('a future day is marked in the sick colour', (tester) async {
await open(tester);
await viaSheet(tester, DateTime(2026, 10, 20), 'Mark sick');
await viaSheet(tester, DateTime(2026, 10, 20), 'sick-mark');
expect(sick.isSick(DateTime(2026, 10, 20)), isTrue);
final box = tester.widget<DayCell>(cell(DateTime(2026, 10, 20)));
expect(box.markColor, DayMarkColors.dark.sick);
@ -175,8 +183,8 @@ void main() {
await tester.drag(find.byType(ListView), const Offset(0, 3000));
await tester.pumpAndSettle();
expect(
find.text('2026-10-20 is a free day — release it in Free days first.'),
findsOneWidget,
statusOf(tester),
contains('2026-10-20 is a free day — release it in Free days first.'),
);
expect(syncs, 1);
});
@ -190,7 +198,7 @@ void main() {
expect(sick.isSick(DateTime(2026, 10, 20)), isFalse);
await tester.drag(find.byType(ListView), const Offset(0, 3000));
await tester.pumpAndSettle();
expect(find.textContaining('Free-day pool unavailable'), findsOneWidget);
expect(statusOf(tester), contains('Free-day pool unavailable'));
});
testWidgets('month arrows reach January and the end of next year', (
@ -198,16 +206,16 @@ void main() {
) async {
await open(tester);
for (var i = 0; i < 9; i++) {
await tester.tap(find.byTooltip('Previous month'));
await tester.tap(find.byIcon(Icons.chevron_left));
await tester.pumpAndSettle();
}
expect(find.text('January 2026'), findsOneWidget);
expect(find.text('01/2026'), findsOneWidget);
for (var i = 0; i < 23; i++) {
await tester.tap(find.byTooltip('Next month'));
await tester.tap(find.byIcon(Icons.chevron_right));
await tester.pumpAndSettle();
}
expect(find.text('December 2027'), findsOneWidget);
await tester.tap(find.byTooltip('Sync now'));
expect(find.text('12/2027'), findsOneWidget);
await tester.tap(find.byIcon(Icons.sync));
await tester.pumpAndSettle();
expect(syncs, 2);
});
@ -223,6 +231,6 @@ void main() {
),
);
await tester.pumpAndSettle();
expect(find.textContaining('Error: pool unavailable'), findsOneWidget);
expect(statusOf(tester), contains('Error: pool unavailable'));
});
}

View File

@ -1,14 +1,28 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_ui/material_ui.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:wake_alarm_sync/screens/home_body.dart';
import 'package:wake_alarm_sync/screens/stakes_card.dart';
import 'package:wake_alarm_sync/services/escape_log.dart';
import 'package:wake_alarm_sync/services/stakes.dart';
import 'package:wake_alarm_sync/ui/cues.dart';
import 'package:wake_alarm_sync/ui/dashed_slot.dart';
import 'package:wake_alarm_sync/ui/theme.dart';
import 'fake_enforcement_channel.dart';
/// Every row's spoken sentence: the card paints glyphs, the words are here.
List<String> spoken(WidgetTester tester) => tester
.widgetList<SpokenLabel>(find.byType(SpokenLabel))
.map((w) => w.semanticLabel)
.toList();
/// The escape outcome, now a StatusCue.
String cueWords(WidgetTester tester) =>
tester.widget<StatusCue>(find.byType(StatusCue)).semanticLabel;
final Finder escapeButton = find.byKey(const Key('stakes-escape'));
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@ -54,12 +68,18 @@ void main() {
lastMiss: '2026-09-30',
);
await pumpCard(tester);
expect(find.text('Today · Thu 1 Oct — stick day'), findsOneWidget);
expect(find.textContaining('no dismiss by 07:15'), findsOneWidget);
expect(find.textContaining('PC rings at 07:02'), findsOneWidget);
expect(find.text('Apps locked through Fri 2 Oct.'), findsOneWidget);
expect(find.text('Streak: 1 missed stick day in a row'), findsOneWidget);
expect(find.text('That was a bug'), findsOneWidget);
final words = spoken(tester);
expect(words, contains('Today · Thu 1 Oct — stick day'));
expect(words.where((w) => w.contains('no dismiss by 07:15')), hasLength(1));
expect(words.where((w) => w.contains('PC rings at 07:02')), hasLength(1));
expect(words, contains('Apps locked through Fri 2 Oct.'));
expect(words, contains('Streak: 1 missed stick day in a row'));
// Painted: the gavel day, the deadline, the lock date and the streak.
expect(find.byIcon(Icons.gavel_outlined), findsOneWidget);
expect(find.text('07:15'), findsOneWidget);
expect(find.text('02.10'), findsWidgets);
expect(find.text('1'), findsOneWidget);
expect(escapeButton, findsOneWidget);
});
testWidgets('absent when the native side answers nothing', (tester) async {
@ -83,26 +103,25 @@ void main() {
);
await tester.pump();
// Friday's ramp with only the seed minutes earned: 07:57.
expect(find.textContaining('Finish the morning by 09:27'), findsOneWidget);
expect(find.textContaining('PC rings at 07:59'), findsOneWidget);
final words = spoken(tester);
expect(words.where((w) => w.contains('Finish the morning by 09:27')), [
isNotEmpty,
]);
expect(words.where((w) => w.contains('PC rings at 07:59')), hasLength(1));
expect(find.text('09:27'), findsOneWidget);
expect(find.text('07:59'), findsOneWidget);
});
testWidgets('the escape button is gone after the last day', (tester) async {
native.state = nativeState(today: '2026-10-16');
await pumpCard(tester, now: DateTime(2026, 10, 16, 6));
expect(find.text('That was a bug'), findsNothing);
expect(escapeButton, findsNothing);
});
testWidgets('the escape button is disabled while syncing', (tester) async {
native.state = nativeState();
await pumpCard(tester, busy: true);
final button = tester.widget<TextButton>(
find.ancestor(
of: find.text('That was a bug'),
matching: find.byWidgetPredicate((w) => w is TextButton),
),
);
expect(button.onPressed, isNull);
expect(tester.widget<IconButton>(escapeButton).onPressed, isNull);
});
testWidgets('escaping needs a long reason, then lifts and publishes', (
@ -123,39 +142,43 @@ void main() {
),
);
await tester.pump();
await tester.tap(find.text('That was a bug'));
await tester.tap(escapeButton);
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
FilledButton confirm() =>
tester.widget<FilledButton>(find.byType(FilledButton));
tester.widget<FilledButton>(find.byKey(const Key('escape-confirm')));
expect(confirm().onPressed, isNull);
await tester.enterText(find.byType(TextField), 'too short');
await tester.pump();
expect(confirm().onPressed, isNull);
expect(find.text('9 / $escapeReasonMinChars characters'), findsOneWidget);
expect(find.text('9/$escapeReasonMinChars'), findsOneWidget);
const reason = 'The tag on the bathroom wall fell off overnight.';
await tester.enterText(find.byType(TextField), reason);
await tester.pump();
expect(confirm().onPressed, isNotNull);
await tester.tap(find.text('Excuse this morning'));
await tester.tap(find.byKey(const Key('escape-confirm')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(native.methods, contains('escapeToday'));
expect((await EscapeLog.load()).single['reason'], reason);
expect(escaped, 1);
expect(find.textContaining('Excused'), findsOneWidget);
expect(cueWords(tester), startsWith('Excused'));
expect(
tester.widget<StatusCue>(find.byType(StatusCue)).resolvedKind,
CueKind.ok,
);
});
testWidgets('cancelling the dialog changes nothing', (tester) async {
native.state = nativeState();
final escaped = await pumpCard(tester);
await tester.tap(find.text('That was a bug'));
await tester.tap(escapeButton);
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.text('Cancel'));
await tester.tap(find.byKey(const Key('escape-cancel')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(native.methods, isNot(contains('escapeToday')));
@ -167,15 +190,19 @@ void main() {
..state = nativeState()
..escapeResult = false;
await pumpCard(tester);
await tester.tap(find.text('That was a bug'));
await tester.tap(escapeButton);
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.enterText(find.byType(TextField), 'y' * escapeReasonMinChars);
await tester.pump();
await tester.tap(find.text('Excuse this morning'));
await tester.tap(find.byKey(const Key('escape-confirm')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(find.textContaining('refused'), findsOneWidget);
expect(cueWords(tester), contains('refused'));
expect(
tester.widget<StatusCue>(find.byType(StatusCue)).resolvedKind,
CueKind.error,
);
expect(await EscapeLog.load(), isEmpty);
});
@ -211,8 +238,9 @@ void main() {
home: Scaffold(body: StakesCard(emptyText: 'No stakes.')),
),
);
expect(find.text('No stakes.'), findsNothing);
expect(find.byType(DashedSlot), findsNothing);
await tester.pump();
expect(find.text('No stakes.'), findsOneWidget);
expect(find.byType(DashedSlot), findsOneWidget);
expect(spoken(tester).first, 'No stakes.');
});
}

View File

@ -0,0 +1,143 @@
import 'package:crdt_sync/crdt_sync.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/screens/free_days_screen.dart';
import 'package:wake_alarm_sync/screens/sick_day_sheet.dart';
import 'package:wake_alarm_sync/screens/sick_days_screen.dart';
import 'package:wake_alarm_sync/services/alarm_scheduler.dart';
import 'package:wake_alarm_sync/services/free_day_pool.dart';
import 'package:wake_alarm_sync/services/free_day_sync.dart';
import 'package:wake_alarm_sync/services/sick_day_pool.dart';
import 'package:wake_alarm_sync/services/sick_day_sync.dart';
import 'package:wake_alarm_sync/ui/theme.dart';
import 'text_free_support.dart';
class _MemPersistence implements LogPersistence {
String? text;
@override
Future<String?> read() async => text;
@override
Future<void> write(String value) async => text = value;
}
final _today = DateTime(2026, 9, 11);
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('settings-type screens — free/sick days', () {
testWidgets('FreeDaysScreen has no words on screen', (tester) async {
phoneView(tester);
const channel = MethodChannel(AlarmScheduler.channelName);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async => null);
final scheduler = AlarmScheduler(channel: channel);
final pool = await FreeDayPool.open(
persistence: _MemPersistence(),
deviceId: 'phone',
);
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: FreeDaysScreen(
openPool: () async => pool,
syncer: FreeDaySync(
openClient: () async => null,
scheduler: scheduler,
),
openSickPool: () => SickDayPool.open(
persistence: _MemPersistence(),
deviceId: 'phone',
),
sickSyncer: SickDaySync(
openClient: () async => null,
scheduler: scheduler,
),
now: () => _today,
),
),
);
await tester.pumpAndSettle();
expectNoWords(tester, 'FreeDaysScreen');
});
testWidgets('SickDaysScreen has no words on screen', (tester) async {
phoneView(tester);
final caches = <List<String>>[];
const cacheUnreadable = false;
const channel = MethodChannel(AlarmScheduler.channelName);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
if (call.method == 'setSickDays') {
caches.add(
((call.arguments as Map)['dates'] as List).cast<String>(),
);
}
if (call.method == 'getSickDays' && !cacheUnreadable) {
return caches.isEmpty ? <String>[] : caches.last;
}
return null;
});
final scheduler = AlarmScheduler(channel: channel);
final sick = await SickDayPool.open(
persistence: _MemPersistence(),
deviceId: 'phone',
);
final free = await FreeDayPool.open(
persistence: _MemPersistence(),
deviceId: 'phone',
);
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: SickDaysScreen(
openPool: () async => sick,
syncer: SickDaySync(
openClient: () async => null,
scheduler: scheduler,
),
openFreePool: () async => free,
freeSyncer: FreeDaySync(
openClient: () async => null,
scheduler: scheduler,
),
now: () => _today,
scheduler: scheduler,
),
),
);
await tester.pumpAndSettle();
expectNoWords(tester, 'SickDaysScreen');
});
testWidgets('SickDaySheet has no words on screen', (tester) async {
phoneView(tester);
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: Scaffold(
body: Center(
child: IconButton(
key: const Key('open'),
onPressed: () => showSickDaySheet(
tester.element(find.byKey(const Key('open'))),
day: DateTime(2026, 11, 20),
isSick: false,
kept: const <String>{},
),
icon: const Icon(Icons.open_in_new),
),
),
),
),
);
await tester.tap(find.byKey(const Key('open')));
await tester.pumpAndSettle();
expectNoWords(tester, 'SickDaySheet');
});
});
}

View File

@ -0,0 +1,214 @@
import 'dart:math';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_ui/material_ui.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:wake_alarm_sync/screens/ring_screen.dart';
import 'package:wake_alarm_sync/screens/session_screen.dart';
import 'package:wake_alarm_sync/services/alarm_scheduler.dart';
import 'package:wake_alarm_sync/services/challenge.dart';
import 'package:wake_alarm_sync/services/dismiss_gate.dart';
import 'package:wake_alarm_sync/services/room_proof_settings.dart';
import 'package:wake_alarm_sync/services/session_notice.dart';
import 'package:wake_alarm_sync/services/session_rules.dart';
import 'package:wake_alarm_sync/services/session_tag_settings.dart';
import 'text_free_support.dart';
const String _tag1 = '04a2b3c4';
const String _tag2 = '04ffee11';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late List<String> calls;
var steps = 0;
var hasNfc = true;
var elapsed = Duration.zero;
void mockChannel() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel(AlarmScheduler.channelName),
(call) async {
calls.add(call.method);
if (call.method == 'stepCount') return steps;
if (call.method == 'hasNfc') return hasNfc;
return null;
},
);
}
Future<void> pollSteps(WidgetTester tester) async {
await tester.pump(kStepPollInterval);
await tester.pump();
}
setUp(() {
calls = <String>[];
steps = 0;
hasNfc = true;
elapsed = Duration.zero;
SharedPreferences.setMockInitialValues(<String, Object>{});
mockChannel();
});
Future<ChallengeGenerator> pumpRing(WidgetTester tester) async {
await RoomProofSettings.saveNfcTagAt(0, _tag1);
await RoomProofSettings.saveNfcTagAt(1, _tag2);
await tester.pumpWidget(
MaterialApp(
home: RingScreen(
generator: ChallengeGenerator(random: Random(1)),
clock: () => elapsed,
),
routes: <String, WidgetBuilder>{
kSessionRoute: (_) => const Scaffold(body: Text('SESSION')),
},
),
);
await tester.pump();
return ChallengeGenerator(random: Random(1));
}
group('ring screen — no words', () {
testWidgets('at tag 1 stage', (tester) async {
phoneView(tester);
await pumpRing(tester);
expectNoWords(tester, 'RingScreen-tag1');
});
testWidgets('after bypass fires (still walk)', (tester) async {
phoneView(tester);
await pumpRing(tester);
await tester.pump(DismissGate.bypassAfter);
await tester.pump();
expectNoWords(tester, 'RingScreen-bypass-walk');
});
testWidgets('in bypass with steps reached — challenge stage', (
tester,
) async {
phoneView(tester);
await pumpRing(tester);
await tester.pump(DismissGate.bypassAfter);
await tester.pump();
steps = 500;
elapsed = const Duration(minutes: 6);
await pollSteps(tester);
expectNoWords(tester, 'RingScreen-challenge-bypass');
});
testWidgets('challenge after tag path — no words', (tester) async {
phoneView(tester);
await pumpRing(tester);
// Simulate tag 1 tap
await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.handlePlatformMessage(
AlarmScheduler.channelName,
const StandardMethodCodec().encodeMethodCall(
const MethodCall('onNfcTag', _tag1),
),
(_) {},
);
steps = 500;
elapsed = const Duration(minutes: 3);
await pollSteps(tester);
// Tag 2 tap to reach challenges
await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.handlePlatformMessage(
AlarmScheduler.channelName,
const StandardMethodCodec().encodeMethodCall(
const MethodCall('onNfcTag', _tag2),
),
(_) {},
);
await tester.pump();
expectNoWords(tester, 'RingScreen-challenge-tags');
});
testWidgets('all four ChallengeKind variants via different seeds', (
tester,
) async {
phoneView(tester);
for (final seed in [1, 2, 3, 4]) {
await RoomProofSettings.saveNfcTagAt(0, _tag1);
await RoomProofSettings.saveNfcTagAt(1, _tag2);
await tester.pumpWidget(
MaterialApp(
home: RingScreen(
generator: ChallengeGenerator(random: Random(seed)),
clock: () => elapsed,
),
routes: <String, WidgetBuilder>{
kSessionRoute: (_) => const Scaffold(body: Text('SESSION')),
},
),
);
await tester.pump();
await tester.pump(DismissGate.bypassAfter);
await tester.pump();
steps = 500;
elapsed = const Duration(minutes: 6);
await pollSteps(tester);
expectNoWords(tester, 'RingScreen-seed-$seed');
}
});
});
group('session screen — no words', () {
testWidgets('right after session starts', (tester) async {
phoneView(tester);
late List<MethodCall> calls;
late DateTime now;
void mockChannelFn() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel(AlarmScheduler.channelName),
(call) async {
calls.add(call);
if (call.method == 'hasNfc') return true;
return null;
},
);
}
calls = <MethodCall>[];
now = DateTime(2026, 9, 17, 8, 4);
SharedPreferences.setMockInitialValues(<String, Object>{});
mockChannelFn();
await SessionTagSettings.replaceAll(<String>['bath', 'ward', 'desk'], '');
await tester.pumpWidget(
MaterialApp(
routes: <String, WidgetBuilder>{
'/': (_) =>
SessionScreen(clock: () => now, publish: () async => 'pushed'),
'/ring': (_) => const Scaffold(body: Text('RING')),
},
),
);
await tester.pump();
await tester.pump();
expectNoWords(tester, 'SessionScreen-start');
});
});
group('notification line', () {
test('noticeLabel for an active session has no L words', () {
final session = SessionRules.start(
now: DateTime(2026, 9, 1, 8),
tags: const SessionTagSettings(),
nfcAvailable: false,
);
final label = noticeLabel(session);
final wordRegex = RegExp(r'\p{L}{2,}', unicode: true);
final words = wordRegex
.allMatches(label)
.map((m) => m.group(0)!)
.toList();
expect(words, isEmpty, reason: 'noticeLabel had words: $words');
});
});
}

View File

@ -0,0 +1,64 @@
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_ui/material_ui.dart';
/// Every word painted on screen: text of each RenderParagraph (covers Text,
/// Text.rich, button/chip labels and InputDecoration labels/hints), minus
/// content subtrees.
List<String> wordsOnScreen(WidgetTester tester) {
final contentElements = <Element>{};
final allElements = tester.allElements;
// First pass: collect all content-subtree elements
for (final element in allElements) {
if (element.widget.key case ValueKey<String>(
value: final v,
) when v.startsWith('content:')) {
_collectSubtree(element, contentElements);
}
if (element.widget is EditableText) {
contentElements.add(element);
}
}
final words = <String>[];
final wordRegex = RegExp(r'\p{L}{2,}', unicode: true);
final unitRegex = RegExp(r'(\d)\s?(kg|km|min|m|s|h)\b');
// Second pass: collect words from RenderParagraphs not in content subtrees
for (final element in allElements) {
if (contentElements.contains(element)) continue;
final renderObject = element.renderObject;
if (renderObject is RenderParagraph) {
final text = renderObject.text.toPlainText();
// Strip unit symbols that follow a digit
final stripped = text.replaceAllMapped(unitRegex, (m) => m.group(1)!);
for (final match in wordRegex.allMatches(stripped)) {
words.add(match.group(0)!);
}
}
}
return words;
}
void _collectSubtree(Element element, Set<Element> out) {
out.add(element);
element.visitChildren((child) => _collectSubtree(child, out));
}
/// Fails with the list of words and the screen name.
void expectNoWords(WidgetTester tester, String screen) {
final words = wordsOnScreen(tester);
expect(words, isEmpty, reason: 'words on $screen: $words');
expect(find.byType(Tooltip), findsNothing, reason: 'tooltip on $screen');
}
/// Pixel 6a portrait: 1080x2400 physical at 2.625.
void phoneView(WidgetTester tester) {
tester.view
..physicalSize = const Size(1080, 2400)
..devicePixelRatio = 2.625
..padding = const FakeViewPadding(top: 132, bottom: 63);
addTearDown(tester.view.reset);
}

View File

@ -0,0 +1,46 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:material_ui/material_ui.dart';
import 'package:sync_settings_ui/sync_settings_ui.dart';
import 'package:wake_alarm_sync/ui/theme.dart';
import 'text_free_support.dart';
/// The Settings sync row opens the shared `sync_settings_ui` screen; it is
/// part of what kuhy sees, so the textgate guard covers it too.
Widget _syncScreen({required bool googleAvailable}) => MaterialApp(
theme: buildAppTheme(),
home: SyncSettingsScreen(
accountLoader: () async => null,
accountSaver: (_) async {},
accountClearer: () async {},
sessionProbe: () async => false,
firebaseFactory: () async => null,
googleFirebaseFactory: () async => null,
googleAvailable: googleAvailable,
),
);
void main() {
for (final google in [true, false]) {
testWidgets('SyncSettingsScreen (google: $google) has no words', (
tester,
) async {
phoneView(tester);
await tester.pumpWidget(_syncScreen(googleAvailable: google));
await tester.pumpAndSettle();
expectNoWords(tester, 'SyncSettingsScreen');
});
}
testWidgets('SyncSettingsScreen expanded password form has no words', (
tester,
) async {
phoneView(tester);
await tester.pumpWidget(_syncScreen(googleAvailable: false));
await tester.pumpAndSettle();
await tester.tap(find.byType(ExpansionTile));
await tester.pumpAndSettle();
expect(find.byType(TextField), findsWidgets);
expectNoWords(tester, 'SyncSettingsScreen-expanded');
});
}

View File

@ -0,0 +1,207 @@
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_ui/material_ui.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:wake_alarm_sync/screens/escape_dialog.dart';
import 'package:wake_alarm_sync/screens/github_mirror_screen.dart';
import 'package:wake_alarm_sync/screens/home_body.dart';
import 'package:wake_alarm_sync/screens/home_history_card.dart';
import 'package:wake_alarm_sync/screens/home_location_screen.dart';
import 'package:wake_alarm_sync/screens/room_proof_screen.dart';
import 'package:wake_alarm_sync/screens/session_tags_screen.dart';
import 'package:wake_alarm_sync/screens/settings_screen.dart';
import 'package:wake_alarm_sync/screens/stakes_card.dart';
import 'package:wake_alarm_sync/services/alarm_scheduler.dart';
import 'package:wake_alarm_sync/services/room_proof_settings.dart';
import 'package:wake_alarm_sync/services/session_rules.dart';
import 'package:wake_alarm_sync/services/session_store.dart';
import 'package:wake_alarm_sync/services/session_tag_settings.dart';
import 'package:wake_alarm_sync/ui/theme.dart';
import 'fake_enforcement_channel.dart';
import 'text_free_support.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('settings-type screens', () {
testWidgets('SettingsScreen has no words on screen', (tester) async {
phoneView(tester);
SharedPreferences.setMockInitialValues(<String, Object>{});
await tester.pumpWidget(const MaterialApp(home: SettingsScreen()));
await tester.pumpAndSettle();
expectNoWords(tester, 'SettingsScreen');
});
testWidgets('HomeBody has no words on screen', (tester) async {
phoneView(tester);
FakeEnforcementChannel().install();
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: Scaffold(
appBar: AppBar(actions: const [Icon(Icons.settings)]),
body: HomeBody(
time: '07:00',
baseline: '07:30',
isOverridden: true,
busy: false,
status: 'Synced to PC ✓',
configured: false,
onPickEarlier: () {},
),
),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expectNoWords(tester, 'HomeBody');
});
testWidgets('RoomProofScreen fresh install has no words', (tester) async {
phoneView(tester);
SharedPreferences.setMockInitialValues(<String, Object>{});
const channel = MethodChannel(AlarmScheduler.channelName);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
if (call.method == 'startTagRegistration') return true;
return null;
});
await tester.pumpWidget(const MaterialApp(home: RoomProofScreen()));
await tester.pumpAndSettle();
expectNoWords(tester, 'RoomProofScreen-fresh');
});
testWidgets('RoomProofScreen with tag registered has no words', (
tester,
) async {
phoneView(tester);
SharedPreferences.setMockInitialValues(<String, Object>{});
await RoomProofSettings.saveNfcTagAt(0, '04a2b3c4');
const channel = MethodChannel(AlarmScheduler.channelName);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
if (call.method == 'startTagRegistration') return true;
return null;
});
await tester.pumpWidget(const MaterialApp(home: RoomProofScreen()));
await tester.pumpAndSettle();
expectNoWords(tester, 'RoomProofScreen-tagged');
});
testWidgets('SessionTagsScreen has no words on screen', (tester) async {
phoneView(tester);
SharedPreferences.setMockInitialValues(<String, Object>{});
const channel = MethodChannel(AlarmScheduler.channelName);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
if (call.method == 'startTagRegistration') return true;
return null;
});
await tester.pumpWidget(const MaterialApp(home: SessionTagsScreen()));
await tester.pump();
await tester.pump();
expectNoWords(tester, 'SessionTagsScreen');
});
testWidgets('HomeLocationScreen has no words on screen', (tester) async {
phoneView(tester);
SharedPreferences.setMockInitialValues(<String, Object>{});
const channel = MethodChannel(AlarmScheduler.channelName);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async => null);
await tester.pumpWidget(const MaterialApp(home: HomeLocationScreen()));
await tester.pump();
await tester.pump();
expectNoWords(tester, 'HomeLocationScreen');
});
testWidgets('GitHubMirrorScreen has no words on screen', (tester) async {
phoneView(tester);
SharedPreferences.setMockInitialValues(<String, Object>{});
const channel = MethodChannel(AlarmScheduler.channelName);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async => null);
await tester.pumpWidget(
MaterialApp(theme: buildAppTheme(), home: const GitHubMirrorScreen()),
);
await tester.pumpAndSettle();
expectNoWords(tester, 'GitHubMirrorScreen');
});
testWidgets('EscapeDialog has no words on screen', (tester) async {
phoneView(tester);
SharedPreferences.setMockInitialValues(<String, Object>{});
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: Scaffold(
body: Center(
child: IconButton(
key: const Key('open'),
onPressed: () => showDialog<String>(
context: tester.element(find.byKey(const Key('open'))),
barrierDismissible: false,
builder: (_) => const EscapeDialog(),
),
icon: const Icon(Icons.open_in_new),
),
),
),
),
);
await tester.tap(find.byKey(const Key('open')));
await tester.pumpAndSettle();
expectNoWords(tester, 'EscapeDialog');
});
testWidgets('HomeHistoryCard has no words on screen', (tester) async {
phoneView(tester);
SharedPreferences.setMockInitialValues(<String, Object>{});
final store = SessionStore();
for (var day = 1; day <= 9; day++) {
await store.archive(
SessionRules.start(
now: DateTime(2026, 9, day, 8),
tags: const SessionTagSettings(),
nfcAvailable: false,
)..weightKg = 70.0 + day,
);
}
// As the home tile shows it: a scrolling bottom sheet.
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(body: SingleChildScrollView(child: HomeHistoryCard())),
),
);
await tester.pump();
expectNoWords(tester, 'HomeHistoryCard');
});
testWidgets('StakesCard has no words on screen', (tester) async {
phoneView(tester);
SharedPreferences.setMockInitialValues(<String, Object>{});
FakeEnforcementChannel()
..install()
..state = nativeState(streak: 1, lockedThrough: '2026-10-02');
final thursday = DateTime(2026, 10, 1, 6, 50);
var escaped = 0;
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: Scaffold(
body: SingleChildScrollView(
child: StakesCard(
now: () => thursday,
nextAlarm: () async => (hour: 6, minute: 59),
onEscaped: () => escaped++,
),
),
),
),
);
await tester.pump();
expectNoWords(tester, 'StakesCard');
});
});
}

View File

@ -0,0 +1,162 @@
/// Tests for cues.dart: haptic feedback and semantics behavior.
library;
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/ui/cues.dart';
void main() {
group('StatusCue haptics', () {
testWidgets('error triggers heavyImpact haptic', (tester) async {
final calls = <MethodCall>[];
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
(call) async {
calls.add(call);
return null;
},
);
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(body: StatusCue(semanticLabel: 'Error: x')),
),
);
expect(calls, isNotEmpty);
final heavyCalls = calls.where(
(c) =>
c.method == 'HapticFeedback.vibrate' &&
c.arguments == 'HapticFeedbackType.heavyImpact',
);
expect(heavyCalls, isNotEmpty);
});
testWidgets('ok triggers lightImpact on kind change', (tester) async {
final calls = <MethodCall>[];
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
(call) async {
calls.add(call);
return null;
},
);
var currentKind = CueKind.info;
var currentLabel = 'Syncing';
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: StatefulBuilder(
builder: (context, setState) {
return Column(
children: [
StatusCue(semanticLabel: currentLabel, kind: currentKind),
TextButton(
onPressed: () {
setState(() {
currentKind = CueKind.ok;
currentLabel = 'Synced ✓';
});
},
child: const Text('Change'),
),
],
);
},
),
),
),
);
calls.clear();
await tester.tap(find.byType(TextButton));
await tester.pump();
final lightCalls = calls.where(
(c) =>
c.method == 'HapticFeedback.vibrate' &&
c.arguments == 'HapticFeedbackType.lightImpact',
);
expect(lightCalls, isNotEmpty);
});
void expectHeavyCalls(int expected, List<MethodCall> calls) {
final count = calls
.where(
(c) =>
c.method == 'HapticFeedback.vibrate' &&
c.arguments == 'HapticFeedbackType.heavyImpact',
)
.length;
expect(count, equals(expected));
}
testWidgets('haptic fires once on MediaQuery change with same error kind', (
tester,
) async {
final calls = <MethodCall>[];
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
(call) async {
calls.add(call);
return null;
},
);
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(body: StatusCue(semanticLabel: 'Error: x')),
),
);
calls.clear();
// Pump again inside a MediaQuery with different viewInsets
await tester.pumpWidget(
const MaterialApp(
home: MediaQuery(
data: MediaQueryData(viewInsets: EdgeInsets.only(bottom: 300)),
child: Scaffold(body: StatusCue(semanticLabel: 'Error: x')),
),
),
);
// Should NOT fire again - same error kind, same label
expectHeavyCalls(1, calls);
});
testWidgets(
'haptic fires again when semanticLabel changes with same error kind',
(tester) async {
final calls = <MethodCall>[];
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
(call) async {
calls.add(call);
return null;
},
);
// Initial pump with error cue
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(body: StatusCue(semanticLabel: 'Error: first')),
),
);
calls.clear();
// Pump with different semanticLabel but same error kind
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(body: StatusCue(semanticLabel: 'Error: second')),
),
);
// Should fire again because semanticLabel changed
expectHeavyCalls(1, calls);
},
);
});
}

View File

@ -0,0 +1,149 @@
/// Tests for cues.dart: CueKind classifier and StatusCue rendering.
library;
import 'package:flutter_test/flutter_test.dart';
import 'package:material_ui/material_ui.dart';
import 'package:wake_alarm_sync/ui/cues.dart';
void main() {
group('cueKindOf', () {
test('empty returns null', () => expect(cueKindOf(''), isNull));
test('busy ellipsis', () {
expect(cueKindOf('Syncing…'), equals(CueKind.busy));
expect(cueKindOf('Syncing...'), equals(CueKind.busy));
});
test('error keywords', () {
const cases = [
'Error: x',
'Could not connect',
'Service unavailable',
'Connection refused',
'Not found',
'Unknown error',
'Too early to dismiss',
'not earlier than 07:00',
'Wrong tag',
'Failed to sync',
'Walk too short',
];
for (final c in cases) {
expect(cueKindOf(c), equals(CueKind.error), reason: c);
}
});
test('ok keywords', () {
const cases = [
'Synced ✓',
'Connected to GitHub',
'Tag registered',
'Tag matched',
'Captured photo',
'Synced',
'Alarm Armed',
'Done',
'Correct answer',
'Dismissed',
'Excused day',
];
for (final c in cases) {
expect(cueKindOf(c), equals(CueKind.ok), reason: c);
}
});
test('warning keywords', () {
expect(
cueKindOf('Connect a sync backend in Settings first.'),
equals(CueKind.warning),
);
expect(cueKindOf('Open Settings to connect'), equals(CueKind.warning));
});
test('default info', () {
expect(cueKindOf('hello'), equals(CueKind.info));
expect(cueKindOf('Syncing now'), equals(CueKind.info));
});
});
group('StatusCue', () {
testWidgets('renders no Text', (tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(body: StatusCue(semanticLabel: 'Error: x')),
),
);
expect(find.byType(Text), findsNothing);
});
testWidgets('renders error icon for error kind', (tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(body: StatusCue(semanticLabel: 'Error: x')),
),
);
expect(find.byIcon(Icons.error_outline), findsOneWidget);
});
testWidgets('renders CircularProgressIndicator for busy', (tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(body: StatusCue(semanticLabel: 'Syncing…')),
),
);
expect(find.byType(CircularProgressIndicator), findsOneWidget);
});
testWidgets('renders SizedBox.shrink for empty label', (tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(body: StatusCue(semanticLabel: '')),
),
);
expect(find.byType(Icon), findsNothing);
expect(find.byType(CircularProgressIndicator), findsNothing);
});
testWidgets('semanticLabel is reachable via semantics', (tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
body: StatusCue(semanticLabel: 'Error: connection failed'),
),
),
);
final handle = tester.ensureSemantics();
try {
final semanticsFinder = find.byWidgetPredicate(
(widget) =>
widget is Semantics &&
widget.properties.label?.contains('connection failed') == true,
);
final semantics = tester.getSemantics(semanticsFinder);
expect(semantics.label, contains('connection failed'));
} finally {
handle.dispose();
}
});
testWidgets('SpokenLabel wraps child with semantics label', (tester) async {
const label = 'Close button';
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
body: SpokenLabel(semanticLabel: label, child: Icon(Icons.close)),
),
),
);
final handle = tester.ensureSemantics();
try {
final semantics = tester.getSemantics(find.byType(SpokenLabel));
expect(semantics.label, equals(label));
} finally {
handle.dispose();
}
});
});
}

View File

@ -4,6 +4,6 @@ import 'package:wake_alarm_sync/main.dart';
void main() {
testWidgets('App renders without crashing', (tester) async {
await tester.pumpWidget(const WakeAlarmSyncApp());
expect(find.text('Wake Alarm Sync'), findsOneWidget);
expect(find.bySemanticsLabel('Alarm is armed for'), findsOneWidget);
});
}

View File

@ -8,15 +8,15 @@ version = "1.0.0"
description = "Trigger that starts the morning routine at the phone's alarm time"
requires-python = ">=3.14"
dependencies = [
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.12.0#subdirectory=gatelock",
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.13.0#subdirectory=gatelock",
# Firebase transport for the alarm read. _sync.py imports it lazily and
# falls back to its stdlib GitHub path if the import fails, so a broken
# install degrades the read instead of killing the per-minute trigger.
"crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync",
"freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays",
"crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.11.0#subdirectory=crdt-sync",
"freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.2#subdirectory=freedays",
# Sick days stand the PC's enforcement down (_sick.py). Imported behind
# a guard: missing means "not sick", never a crashed tick.
"sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.4.0#subdirectory=sickdays",
"sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.5.3#subdirectory=sickdays",
]
# Package discovery must be explicit: phone_app/ is a Flutter app, not a Python

Some files were not shown because too many files have changed in this diff Show More