diff --git a/app/lib/screens/github_client_id_dialog.dart b/app/lib/screens/github_client_id_dialog.dart index 0fa07cb..048c2be 100644 --- a/app/lib/screens/github_client_id_dialog.dart +++ b/app/lib/screens/github_client_id_dialog.dart @@ -28,54 +28,27 @@ class _ClientIdSetupDialogState extends State<_ClientIdSetupDialog> { @override Widget build(BuildContext context) { + // Icon-only: the setup steps (open github.com/settings/developers, make + // an OAuth App with Device Flow, paste its client id) live in the repo + // docs, not on screen. return AlertDialog( - title: const Text('One-time GitHub setup needed'), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Diet Guard signs in via a GitHub OAuth App (no password ' - 'typed into this app). You only have to set this up once:', - ), - const SizedBox(height: 12), - const Text( - '1. On any device, open ' - 'github.com/settings/developers → "New OAuth App".\n' - '2. Name/Homepage/Callback URL can be anything (device flow ' - "doesn't use the callback) — e.g. " - '"Diet Guard" and your GitHub profile URL.\n' - '3. Check "Enable Device Flow", then click "Register ' - 'application".\n' - "4. Copy the Client ID shown on the app's page and paste it " - 'below.', - ), - const SizedBox(height: 12), - const Text( - 'When you connect below, log in with the GitHub account that ' - 'has write access to kuhyx/syncs.', - ), - const SizedBox(height: 16), - TextField( - controller: _controller, - autofocus: true, - decoration: const InputDecoration(labelText: 'Client ID'), - ), - ], - ), + content: IconField( + icon: Icons.apps, + semanticLabel: 'Client ID', + controller: _controller, + autofocus: true, ), actions: [ - TextButton( + IconButton( onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), + icon: const Icon(Icons.close, semanticLabel: 'Cancel'), ), - FilledButton( + IconButton.filled( onPressed: () { final id = _controller.text.trim(); if (id.isNotEmpty) Navigator.of(context).pop(id); }, - child: const Text('Continue'), + icon: const Icon(Icons.arrow_forward, semanticLabel: 'Continue'), ), ], ); diff --git a/app/lib/screens/github_mirror_actions.dart b/app/lib/screens/github_mirror_actions.dart index 69e2fb0..dc4b5f0 100644 --- a/app/lib/screens/github_mirror_actions.dart +++ b/app/lib/screens/github_mirror_actions.dart @@ -21,7 +21,7 @@ mixin _GitHubMirrorActions on State { TextEditingController get _clientIdController; bool _busy = false; - String? _status; + Widget? _status; String _storedToken = ''; SyncSettings _currentSettings() { final typed = _tokenController.text.trim(); @@ -37,9 +37,18 @@ mixin _GitHubMirrorActions on State { clientId: _clientIdController.text.trim(), ); } - void _showMessage(String message) { + + /// Shows the outcome as an icon cue; [semanticLabel] is spoken, never + /// painted. + void _showCue(CueKind kind, String semanticLabel) { if (!mounted) return; - setState(() => _status = message); + setState( + () => _status = StatusCue( + key: UniqueKey(), + kind: kind, + semanticLabel: semanticLabel, + ), + ); } Future _connectGitHub() async { @@ -69,12 +78,12 @@ mixin _GitHubMirrorActions on State { _storedToken = token; if (SyncSettings.exposesTokenValue) _tokenController.text = token; }); - _showMessage('Connected — syncing…'); + _showCue(CueKind.info, 'Connected — syncing…'); await _currentSettings().save(); await _syncAfterConnect(); } } on Exception catch (e) { - _showMessage('Could not start device flow: $e'); + _showCue(CueKind.error, 'Could not start device flow: $e'); } finally { auth.close(); } @@ -88,10 +97,10 @@ mixin _GitHubMirrorActions on State { try { await runSync(await syncBackend(client)); await SyncHealth.recordSuccess(); - _showMessage('Connected and synced.'); + _showCue(CueKind.ok, 'Connected and synced.'); } on Exception catch (e) { await SyncHealth.recordFailure(); - _showMessage('Connected, but sync failed: $e'); + _showCue(CueKind.error, 'Connected, but sync failed: $e'); } finally { client.close(); } @@ -102,7 +111,7 @@ mixin _GitHubMirrorActions on State { await _currentSettings().save(); if (!mounted) return; setState(() => _busy = false); - _showMessage('Saved.'); + _showCue(CueKind.ok, 'Saved.'); } Future _testConnection() async { @@ -111,11 +120,13 @@ mixin _GitHubMirrorActions on State { final client = createGitHubClient(settings, httpClient: widget.httpClient); try { final ok = await client.canAccessRepo(); - _showMessage( - ok ? 'GitHub connection OK.' : 'GitHub connection failed.', - ); + if (ok) { + _showCue(CueKind.ok, 'GitHub connection OK.'); + } else { + _showCue(CueKind.error, 'GitHub connection failed.'); + } } on Exception catch (e) { - _showMessage('GitHub connection failed: $e'); + _showCue(CueKind.error, 'GitHub connection failed: $e'); } finally { client.close(); if (mounted) setState(() => _busy = false); @@ -133,10 +144,10 @@ mixin _GitHubMirrorActions on State { // *because* the banner told them syncing had stopped, so a successful // run here must dismiss the warning it caused. await SyncHealth.recordSuccess(); - _showMessage('Synced.'); + _showCue(CueKind.ok, 'Synced.'); } on Exception catch (e) { await SyncHealth.recordFailure(); - _showMessage('Sync failed: $e'); + _showCue(CueKind.error, 'Sync failed: $e'); } finally { client.close(); if (mounted) setState(() => _busy = false); diff --git a/app/lib/screens/github_mirror_screen.dart b/app/lib/screens/github_mirror_screen.dart index 8a44072..7aa44b8 100644 --- a/app/lib/screens/github_mirror_screen.dart +++ b/app/lib/screens/github_mirror_screen.dart @@ -13,6 +13,7 @@ import 'package:diet_guard_app/services/github_client_factory.dart'; import 'package:diet_guard_app/services/sync_health.dart'; import 'package:diet_guard_app/services/sync_service.dart'; import 'package:diet_guard_app/services/sync_settings.dart'; +import 'package:diet_guard_app/ui/cues.dart'; import 'package:diet_guard_app/ui/theme.dart'; import 'package:flutter/material.dart'; import 'package:github_device_auth/github_device_auth.dart'; @@ -94,107 +95,86 @@ class _GitHubMirrorScreenState extends State /// The token as loaded, so a platform that cannot display it (web) still /// round-trips it instead of blanking it on the next save. - - - - /// Runs the OAuth device flow and, on success, fills in the token field. - @override Widget build(BuildContext context) { if (_loading) { return const Scaffold(body: Center(child: CircularProgressIndicator())); } return Scaffold( - appBar: AppBar(title: const Text('Advanced sync (GitHub)')), - // Centred and capped at the prose width -- see settings_screen.dart's - // note on why: on the desktop surface an uncapped ListView spanned the - // full window, so explanatory paragraphs ran far past the readable - // line-length limit. + // No title and no explanatory paragraph: the code glyph that opened this + // screen names it, and the connect button is self-evident. + appBar: AppBar(), + // Centred and capped at the prose width so fields do not span the + // whole window on the desktop surface. body: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: AppWidth.prose), child: ListView( padding: const EdgeInsets.all(16), children: [ - Text( - 'Syncs still mirror to GitHub until every device has moved ' - 'to Firebase. Authorize in your browser — no token to ' - 'paste.', - style: Theme.of(context).textTheme.bodySmall, - ), - const SizedBox(height: 12), Align( alignment: Alignment.centerLeft, - child: FilledButton.icon( + child: IconButton.filled( onPressed: _connectGitHub, - icon: const Icon(Icons.login), - label: const Text('Connect GitHub'), + icon: const Icon( + Icons.login, + semanticLabel: 'Connect GitHub', + ), ), ), const SizedBox(height: 16), - TextField( + IconField( + icon: Icons.person_outline, + semanticLabel: 'GitHub owner', controller: _ownerController, - decoration: const InputDecoration(labelText: 'GitHub owner'), ), const SizedBox(height: 8), - TextField( + IconField( + icon: Icons.folder_outlined, + semanticLabel: 'Repo', controller: _repoController, - decoration: const InputDecoration(labelText: 'Repo'), ), const SizedBox(height: 8), - TextField( + IconField( + icon: Icons.apps, + semanticLabel: 'OAuth App client id', controller: _clientIdController, - decoration: const InputDecoration( - labelText: 'OAuth App client id', - helperText: 'Needed for the Connect GitHub button', - ), ), const SizedBox(height: 8), - TextField( + IconField( + icon: Icons.key, + semanticLabel: 'Personal access token (fallback)', controller: _tokenController, - obscureText: true, - decoration: InputDecoration( - labelText: 'Personal access token (fallback)', - // Both wrapper hints are web-only; a VM test always takes - // the null branch. - // coverage:ignore-start - helperText: SyncSettings.exposesTokenValue - ? null - : _storedToken.isEmpty - ? 'Stored by the desktop wrapper, never by the browser' - : 'A token is stored by the desktop wrapper; type ' - 'here only to replace it', - // coverage:ignore-end - helperMaxLines: 2, - ), + obscure: true, ), const SizedBox(height: 16), Wrap( spacing: 8, children: [ - ElevatedButton( + IconButton.filled( onPressed: _busy ? null : _save, - child: const Text('Save'), + icon: const Icon(Icons.check, semanticLabel: 'Save'), ), - OutlinedButton( + // Tests the GitHub connection only (a canAccessRepo call); + // it says nothing about Firebase. + IconButton.outlined( onPressed: _busy ? null : _testConnection, - // Named for what it actually does: _testConnection builds - // a GitHub client and calls canAccessRepo(). It says - // nothing about Firebase, and the bare label implied - // otherwise. - child: const Text('Test GitHub connection'), + icon: const Icon( + Icons.network_check, + semanticLabel: 'Test GitHub connection', + ), ), - ElevatedButton( + IconButton.filled( onPressed: _busy ? null : _syncNow, - child: const Text('Sync now'), + icon: const Icon(Icons.sync, semanticLabel: 'Sync now'), ), ], ), if (_status != null) ...[ const SizedBox(height: 16), - Text(_status!, style: Theme.of(context).textTheme.bodyMedium), + Align(alignment: Alignment.centerLeft, child: _status), ], ], ), diff --git a/app/lib/screens/settings_kcal_goal.dart b/app/lib/screens/settings_kcal_goal.dart index 7a22eb9..1d8ee4f 100644 --- a/app/lib/screens/settings_kcal_goal.dart +++ b/app/lib/screens/settings_kcal_goal.dart @@ -7,11 +7,18 @@ part of 'settings_screen.dart'; mixin _SettingsKcalGoal on State { - String? _status; + /// The latest outcome as an icon cue; null until something happened. + Widget? _status; - void _showMessage(String message) { + void _showCue(CueKind kind, String semanticLabel) { if (!mounted) return; - setState(() => _status = message); + setState( + () => _status = StatusCue( + key: UniqueKey(), + kind: kind, + semanticLabel: semanticLabel, + ), + ); } Timer? _kcalGoalDebounce; @@ -50,13 +57,13 @@ mixin _SettingsKcalGoal on State { () => Permission.ignoreBatteryOptimizations.request(); try { final status = await request(); - _showMessage( - status.isGranted - ? 'Battery optimization exemption granted.' - : 'Exemption not granted -- notifications may be delayed.', - ); + if (status.isGranted) { + _showCue(CueKind.ok, 'Battery optimization exemption granted.'); + } else { + _showCue(CueKind.warn, 'Exemption not granted.'); + } } on Exception catch (e) { - _showMessage('Could not request exemption: $e'); + _showCue(CueKind.error, 'Could not request exemption: $e'); } } } diff --git a/app/lib/screens/settings_kuchnia.dart b/app/lib/screens/settings_kuchnia.dart index 3c5b9d1..abdb49f 100644 --- a/app/lib/screens/settings_kuchnia.dart +++ b/app/lib/screens/settings_kuchnia.dart @@ -18,21 +18,39 @@ library; import 'package:diet_guard_app/services/kuchnia_client.dart'; import 'package:diet_guard_app/services/kuchnia_credential_service.dart'; import 'package:diet_guard_app/services/kuchnia_import.dart'; +import 'package:diet_guard_app/ui/cues.dart'; import 'package:diet_guard_app/ui/theme.dart'; import 'package:flutter/material.dart'; +/// What a delivery fetch reports: a cue [kind], a spoken [label] (never +/// painted) and, when dishes were banked, their [count] (painted as a number). +class FetchOutcome { + /// Creates an outcome. + const FetchOutcome(this.kind, this.label, {this.count}); + + /// Icon/colour of the cue. + final CueKind kind; + + /// Spoken description; never painted. + final String label; + + /// Dishes banked, shown as a bare number next to the cue. + final int? count; +} + /// Fetches today's delivery and reports what was banked. /// -/// Deliberately says "banked", not "logged": a delivered meal is not an eaten +/// Deliberately "banked", not "logged": a delivered meal is not an eaten /// meal, so the dishes land in the food bank and the user still taps to log /// each one. Top-level so the settings screen can pass it without growing. -Future fetchTodaysDelivery() async { +Future fetchTodaysDelivery() async { final result = await refreshDelivery(DateTime.now()); - if (!result.ok) return result.reason!; - if (result.dishes.isEmpty) return 'No delivery found for today.'; + if (!result.ok) return FetchOutcome(CueKind.error, result.reason!); + if (result.dishes.isEmpty) { + return const FetchOutcome(CueKind.warn, 'No delivery found for today.'); + } final count = result.dishes.length; - return '$count ${count == 1 ? 'dish' : 'dishes'} added to your food bank. ' - 'Log them from the meal screen when you eat them.'; + return FetchOutcome(CueKind.ok, '$count dishes added', count: count); } /// Credential fields plus, on Android, a "fetch today's delivery" action. @@ -43,18 +61,14 @@ class KuchniaSettingsSection extends StatefulWidget { /// button is replaced by a one-line explanation rather than silently /// vanishing. [onFetch] defaults to the real fetch. Both are injectable so a /// widget test can drive the section without a network or a platform check. - const KuchniaSettingsSection({ - this.canFetch, - this.onFetch, - super.key, - }); + const KuchniaSettingsSection({this.canFetch, this.onFetch, super.key}); /// Whether this platform can reach the catering panel; null means ask. final bool? canFetch; /// Fetches today's delivery and returns a status line; null means the real /// one. - final Future Function()? onFetch; + final Future Function()? onFetch; @override State createState() => _KuchniaSettingsSectionState(); @@ -69,7 +83,7 @@ class _KuchniaSettingsSectionState extends State { ); bool _obscured = true; bool _busy = false; - String? _status; + Widget? _status; @override void dispose() { @@ -85,7 +99,13 @@ class _KuchniaSettingsSectionState extends State { _password.text, ); if (!mounted) return; - setState(() => _status = 'Saved. It will sync to your other devices.'); + setState( + () => _status = StatusCue( + key: UniqueKey(), + kind: CueKind.ok, + semanticLabel: 'Saved. It will sync to your other devices.', + ), + ); } Future _fetch() async { @@ -93,62 +113,65 @@ class _KuchniaSettingsSectionState extends State { _busy = true; _status = null; }); - final message = await (widget.onFetch ?? fetchTodaysDelivery)(); + final outcome = await (widget.onFetch ?? fetchTodaysDelivery)(); if (!mounted) return; setState(() { _busy = false; - _status = message; + _status = Row( + mainAxisSize: MainAxisSize.min, + spacing: AppSpacing.sm, + children: [ + StatusCue( + key: UniqueKey(), + kind: outcome.kind, + semanticLabel: outcome.label, + ), + if (outcome.count != null) Text('${outcome.count}'), + ], + ); }); } @override Widget build(BuildContext context) { - final theme = Theme.of(context); final canFetch = widget.canFetch ?? kuchniaFetchSupported; // The section owns its own leading divider so the settings screen needs a // single line for it -- that file sits on the repo's 250-line ceiling. + // No heading or blurb: the delivery truck on the fetch button names it. return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 24), const Divider(), const SizedBox(height: 8), - Text('Kuchnia Wikinga', style: theme.textTheme.titleMedium), - const SizedBox(height: 4), - Text( - "Your catering panel login, so this device can look up the day's " - 'delivered dishes and their macros. Stored and synced in plain ' - 'text, like the rest of your synced data.', - style: theme.textTheme.bodySmall, - ), - const SizedBox(height: 12), ConstrainedBox( constraints: const BoxConstraints(maxWidth: AppWidth.field), - child: TextField( + child: IconField( + icon: Icons.alternate_email, + semanticLabel: 'Panel e-mail', controller: _username, keyboardType: TextInputType.emailAddress, autocorrect: false, - decoration: const InputDecoration(labelText: 'Panel e-mail'), onSubmitted: (_) => _save(), ), ), const SizedBox(height: 12), ConstrainedBox( constraints: const BoxConstraints(maxWidth: AppWidth.field), - child: TextField( + child: IconField( + icon: Icons.key, + semanticLabel: 'Panel password', controller: _password, - obscureText: _obscured, + obscure: _obscured, autocorrect: false, - enableSuggestions: false, - decoration: InputDecoration( - labelText: 'Panel password', - suffixIcon: IconButton( - icon: Icon(_obscured ? Icons.visibility : Icons.visibility_off), - tooltip: _obscured ? 'Show password' : 'Hide password', - onPressed: () => setState(() => _obscured = !_obscured), - ), - ), onSubmitted: (_) => _save(), + suffix: IconButton( + icon: Icon( + _obscured ? Icons.visibility : Icons.visibility_off, + semanticLabel: _obscured ? 'Show password' : 'Hide password', + ), + onPressed: () => setState(() => _obscured = !_obscured), + ), ), ), const SizedBox(height: 12), @@ -156,29 +179,27 @@ class _KuchniaSettingsSectionState extends State { spacing: 8, runSpacing: 8, children: [ - FilledButton(onPressed: _save, child: const Text('Save login')), - if (canFetch) - OutlinedButton.icon( - onPressed: _busy ? null : _fetch, - icon: const Icon(Icons.restaurant), - label: Text( - _busy ? 'Fetching…' : "Fetch today's delivery", - ), - ), + IconButton.filled( + onPressed: _save, + icon: const Icon(Icons.check, semanticLabel: 'Save login'), + ), + // Browser builds show the button greyed out: the panel blocks + // browser requests, and the disabled state is the cue. + IconButton.outlined( + onPressed: canFetch && !_busy ? _fetch : null, + icon: _busy + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon( + Icons.local_shipping_outlined, + semanticLabel: "Fetch today's delivery", + ), + ), ], ), - if (!canFetch) ...[ - const SizedBox(height: 8), - Text( - 'Android only — the catering panel blocks browser requests. Your ' - 'login still syncs to your phone from here.', - style: theme.textTheme.bodySmall, - ), - ], - if (_status != null) ...[ - const SizedBox(height: 8), - Text(_status!, style: theme.textTheme.bodyMedium), - ], + if (_status != null) ...[const SizedBox(height: 8), _status!], ], ); } diff --git a/app/lib/screens/settings_screen.dart b/app/lib/screens/settings_screen.dart index 2cb9669..d54e7b3 100644 --- a/app/lib/screens/settings_screen.dart +++ b/app/lib/screens/settings_screen.dart @@ -15,6 +15,8 @@ import 'package:diet_guard_app/services/firebase_backend.dart'; import 'package:diet_guard_app/services/firebase_client.dart'; import 'package:diet_guard_app/services/google_sign_in_backend.dart'; import 'package:diet_guard_app/services/meal_schedule_service.dart'; +import 'package:diet_guard_app/ui/cues.dart'; +import 'package:diet_guard_app/ui/macro_kind.dart'; import 'package:diet_guard_app/ui/theme.dart'; import 'package:diet_guard_app/widgets/data_backup_row.dart'; import 'package:diet_guard_app/widgets/log_sound_tile.dart'; @@ -149,30 +151,26 @@ class _SettingsScreenState extends State @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text('Settings')), - // Capped at the prose width: on the ~1366px desktop window the - // paragraphs below ran ~180 chars/line, twice tokens.md rule 21's limit. + // No title: the gear that opened this screen already names it. + appBar: AppBar(), + // Capped at the prose width so fields do not span a ~1366px window. body: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: AppWidth.prose), child: ListView( padding: const EdgeInsets.all(16), children: [ - Text('Nutrition', style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 8), // A four-digit number: capped so the field does not read as a // layout error by spanning the whole column. ConstrainedBox( constraints: const BoxConstraints(maxWidth: AppWidth.field), - child: TextField( + child: IconField( + icon: MacroKind.kcal.icon, + iconColor: MacroKind.kcal.color, + semanticLabel: 'Daily kcal goal', controller: _kcalGoalController, keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly], - decoration: const InputDecoration( - labelText: 'Daily kcal goal', - helperText: 'Shown in the history day summary', - suffixText: 'kcal', - ), onChanged: _onKcalGoalChanged, ), ), @@ -186,15 +184,21 @@ class _SettingsScreenState extends State const Divider(), ListTile( 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: () => unawaited(_openSyncSettings()), ), + // Legacy mirror: muted tint marks it as the secondary path. ListTile( contentPadding: EdgeInsets.zero, - title: const Text('Advanced sync (GitHub)'), - subtitle: const Text('Cutover mirror — not recommended'), + leading: Icon( + Icons.code, + color: Theme.of(context).colorScheme.onSurfaceVariant, + semanticLabel: 'Advanced sync (GitHub)', + ), trailing: const Icon(Icons.chevron_right), onTap: () => unawaited(_openGitHubMirror()), ), @@ -203,42 +207,27 @@ class _SettingsScreenState extends State .dailyKcalGoal .toString(), ), - const SizedBox(height: 24), - const Divider(), - const SizedBox(height: 8), - Text( - 'Notifications', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 4), - Text( - _isAndroid - ? 'A background check nags you every ~15 min if a ' - 'meal slot is overdue. Aggressive OEM battery ' - 'optimization (MIUI, some Samsung configs) can ' - 'delay this well past 15 min -- request an ' - 'exemption for reliable nagging.' - : 'A check runs every 5 min while this window is open and ' - 'notifies you about an overdue meal slot. A browser ' - 'cannot run anything once the window is closed -- on ' - 'the PC the real backstop is the diet_guard gate, ' - 'which locks the screen instead.', - style: Theme.of(context).textTheme.bodySmall, - ), // Battery-optimization exemption is Android-only, and // `permission_handler` has no web implementation at all; // the row would throw rather than degrade in the browser. if (_isAndroid) ...[ - const SizedBox(height: 12), - OutlinedButton.icon( - onPressed: _requestBatteryExemption, - icon: const Icon(Icons.battery_alert), - label: const Text('Disable battery optimization'), + const SizedBox(height: 24), + const Divider(), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerLeft, + child: IconButton.outlined( + onPressed: _requestBatteryExemption, + icon: const Icon( + Icons.battery_alert, + semanticLabel: 'Disable battery optimization', + ), + ), ), ], if (_status != null) ...[ const SizedBox(height: 16), - Text(_status!, style: Theme.of(context).textTheme.bodyMedium), + Align(alignment: Alignment.centerLeft, child: _status), ], ], ), diff --git a/app/lib/services/notification_backend.dart b/app/lib/services/notification_backend.dart index 1f6e021..4fc7459 100644 --- a/app/lib/services/notification_backend.dart +++ b/app/lib/services/notification_backend.dart @@ -20,8 +20,9 @@ abstract class NotificationBackend { /// of the app. Future requestPermission(); - /// Posts (or replaces) the notification for [slot]. - Future show(int slot, String title, String body); + /// Posts (or replaces) the notification for [slot], titled with the slot's + /// clock time and carrying no body text. + Future show(int slot, String title); /// Removes the notification for [slot], if one is showing. Future cancel(int slot); diff --git a/app/lib/services/notification_backend_io.dart b/app/lib/services/notification_backend_io.dart index bae7ec2..5b78bbb 100644 --- a/app/lib/services/notification_backend_io.dart +++ b/app/lib/services/notification_backend_io.dart @@ -47,7 +47,7 @@ class LocalNotificationsBackend implements NotificationBackend { Future.value(); @override - Future show(int slot, String title, String body) async { + Future show(int slot, String title) async { const details = NotificationDetails( android: AndroidNotificationDetails( _channelId, @@ -56,12 +56,7 @@ class LocalNotificationsBackend implements NotificationBackend { priority: Priority.high, ), ); - await plugin.show( - id: slot, - title: title, - body: body, - notificationDetails: details, - ); + await plugin.show(id: slot, title: title, notificationDetails: details); } @override diff --git a/app/lib/services/notification_backend_web.dart b/app/lib/services/notification_backend_web.dart index dfb0695..d39f6b3 100644 --- a/app/lib/services/notification_backend_web.dart +++ b/app/lib/services/notification_backend_web.dart @@ -36,13 +36,13 @@ class WebNotificationBackend implements NotificationBackend { } @override - Future show(int slot, String title, String body) async { + Future show(int slot, String title) async { // Posting without permission throws; a silent no-op matches this // service's degrade-quietly stance everywhere else. if (!_granted) return; _shown[slot] = web.Notification( title, - web.NotificationOptions(body: body, tag: 'diet_guard_slot_$slot'), + web.NotificationOptions(tag: 'diet_guard_slot_$slot'), ); } diff --git a/app/lib/services/notification_service.dart b/app/lib/services/notification_service.dart index be70982..5043f9a 100644 --- a/app/lib/services/notification_service.dart +++ b/app/lib/services/notification_service.dart @@ -86,11 +86,9 @@ class NotificationService { await _backend.cancel(id); } for (final slot in due) { - await _backend.show( - slot, - 'Meal not logged', - "You haven't logged your ${slotLabel(slot)} meal yet.", - ); + // The title is only the overdue slot's clock time (a number from the + // user's own schedule); the launcher icon is the second channel. + await _backend.show(slot, slotLabel(slot)); } } } diff --git a/app/lib/widgets/meal_schedule_editor.dart b/app/lib/widgets/meal_schedule_editor.dart index cde3008..b5d9ed8 100644 --- a/app/lib/widgets/meal_schedule_editor.dart +++ b/app/lib/widgets/meal_schedule_editor.dart @@ -4,6 +4,7 @@ library; import 'package:diet_guard_app/models/meal_schedule.dart'; import 'package:diet_guard_app/models/slot.dart'; +import 'package:diet_guard_app/ui/icon_field.dart'; import 'package:diet_guard_app/ui/theme.dart'; import 'package:diet_guard_app/widgets/meal_time_dropdown.dart'; import 'package:flutter/material.dart'; @@ -61,15 +62,9 @@ class MealScheduleEditor extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text('Meal times', style: theme.textTheme.titleMedium), - const SizedBox(height: AppSpacing.xs), - Text( - 'Checkpoints are spread evenly between your first and last meal.', - style: theme.textTheme.bodySmall, - ), - const SizedBox(height: 12), MealTimeDropdown( - label: 'First meal', + icon: Icons.light_mode_outlined, + semanticLabel: 'First meal', value: schedule.firstMinute, minMinute: 0, maxMinute: _firstMax, @@ -78,7 +73,8 @@ class MealScheduleEditor extends StatelessWidget { ), const SizedBox(height: 12), MealTimeDropdown( - label: 'Last meal', + icon: Icons.dark_mode_outlined, + semanticLabel: 'Last meal', value: schedule.lastMinute, // At least one grid step after the first meal: the shortest window // that still holds two distinct checkpoints. @@ -89,21 +85,26 @@ class MealScheduleEditor extends StatelessWidget { const SizedBox(height: 12), ConstrainedBox( constraints: const BoxConstraints(maxWidth: AppWidth.field), - child: InputDecorator( - decoration: const InputDecoration(labelText: 'Meals per day'), - child: DropdownButtonHideUnderline( - child: DropdownButton( - value: counts.contains(schedule.count) - ? schedule.count - : counts.last, - isDense: true, - isExpanded: true, - items: [ - for (final count in counts) - DropdownMenuItem(value: count, child: Text('$count')), - ], - onChanged: (count) => - count == null ? null : onChanged(_with(count: count)), + child: SpokenLabel( + semanticLabel: 'Meals per day', + child: InputDecorator( + decoration: const InputDecoration( + prefixIcon: ExcludeSemantics(child: Icon(Icons.restaurant)), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: counts.contains(schedule.count) + ? schedule.count + : counts.last, + isDense: true, + isExpanded: true, + items: [ + for (final count in counts) + DropdownMenuItem(value: count, child: Text('$count')), + ], + onChanged: (count) => + count == null ? null : onChanged(_with(count: count)), + ), ), ), ), diff --git a/app/lib/widgets/meal_time_dropdown.dart b/app/lib/widgets/meal_time_dropdown.dart index 0085315..ae7ba85 100644 --- a/app/lib/widgets/meal_time_dropdown.dart +++ b/app/lib/widgets/meal_time_dropdown.dart @@ -26,7 +26,8 @@ import 'package:flutter/material.dart'; class MealTimeDropdown extends StatelessWidget { /// Creates a [MealTimeDropdown]. const MealTimeDropdown({ - required this.label, + required this.icon, + required this.semanticLabel, required this.value, required this.minMinute, required this.maxMinute, @@ -35,8 +36,11 @@ class MealTimeDropdown extends StatelessWidget { super.key, }); - /// The field label, e.g. "First meal". - final String label; + /// The painted caption glyph, e.g. a sun for the first meal. + final IconData icon; + + /// The spoken caption, e.g. "First meal"; never painted. + final String semanticLabel; /// The current minute of day. final int value; @@ -81,7 +85,8 @@ class MealTimeDropdown extends StatelessWidget { final picked = await showTimePicker( context: context, initialTime: TimeOfDay(hour: value ~/ 60, minute: value % 60), - helpText: label, + // Empty so the picker does not print its own heading over the clock. + helpText: '', // The rest of the app writes times as HH:MM; an AM/PM dial here would // be the one place that reads differently. builder: (context, child) => MediaQuery( @@ -103,29 +108,37 @@ class MealTimeDropdown extends StatelessWidget { spacing: AppSpacing.xs, children: [ Expanded( - child: InputDecorator( - decoration: InputDecoration(labelText: label), - child: DropdownButtonHideUnderline( - child: DropdownButton( - value: value, - isDense: true, - isExpanded: true, - items: [ - for (final minute in options) - DropdownMenuItem( - value: minute, - child: Text(slotLabel(minute)), - ), - ], - onChanged: (minute) => - minute == null ? null : onChanged(minute), + child: Semantics( + label: semanticLabel, + container: true, + child: InputDecorator( + decoration: InputDecoration( + prefixIcon: ExcludeSemantics(child: Icon(icon)), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: value, + isDense: true, + isExpanded: true, + items: [ + for (final minute in options) + DropdownMenuItem( + value: minute, + child: Text(slotLabel(minute)), + ), + ], + onChanged: (minute) => + minute == null ? null : onChanged(minute), + ), ), ), ), ), IconButton( - icon: const Icon(Icons.schedule), - tooltip: 'Type or pick an exact time', + icon: const Icon( + Icons.schedule, + semanticLabel: 'Type or pick an exact time', + ), onPressed: () => unawaited(_pickExact(context)), ), ], diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 30241b4..626a5cb 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -2,7 +2,7 @@ name: diet_guard_app description: "Companion phone app for diet_guard: log meals on the go." publish_to: 'none' -version: 1.0.141+1 +version: 1.0.142+1 environment: sdk: ^3.12.2 diff --git a/app/test/screens/github_mirror_device_dialog_test.dart b/app/test/screens/github_mirror_device_dialog_test.dart index 5faa67c..84ebf24 100644 --- a/app/test/screens/github_mirror_device_dialog_test.dart +++ b/app/test/screens/github_mirror_device_dialog_test.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:io'; +import 'package:diet_guard_app/ui/cues.dart'; import 'package:diet_guard_app/screens/github_mirror_screen.dart'; import 'package:diet_guard_app/services/app_settings_service.dart'; import 'package:diet_guard_app/services/budget_history_service.dart'; @@ -17,6 +18,7 @@ import 'package:url_launcher_platform_interface/link.dart'; import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; import '../fake_secure_storage.dart'; +import '../label_finders.dart'; /// Stub launcher that records the URL instead of opening it, so the device /// dialog's "Open GitHub & copy code" can be exercised without a real @@ -39,8 +41,6 @@ class _FakeUrlLauncher extends UrlLauncherPlatform } void main() { - - late Directory tempDir; setUp(() async { @@ -48,9 +48,7 @@ void main() { LogStorageService.resetForTesting(store: FileDocumentStore(tempDir)); FoodBankService.resetForTesting(store: FileDocumentStore(tempDir)); AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir)); - BudgetHistoryService.resetForTesting( - store: FileDocumentStore(tempDir), - ); + BudgetHistoryService.resetForTesting(store: FileDocumentStore(tempDir)); SharedPreferences.setMockInitialValues({}); installFakeSecureStorage(); }); @@ -90,13 +88,9 @@ void main() { } } - /// Types [clientId] into the client-id field. Future enterClientId(WidgetTester tester, String clientId) async { - await tester.enterText( - find.widgetWithText(TextField, 'OAuth App client id'), - clientId, - ); + await tester.enterText(fieldByLabel('OAuth App client id'), clientId); } testWidgets('device flow happy path saves the token and syncs', ( @@ -136,7 +130,7 @@ void main() { await settle(tester); await enterClientId(tester, 'cid'); - await tester.tap(find.text('Connect GitHub')); + await tester.tap(byIconLabel('Connect GitHub')); await pumpUntil( tester, () => find.text('WXYZ-1234').evaluate().isNotEmpty, @@ -147,49 +141,48 @@ void main() { // post-connect sync runs against the mock. await pumpUntil( tester, - () => find.textContaining('Connected and synced').evaluate().isNotEmpty, + () => cueLabelled('Connected and synced.').evaluate().isNotEmpty, ); - expect(find.textContaining('Connected and synced'), findsOneWidget); + expect(cueLabelled('Connected and synced.'), findsOneWidget); }); }); - testWidgets( - 'device flow connects but surfaces a post-connect sync failure', - (tester) async { - final mock = MockClient((req) async { - if (req.url.path.contains('device/code')) { - return http.Response( - jsonEncode({ - 'device_code': 'dev123', - 'user_code': 'WXYZ-1234', - 'verification_uri': 'https://github.com/login/device', - 'interval': 0, - 'expires_in': 900, - }), - 200, - ); - } - if (req.url.path.contains('login/oauth/access_token')) { - return http.Response(jsonEncode({'access_token': 'gho_test'}), 200); - } - return http.Response('boom', 500); // the sync's repo calls fail - }); - - await tester.runAsync(() async { - await tester.pumpWidget( - MaterialApp(home: GitHubMirrorScreen(httpClient: mock)), + testWidgets('device flow connects but surfaces a post-connect sync failure', ( + tester, + ) async { + final mock = MockClient((req) async { + if (req.url.path.contains('device/code')) { + return http.Response( + jsonEncode({ + 'device_code': 'dev123', + 'user_code': 'WXYZ-1234', + 'verification_uri': 'https://github.com/login/device', + 'interval': 0, + 'expires_in': 900, + }), + 200, ); - await settle(tester); - await enterClientId(tester, 'cid'); + } + if (req.url.path.contains('login/oauth/access_token')) { + return http.Response(jsonEncode({'access_token': 'gho_test'}), 200); + } + return http.Response('boom', 500); // the sync's repo calls fail + }); - await tester.tap(find.text('Connect GitHub')); - await pumpUntil( - tester, - () => find.textContaining('sync failed').evaluate().isNotEmpty, - ); + await tester.runAsync(() async { + await tester.pumpWidget( + MaterialApp(home: GitHubMirrorScreen(httpClient: mock)), + ); + await settle(tester); + await enterClientId(tester, 'cid'); - expect(find.textContaining('sync failed'), findsOneWidget); - }); - }, - ); + await tester.tap(byIconLabel('Connect GitHub')); + await pumpUntil( + tester, + () => cueOfKind(CueKind.error).evaluate().isNotEmpty, + ); + + expect(cueOfKind(CueKind.error), findsOneWidget); + }); + }); } diff --git a/app/test/screens/github_mirror_device_flow_test.dart b/app/test/screens/github_mirror_device_flow_test.dart index 2d308ba..9f3f544 100644 --- a/app/test/screens/github_mirror_device_flow_test.dart +++ b/app/test/screens/github_mirror_device_flow_test.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:diet_guard_app/ui/cues.dart'; import 'package:diet_guard_app/screens/github_mirror_screen.dart'; import 'package:diet_guard_app/services/app_settings_service.dart'; import 'package:diet_guard_app/services/budget_history_service.dart'; @@ -16,6 +17,7 @@ import 'package:url_launcher_platform_interface/link.dart'; import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; import '../fake_secure_storage.dart'; +import '../label_finders.dart'; /// Stub launcher that records the URL instead of opening it, so the device /// dialog's "Open GitHub & copy code" can be exercised without a real @@ -38,7 +40,6 @@ class _FakeUrlLauncher extends UrlLauncherPlatform } void main() { - late Directory tempDir; setUp(() async { @@ -46,9 +47,7 @@ void main() { LogStorageService.resetForTesting(store: FileDocumentStore(tempDir)); FoodBankService.resetForTesting(store: FileDocumentStore(tempDir)); AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir)); - BudgetHistoryService.resetForTesting( - store: FileDocumentStore(tempDir), - ); + BudgetHistoryService.resetForTesting(store: FileDocumentStore(tempDir)); SharedPreferences.setMockInitialValues({}); installFakeSecureStorage(); }); @@ -88,13 +87,9 @@ void main() { } } - /// Types [clientId] into the client-id field. Future enterClientId(WidgetTester tester, String clientId) async { - await tester.enterText( - find.widgetWithText(TextField, 'OAuth App client id'), - clientId, - ); + await tester.enterText(fieldByLabel('OAuth App client id'), clientId); } testWidgets('Connect GitHub without a client id opens setup guidance', ( @@ -105,11 +100,11 @@ void main() { await settle(tester); await enterClientId(tester, ''); - await tester.tap(find.text('Connect GitHub')); + await tester.tap(byIconLabel('Connect GitHub')); await settle(tester); - expect(find.text('One-time GitHub setup needed'), findsOneWidget); - expect(find.widgetWithText(TextField, 'Client ID'), findsOneWidget); + expect(find.byType(AlertDialog), findsOneWidget); + expect(fieldByLabel('Client ID'), findsOneWidget); }); }); testWidgets('cancelling the client id setup dialog aborts the connect', ( @@ -120,12 +115,12 @@ void main() { await settle(tester); await enterClientId(tester, ''); - await tester.tap(find.text('Connect GitHub')); + await tester.tap(byIconLabel('Connect GitHub')); await settle(tester); - await tester.tap(find.text('Cancel')); + await tester.tap(byIconLabel('Cancel')); await settle(tester); - expect(find.text('One-time GitHub setup needed'), findsNothing); + expect(find.byType(AlertDialog), findsNothing); }); }); testWidgets( @@ -139,28 +134,16 @@ void main() { await settle(tester); await enterClientId(tester, ''); - await tester.tap(find.text('Connect GitHub')); + await tester.tap(byIconLabel('Connect GitHub')); await settle(tester); - await tester.enterText( - find.widgetWithText(TextField, 'Client ID'), - 'cid', - ); - await tester.tap(find.text('Continue')); + await tester.enterText(fieldByLabel('Client ID'), 'cid'); + await tester.tap(byIconLabel('Continue')); await settle(tester); + expect(cueOfKind(CueKind.error), findsOneWidget); + expect(fieldByLabel('OAuth App client id'), findsOneWidget); expect( - find.textContaining('Could not start device flow'), - findsOneWidget, - ); - expect( - find.widgetWithText(TextField, 'OAuth App client id'), - findsOneWidget, - ); - expect( - (tester.widget( - find.widgetWithText(TextField, 'OAuth App client id'), - ) - as TextField) + (tester.widget(fieldByLabel('OAuth App client id')) as TextField) .controller! .text, 'cid', @@ -177,13 +160,10 @@ void main() { await settle(tester); await enterClientId(tester, 'cid'); - await tester.tap(find.text('Connect GitHub')); + await tester.tap(byIconLabel('Connect GitHub')); await settle(tester); - expect( - find.textContaining('Could not start device flow'), - findsOneWidget, - ); + expect(cueOfKind(CueKind.error), findsOneWidget); }); }); } diff --git a/app/test/screens/github_mirror_device_poll_test.dart b/app/test/screens/github_mirror_device_poll_test.dart index dc0f968..1762200 100644 --- a/app/test/screens/github_mirror_device_poll_test.dart +++ b/app/test/screens/github_mirror_device_poll_test.dart @@ -18,6 +18,7 @@ import 'package:url_launcher_platform_interface/link.dart'; import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; import '../fake_secure_storage.dart'; +import '../label_finders.dart'; /// Stub launcher that records the URL instead of opening it, so the device /// dialog's "Open GitHub & copy code" can be exercised without a real @@ -40,8 +41,6 @@ class _FakeUrlLauncher extends UrlLauncherPlatform } void main() { - - late Directory tempDir; setUp(() async { @@ -49,9 +48,7 @@ void main() { LogStorageService.resetForTesting(store: FileDocumentStore(tempDir)); FoodBankService.resetForTesting(store: FileDocumentStore(tempDir)); AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir)); - BudgetHistoryService.resetForTesting( - store: FileDocumentStore(tempDir), - ); + BudgetHistoryService.resetForTesting(store: FileDocumentStore(tempDir)); SharedPreferences.setMockInitialValues({}); installFakeSecureStorage(); }); @@ -91,13 +88,9 @@ void main() { } } - /// Types [clientId] into the client-id field. Future enterClientId(WidgetTester tester, String clientId) async { - await tester.enterText( - find.widgetWithText(TextField, 'OAuth App client id'), - clientId, - ); + await tester.enterText(fieldByLabel('OAuth App client id'), clientId); } testWidgets('device dialog: failed poll shows the error and Open launches', ( @@ -144,7 +137,7 @@ void main() { await settle(tester); await enterClientId(tester, 'cid'); - await tester.tap(find.text('Connect GitHub')); + await tester.tap(byIconLabel('Connect GitHub')); await pumpUntil( tester, () => find.text('WXYZ-1234').evaluate().isNotEmpty, diff --git a/app/test/screens/github_mirror_screen_test.dart b/app/test/screens/github_mirror_screen_test.dart index d8d714f..d8a1dc2 100644 --- a/app/test/screens/github_mirror_screen_test.dart +++ b/app/test/screens/github_mirror_screen_test.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:diet_guard_app/ui/cues.dart'; import 'package:diet_guard_app/screens/github_mirror_screen.dart'; import 'package:diet_guard_app/services/app_settings_service.dart'; import 'package:diet_guard_app/services/budget_history_service.dart'; @@ -17,6 +18,7 @@ import 'package:url_launcher_platform_interface/link.dart'; import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; import '../fake_secure_storage.dart'; +import '../label_finders.dart'; /// Stub launcher that records the URL instead of opening it, so the device /// dialog's "Open GitHub & copy code" can be exercised without a real @@ -39,7 +41,6 @@ class _FakeUrlLauncher extends UrlLauncherPlatform } void main() { - late Directory tempDir; setUp(() async { @@ -47,9 +48,7 @@ void main() { LogStorageService.resetForTesting(store: FileDocumentStore(tempDir)); FoodBankService.resetForTesting(store: FileDocumentStore(tempDir)); AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir)); - BudgetHistoryService.resetForTesting( - store: FileDocumentStore(tempDir), - ); + BudgetHistoryService.resetForTesting(store: FileDocumentStore(tempDir)); SharedPreferences.setMockInitialValues({}); installFakeSecureStorage(); }); @@ -89,13 +88,9 @@ void main() { } } - /// Types [clientId] into the client-id field. Future enterClientId(WidgetTester tester, String clientId) async { - await tester.enterText( - find.widgetWithText(TextField, 'OAuth App client id'), - clientId, - ); + await tester.enterText(fieldByLabel('OAuth App client id'), clientId); } testWidgets('shows the kuhyx/syncs defaults on a fresh install', ( @@ -115,31 +110,27 @@ void main() { await settle(tester); await tester.enterText( - find.widgetWithText(TextField, 'Personal access token (fallback)'), + fieldByLabel('Personal access token (fallback)'), 'my-pat', ); - await tester.tap(find.widgetWithText(ElevatedButton, 'Save')); + await tester.tap(controlByLabel('Save')); await settle(tester); - expect(find.text('Saved.'), findsOneWidget); + expect(cueLabelled('Saved.'), findsOneWidget); }); }); testWidgets('Test connection reports success', (tester) async { - final mock = MockClient( - (_) async => http.Response('{}', 200), - ); + final mock = MockClient((_) async => http.Response('{}', 200)); await tester.runAsync(() async { await tester.pumpWidget( MaterialApp(home: GitHubMirrorScreen(httpClient: mock)), ); await settle(tester); - await tester.tap( - find.widgetWithText(OutlinedButton, 'Test GitHub connection'), - ); + await tester.tap(controlByLabel('Test GitHub connection')); await settle(tester); - expect(find.text('GitHub connection OK.'), findsOneWidget); + expect(cueLabelled('GitHub connection OK.'), findsOneWidget); }); }); testWidgets('Test connection reports failure', (tester) async { @@ -150,17 +141,13 @@ void main() { ); await settle(tester); - await tester.tap( - find.widgetWithText(OutlinedButton, 'Test GitHub connection'), - ); + await tester.tap(controlByLabel('Test GitHub connection')); await settle(tester); - expect(find.text('GitHub connection failed.'), findsOneWidget); + expect(cueLabelled('GitHub connection failed.'), findsOneWidget); }); }); - testWidgets('Sync now runs a sync tick and reports success', ( - tester, - ) async { + testWidgets('Sync now runs a sync tick and reports success', (tester) async { final mock = MockClient((req) async { if (req.method == 'PUT') return http.Response('{}', 200); // A bare `/repos//` GET is crdt_sync's GitHubClient @@ -178,10 +165,10 @@ void main() { ); await settle(tester); - await tester.tap(find.widgetWithText(ElevatedButton, 'Sync now')); + await tester.tap(controlByLabel('Sync now')); await settle(tester); - expect(find.text('Synced.'), findsOneWidget); + expect(cueLabelled('Synced.'), findsOneWidget); }); }); testWidgets('Sync now clears a stored failure so the banner lifts', ( @@ -208,10 +195,10 @@ void main() { ); await settle(tester); - await tester.tap(find.widgetWithText(ElevatedButton, 'Sync now')); + await tester.tap(controlByLabel('Sync now')); await settle(tester); - expect(find.text('Synced.'), findsOneWidget); + expect(cueLabelled('Synced.'), findsOneWidget); final status = await SyncHealth.read(); expect(status.failureKind, isNull); expect(status.isStalled, isFalse); @@ -225,12 +212,10 @@ void main() { ); await settle(tester); - await tester.tap( - find.widgetWithText(OutlinedButton, 'Test GitHub connection'), - ); + await tester.tap(controlByLabel('Test GitHub connection')); await settle(tester); - expect(find.textContaining('GitHub connection failed:'), findsOneWidget); + expect(cueOfKind(CueKind.error), findsOneWidget); }); }); testWidgets('Sync now reports a GitHub error', (tester) async { @@ -241,10 +226,10 @@ void main() { ); await settle(tester); - await tester.tap(find.widgetWithText(ElevatedButton, 'Sync now')); + await tester.tap(controlByLabel('Sync now')); await settle(tester); - expect(find.textContaining('Sync failed:'), findsOneWidget); + expect(cueOfKind(CueKind.error), findsOneWidget); }); }); } diff --git a/app/test/screens/settings_kuchnia_test.dart b/app/test/screens/settings_kuchnia_test.dart index d8afe6e..805565a 100644 --- a/app/test/screens/settings_kuchnia_test.dart +++ b/app/test/screens/settings_kuchnia_test.dart @@ -6,6 +6,9 @@ /// likely to be typed, and it syncs from there to the phone), while the /// **fetch button** is Android-only, because the panel sends no CORS headers /// and a browser cannot call it at all. +/// +/// The section paints no text: controls are found by the spoken label their +/// icon carries, outcomes by the [StatusCue] kind they show. library; import 'dart:async'; @@ -13,9 +16,12 @@ import 'dart:async'; import 'package:diet_guard_app/screens/settings_kuchnia.dart'; import 'package:diet_guard_app/services/document_store.dart'; import 'package:diet_guard_app/services/kuchnia_credential_service.dart'; +import 'package:diet_guard_app/ui/cues.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import '../label_finders.dart'; + /// In-memory store, so these tests need no plugin channel. class _MemoryStore implements DocumentStore { final Map documents = {}; @@ -29,14 +35,29 @@ class _MemoryStore implements DocumentStore { } } -Widget _host({required bool canFetch, Future Function()? onFetch}) => - MaterialApp( - home: Scaffold( - body: SingleChildScrollView( - child: KuchniaSettingsSection(canFetch: canFetch, onFetch: onFetch), - ), - ), - ); +Widget _host({ + required bool canFetch, + Future Function()? onFetch, +}) => MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: KuchniaSettingsSection(canFetch: canFetch, onFetch: onFetch), + ), + ), +); + +const _fetchLabel = "Fetch today's delivery"; + +/// The fetch button: the [IconButton] holding the delivery icon (or, while a +/// fetch runs, the spinner that replaces it). +Finder _fetchButton() => find.ancestor( + of: find.byWidgetPredicate( + (w) => + w is CircularProgressIndicator || + (w is Icon && w.semanticLabel == _fetchLabel), + ), + matching: find.byType(IconButton), +); void main() { setUp(() => KuchniaCredentialService.resetForTesting(store: _MemoryStore())); @@ -44,28 +65,22 @@ void main() { testWidgets('shows both credential fields on every platform', (tester) async { await tester.pumpWidget(_host(canFetch: false)); - expect(find.text('Panel e-mail'), findsOneWidget); - expect(find.text('Panel password'), findsOneWidget); + expect(fieldByLabel('Panel e-mail'), findsOneWidget); + expect(fieldByLabel('Panel password'), findsOneWidget); }); - testWidgets('offers the fetch button only where it can work', (tester) async { + testWidgets('the fetch button works only where it can', (tester) async { await tester.pumpWidget(_host(canFetch: true)); - expect(find.text("Fetch today's delivery"), findsOneWidget); + expect(tester.widget(_fetchButton()).onPressed, isNotNull); await tester.pumpWidget(_host(canFetch: false)); await tester.pumpAndSettle(); - expect(find.text("Fetch today's delivery"), findsNothing); - }); - - testWidgets('explains the missing button rather than hiding it silently', ( - tester, - ) async { - await tester.pumpWidget(_host(canFetch: false)); expect( - find.textContaining('Android only'), - findsOneWidget, - reason: 'a browser user needs to know why there is no fetch button, and ' - 'that entering the login here still helps their phone', + tester.widget(_fetchButton()).onPressed, + isNull, + reason: + 'a browser user sees the button greyed out instead of vanishing; ' + 'entering the login here still helps their phone', ); }); @@ -73,12 +88,12 @@ void main() { await tester.pumpWidget(_host(canFetch: true)); await tester.enterText(find.byType(TextField).first, 'me@example.com'); await tester.enterText(find.byType(TextField).last, 'hunter2'); - await tester.tap(find.text('Save login')); + await tester.tap(controlByLabel('Save login')); await tester.pumpAndSettle(); expect(KuchniaCredentialService.username, 'me@example.com'); expect(KuchniaCredentialService.password, 'hunter2'); - expect(find.textContaining('Saved'), findsOneWidget); + expect(cueOfKind(CueKind.ok), findsOneWidget); }); testWidgets('the password is obscured until revealed', (tester) async { @@ -87,27 +102,37 @@ void main() { tester.widget(find.byType(TextField).last); expect(passwordField().obscureText, isTrue); - await tester.tap(find.byTooltip('Show password')); + await tester.tap(controlByLabel('Show password')); await tester.pumpAndSettle(); expect(passwordField().obscureText, isFalse); }); - testWidgets('a fetch shows its result', (tester) async { + testWidgets('a fetch shows its result as a cue and a count', (tester) async { await tester.pumpWidget( - _host(canFetch: true, onFetch: () async => '3 dishes added'), + _host( + canFetch: true, + onFetch: () async => + const FetchOutcome(CueKind.ok, '3 dishes added', count: 3), + ), ); - await tester.tap(find.text("Fetch today's delivery")); + await tester.tap(controlByLabel(_fetchLabel)); await tester.pumpAndSettle(); - expect(find.text('3 dishes added'), findsOneWidget); + expect(cueOfKind(CueKind.ok), findsOneWidget); + expect(find.text('3'), findsOneWidget); }); - testWidgets('a failed fetch shows the reason, not a crash', (tester) async { + testWidgets('a failed fetch shows an error cue, not a crash', (tester) async { await tester.pumpWidget( - _host(canFetch: true, onFetch: () async => 'catering login rejected'), + _host( + canFetch: true, + onFetch: () async => + const FetchOutcome(CueKind.error, 'catering login rejected'), + ), ); - await tester.tap(find.text("Fetch today's delivery")); + await tester.tap(controlByLabel(_fetchLabel)); await tester.pumpAndSettle(); - expect(find.text('catering login rejected'), findsOneWidget); + expect(cueLabelled('catering login rejected'), findsOneWidget); + expect(cueOfKind(CueKind.error), findsOneWidget); }); testWidgets('the fetch button is disabled while a fetch is in flight', ( @@ -115,16 +140,15 @@ void main() { ) async { // Otherwise an impatient double-tap pays a second login plus three // requests against a third party. - final gate = Completer(); + final gate = Completer(); await tester.pumpWidget(_host(canFetch: true, onFetch: () => gate.future)); - await tester.tap(find.text("Fetch today's delivery")); + await tester.tap(controlByLabel(_fetchLabel)); await tester.pump(); - expect(find.text('Fetching…'), findsOneWidget); - final button = tester.widget(find.byType(OutlinedButton)); - expect(button.onPressed, isNull); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(tester.widget(_fetchButton()).onPressed, isNull); - gate.complete('done'); + gate.complete(const FetchOutcome(CueKind.ok, 'done')); await tester.pumpAndSettle(); }); } diff --git a/app/test/screens/settings_screen_battery_test.dart b/app/test/screens/settings_screen_battery_test.dart index 64ba2cb..647026f 100644 --- a/app/test/screens/settings_screen_battery_test.dart +++ b/app/test/screens/settings_screen_battery_test.dart @@ -8,15 +8,16 @@ import 'package:diet_guard_app/services/budget_history_service.dart'; import 'package:diet_guard_app/services/firebase_backend.dart'; import 'package:diet_guard_app/services/foodbank_service.dart'; import 'package:diet_guard_app/services/log_storage_service.dart'; +import 'package:diet_guard_app/ui/cues.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../fake_secure_storage.dart'; +import '../label_finders.dart'; void main() { - late Directory tempDir; setUp(() async { @@ -24,9 +25,7 @@ void main() { LogStorageService.resetForTesting(store: FileDocumentStore(tempDir)); FoodBankService.resetForTesting(store: FileDocumentStore(tempDir)); AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir)); - BudgetHistoryService.resetForTesting( - store: FileDocumentStore(tempDir), - ); + BudgetHistoryService.resetForTesting(store: FileDocumentStore(tempDir)); SharedPreferences.setMockInitialValues({}); installFakeSecureStorage(); }); @@ -68,10 +67,10 @@ void main() { ); await settle(tester); - await tester.tap(find.text('Disable battery optimization')); + await tester.tap(controlByLabel('Disable battery optimization')); await settle(tester); - expect(find.textContaining('exemption granted'), findsOneWidget); + expect(cueOfKind(CueKind.ok), findsOneWidget); }); }); testWidgets('battery exemption button reports a denied status', ( @@ -87,10 +86,10 @@ void main() { ); await settle(tester); - await tester.tap(find.text('Disable battery optimization')); + await tester.tap(controlByLabel('Disable battery optimization')); await settle(tester); - expect(find.textContaining('not granted'), findsOneWidget); + expect(cueOfKind(CueKind.warn), findsOneWidget); }); }); testWidgets('battery exemption defaults to the real permission_handler ' @@ -99,13 +98,10 @@ void main() { await tester.pumpWidget(const MaterialApp(home: SettingsScreen())); await settle(tester); - await tester.tap(find.text('Disable battery optimization')); + await tester.tap(controlByLabel('Disable battery optimization')); await settle(tester); - expect( - find.textContaining('Could not request exemption'), - findsOneWidget, - ); + expect(cueOfKind(CueKind.error), findsOneWidget); }); }); testWidgets('battery exemption button surfaces a request failure', ( @@ -122,13 +118,10 @@ void main() { ); await settle(tester); - await tester.tap(find.text('Disable battery optimization')); + await tester.tap(controlByLabel('Disable battery optimization')); await settle(tester); - expect( - find.textContaining('Could not request exemption'), - findsOneWidget, - ); + expect(cueOfKind(CueKind.error), findsOneWidget); }); }); } diff --git a/app/test/screens/settings_screen_test.dart b/app/test/screens/settings_screen_test.dart index 27a86e6..7845f06 100644 --- a/app/test/screens/settings_screen_test.dart +++ b/app/test/screens/settings_screen_test.dart @@ -16,6 +16,7 @@ import 'package:permission_handler/permission_handler.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../fake_secure_storage.dart'; +import '../label_finders.dart'; void main() { late Directory tempDir; @@ -25,9 +26,7 @@ void main() { LogStorageService.resetForTesting(store: FileDocumentStore(tempDir)); FoodBankService.resetForTesting(store: FileDocumentStore(tempDir)); AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir)); - BudgetHistoryService.resetForTesting( - store: FileDocumentStore(tempDir), - ); + BudgetHistoryService.resetForTesting(store: FileDocumentStore(tempDir)); await MealScheduleService.initForTesting(FileDocumentStore(tempDir)); SharedPreferences.setMockInitialValues({}); installFakeSecureStorage(); @@ -76,13 +75,10 @@ void main() { // The default schedule still derives the hours that used to be // hardcoded, so an existing install sees no change. - expect( - find.text('08:00 · 12:00 · 16:00 · 20:00'), - findsOneWidget, - ); - expect(find.text('First meal'), findsOneWidget); - expect(find.text('Last meal'), findsOneWidget); - expect(find.text('Meals per day'), findsOneWidget); + expect(find.text('08:00 · 12:00 · 16:00 · 20:00'), findsOneWidget); + expect(bySemLabel('First meal'), findsOneWidget); + expect(bySemLabel('Last meal'), findsOneWidget); + expect(bySemLabel('Meals per day'), findsOneWidget); }); }); testWidgets('an exact 07:23 first meal saves the schedule', (tester) async { @@ -90,7 +86,7 @@ void main() { await tester.pumpWidget(const MaterialApp(home: SettingsScreen())); await settle(tester); - await tester.tap(find.byTooltip('Type or pick an exact time').first); + await tester.tap(byIconLabel('Type or pick an exact time').first); await settle(tester); await tester.tap(find.byIcon(Icons.keyboard_outlined)); await settle(tester); @@ -129,10 +125,7 @@ void main() { await tester.pumpWidget(const MaterialApp(home: SettingsScreen())); await settle(tester); - await tester.enterText( - find.widgetWithText(TextField, 'Daily kcal goal'), - '2100', - ); + await tester.enterText(fieldByLabel('Daily kcal goal'), '2100'); // Immediately after typing, the debounce has not fired yet. expect(AppSettingsService.dailyKcalGoal, isNot(2100)); @@ -147,10 +140,7 @@ void main() { await tester.pumpWidget(const MaterialApp(home: SettingsScreen())); await settle(tester); - await tester.enterText( - find.widgetWithText(TextField, 'Daily kcal goal'), - '0', - ); + await tester.enterText(fieldByLabel('Daily kcal goal'), '0'); await Future.delayed(const Duration(milliseconds: 700)); await tester.pump(); @@ -164,10 +154,7 @@ void main() { await tester.pumpWidget(const MaterialApp(home: SettingsScreen())); await settle(tester); - await tester.enterText( - find.widgetWithText(TextField, 'Daily kcal goal'), - '1950', - ); + await tester.enterText(fieldByLabel('Daily kcal goal'), '1950'); // Leave before the 600ms debounce fires. dispose()'s flush fires the // persist unawaited (the framework cannot await dispose()), so the // in-memory value updates synchronously but the disk write is still a @@ -195,42 +182,39 @@ void main() { ); await settle(tester); - await tester.tap(find.text('Sync settings')); + await tester.tap(byIconLabel('Sync settings')); await settle(tester); expect(find.text('Firebase sync'), findsOneWidget); }); }); - testWidgets( - 'Sync settings wires storedAccount, not loadAccount, as the ' - 'default accountLoader', - (tester) async { - // Verifies the read-back fix documented on SettingsScreen.accountLoader: - // on a device with a stored account and a live session, the shared - // screen must show it as connected using the injected default, with no - // fake required to prove the wiring compiles and runs end-to-end. - await tester.runAsync(() async { - await saveAccount( - const FirebaseAccount(email: 'sync@example.com', password: 'pw'), - ); - await credentialStore().save( - FirebaseCredentials( - idToken: 'id', - refreshToken: 'refresh', - expiresAt: DateTime.now().toUtc().add(const Duration(hours: 1)), - ), - ); - await tester.pumpWidget(const MaterialApp(home: SettingsScreen())); - await settle(tester); + testWidgets('Sync settings wires storedAccount, not loadAccount, as the ' + 'default accountLoader', (tester) async { + // Verifies the read-back fix documented on SettingsScreen.accountLoader: + // on a device with a stored account and a live session, the shared + // screen must show it as connected using the injected default, with no + // fake required to prove the wiring compiles and runs end-to-end. + await tester.runAsync(() async { + await saveAccount( + const FirebaseAccount(email: 'sync@example.com', password: 'pw'), + ); + await credentialStore().save( + FirebaseCredentials( + idToken: 'id', + refreshToken: 'refresh', + expiresAt: DateTime.now().toUtc().add(const Duration(hours: 1)), + ), + ); + await tester.pumpWidget(const MaterialApp(home: SettingsScreen())); + await settle(tester); - await tester.tap(find.text('Sync settings')); - await settle(tester); + await tester.tap(byIconLabel('Sync settings')); + await settle(tester); - expect(find.text('sync@example.com'), findsOneWidget); - expect(find.text('Disconnect'), findsOneWidget); - }); - }, - ); + expect(find.text('sync@example.com'), findsOneWidget); + expect(find.text('Disconnect'), findsOneWidget); + }); + }); testWidgets( 'tapping Advanced sync (GitHub) opens the local GitHubMirrorScreen', (tester) async { @@ -238,11 +222,10 @@ void main() { await tester.pumpWidget(const MaterialApp(home: SettingsScreen())); await settle(tester); - await tester.tap(find.text('Advanced sync (GitHub)')); + await tester.tap(byIconLabel('Advanced sync (GitHub)')); await settle(tester); - expect(find.text('Advanced sync (GitHub)'), findsWidgets); - expect(find.text('Connect GitHub'), findsOneWidget); + expect(byIconLabel('Connect GitHub'), findsOneWidget); }); }, ); diff --git a/app/test/services/notification_service_test.dart b/app/test/services/notification_service_test.dart index a3c106a..90d2c85 100644 --- a/app/test/services/notification_service_test.dart +++ b/app/test/services/notification_service_test.dart @@ -163,8 +163,7 @@ class _FakeBackend implements NotificationBackend { Future requestPermission() async => null; @override - Future show(int slot, String title, String body) async => - shown.add(slot); + Future show(int slot, String title) async => shown.add(slot); @override Future cancel(int slot) async => cancelled.add(slot); diff --git a/app/test/widgets/meal_schedule_editor_test.dart b/app/test/widgets/meal_schedule_editor_test.dart index f980ff8..0fb14c1 100644 --- a/app/test/widgets/meal_schedule_editor_test.dart +++ b/app/test/widgets/meal_schedule_editor_test.dart @@ -4,6 +4,8 @@ import 'package:diet_guard_app/widgets/meal_time_dropdown.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import '../label_finders.dart'; + /// Holds the schedule the way `_SettingsMealSchedule` does: every edit is /// normalised and fed back, so the preview reflects what would be saved. class _Harness extends StatefulWidget { @@ -24,6 +26,10 @@ class _HarnessState extends State<_Harness> { home: Scaffold( body: ListView( children: [ + // The editor lost its heading, so its first dropdown now sits at the + // very top; an open menu would clip the items above the selected + // one. Push it down to where it sits on the real screen. + const SizedBox(height: 200), MealScheduleEditor( schedule: _schedule, onChanged: (next) { @@ -51,7 +57,12 @@ void main() { } Future openDropdown(WidgetTester tester, String label) async { - await tester.tap(find.widgetWithText(InputDecorator, label)); + await tester.tap( + find.descendant( + of: bySemLabel(label), + matching: find.byType(DropdownButton), + ), + ); await tester.pumpAndSettle(); } @@ -66,9 +77,9 @@ void main() { await tester.tap( find.descendant( of: find.byWidgetPredicate( - (w) => w is MealTimeDropdown && w.label == label, + (w) => w is MealTimeDropdown && w.semanticLabel == label, ), - matching: find.byTooltip('Type or pick an exact time'), + matching: byIconLabel('Type or pick an exact time'), ), ); await tester.pumpAndSettle(); @@ -102,14 +113,20 @@ void main() { testWidgets('choosing a 15-minute value updates the preview', (tester) async { await pump(tester, _fiveMeals); - expect(find.text('08:00 · 11:00 · 14:00 · 17:00 · 20:00'), findsOne); + expect( + find.text('08:00 · 11:00 · 14:00 · 17:00 · 20:00'), + findsOne, + ); await openDropdown(tester, 'First meal'); await tester.tap(find.text('07:45').last); await tester.pumpAndSettle(); expect(edits.last.firstMinute, 465); - expect(find.text('07:45 · 10:45 · 14:00 · 17:00 · 20:00'), findsOne); + expect( + find.text('07:45 · 10:45 · 14:00 · 17:00 · 20:00'), + findsOne, + ); }); testWidgets('the clock button opens the time picker and applies 07:23', ( @@ -155,7 +172,9 @@ void main() { await pump(tester, _fiveMeals); MealTimeDropdown field(String label) => tester.widget( - find.byWidgetPredicate((w) => w is MealTimeDropdown && w.label == label), + find.byWidgetPredicate( + (w) => w is MealTimeDropdown && w.semanticLabel == label, + ), ); final last = field('Last meal').options; expect(last.first, 495, reason: '08:15, one grid step after 08:00'); @@ -171,13 +190,17 @@ void main() { ) async { const narrow = MealSchedule(firstMinute: 480, lastMinute: 510, count: 2); expect( - const MealScheduleEditor(schedule: narrow, onChanged: _ignore) - .selectableCounts, + const MealScheduleEditor( + schedule: narrow, + onChanged: _ignore, + ).selectableCounts, [2, 3], ); expect( - const MealScheduleEditor(schedule: _fiveMeals, onChanged: _ignore) - .selectableCounts, + const MealScheduleEditor( + schedule: _fiveMeals, + onChanged: _ignore, + ).selectableCounts, [2, 3, 4, 5, 6], );