Drop text from settings, GitHub, alerts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYndZhdUrRGFEjBazQ
This commit is contained in:
Krzysztof Rudnicki 2026-11-09 08:21:06 +01:00
parent 737d09d37c
commit f25d22da20
22 changed files with 505 additions and 543 deletions

View File

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

View File

@ -21,7 +21,7 @@ mixin _GitHubMirrorActions on State<GitHubMirrorScreen> {
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<GitHubMirrorScreen> {
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<void> _connectGitHub() async {
@ -69,12 +78,12 @@ mixin _GitHubMirrorActions on State<GitHubMirrorScreen> {
_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<GitHubMirrorScreen> {
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<GitHubMirrorScreen> {
await _currentSettings().save();
if (!mounted) return;
setState(() => _busy = false);
_showMessage('Saved.');
_showCue(CueKind.ok, 'Saved.');
}
Future<void> _testConnection() async {
@ -111,11 +120,13 @@ mixin _GitHubMirrorActions on State<GitHubMirrorScreen> {
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<GitHubMirrorScreen> {
// *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);

View File

@ -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<GitHubMirrorScreen>
/// 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),
],
],
),

View File

@ -7,11 +7,18 @@
part of 'settings_screen.dart';
mixin _SettingsKcalGoal on State<SettingsScreen> {
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<SettingsScreen> {
() => 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');
}
}
}

View File

@ -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<String> fetchTodaysDelivery() async {
Future<FetchOutcome> 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<String> Function()? onFetch;
final Future<FetchOutcome> Function()? onFetch;
@override
State<KuchniaSettingsSection> createState() => _KuchniaSettingsSectionState();
@ -69,7 +83,7 @@ class _KuchniaSettingsSectionState extends State<KuchniaSettingsSection> {
);
bool _obscured = true;
bool _busy = false;
String? _status;
Widget? _status;
@override
void dispose() {
@ -85,7 +99,13 @@ class _KuchniaSettingsSectionState extends State<KuchniaSettingsSection> {
_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<void> _fetch() async {
@ -93,62 +113,65 @@ class _KuchniaSettingsSectionState extends State<KuchniaSettingsSection> {
_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<KuchniaSettingsSection> {
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!],
],
);
}

View File

@ -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<SettingsScreen>
@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<SettingsScreen>
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<SettingsScreen>
.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),
],
],
),

View File

@ -20,8 +20,9 @@ abstract class NotificationBackend {
/// of the app.
Future<bool?> requestPermission();
/// Posts (or replaces) the notification for [slot].
Future<void> 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<void> show(int slot, String title);
/// Removes the notification for [slot], if one is showing.
Future<void> cancel(int slot);

View File

@ -47,7 +47,7 @@ class LocalNotificationsBackend implements NotificationBackend {
Future.value();
@override
Future<void> show(int slot, String title, String body) async {
Future<void> 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

View File

@ -36,13 +36,13 @@ class WebNotificationBackend implements NotificationBackend {
}
@override
Future<void> show(int slot, String title, String body) async {
Future<void> 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'),
);
}

View File

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

View File

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

View File

@ -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<int>(
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<int>(
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)),
),
],

View File

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

View File

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

View File

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

View File

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

View File

@ -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<void> 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/<owner>/<repo>` 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);
});
});
}

View File

@ -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<String, String> documents = {};
@ -29,14 +35,29 @@ class _MemoryStore implements DocumentStore {
}
}
Widget _host({required bool canFetch, Future<String> Function()? onFetch}) =>
MaterialApp(
home: Scaffold(
body: SingleChildScrollView(
child: KuchniaSettingsSection(canFetch: canFetch, onFetch: onFetch),
),
),
);
Widget _host({
required bool canFetch,
Future<FetchOutcome> 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<IconButton>(_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<IconButton>(_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<TextField>(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<String>();
final gate = Completer<FetchOutcome>();
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<OutlinedButton>(find.byType(OutlinedButton));
expect(button.onPressed, isNull);
expect(find.byType(CircularProgressIndicator), findsOneWidget);
expect(tester.widget<IconButton>(_fetchButton()).onPressed, isNull);
gate.complete('done');
gate.complete(const FetchOutcome(CueKind.ok, 'done'));
await tester.pumpAndSettle();
});
}

View File

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

View File

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

View File

@ -163,8 +163,7 @@ class _FakeBackend implements NotificationBackend {
Future<bool?> requestPermission() async => null;
@override
Future<void> show(int slot, String title, String body) async =>
shown.add(slot);
Future<void> show(int slot, String title) async => shown.add(slot);
@override
Future<void> cancel(int slot) async => cancelled.add(slot);

View File

@ -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<void> 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<int>),
),
);
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],
);