From 45471546f7201968f07d754cc6358f57e4f696cf Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Fri, 14 Aug 2026 11:47:43 +0200 Subject: [PATCH] Migrate Sync settings to the shared sync_settings_ui package Splits the settings screen: Nutrition (daily kcal goal) and Notifications (battery exemption) stay in place, Firebase+GitHub content is replaced by a Sync settings link using the shared sync_settings_ui package. GitHub stays app-local as its own GitHubMirrorScreen (device-flow connect, _ClientIdSetupDialog, the wrapperManagedToken desktop-wrapper path) since connecting it also triggers a real cross-device sync via runSync(), not just a settings save. BackupSlot left null: diet-guard has no local export/import feature. 553/553 tests passing, 100% coverage on settings_screen.dart, flutter analyze clean. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KdSp6Nbrs9xvsWkCMDN9a1 --- app/lib/screens/github_mirror_screen.dart | 481 ++++++++++++ app/lib/screens/settings_screen.dart | 720 ++---------------- app/pubspec.lock | 9 + app/pubspec.yaml | 12 +- .../screens/github_mirror_screen_test.dart | 512 +++++++++++++ app/test/screens/settings_screen_test.dart | 660 +++------------- 6 files changed, 1217 insertions(+), 1177 deletions(-) create mode 100644 app/lib/screens/github_mirror_screen.dart create mode 100644 app/test/screens/github_mirror_screen_test.dart diff --git a/app/lib/screens/github_mirror_screen.dart b/app/lib/screens/github_mirror_screen.dart new file mode 100644 index 0000000..cca7a65 --- /dev/null +++ b/app/lib/screens/github_mirror_screen.dart @@ -0,0 +1,481 @@ +/// Sync configuration for the GitHub cutover mirror. Kept app-local rather +/// than folded into the shared `sync_settings_ui` package because connecting +/// here also triggers a real log sync via [_syncAfterConnect] -- unlike the +/// shared package's Firebase section, which only saves settings. See +/// `lib/screens/settings_screen.dart` for the link to this screen and to the +/// shared Sync settings screen. +library; + +import 'dart:async'; + +import 'package:diet_guard_app/services/firebase_backend.dart'; +import 'package:diet_guard_app/services/github_client_factory.dart'; +import 'package:diet_guard_app/services/github_device_auth.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/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:http/http.dart' as http; +import 'package:url_launcher/url_launcher.dart'; + +/// Screen for configuring and triggering the GitHub mirror sync. +class GitHubMirrorScreen extends StatefulWidget { + /// Creates a [GitHubMirrorScreen]. + const GitHubMirrorScreen({super.key, this.httpClient}); + + /// Injectable HTTP client; tests pass a [MockClient]. + final http.Client? httpClient; + + @override + State createState() => _GitHubMirrorScreenState(); +} + +class _GitHubMirrorScreenState extends State { + final _ownerController = TextEditingController(); + final _repoController = TextEditingController(); + final _tokenController = TextEditingController(); + final _clientIdController = TextEditingController(); + bool _loading = true; + bool _busy = false; + String? _status; + + @override + void initState() { + super.initState(); + unawaited(_load()); + } + + /// Loads saved settings, defaulting to blank fields if loading itself + /// fails (e.g. no secret service available yet) -- the screen must still + /// render, not spin forever, so the user can fill them in from scratch. + Future _load() async { + SyncSettings settings; + try { + settings = await SyncSettings.load(); + // SyncSettings.load()'s only awaited calls are SharedPreferences + // (always mocked successfully under flutter test) and the token + // vault, whose own read() already swallows its errors internally -- + // so this catch has no reachable path from a VM test, the same reason + // _currentSettings()'s web-only branch below is marked unreachable. + // coverage:ignore-start + } on Exception { + settings = const SyncSettings(owner: '', repo: '', token: ''); + // coverage:ignore-end + } + if (!mounted) return; + _ownerController.text = settings.owner; + _repoController.text = settings.repo; + // On web the stored "token" is only a stand-in for one the desktop + // wrapper holds (see TokenVault); showing it would invite the user to + // edit a literal. [_storedToken] keeps it for round-tripping. + _storedToken = settings.token; + _tokenController.text = SyncSettings.exposesTokenValue + ? settings.token + : ''; + _clientIdController.text = settings.clientId; + setState(() => _loading = false); + } + + @override + void dispose() { + _ownerController.dispose(); + _repoController.dispose(); + _tokenController.dispose(); + _clientIdController.dispose(); + super.dispose(); + } + + /// The token as loaded, so a platform that cannot display it (web) still + /// round-trips it instead of blanking it on the next save. + String _storedToken = ''; + + SyncSettings _currentSettings() { + final typed = _tokenController.text.trim(); + return SyncSettings( + owner: _ownerController.text.trim(), + repo: _repoController.text.trim(), + // The `_storedToken` side is web-only (the wrapper holds the real + // token), so it is unreachable from a VM test. + token: typed.isEmpty && !SyncSettings.exposesTokenValue + // coverage:ignore-line + ? _storedToken + : typed, + clientId: _clientIdController.text.trim(), + ); + } + + void _showMessage(String message) { + if (!mounted) return; + setState(() => _status = message); + } + + /// Runs the OAuth device flow and, on success, fills in the token field. + Future _connectGitHub() async { + var clientId = _clientIdController.text.trim(); + if (clientId.isEmpty) { + final entered = await showDialog( + context: context, + builder: (_) => const _ClientIdSetupDialog(), + ); + if (entered == null || entered.isEmpty) return; + clientId = entered; + if (!mounted) return; + setState(() => _clientIdController.text = clientId); + await _currentSettings().save(); + } + final auth = createDeviceAuth(clientId, httpClient: widget.httpClient); + try { + final device = await auth.requestDeviceCode(); + if (!mounted) return; + final token = await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => _DeviceCodeDialog(device: device, auth: auth), + ); + if (token != null && token.isNotEmpty) { + setState(() { + _storedToken = token; + if (SyncSettings.exposesTokenValue) _tokenController.text = token; + }); + _showMessage('Connected — syncing…'); + await _currentSettings().save(); + await _syncAfterConnect(); + } + } on Exception catch (e) { + _showMessage('Could not start device flow: $e'); + } finally { + auth.close(); + } + } + + /// Runs a sync right after connecting so the device-flow token is proven + /// to work immediately, with clear confirmation either way. + Future _syncAfterConnect() async { + final settings = _currentSettings(); + final client = createGitHubClient(settings, httpClient: widget.httpClient); + try { + await runSync(await syncBackend(client)); + await SyncHealth.recordSuccess(); + _showMessage('Connected and synced.'); + } on Exception catch (e) { + await SyncHealth.recordFailure(); + _showMessage('Connected, but sync failed: $e'); + } finally { + client.close(); + } + } + + Future _save() async { + setState(() => _busy = true); + await _currentSettings().save(); + if (!mounted) return; + setState(() => _busy = false); + _showMessage('Saved.'); + } + + Future _testConnection() async { + setState(() => _busy = true); + final settings = _currentSettings(); + final client = createGitHubClient(settings, httpClient: widget.httpClient); + try { + final ok = await client.canAccessRepo(); + _showMessage( + ok ? 'GitHub connection OK.' : 'GitHub connection failed.', + ); + } on Exception catch (e) { + _showMessage('GitHub connection failed: $e'); + } finally { + client.close(); + if (mounted) setState(() => _busy = false); + } + } + + Future _syncNow() async { + setState(() => _busy = true); + final settings = _currentSettings(); + await settings.save(); + final client = createGitHubClient(settings, httpClient: widget.httpClient); + try { + await runSync(await syncBackend(client)); + // Clears any stored failure: this is the button a user reaches + // *because* the banner told them syncing had stopped, so a successful + // run here must dismiss the warning it caused. + await SyncHealth.recordSuccess(); + _showMessage('Synced.'); + } on Exception catch (e) { + await SyncHealth.recordFailure(); + _showMessage('Sync failed: $e'); + } finally { + client.close(); + if (mounted) setState(() => _busy = false); + } + } + + @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. + 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( + onPressed: _connectGitHub, + icon: const Icon(Icons.login), + label: const Text('Connect GitHub'), + ), + ), + const SizedBox(height: 16), + TextField( + controller: _ownerController, + decoration: const InputDecoration(labelText: 'GitHub owner'), + ), + const SizedBox(height: 8), + TextField( + controller: _repoController, + decoration: const InputDecoration(labelText: 'Repo'), + ), + const SizedBox(height: 8), + TextField( + controller: _clientIdController, + decoration: const InputDecoration( + labelText: 'OAuth App client id', + helperText: 'Needed for the Connect GitHub button', + ), + ), + const SizedBox(height: 8), + TextField( + 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, + ), + ), + const SizedBox(height: 16), + Wrap( + spacing: 8, + children: [ + ElevatedButton( + onPressed: _busy ? null : _save, + child: const Text('Save'), + ), + OutlinedButton( + 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'), + ), + ElevatedButton( + onPressed: _busy ? null : _syncNow, + child: const Text('Sync now'), + ), + ], + ), + if (_status != null) ...[ + const SizedBox(height: 16), + Text(_status!, style: Theme.of(context).textTheme.bodyMedium), + ], + ], + ), + ), + ), + ); + } +} + +/// Dialog shown when "Connect GitHub" is tapped with no OAuth App client id +/// configured yet. Explains what it is, how to get one, and lets the user +/// paste it in directly — rather than leaving them to discover a buried +/// field on their own. Pops the trimmed client id, or null if cancelled. +class _ClientIdSetupDialog extends StatefulWidget { + const _ClientIdSetupDialog(); + + @override + State<_ClientIdSetupDialog> createState() => _ClientIdSetupDialogState(); +} + +class _ClientIdSetupDialogState extends State<_ClientIdSetupDialog> { + final _controller = TextEditingController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + 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'), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + final id = _controller.text.trim(); + if (id.isNotEmpty) Navigator.of(context).pop(id); + }, + child: const Text('Continue'), + ), + ], + ); + } +} + +/// Dialog shown during the device flow: displays the user code, opens the +/// verification page, and polls until authorized — popping the token (or +/// null if cancelled / failed). +class _DeviceCodeDialog extends StatefulWidget { + const _DeviceCodeDialog({required this.device, required this.auth}); + + final DeviceCodeResponse device; + final GitHubDeviceAuth auth; + + @override + State<_DeviceCodeDialog> createState() => _DeviceCodeDialogState(); +} + +class _DeviceCodeDialogState extends State<_DeviceCodeDialog> { + String? _error; + + @override + void initState() { + super.initState(); + unawaited(_poll()); + } + + Future _poll() async { + try { + final token = await widget.auth.pollForToken(widget.device); + if (mounted) Navigator.of(context).pop(token); + } on Exception catch (e) { + if (mounted) setState(() => _error = '$e'); + } + } + + Future _openPage() async { + await Clipboard.setData(ClipboardData(text: widget.device.userCode)); + await launchUrl( + Uri.parse(widget.device.verificationUri), + mode: LaunchMode.externalApplication, + ); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Authorize on GitHub'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Enter this code on GitHub:'), + const SizedBox(height: 8), + SelectableText( + widget.device.userCode, + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 16), + if (_error == null) + const Row( + children: [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + SizedBox(width: 12), + Expanded(child: Text('Waiting for authorization…')), + ], + ) + else + Text( + _error!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton.icon( + onPressed: _openPage, + icon: const Icon(Icons.open_in_new), + label: const Text('Open GitHub & copy code'), + ), + ], + ); + } +} diff --git a/app/lib/screens/settings_screen.dart b/app/lib/screens/settings_screen.dart index 3ccc11c..8c6fdfa 100644 --- a/app/lib/screens/settings_screen.dart +++ b/app/lib/screens/settings_screen.dart @@ -1,33 +1,29 @@ -/// Sync configuration. Primary path: "Connect Firebase" — sign in with the -/// shared sync account, whose password is kept in the OS keystore. GitHub is -/// only the cutover mirror, so its whole setup (device-flow OAuth, owner/repo, -/// client id, PAT fallback) sits under "Advanced (GitHub mirror)" rather than -/// competing with Firebase as a visible choice. Auto-sync (app launch + -/// lifecycle pause/resume) lives in [LogMealScreen] and is silent on failure -/// -- this screen is where errors get surfaced, as inline status text. +/// App settings: kcal goal, notifications, and links to the two sync +/// surfaces. +/// +/// "Sync settings" is the shared `sync_settings_ui` package (Firebase +/// only -- diet_guard has no local backup format, so `BackupSlot` is null). +/// "Advanced sync (GitHub)" stays app-local ([GitHubMirrorScreen]) because +/// connecting it also triggers a real log sync via `runSync`, which the +/// shared package's Firebase section does not do -- it only saves settings. library; import 'dart:async'; import 'package:crdt_sync/crdt_sync.dart'; -import 'package:diet_guard_app/screens/log_meal_screen.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/firebase_backend.dart'; -import 'package:diet_guard_app/services/github_client_factory.dart'; -import 'package:diet_guard_app/services/github_device_auth.dart'; import 'package:diet_guard_app/services/google_sign_in_backend.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/theme.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:http/http.dart' as http; import 'package:permission_handler/permission_handler.dart'; -import 'package:url_launcher/url_launcher.dart'; +import 'package:sync_settings_ui/sync_settings_ui.dart'; -/// Screen for configuring and triggering cross-device sync. +/// Screen for app-specific settings and links to sync configuration. class SettingsScreen extends StatefulWidget { /// Creates a [SettingsScreen]. const SettingsScreen({ @@ -37,10 +33,14 @@ class SettingsScreen extends StatefulWidget { this.googleFirebaseFactory, this.googleAvailable, this.accountLoader, + this.accountSaver, + this.accountClearer, this.sessionProbe, + this.firebaseFactory, }); - /// Injectable HTTP client; tests pass a [MockClient]. + /// Injectable HTTP client for the linked [GitHubMirrorScreen]; tests pass + /// a [MockClient]. final http.Client? httpClient; /// Injectable battery-optimization-exemption request; tests pass a fake. @@ -48,8 +48,9 @@ class SettingsScreen extends StatefulWidget { /// `Permission.ignoreBatteryOptimizations.request()`. final Future Function()? requestBatteryExemption; - /// Builds the Firebase backend via Google sign-in. Injected so tests need - /// no platform channel -- the plugin reaches the OS account picker. + /// Builds the Firebase backend via Google sign-in, for the shared Sync + /// settings screen. Injected so tests need no platform channel -- the + /// plugin reaches the OS account picker. final Future Function()? googleFirebaseFactory; /// Whether to offer the Google button. Defaults to what the platform @@ -58,75 +59,41 @@ class SettingsScreen extends StatefulWidget { /// Reads the stored Firebase account. Injected for the same reason as /// [googleFirebaseFactory]: the keystore is a platform channel. + /// + /// Deliberately backed by [storedAccount], not [loadAccount]: the shared + /// `SyncSettingsScreen` uses this single closure both to display the + /// status and to read back the account right after a Google sign-in. + /// [loadAccount]'s desktop-wrapper fallback resolves to `file:///` on + /// Android and throws -- verified on the phone, where it turned a + /// successful sign-in into "Google sign-in failed". `storedAccount` is the + /// keystore-only read [firebase_backend.dart] documents for exactly this + /// read-back case. final Future Function()? accountLoader; + /// Persists the account. See [accountLoader]. + final Future Function(FirebaseAccount)? accountSaver; + + /// Forgets the account and any cached session. See [accountLoader]. + final Future Function()? accountClearer; + /// Whether a Firebase session is stored. See [accountLoader]. - /// - /// Separate from [accountLoader] because the two answer different - /// questions: the account marker is bookkeeping, the session is the - /// credential. A device can hold the second without the first, and - /// reporting only the first is what made a syncing phone read as - /// "not connected". final Future Function()? sessionProbe; + /// Builds the Firebase backend from the stored account. Injected so tests + /// can supply a fake. + final Future Function()? firebaseFactory; + @override State createState() => _SettingsScreenState(); } class _SettingsScreenState extends State { final _kcalGoalController = TextEditingController(); - final _ownerController = TextEditingController(); - final _repoController = TextEditingController(); - final _tokenController = TextEditingController(); - final _clientIdController = TextEditingController(); - final _firebaseEmailController = TextEditingController(); - final _firebasePasswordController = TextEditingController(); - bool _firebaseConfigured = false; - bool _loading = true; - bool _busy = false; - String? _status; @override void initState() { super.initState(); - unawaited(_load()); - } - - /// Loads saved settings, defaulting to blank fields if loading itself - /// fails (e.g. no secret service available yet) -- the screen must still - /// render, not spin forever, so the user can fill them in from scratch. - Future _load() async { - SyncSettings settings; - try { - settings = await SyncSettings.load(); - } on Exception { - settings = const SyncSettings(owner: '', repo: '', token: ''); - } - if (!mounted) return; _kcalGoalController.text = AppSettingsService.dailyKcalGoal.toString(); - _ownerController.text = settings.owner; - _repoController.text = settings.repo; - // On web the stored "token" is only a stand-in for one the desktop - // wrapper holds (see TokenVault); showing it would invite the user to - // edit a literal. [_storedToken] keeps it for round-tripping. - _storedToken = settings.token; - _tokenController.text = SyncSettings.exposesTokenValue - ? settings.token - : ''; - _clientIdController.text = settings.clientId; - // Reflect a previously-stored account, so a returning user sees the - // real state instead of an empty form that looks unconfigured. - final account = await loadAccount(); - // The stored session, not the account marker, decides "connected": a - // Google sign-in leaves a refresh token that authenticates every request - // even when no marker was written beside it. - final configured = await (widget.sessionProbe ?? isFirebaseConfigured)(); - if (!mounted) return; - if (account != null) _firebaseEmailController.text = account.email; - setState(() { - _firebaseConfigured = configured; - _loading = false; - }); } @override @@ -136,19 +103,9 @@ class _SettingsScreenState extends State { // the edit the user just typed. _flushKcalGoal(); _kcalGoalController.dispose(); - _ownerController.dispose(); - _repoController.dispose(); - _tokenController.dispose(); - _firebaseEmailController.dispose(); - _firebasePasswordController.dispose(); - _clientIdController.dispose(); super.dispose(); } - /// The token as loaded, so a platform that cannot display it (web) still - /// round-trips it instead of blanking it on the next save. - String _storedToken = ''; - Timer? _kcalGoalDebounce; int? _pendingKcalGoal; @@ -176,128 +133,6 @@ class _SettingsScreenState extends State { ); } - SyncSettings _currentSettings() { - final typed = _tokenController.text.trim(); - return SyncSettings( - owner: _ownerController.text.trim(), - repo: _repoController.text.trim(), - // The `_storedToken` side is web-only (the wrapper holds the real - // token), so it is unreachable from a VM test. - token: typed.isEmpty && !SyncSettings.exposesTokenValue - // coverage:ignore-line - ? _storedToken - : typed, - clientId: _clientIdController.text.trim(), - ); - } - - void _showMessage(String message) { - if (!mounted) return; - setState(() => _status = message); - } - - /// Runs the OAuth device flow and, on success, fills in the token field. - Future _connectGitHub() async { - var clientId = _clientIdController.text.trim(); - if (clientId.isEmpty) { - final entered = await showDialog( - context: context, - builder: (_) => const _ClientIdSetupDialog(), - ); - if (entered == null || entered.isEmpty) return; - clientId = entered; - if (!mounted) return; - setState(() => _clientIdController.text = clientId); - await _currentSettings().save(); - } - final auth = createDeviceAuth(clientId, httpClient: widget.httpClient); - try { - final device = await auth.requestDeviceCode(); - if (!mounted) return; - final token = await showDialog( - context: context, - barrierDismissible: false, - builder: (_) => _DeviceCodeDialog(device: device, auth: auth), - ); - if (token != null && token.isNotEmpty) { - setState(() { - _storedToken = token; - if (SyncSettings.exposesTokenValue) _tokenController.text = token; - }); - _showMessage('Connected — syncing…'); - await _currentSettings().save(); - await _syncAfterConnect(); - } - } on Exception catch (e) { - _showMessage('Could not start device flow: $e'); - } finally { - auth.close(); - } - } - - /// Runs a sync right after connecting so the device-flow token is proven - /// to work immediately, with clear confirmation either way. - Future _syncAfterConnect() async { - final settings = _currentSettings(); - final client = createGitHubClient(settings, httpClient: widget.httpClient); - try { - await runSync(await syncBackend(client)); - await SyncHealth.recordSuccess(); - _showMessage('Connected and synced.'); - } on Exception catch (e) { - await SyncHealth.recordFailure(); - _showMessage('Connected, but sync failed: $e'); - } finally { - client.close(); - } - } - - Future _save() async { - setState(() => _busy = true); - await _currentSettings().save(); - if (!mounted) return; - setState(() => _busy = false); - _showMessage('Saved.'); - } - - Future _testConnection() async { - setState(() => _busy = true); - final settings = _currentSettings(); - final client = createGitHubClient(settings, httpClient: widget.httpClient); - try { - final ok = await client.canAccessRepo(); - _showMessage( - ok ? 'GitHub connection OK.' : 'GitHub connection failed.', - ); - } on Exception catch (e) { - _showMessage('GitHub connection failed: $e'); - } finally { - client.close(); - if (mounted) setState(() => _busy = false); - } - } - - Future _syncNow() async { - setState(() => _busy = true); - final settings = _currentSettings(); - await settings.save(); - final client = createGitHubClient(settings, httpClient: widget.httpClient); - try { - await runSync(await syncBackend(client)); - // Clears any stored failure: this is the button a user reaches - // *because* the banner told them syncing had stopped, so a successful - // run here must dismiss the warning it caused. - await SyncHealth.recordSuccess(); - _showMessage('Synced.'); - } on Exception catch (e) { - await SyncHealth.recordFailure(); - _showMessage('Sync failed: $e'); - } finally { - client.close(); - if (mounted) setState(() => _busy = false); - } - } - /// Requests exemption from OEM battery optimization (MIUI, some Samsung /// configs), which can otherwise degrade the 15-minute background-check /// reliability well past its accepted ±15 min target. @@ -317,6 +152,13 @@ class _SettingsScreenState extends State { } } + String? _status; + + void _showMessage(String message) { + if (!mounted) return; + setState(() => _status = message); + } + /// True on the one platform with OEM battery optimization to exempt from. /// /// Uses [defaultTargetPlatform] rather than `Platform.isAndroid` because @@ -324,121 +166,33 @@ class _SettingsScreenState extends State { bool get _isAndroid => !kIsWeb && defaultTargetPlatform == TargetPlatform.android; - /// Stores the Firebase account in the OS keystore and proves it works. - /// - /// Signing in here rather than only saving means a typo surfaces now, on - /// the screen where it can be fixed, instead of as a silently failed - /// background sync hours later. - - /// Signs in by picking a Google account -- the one-tap path. - /// - /// A dismissed picker is not an error; a wrong-account sign-in reports why, - /// because that is the failure that otherwise looks like a working sync - /// which silently never syncs. - Future _connectGoogle() async { - setState(() { - _busy = true; - _status = 'Signing in...'; - }); - try { - final client = - await (widget.googleFirebaseFactory ?? openFirebaseWithGoogle)(); - if (!mounted) return; - if (client == null) { - setState(() { - _busy = false; - _status = 'Google sign-in was cancelled.'; - }); - return; - } - // openFirebaseWithGoogle stored the account under the email Firebase - // reported; reflect it rather than reading the (empty) form field. - // - // Read it back rather than trusting the returned client: this banner - // claimed "Connected to Firebase" on four apps that were not connected, - // because a non-null client only means the sign-in call succeeded in - // that moment, not that anything survives a restart. Reporting the - // persisted state is the whole point -- if the read-back is empty the - // next launch will sync over GitHub, and the user needs to know now. - final account = await (widget.accountLoader ?? storedAccount)(); - final configured = await (widget.sessionProbe ?? isFirebaseConfigured)(); - if (!mounted) return; - if (account != null) _firebaseEmailController.text = account.email; - setState(() { - _busy = false; - _firebaseConfigured = configured; - _status = configured - ? 'Connected to Firebase.' - : 'Signed in, but this device did not save the session - it will ' - 'sync over GitHub after a restart. Try connecting again.'; - }); - } on FirebaseAuthError catch (error) { - if (!mounted) return; - setState(() { - _busy = false; - _firebaseConfigured = false; - _status = error.message; - }); - } + Future _openSyncSettings() async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => SyncSettingsScreen( + accountLoader: widget.accountLoader ?? storedAccount, + accountSaver: widget.accountSaver ?? saveAccount, + accountClearer: widget.accountClearer ?? clearAccount, + sessionProbe: widget.sessionProbe ?? isFirebaseConfigured, + firebaseFactory: widget.firebaseFactory ?? openFirebase, + googleFirebaseFactory: + widget.googleFirebaseFactory ?? openFirebaseWithGoogle, + googleAvailable: widget.googleAvailable ?? googleSignInSupported, + ), + ), + ); } - Future _saveFirebaseAccount() async { - final email = _firebaseEmailController.text.trim(); - final password = _firebasePasswordController.text; - if (email.isEmpty || password.isEmpty) { - setState(() => _status = 'Enter the sync account email and password.'); - return; - } - setState(() { - _busy = true; - _status = null; - }); - try { - await saveAccount(FirebaseAccount(email: email, password: password)); - final client = await openFirebase(); - if (client == null) { - throw Exception('could not reach Firebase'); - } - final reachable = await client.canAccessRemote(); - client.close(); - if (!mounted) return; - setState(() { - _firebaseConfigured = reachable; - _status = reachable - ? 'Firebase connected - syncs now go there first.' - : 'Signed in, but the database refused the read.'; - }); - } on Object catch (error) { - // Broader than Exception: a missing platform binding raises an Error. - // A half-stored account is worse than none, so forget it. - await clearAccount(); - if (!mounted) return; - setState(() { - _firebaseConfigured = false; - _status = 'Firebase sign-in failed: $error'; - }); - } finally { - if (mounted) setState(() => _busy = false); - } - } - - /// Forgets the Firebase account, so sync falls back to GitHub alone. - Future _disconnectFirebase() async { - await clearAccount(); - _firebaseEmailController.clear(); - _firebasePasswordController.clear(); - if (!mounted) return; - setState(() { - _firebaseConfigured = false; - _status = 'Firebase disconnected - syncing over GitHub only.'; - }); + Future _openGitHubMirror() async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => GitHubMirrorScreen(httpClient: widget.httpClient), + ), + ); } @override Widget build(BuildContext context) { - if (_loading) { - return const Scaffold(body: Center(child: CircularProgressIndicator())); - } return Scaffold( appBar: AppBar(title: const Text('Settings')), // Centred and capped at the prose width. On the desktop surface this @@ -472,172 +226,19 @@ class _SettingsScreenState extends State { ), const SizedBox(height: 24), const Divider(), - const SizedBox(height: 8), - Text( - 'Firebase sync', - style: Theme.of(context).textTheme.titleMedium, + ListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Sync settings'), + subtitle: const Text('Firebase sync'), + trailing: const Icon(Icons.chevron_right), + onTap: () => unawaited(_openSyncSettings()), ), - const SizedBox(height: 8), - Text( - _firebaseConfigured - ? 'Connected. Syncs go to Firebase first, and still ' - 'mirror to GitHub until every device has moved.' - : 'Not connected - syncing over GitHub only. Enter the ' - 'shared sync account to move this device over. The ' - 'password is kept in the device keystore, never in ' - 'the app or the repo.', - style: Theme.of(context).textTheme.bodySmall, - ), - const SizedBox(height: 12), - // Once connected, the account is shown as read-only text rather - // than as prefilled inputs: an editable email box next to an - // empty password box reads as "you still have to enter this", - // which made a connected device look unconfigured. - if (_firebaseConfigured) - Row( - children: [ - const Icon(Icons.cloud_done, size: 20), - const SizedBox(width: 8), - Expanded( - child: Text( - _firebaseEmailController.text, - style: Theme.of(context).textTheme.bodyMedium, - ), - ), - TextButton( - onPressed: _busy ? null : _disconnectFirebase, - child: const Text('Disconnect'), - ), - ], - ) - else ...[ - TextField( - controller: _firebaseEmailController, - keyboardType: TextInputType.emailAddress, - autocorrect: false, - decoration: const InputDecoration( - labelText: 'Sync account email', - ), - ), - const SizedBox(height: 8), - TextField( - controller: _firebasePasswordController, - obscureText: true, - autocorrect: false, - decoration: const InputDecoration( - labelText: 'Sync account password', - ), - ), - const SizedBox(height: 12), - FilledButton.icon( - onPressed: _busy ? null : _saveFirebaseAccount, - icon: const Icon(Icons.cloud_done), - label: const Text('Connect Firebase'), - ), - // One tap, no typing -- the path that matters after a - // reinstall. Hidden where the platform has no programmatic - // Google flow (see google_platform.dart); a button that - // always failed would be worse than none. - if (widget.googleAvailable ?? googleSignInSupported) ...[ - const SizedBox(height: 12), - FilledButton.icon( - onPressed: _busy ? null : _connectGoogle, - icon: const Icon(Icons.account_circle), - label: const Text('Sign in with Google'), - ), - ], - ], - const SizedBox(height: 24), - const Divider(), - const SizedBox(height: 8), - // GitHub is a mirror during the cutover, not something to pick: - // its whole configuration lives under "Advanced" so the primary - // path stays a single choice. See CLAUDE.md "Cross-device sync". - ExpansionTile( - title: const Text('Advanced (GitHub mirror)'), - tilePadding: EdgeInsets.zero, - childrenPadding: const EdgeInsets.only(bottom: 8), - children: [ - Align( - alignment: Alignment.centerLeft, - child: 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( - onPressed: _connectGitHub, - icon: const Icon(Icons.login), - label: const Text('Connect GitHub'), - ), - ), - const SizedBox(height: 16), - TextField( - controller: _ownerController, - decoration: const InputDecoration( - labelText: 'GitHub owner', - ), - ), - const SizedBox(height: 8), - TextField( - controller: _repoController, - decoration: const InputDecoration(labelText: 'Repo'), - ), - const SizedBox(height: 8), - TextField( - controller: _clientIdController, - decoration: const InputDecoration( - labelText: 'OAuth App client id', - helperText: 'Needed for the Connect GitHub button', - ), - ), - const SizedBox(height: 8), - TextField( - 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, - ), - ), - ], - ), - const SizedBox(height: 16), - Wrap( - spacing: 8, - children: [ - ElevatedButton( - onPressed: _busy ? null : _save, - child: const Text('Save'), - ), - OutlinedButton( - 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'), - ), - ElevatedButton( - onPressed: _busy ? null : _syncNow, - child: const Text('Sync now'), - ), - ], + ListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Advanced sync (GitHub)'), + subtitle: const Text('Cutover mirror — not recommended'), + trailing: const Icon(Icons.chevron_right), + onTap: () => unawaited(_openGitHubMirror()), ), const SizedBox(height: 24), const Divider(), @@ -683,168 +284,3 @@ class _SettingsScreenState extends State { ); } } - -/// Dialog shown when "Connect GitHub" is tapped with no OAuth App client id -/// configured yet. Explains what it is, how to get one, and lets the user -/// paste it in directly — rather than leaving them to discover a buried -/// "Advanced" field on their own. Pops the trimmed client id, or null if -/// cancelled. -class _ClientIdSetupDialog extends StatefulWidget { - const _ClientIdSetupDialog(); - - @override - State<_ClientIdSetupDialog> createState() => _ClientIdSetupDialogState(); -} - -class _ClientIdSetupDialogState extends State<_ClientIdSetupDialog> { - final _controller = TextEditingController(); - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - 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'), - ), - ], - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () { - final id = _controller.text.trim(); - if (id.isNotEmpty) Navigator.of(context).pop(id); - }, - child: const Text('Continue'), - ), - ], - ); - } -} - -/// Dialog shown during the device flow: displays the user code, opens the -/// verification page, and polls until authorized — popping the token (or -/// null if cancelled / failed). -class _DeviceCodeDialog extends StatefulWidget { - const _DeviceCodeDialog({required this.device, required this.auth}); - - final DeviceCodeResponse device; - final GitHubDeviceAuth auth; - - @override - State<_DeviceCodeDialog> createState() => _DeviceCodeDialogState(); -} - -class _DeviceCodeDialogState extends State<_DeviceCodeDialog> { - String? _error; - - @override - void initState() { - super.initState(); - unawaited(_poll()); - } - - Future _poll() async { - try { - final token = await widget.auth.pollForToken(widget.device); - if (mounted) Navigator.of(context).pop(token); - } on Exception catch (e) { - if (mounted) setState(() => _error = '$e'); - } - } - - Future _openPage() async { - await Clipboard.setData(ClipboardData(text: widget.device.userCode)); - await launchUrl( - Uri.parse(widget.device.verificationUri), - mode: LaunchMode.externalApplication, - ); - } - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: const Text('Authorize on GitHub'), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('Enter this code on GitHub:'), - const SizedBox(height: 8), - SelectableText( - widget.device.userCode, - style: Theme.of(context).textTheme.headlineSmall, - ), - const SizedBox(height: 16), - if (_error == null) - const Row( - children: [ - SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ), - SizedBox(width: 12), - Expanded(child: Text('Waiting for authorization…')), - ], - ) - else - Text( - _error!, - style: TextStyle(color: Theme.of(context).colorScheme.error), - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - FilledButton.icon( - onPressed: _openPage, - icon: const Icon(Icons.open_in_new), - label: const Text('Open GitHub & copy code'), - ), - ], - ); - } -} diff --git a/app/pubspec.lock b/app/pubspec.lock index 7c9223f..99826b7 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -694,6 +694,15 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + sync_settings_ui: + dependency: "direct main" + description: + path: sync_settings_ui + ref: "sync_settings_ui-v0.1.0" + resolved-ref: "23c693da4e6fd6004df09829a7d75365dc086c2c" + url: "https://github.com/kuhyx/utils" + source: git + version: "0.1.0" synchronized: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 31f6afd..188356c 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -2,7 +2,7 @@ name: diet_guard_app description: "Companion phone app for diet_guard: log meals on the go." publish_to: 'none' -version: 1.0.5+1 +version: 1.0.6+1 environment: sdk: ^3.12.2 @@ -28,6 +28,16 @@ dependencies: path_provider: ^2.1.5 permission_handler: ^12.0.3 shared_preferences: ^2.5.5 + + # Shared "Sync settings" screen (Firebase + optional local backup), reused + # across every kuhy app that syncs. GitHub mirror stays app-local (see + # lib/screens/github_mirror_screen.dart) since connecting it here also + # triggers a real log sync, not just a settings save. + sync_settings_ui: + git: + url: https://github.com/kuhyx/utils + ref: sync_settings_ui-v0.1.0 + path: sync_settings_ui url_launcher: ^6.3.2 uuid: ^4.5.3 web: ^1.1.1 diff --git a/app/test/screens/github_mirror_screen_test.dart b/app/test/screens/github_mirror_screen_test.dart new file mode 100644 index 0000000..02e04ef --- /dev/null +++ b/app/test/screens/github_mirror_screen_test.dart @@ -0,0 +1,512 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:diet_guard_app/screens/github_mirror_screen.dart'; +import 'package:diet_guard_app/services/document_store_io.dart'; +import 'package:diet_guard_app/services/app_settings_service.dart'; +import 'package:diet_guard_app/services/budget_history_service.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/services/sync_health.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:url_launcher_platform_interface/link.dart'; +import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; + +import '../fake_secure_storage.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 +/// platform channel. +class _FakeUrlLauncher extends UrlLauncherPlatform + with MockPlatformInterfaceMixin { + String? launched; + + @override + final LinkDelegate? linkDelegate = null; + + @override + Future supportsMode(PreferredLaunchMode mode) async => true; + + @override + Future launchUrl(String url, LaunchOptions options) async { + launched = url; + return true; + } +} + +void main() { + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('diet_guard_github_'); + LogStorageService.resetForTesting(store: FileDocumentStore(tempDir)); + FoodBankService.resetForTesting(store: FileDocumentStore(tempDir)); + AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir)); + BudgetHistoryService.resetForTesting( + store: FileDocumentStore(tempDir), + ); + SharedPreferences.setMockInitialValues({}); + installFakeSecureStorage(); + }); + + tearDown(() async { + LogStorageService.resetForTesting(); + FoodBankService.resetForTesting(); + AppSettingsService.resetForTesting(); + BudgetHistoryService.resetForTesting(); + await tempDir.delete(recursive: true); + }); + + // GitHubMirrorScreen loads its settings via a fire-and-forget Future in + // initState that Flutter's frame scheduler does not track -- same pitfall + // as HistoryScreen/LogMealScreen. + Future settle(WidgetTester tester) async { + tester.view.physicalSize = const Size(800, 1400); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await Future.delayed(const Duration(milliseconds: 200)); + await tester.pumpAndSettle(); + } + + /// Drains the device flow's real `Future.delayed` poll (GitHubDeviceAuth + /// injects no test delay, so under `runAsync` it is a genuine Timer, not + /// the fake-clock one `tester.pump(duration)` advances) by interleaving + /// real waits with frame pumps until [done] is true or [maxTries] is hit. + Future pumpUntil( + WidgetTester tester, + bool Function() done, { + int maxTries = 200, + }) async { + for (var i = 0; i < maxTries && !done(); i++) { + await Future.delayed(const Duration(milliseconds: 10)); + await tester.pump(); + } + } + + testWidgets('shows the kuhyx/syncs defaults on a fresh install', ( + tester, + ) async { + await tester.runAsync(() async { + await tester.pumpWidget(const MaterialApp(home: GitHubMirrorScreen())); + await settle(tester); + + expect(find.widgetWithText(TextField, 'kuhyx'), findsOneWidget); + expect(find.widgetWithText(TextField, 'syncs'), findsOneWidget); + }); + }); + + testWidgets('Save persists the entered token', (tester) async { + await tester.runAsync(() async { + await tester.pumpWidget(const MaterialApp(home: GitHubMirrorScreen())); + await settle(tester); + + await tester.enterText( + find.widgetWithText(TextField, 'Personal access token (fallback)'), + 'my-pat', + ); + await tester.tap(find.widgetWithText(ElevatedButton, 'Save')); + await settle(tester); + + expect(find.text('Saved.'), findsOneWidget); + }); + }); + + testWidgets('Test connection reports success', (tester) async { + 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 settle(tester); + + expect(find.text('GitHub connection OK.'), findsOneWidget); + }); + }); + + testWidgets('Test connection reports failure', (tester) async { + final mock = MockClient((_) async => http.Response('', 403)); + 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 settle(tester); + + expect(find.text('GitHub connection failed.'), findsOneWidget); + }); + }); + + testWidgets('Sync now runs a sync tick and reports success', ( + tester, + ) async { + final mock = MockClient((req) async { + if (req.method == 'PUT') return http.Response('{}', 200); + // A bare `/repos//` GET is crdt_sync's GitHubClient + // probing whether the repo itself exists (vs. a content path just + // being unused) -- must succeed so an empty repo isn't mistaken for + // a missing one. + if (req.method == 'GET' && req.url.pathSegments.length == 3) { + return http.Response('{}', 200); + } + return http.Response('', 404); + }); + await tester.runAsync(() async { + await tester.pumpWidget( + MaterialApp(home: GitHubMirrorScreen(httpClient: mock)), + ); + await settle(tester); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Sync now')); + await settle(tester); + + expect(find.text('Synced.'), findsOneWidget); + }); + }); + + testWidgets('Sync now clears a stored failure so the banner lifts', ( + tester, + ) async { + // "Sync now" is the button a user reaches *because* the log screen's + // banner told them syncing had stopped. If a successful run here left + // the recorded failure in place, the recovery action would not dismiss + // the warning it caused, and the banner would keep accusing a device + // that is now publishing fine. + await SyncHealth.recordFailure(); + expect((await SyncHealth.read()).failureKind, SyncFailureKind.failed); + + final mock = MockClient((req) async { + if (req.method == 'PUT') return http.Response('{}', 200); + if (req.method == 'GET' && req.url.pathSegments.length == 3) { + return http.Response('{}', 200); + } + return http.Response('', 404); + }); + await tester.runAsync(() async { + await tester.pumpWidget( + MaterialApp(home: GitHubMirrorScreen(httpClient: mock)), + ); + await settle(tester); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Sync now')); + await settle(tester); + + expect(find.text('Synced.'), findsOneWidget); + final status = await SyncHealth.read(); + expect(status.failureKind, isNull); + expect(status.isStalled, isFalse); + }); + }); + + testWidgets('Test connection reports a network exception', (tester) async { + final mock = MockClient((_) async => throw const FormatException('no net')); + 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 settle(tester); + + expect(find.textContaining('GitHub connection failed:'), findsOneWidget); + }); + }); + + testWidgets('Sync now reports a GitHub error', (tester) async { + final mock = MockClient((_) async => http.Response('boom', 500)); + await tester.runAsync(() async { + await tester.pumpWidget( + MaterialApp(home: GitHubMirrorScreen(httpClient: mock)), + ); + await settle(tester); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Sync now')); + await settle(tester); + + expect(find.textContaining('Sync failed:'), findsOneWidget); + }); + }); + + /// Types [clientId] into the client-id field. + Future enterClientId(WidgetTester tester, String clientId) async { + await tester.enterText( + find.widgetWithText(TextField, 'OAuth App client id'), + clientId, + ); + } + + testWidgets('Connect GitHub without a client id opens setup guidance', ( + tester, + ) async { + await tester.runAsync(() async { + await tester.pumpWidget(const MaterialApp(home: GitHubMirrorScreen())); + await settle(tester); + await enterClientId(tester, ''); + + await tester.tap(find.text('Connect GitHub')); + await settle(tester); + + expect(find.text('One-time GitHub setup needed'), findsOneWidget); + expect(find.widgetWithText(TextField, 'Client ID'), findsOneWidget); + }); + }); + + testWidgets('cancelling the client id setup dialog aborts the connect', ( + tester, + ) async { + await tester.runAsync(() async { + await tester.pumpWidget(const MaterialApp(home: GitHubMirrorScreen())); + await settle(tester); + await enterClientId(tester, ''); + + await tester.tap(find.text('Connect GitHub')); + await settle(tester); + await tester.tap(find.text('Cancel')); + await settle(tester); + + expect(find.text('One-time GitHub setup needed'), findsNothing); + }); + }); + + testWidgets( + 'entering a client id in the setup dialog saves it and proceeds', + (tester) async { + final mock = MockClient((_) async => http.Response('nope', 422)); + await tester.runAsync(() async { + await tester.pumpWidget( + MaterialApp(home: GitHubMirrorScreen(httpClient: mock)), + ); + await settle(tester); + await enterClientId(tester, ''); + + await tester.tap(find.text('Connect GitHub')); + await settle(tester); + await tester.enterText( + find.widgetWithText(TextField, 'Client ID'), + 'cid', + ); + await tester.tap(find.text('Continue')); + await settle(tester); + + 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) + .controller! + .text, + 'cid', + ); + }); + }, + ); + + testWidgets('device flow failure to start shows a message', (tester) async { + final mock = MockClient((_) async => http.Response('nope', 422)); + await tester.runAsync(() async { + await tester.pumpWidget( + MaterialApp(home: GitHubMirrorScreen(httpClient: mock)), + ); + await settle(tester); + await enterClientId(tester, 'cid'); + + await tester.tap(find.text('Connect GitHub')); + await settle(tester); + + expect( + find.textContaining('Could not start device flow'), + findsOneWidget, + ); + }); + }); + + testWidgets('device flow happy path saves the token and syncs', ( + 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); + } + if (req.method == 'PUT') return http.Response('{}', 200); + // A bare `/repos//` GET is crdt_sync's GitHubClient + // probing whether the repo itself exists (vs. a content path just + // being unused) -- must succeed so an empty repo isn't mistaken for + // a missing one. + if (req.method == 'GET' && req.url.pathSegments.length == 3) { + return http.Response('{}', 200); + } + return http.Response('', 404); // sync's pull-side list/read calls + }); + + await tester.runAsync(() async { + await tester.pumpWidget( + MaterialApp(home: GitHubMirrorScreen(httpClient: mock)), + ); + await settle(tester); + await enterClientId(tester, 'cid'); + + await tester.tap(find.text('Connect GitHub')); + await pumpUntil( + tester, + () => find.text('WXYZ-1234').evaluate().isNotEmpty, + ); + expect(find.text('WXYZ-1234'), findsOneWidget); + + // Let the dialog poll (interval 0) and resolve the token, then the + // post-connect sync runs against the mock. + await pumpUntil( + tester, + () => find.textContaining('Connected and synced').evaluate().isNotEmpty, + ); + + expect(find.textContaining('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)), + ); + await settle(tester); + await enterClientId(tester, 'cid'); + + await tester.tap(find.text('Connect GitHub')); + await pumpUntil( + tester, + () => find.textContaining('sync failed').evaluate().isNotEmpty, + ); + + expect(find.textContaining('sync failed'), findsOneWidget); + }); + }, + ); + + testWidgets('device dialog: failed poll shows the error and Open launches', ( + tester, + ) async { + final launcher = _FakeUrlLauncher(); + UrlLauncherPlatform.instance = launcher; + + // The dialog's Open button copies the code to the clipboard first; + // there's no clipboard plugin in the test host, so stub the channel. + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async => null, + ); + addTearDown( + () => messenger.setMockMethodCallHandler(SystemChannels.platform, null), + ); + + 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, + ); + } + return http.Response( + jsonEncode({'error': 'access_denied', 'error_description': 'no'}), + 200, + ); + }); + + await tester.runAsync(() async { + await tester.pumpWidget( + MaterialApp(home: GitHubMirrorScreen(httpClient: mock)), + ); + await settle(tester); + await enterClientId(tester, 'cid'); + + await tester.tap(find.text('Connect GitHub')); + await pumpUntil( + tester, + () => find.text('WXYZ-1234').evaluate().isNotEmpty, + ); + expect(find.text('WXYZ-1234'), findsOneWidget); + + await pumpUntil( + tester, + () => find.textContaining('access_denied').evaluate().isNotEmpty, + ); + + expect(find.textContaining('access_denied'), findsOneWidget); + + await tester.tap(find.text('Open GitHub & copy code')); + await tester.pump(); + expect(launcher.launched, 'https://github.com/login/device'); + + await tester.tap(find.text('Cancel')); + await settle(tester); + }); + }); +} diff --git a/app/test/screens/settings_screen_test.dart b/app/test/screens/settings_screen_test.dart index 8b16c07..7b0e8a5 100644 --- a/app/test/screens/settings_screen_test.dart +++ b/app/test/screens/settings_screen_test.dart @@ -1,48 +1,20 @@ -import 'dart:convert'; import 'dart:io'; import 'package:crdt_sync/crdt_sync.dart'; -import 'package:diet_guard_app/services/document_store_io.dart'; import 'package:diet_guard_app/screens/settings_screen.dart'; +import 'package:diet_guard_app/services/document_store_io.dart'; import 'package:diet_guard_app/services/app_settings_service.dart'; 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/services/sync_health.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:http/http.dart' as http; -import 'package:http/testing.dart'; import 'package:permission_handler/permission_handler.dart'; -import 'package:plugin_platform_interface/plugin_platform_interface.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:url_launcher_platform_interface/link.dart'; -import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; import '../fake_secure_storage.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 -/// platform channel. -class _FakeUrlLauncher extends UrlLauncherPlatform - with MockPlatformInterfaceMixin { - String? launched; - - @override - final LinkDelegate? linkDelegate = null; - - @override - Future supportsMode(PreferredLaunchMode mode) async => true; - - @override - Future launchUrl(String url, LaunchOptions options) async { - launched = url; - return true; - } -} - void main() { late Directory tempDir; @@ -66,9 +38,11 @@ void main() { await tempDir.delete(recursive: true); }); - // SettingsScreen loads its settings via a fire-and-forget Future in - // initState that Flutter's frame scheduler does not track -- same pitfall - // as HistoryScreen/LogMealScreen. Also grows the test viewport: the + // SettingsScreen loads its settings via real (non-fake-clocked) I/O + // through FileDocumentStore, so every test body runs under + // tester.runAsync() -- without it, those Futures never get a chance to + // complete inside TestWidgetsFlutterBinding's fake-clocked zone, and + // pumpAndSettle spins forever. Also grows the test viewport: the // Notifications section pushes earlier fields/buttons below the default // 800x600 fold, making them unreachable to tester.tap otherwise. Future settle(WidgetTester tester) async { @@ -80,526 +54,144 @@ void main() { await tester.pumpAndSettle(); } - /// Expands the "Advanced (GitHub mirror)" section. - /// - /// GitHub is the cutover mirror rather than a choice the user makes, so its - /// whole configuration (owner, repo, client id, PAT, "Connect GitHub") is - /// collapsed by default. Every GitHub-facing test has to open it first. - Future openAdvanced(WidgetTester tester) async { - await tester.tap(find.text('Advanced (GitHub mirror)')); - await tester.pumpAndSettle(); - } - - /// Drains the device flow's real `Future.delayed` poll (GitHubDeviceAuth - /// injects no test delay, so under `runAsync` it is a genuine Timer, not - /// the fake-clock one `tester.pump(duration)` advances) by interleaving - /// real waits with frame pumps until [done] is true or [maxTries] is hit. - Future pumpUntil( - WidgetTester tester, - bool Function() done, { - int maxTries = 200, - }) async { - for (var i = 0; i < maxTries && !done(); i++) { - await Future.delayed(const Duration(milliseconds: 10)); - await tester.pump(); - } - } - - testWidgets('shows the kuhyx/syncs defaults on a fresh install', ( - tester, - ) async { + testWidgets('shows the current kcal goal on load', (tester) async { await tester.runAsync(() async { + await AppSettingsService.instance.saveDailyKcalGoal(1800); await tester.pumpWidget(const MaterialApp(home: SettingsScreen())); await settle(tester); - // owner/repo moved under the collapsed GitHub-mirror section. - await openAdvanced(tester); - - expect(find.widgetWithText(TextField, 'kuhyx'), findsOneWidget); - expect(find.widgetWithText(TextField, 'syncs'), findsOneWidget); + expect(find.widgetWithText(TextField, '1800'), findsOneWidget); }); }); - testWidgets('Save persists the entered token', (tester) async { + testWidgets('typing a kcal goal debounces the save', (tester) async { await tester.runAsync(() async { await tester.pumpWidget(const MaterialApp(home: SettingsScreen())); await settle(tester); - await openAdvanced(tester); await tester.enterText( - find.widgetWithText(TextField, 'Personal access token (fallback)'), - 'my-pat', + find.widgetWithText(TextField, 'Daily kcal goal'), + '2100', ); - await tester.tap(find.widgetWithText(ElevatedButton, 'Save')); - await settle(tester); + // Immediately after typing, the debounce has not fired yet. + expect(AppSettingsService.dailyKcalGoal, isNot(2100)); - expect(find.text('Saved.'), findsOneWidget); - }); - }); - - testWidgets('Test connection reports success', (tester) async { - final mock = MockClient( - (_) async => http.Response('{}', 200), - ); - await tester.runAsync(() async { - await tester.pumpWidget( - MaterialApp(home: SettingsScreen(httpClient: mock)), - ); - await settle(tester); - - await tester.tap( - find.widgetWithText(OutlinedButton, 'Test GitHub connection'), - ); - await settle(tester); - - expect(find.text('GitHub connection OK.'), findsOneWidget); - }); - }); - - testWidgets('Test connection reports failure', (tester) async { - final mock = MockClient((_) async => http.Response('', 403)); - await tester.runAsync(() async { - await tester.pumpWidget( - MaterialApp(home: SettingsScreen(httpClient: mock)), - ); - await settle(tester); - - await tester.tap( - find.widgetWithText(OutlinedButton, 'Test GitHub connection'), - ); - await settle(tester); - - expect(find.text('GitHub connection failed.'), findsOneWidget); - }); - }); - - testWidgets('Sync now runs a sync tick and reports success', ( - tester, - ) async { - final mock = MockClient((req) async { - if (req.method == 'PUT') return http.Response('{}', 200); - // A bare `/repos//` GET is crdt_sync's GitHubClient - // probing whether the repo itself exists (vs. a content path just - // being unused) -- must succeed so an empty repo isn't mistaken for - // a missing one. - if (req.method == 'GET' && req.url.pathSegments.length == 3) { - return http.Response('{}', 200); - } - return http.Response('', 404); - }); - await tester.runAsync(() async { - await tester.pumpWidget( - MaterialApp(home: SettingsScreen(httpClient: mock)), - ); - await settle(tester); - - await tester.tap(find.widgetWithText(ElevatedButton, 'Sync now')); - await settle(tester); - - expect(find.text('Synced.'), findsOneWidget); - }); - }); - - testWidgets('Sync now clears a stored failure so the banner lifts', ( - tester, - ) async { - // "Sync now" is the button a user reaches *because* the log screen's - // banner told them syncing had stopped. If a successful run here left - // the recorded failure in place, the recovery action would not dismiss - // the warning it caused, and the banner would keep accusing a device - // that is now publishing fine. - await SyncHealth.recordFailure(); - expect((await SyncHealth.read()).failureKind, SyncFailureKind.failed); - - final mock = MockClient((req) async { - if (req.method == 'PUT') return http.Response('{}', 200); - if (req.method == 'GET' && req.url.pathSegments.length == 3) { - return http.Response('{}', 200); - } - return http.Response('', 404); - }); - await tester.runAsync(() async { - await tester.pumpWidget( - MaterialApp(home: SettingsScreen(httpClient: mock)), - ); - await settle(tester); - - await tester.tap(find.widgetWithText(ElevatedButton, 'Sync now')); - await settle(tester); - - expect(find.text('Synced.'), findsOneWidget); - final status = await SyncHealth.read(); - expect(status.failureKind, isNull); - expect(status.isStalled, isFalse); - }); - }); - - testWidgets('Test connection reports a network exception', (tester) async { - final mock = MockClient((_) async => throw const FormatException('no net')); - await tester.runAsync(() async { - await tester.pumpWidget( - MaterialApp(home: SettingsScreen(httpClient: mock)), - ); - await settle(tester); - - await tester.tap( - find.widgetWithText(OutlinedButton, 'Test GitHub connection'), - ); - await settle(tester); - - expect(find.textContaining('GitHub connection failed:'), findsOneWidget); - }); - }); - - testWidgets('Sync now reports a GitHub error', (tester) async { - final mock = MockClient((_) async => http.Response('boom', 500)); - await tester.runAsync(() async { - await tester.pumpWidget( - MaterialApp(home: SettingsScreen(httpClient: mock)), - ); - await settle(tester); - - await tester.tap(find.widgetWithText(ElevatedButton, 'Sync now')); - await settle(tester); - - expect(find.textContaining('Sync failed:'), findsOneWidget); - }); - }); - - testWidgets('offers the account form when Firebase is not connected', ( - tester, - ) async { - await tester.runAsync(() async { - await tester.pumpWidget(const MaterialApp(home: SettingsScreen())); - await settle(tester); - - expect( - find.widgetWithText(TextField, 'Sync account email'), - findsOneWidget, - ); - expect( - find.widgetWithText(TextField, 'Sync account password'), - findsOneWidget, - ); - expect(find.text('Disconnect'), findsNothing); - }); - }); - - testWidgets('shows the connected account as text, not as an empty form', ( - tester, - ) async { - // An editable email box beside a blank password box reads as "you still - // have to enter this", so a connected device looked unconfigured. - await saveAccount( - const FirebaseAccount(email: 'sync@example.com', password: 'pw'), - ); - // A live session too, not just the marker: since 2026-08-11 a marker with - // no session behind it is treated as stale and cleared, because that is - // the state a revoked refresh token leaves behind and it used to report - // "Connected" while every sync failed with TOKEN_EXPIRED. - await credentialStore().save( - FirebaseCredentials( - idToken: 'id', - refreshToken: 'refresh', - expiresAt: DateTime.now().toUtc().add(const Duration(hours: 1)), - ), - ); - await tester.runAsync(() async { - await tester.pumpWidget(const MaterialApp(home: SettingsScreen())); - await settle(tester); - - expect(find.text('sync@example.com'), findsOneWidget); - expect(find.text('Disconnect'), findsOneWidget); - expect( - find.widgetWithText(TextField, 'Sync account password'), - findsNothing, - ); - expect(find.text('Connect Firebase'), findsNothing); - }); - }); - - testWidgets('hides the Connect GitHub button until Advanced is expanded', ( - tester, - ) async { - await tester.runAsync(() async { - await tester.pumpWidget(const MaterialApp(home: SettingsScreen())); - await settle(tester); - - // GitHub is the cutover mirror, not a choice competing with Firebase, - // so nothing GitHub-facing is visible on the primary path. - expect(find.text('Connect GitHub'), findsNothing); - - await openAdvanced(tester); - - expect(find.text('Connect GitHub'), findsOneWidget); - }); - }); - - /// Expands "Advanced" and types [clientId] into the client-id field. - Future enterClientId(WidgetTester tester, String clientId) async { - await tester.tap(find.text('Advanced (GitHub mirror)')); - await tester.pumpAndSettle(); - await tester.enterText( - find.widgetWithText(TextField, 'OAuth App client id'), - clientId, - ); - } - - testWidgets('Connect GitHub without a client id opens setup guidance', ( - tester, - ) async { - await tester.runAsync(() async { - await tester.pumpWidget(const MaterialApp(home: SettingsScreen())); - await settle(tester); - await enterClientId(tester, ''); - - await tester.tap(find.text('Connect GitHub')); - await settle(tester); - - expect(find.text('One-time GitHub setup needed'), findsOneWidget); - expect(find.widgetWithText(TextField, 'Client ID'), findsOneWidget); - }); - }); - - testWidgets('cancelling the client id setup dialog aborts the connect', ( - tester, - ) async { - await tester.runAsync(() async { - await tester.pumpWidget(const MaterialApp(home: SettingsScreen())); - await settle(tester); - await enterClientId(tester, ''); - - await tester.tap(find.text('Connect GitHub')); - await settle(tester); - await tester.tap(find.text('Cancel')); - await settle(tester); - - expect(find.text('One-time GitHub setup needed'), findsNothing); - }); - }); - - testWidgets( - 'entering a client id in the setup dialog saves it and proceeds', - (tester) async { - final mock = MockClient((_) async => http.Response('nope', 422)); - await tester.runAsync(() async { - await tester.pumpWidget( - MaterialApp(home: SettingsScreen(httpClient: mock)), - ); - await settle(tester); - await enterClientId(tester, ''); - - await tester.tap(find.text('Connect GitHub')); - await settle(tester); - await tester.enterText( - find.widgetWithText(TextField, 'Client ID'), - 'cid', - ); - await tester.tap(find.text('Continue')); - await settle(tester); - - 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) - .controller! - .text, - 'cid', - ); - }); - }, - ); - - testWidgets('device flow failure to start shows a message', (tester) async { - final mock = MockClient((_) async => http.Response('nope', 422)); - await tester.runAsync(() async { - await tester.pumpWidget( - MaterialApp(home: SettingsScreen(httpClient: mock)), - ); - await settle(tester); - await enterClientId(tester, 'cid'); - - await tester.tap(find.text('Connect GitHub')); - await settle(tester); - - expect( - find.textContaining('Could not start device flow'), - findsOneWidget, - ); - }); - }); - - testWidgets('device flow happy path saves the token and syncs', ( - 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); - } - if (req.method == 'PUT') return http.Response('{}', 200); - // A bare `/repos//` GET is crdt_sync's GitHubClient - // probing whether the repo itself exists (vs. a content path just - // being unused) -- must succeed so an empty repo isn't mistaken for - // a missing one. - if (req.method == 'GET' && req.url.pathSegments.length == 3) { - return http.Response('{}', 200); - } - return http.Response('', 404); // sync's pull-side list/read calls - }); - - await tester.runAsync(() async { - await tester.pumpWidget( - MaterialApp(home: SettingsScreen(httpClient: mock)), - ); - await settle(tester); - await enterClientId(tester, 'cid'); - - await tester.tap(find.text('Connect GitHub')); - await pumpUntil( - tester, - () => find.text('WXYZ-1234').evaluate().isNotEmpty, - ); - expect(find.text('WXYZ-1234'), findsOneWidget); - - // Let the dialog poll (interval 0) and resolve the token, then the - // post-connect sync runs against the mock. - await pumpUntil( - tester, - () => find.textContaining('Connected and synced').evaluate().isNotEmpty, - ); - - expect(find.textContaining('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: SettingsScreen(httpClient: mock)), - ); - await settle(tester); - await enterClientId(tester, 'cid'); - - await tester.tap(find.text('Connect GitHub')); - await pumpUntil( - tester, - () => find.textContaining('sync failed').evaluate().isNotEmpty, - ); - - expect(find.textContaining('sync failed'), findsOneWidget); - }); - }, - ); - - testWidgets('device dialog: failed poll shows the error and Open launches', ( - tester, - ) async { - final launcher = _FakeUrlLauncher(); - UrlLauncherPlatform.instance = launcher; - - // The dialog's Open button copies the code to the clipboard first; - // there's no clipboard plugin in the test host, so stub the channel. - final messenger = - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; - messenger.setMockMethodCallHandler( - SystemChannels.platform, - (call) async => null, - ); - addTearDown( - () => messenger.setMockMethodCallHandler(SystemChannels.platform, null), - ); - - 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, - ); - } - return http.Response( - jsonEncode({'error': 'access_denied', 'error_description': 'no'}), - 200, - ); - }); - - await tester.runAsync(() async { - await tester.pumpWidget( - MaterialApp(home: SettingsScreen(httpClient: mock)), - ); - await settle(tester); - await enterClientId(tester, 'cid'); - - await tester.tap(find.text('Connect GitHub')); - await pumpUntil( - tester, - () => find.text('WXYZ-1234').evaluate().isNotEmpty, - ); - expect(find.text('WXYZ-1234'), findsOneWidget); - - await pumpUntil( - tester, - () => find.textContaining('access_denied').evaluate().isNotEmpty, - ); - - expect(find.textContaining('access_denied'), findsOneWidget); - - await tester.tap(find.text('Open GitHub & copy code')); + await Future.delayed(const Duration(milliseconds: 700)); await tester.pump(); - expect(launcher.launched, 'https://github.com/login/device'); - - await tester.tap(find.text('Cancel')); - await settle(tester); + expect(AppSettingsService.dailyKcalGoal, 2100); }); }); + testWidgets('an invalid kcal goal is not saved', (tester) async { + await tester.runAsync(() async { + final initial = AppSettingsService.dailyKcalGoal; + await tester.pumpWidget(const MaterialApp(home: SettingsScreen())); + await settle(tester); + + await tester.enterText( + find.widgetWithText(TextField, 'Daily kcal goal'), + '0', + ); + await Future.delayed(const Duration(milliseconds: 700)); + await tester.pump(); + + expect(AppSettingsService.dailyKcalGoal, initial); + }); + }); + + testWidgets('disposing mid-debounce still flushes the pending goal', ( + tester, + ) async { + await tester.runAsync(() async { + await tester.pumpWidget(const MaterialApp(home: SettingsScreen())); + await settle(tester); + + await tester.enterText( + find.widgetWithText(TextField, '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 + // real pending Future -- give it a moment to finish before tearDown + // deletes the temp dir out from under it. + await tester.pumpWidget(const MaterialApp(home: SizedBox())); + await tester.pump(); + await Future.delayed(const Duration(milliseconds: 50)); + + expect(AppSettingsService.dailyKcalGoal, 1950); + }); + }); + + testWidgets('tapping Sync settings opens the shared SyncSettingsScreen', ( + tester, + ) async { + await tester.runAsync(() async { + await tester.pumpWidget( + MaterialApp( + home: SettingsScreen( + accountLoader: () async => null, + sessionProbe: () async => false, + googleAvailable: false, + ), + ), + ); + await settle(tester); + + await tester.tap(find.text('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); + + await tester.tap(find.text('Sync settings')); + await settle(tester); + + expect(find.text('sync@example.com'), findsOneWidget); + expect(find.text('Disconnect'), findsOneWidget); + }); + }, + ); + + testWidgets( + 'tapping Advanced sync (GitHub) opens the local GitHubMirrorScreen', + (tester) async { + await tester.runAsync(() async { + await tester.pumpWidget(const MaterialApp(home: SettingsScreen())); + await settle(tester); + + await tester.tap(find.text('Advanced sync (GitHub)')); + await settle(tester); + + expect(find.text('Advanced sync (GitHub)'), findsWidgets); + expect(find.text('Connect GitHub'), findsOneWidget); + }); + }, + ); + testWidgets('battery exemption button reports a granted status', ( tester, ) async {