mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-11 12:27:42 +01:00
The largest consumer moves last on purpose: diet_guard was 71% of all sync traffic, and both its surfaces (the PC timer and the phone app) share one prefix, so they move together. Firebase becomes the primary backend when it is set up, with GitHub kept as a mirror so a device that has not moved yet still converges. An unconfigured device keeps syncing exactly as before, which is also the rollback. Revision tracking is new here. The PC hand-rolled its own pull-merge-push instead of calling sync_log, so it had none: every tick re-downloaded every peer's whole food log -- hundreds of KB, 96 times a day -- and re-pushed its own regardless of change. It now reads one small revision map first, skips any peer whose hash is unchanged, and suppresses a no-op push. Every sub-sync (log, budget, food bank, manual bank) takes the client as a parameter, so one swap covers all four. The app could not have reached Firebase at all: nothing called saveAccount(), so loadAccount() always returned null and openFirebase() always returned null. Settings now has a Firebase section that stores the account in the OS keystore and signs in immediately, so a typo surfaces on the screen where it can be fixed rather than as a silently failed background sync hours later. The email and password never touch the repo -- it is public. Platform reaches now degrade instead of failing: an unavailable keystore or application-support directory costs one tick of extra traffic, where before it would have taken sync down entirely. Caught by `on Object` rather than `on Exception`, because an uninitialised binding raises an Error. Verified end to end against the real database, not just in tests: a live PC sync merged 57 days / 339 entries and put 221 KB of food log plus 97 food-bank records into Firebase; a second unchanged tick issued zero pushes; and the Dart client signed in and read back all 339 meals and 97 foods, then completed a push / publish-revision / suppress-no-op round trip under a throwaway prefix. diet_guard: 618 tests at 100% branch coverage. diet_guard_app: 477 tests. Two assertions moved from 4 pushes to 5 -- the extra one is this device's revision, published after the log so a peer can never cache "seen rev X" against a log it never received. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SJ9sxEw3ymzXNjL7SHNpJc
718 lines
26 KiB
Dart
718 lines
26 KiB
Dart
/// GitHub sync configuration. Primary path: "Connect GitHub" runs the OAuth
|
|
/// **device flow** (authorize in a browser, no token pasting). A manually
|
|
/// pasted PAT remains as a fallback under "Advanced". 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.
|
|
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/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/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';
|
|
|
|
/// Screen for configuring and triggering cross-device sync.
|
|
class SettingsScreen extends StatefulWidget {
|
|
/// Creates a [SettingsScreen].
|
|
const SettingsScreen({
|
|
super.key,
|
|
this.httpClient,
|
|
this.requestBatteryExemption,
|
|
});
|
|
|
|
/// Injectable HTTP client; tests pass a [MockClient].
|
|
final http.Client? httpClient;
|
|
|
|
/// Injectable battery-optimization-exemption request; tests pass a fake.
|
|
/// Production defaults to
|
|
/// `Permission.ignoreBatteryOptimizations.request()`.
|
|
final Future<PermissionStatus> Function()? requestBatteryExemption;
|
|
|
|
@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();
|
|
if (!mounted) return;
|
|
if (account != null) _firebaseEmailController.text = account.email;
|
|
setState(() {
|
|
_firebaseConfigured = account != null;
|
|
_loading = false;
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_kcalGoalDebounce?.cancel();
|
|
// Leaving the screen inside the debounce window must not silently drop
|
|
// 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;
|
|
|
|
/// Persists the pending goal, if any, and clears it.
|
|
void _flushKcalGoal() {
|
|
final goal = _pendingKcalGoal;
|
|
if (goal == null) return;
|
|
_pendingKcalGoal = null;
|
|
unawaited(AppSettingsService.instance.saveDailyKcalGoal(goal));
|
|
}
|
|
|
|
/// Saves the typed goal once typing settles.
|
|
///
|
|
/// Debounced because every keystroke would otherwise be a real edit:
|
|
/// typing "2000" saved 2, 20, 200, 2000 in turn, and a sync tick landing
|
|
/// between keystrokes would push a nonsense budget to the other device.
|
|
void _onKcalGoalChanged(String value) {
|
|
_kcalGoalDebounce?.cancel();
|
|
final goal = int.tryParse(value);
|
|
if (goal == null || goal <= 0) return;
|
|
_pendingKcalGoal = goal;
|
|
_kcalGoalDebounce = Timer(
|
|
const Duration(milliseconds: 600),
|
|
_flushKcalGoal,
|
|
);
|
|
}
|
|
|
|
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));
|
|
_showMessage('Connected and synced.');
|
|
} on Exception catch (e) {
|
|
_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 ? 'Connection OK.' : 'Connection failed.');
|
|
} on Exception catch (e) {
|
|
_showMessage('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));
|
|
_showMessage('Synced.');
|
|
} on Exception catch (e) {
|
|
_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.
|
|
Future<void> _requestBatteryExemption() async {
|
|
final request =
|
|
widget.requestBatteryExemption ??
|
|
() => Permission.ignoreBatteryOptimizations.request();
|
|
try {
|
|
final status = await request();
|
|
_showMessage(
|
|
status.isGranted
|
|
? 'Battery optimization exemption granted.'
|
|
: 'Exemption not granted -- notifications may be delayed.',
|
|
);
|
|
} on Exception catch (e) {
|
|
_showMessage('Could not request exemption: $e');
|
|
}
|
|
}
|
|
|
|
/// True on the one platform with OEM battery optimization to exempt from.
|
|
///
|
|
/// Uses [defaultTargetPlatform] rather than `Platform.isAndroid` because
|
|
/// `dart:io` is a throwing stub in the desktop web build.
|
|
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.
|
|
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.';
|
|
});
|
|
}
|
|
|
|
@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
|
|
// ListView spanned the full ~1366px window, so the explanatory
|
|
// paragraphs below ran ~180 characters per line -- roughly twice the
|
|
// readable limit (tokens.md rule 21) -- and every field stretched with
|
|
// them. Invisible on the phone, where the window is already narrow.
|
|
body: Center(
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: AppWidth.prose),
|
|
child: ListView(
|
|
padding: const EdgeInsets.all(16),
|
|
children: [
|
|
Text('Nutrition', style: Theme.of(context).textTheme.titleMedium),
|
|
const SizedBox(height: 8),
|
|
// A four-digit number: capped so the field does not read as a
|
|
// layout error by spanning the whole column.
|
|
ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: AppWidth.field),
|
|
child: TextField(
|
|
controller: _kcalGoalController,
|
|
keyboardType: TextInputType.number,
|
|
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
|
decoration: const InputDecoration(
|
|
labelText: 'Daily kcal goal',
|
|
helperText: 'Shown in the history day summary',
|
|
suffixText: 'kcal',
|
|
),
|
|
onChanged: _onKcalGoalChanged,
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
const Divider(),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'Firebase sync',
|
|
style: Theme.of(context).textTheme.titleMedium,
|
|
),
|
|
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),
|
|
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),
|
|
Row(
|
|
children: [
|
|
FilledButton.icon(
|
|
onPressed: _busy ? null : _saveFirebaseAccount,
|
|
icon: const Icon(Icons.cloud_done),
|
|
label: const Text('Connect Firebase'),
|
|
),
|
|
if (_firebaseConfigured) ...[
|
|
const SizedBox(width: 8),
|
|
TextButton(
|
|
onPressed: _busy ? null : _disconnectFirebase,
|
|
child: const Text('Disconnect'),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
const SizedBox(height: 24),
|
|
const Divider(),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'Authorize in your browser — no token to paste. Syncs to '
|
|
'kuhyx/syncs by default.',
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
),
|
|
const SizedBox(height: 12),
|
|
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),
|
|
ExpansionTile(
|
|
title: const Text('Advanced'),
|
|
tilePadding: EdgeInsets.zero,
|
|
childrenPadding: const EdgeInsets.only(bottom: 8),
|
|
children: [
|
|
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,
|
|
child: const Text('Test connection'),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: _busy ? null : _syncNow,
|
|
child: const Text('Sync now'),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 24),
|
|
const Divider(),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'Notifications',
|
|
style: Theme.of(context).textTheme.titleMedium,
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
_isAndroid
|
|
? 'A background check nags you every ~15 min if a '
|
|
'meal slot is overdue. Aggressive OEM battery '
|
|
'optimization (MIUI, some Samsung configs) can '
|
|
'delay this well past 15 min -- request an '
|
|
'exemption for reliable nagging.'
|
|
: 'A check runs every 5 min while this window is open and '
|
|
'notifies you about an overdue meal slot. A browser '
|
|
'cannot run anything once the window is closed -- on '
|
|
'the PC the real backstop is the diet_guard gate, '
|
|
'which locks the screen instead.',
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
),
|
|
// Battery-optimization exemption is Android-only, and
|
|
// `permission_handler` has no web implementation at all;
|
|
// the row would throw rather than degrade in the browser.
|
|
if (_isAndroid) ...[
|
|
const SizedBox(height: 12),
|
|
OutlinedButton.icon(
|
|
onPressed: _requestBatteryExemption,
|
|
icon: const Icon(Icons.battery_alert),
|
|
label: const Text('Disable battery optimization'),
|
|
),
|
|
],
|
|
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
|
|
/// "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'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|