mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-11 12:27:42 +01:00
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:
parent
737d09d37c
commit
f25d22da20
@ -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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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!],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@ -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),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -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)),
|
||||
),
|
||||
],
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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],
|
||||
);
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user