mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-11 13:47:38 +01:00
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KdSp6Nbrs9xvsWkCMDN9a1
This commit is contained in:
parent
546041584f
commit
45471546f7
481
app/lib/screens/github_mirror_screen.dart
Normal file
481
app/lib/screens/github_mirror_screen.dart
Normal file
@ -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<GitHubMirrorScreen> createState() => _GitHubMirrorScreenState();
|
||||
}
|
||||
|
||||
class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
|
||||
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<void> _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<void> _connectGitHub() async {
|
||||
var clientId = _clientIdController.text.trim();
|
||||
if (clientId.isEmpty) {
|
||||
final entered = await showDialog<String>(
|
||||
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<String>(
|
||||
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<void> _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<void> _save() async {
|
||||
setState(() => _busy = true);
|
||||
await _currentSettings().save();
|
||||
if (!mounted) return;
|
||||
setState(() => _busy = false);
|
||||
_showMessage('Saved.');
|
||||
}
|
||||
|
||||
Future<void> _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<void> _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<void> _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<void> _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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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<PermissionStatus> 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<FirebaseRestClient?> 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<FirebaseAccount?> Function()? accountLoader;
|
||||
|
||||
/// Persists the account. See [accountLoader].
|
||||
final Future<void> Function(FirebaseAccount)? accountSaver;
|
||||
|
||||
/// Forgets the account and any cached session. See [accountLoader].
|
||||
final Future<void> 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<bool> Function()? sessionProbe;
|
||||
|
||||
/// Builds the Firebase backend from the stored account. Injected so tests
|
||||
/// can supply a fake.
|
||||
final Future<FirebaseRestClient?> Function()? firebaseFactory;
|
||||
|
||||
@override
|
||||
State<SettingsScreen> createState() => _SettingsScreenState();
|
||||
}
|
||||
|
||||
class _SettingsScreenState extends State<SettingsScreen> {
|
||||
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<void> _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<SettingsScreen> {
|
||||
// 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<SettingsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
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<void> _connectGitHub() async {
|
||||
var clientId = _clientIdController.text.trim();
|
||||
if (clientId.isEmpty) {
|
||||
final entered = await showDialog<String>(
|
||||
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<String>(
|
||||
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<void> _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<void> _save() async {
|
||||
setState(() => _busy = true);
|
||||
await _currentSettings().save();
|
||||
if (!mounted) return;
|
||||
setState(() => _busy = false);
|
||||
_showMessage('Saved.');
|
||||
}
|
||||
|
||||
Future<void> _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<void> _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<SettingsScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
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<SettingsScreen> {
|
||||
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<void> _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<void> _openSyncSettings() async {
|
||||
await Navigator.of(context).push<void>(
|
||||
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<void> _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<void> _disconnectFirebase() async {
|
||||
await clearAccount();
|
||||
_firebaseEmailController.clear();
|
||||
_firebasePasswordController.clear();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_firebaseConfigured = false;
|
||||
_status = 'Firebase disconnected - syncing over GitHub only.';
|
||||
});
|
||||
Future<void> _openGitHubMirror() async {
|
||||
await Navigator.of(context).push<void>(
|
||||
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<SettingsScreen> {
|
||||
),
|
||||
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<SettingsScreen> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<void> _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<void> _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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
512
app/test/screens/github_mirror_screen_test.dart
Normal file
512
app/test/screens/github_mirror_screen_test.dart
Normal file
@ -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<bool> supportsMode(PreferredLaunchMode mode) async => true;
|
||||
|
||||
@override
|
||||
Future<bool> 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<void> 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<void>.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<void> pumpUntil(
|
||||
WidgetTester tester,
|
||||
bool Function() done, {
|
||||
int maxTries = 200,
|
||||
}) async {
|
||||
for (var i = 0; i < maxTries && !done(); i++) {
|
||||
await Future<void>.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/<owner>/<repo>` 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<void> 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/<owner>/<repo>` 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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<bool> supportsMode(PreferredLaunchMode mode) async => true;
|
||||
|
||||
@override
|
||||
Future<bool> 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<void> 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<void> 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<void> pumpUntil(
|
||||
WidgetTester tester,
|
||||
bool Function() done, {
|
||||
int maxTries = 200,
|
||||
}) async {
|
||||
for (var i = 0; i < maxTries && !done(); i++) {
|
||||
await Future<void>.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/<owner>/<repo>` 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<void> 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/<owner>/<repo>` 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<void>.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<void>.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<void>.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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user