diff --git a/phone_app/lib/screens/github_mirror_screen.dart b/phone_app/lib/screens/github_mirror_screen.dart new file mode 100644 index 0000000..54f5583 --- /dev/null +++ b/phone_app/lib/screens/github_mirror_screen.dart @@ -0,0 +1,240 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import 'package:wake_alarm_sync/services/github_device_auth.dart'; +import 'package:wake_alarm_sync/services/sync_settings.dart'; + +/// The GitHub mirror screen: cutover-only sync transport, not a peer of +/// Firebase. +/// +/// Kept app-local rather than folded into the shared `sync_settings_ui` +/// package since that package carries no GitHub surface at all. Logic moved +/// verbatim from the old combined settings screen. +class GitHubMirrorScreen extends StatefulWidget { + /// Creates a [GitHubMirrorScreen]. + const GitHubMirrorScreen({super.key}); + + @override + State createState() => _GitHubMirrorScreenState(); +} + +class _GitHubMirrorScreenState extends State { + SyncSettings _settings = const SyncSettings(token: ''); + bool _busy = false; + String _status = ''; + + @override + void initState() { + super.initState(); + unawaited(_load()); + } + + Future _load() async { + final s = await SyncSettings.load(); + if (!mounted) return; + setState(() => _settings = s); + } + + Future _connectGitHub() async { + final auth = GitHubDeviceAuth(clientId: SyncSettings.defaultClientId); + try { + final device = await auth.requestDeviceCode(); + if (!mounted) return; + final token = await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => _DeviceCodeDialog(device: device, auth: auth), + ); + if (token == null || token.isEmpty) return; + + setState(() { + _busy = true; + _status = 'Connecting…'; + }); + final updated = SyncSettings(token: token); + await updated.save(); + if (mounted) { + setState(() { + _settings = updated; + _busy = false; + _status = 'Connected! Alarm will sync to kuhyx/syncs.'; + }); + } + } on Exception catch (e) { + if (mounted) { + setState(() { + _busy = false; + _status = 'Error: $e'; + }); + } + } finally { + auth.close(); + } + } + + Future _disconnect() async { + await SyncSettings.clear(); + if (mounted) { + setState(() { + _settings = const SyncSettings(token: ''); + _status = 'Disconnected.'; + }); + } + } + + @override + Widget build(BuildContext context) { + final isConnected = _settings.isConfigured; + return Scaffold( + appBar: AppBar(title: const Text('Advanced sync (GitHub)')), + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Align( + alignment: Alignment.centerLeft, + child: Text( + isConnected + ? 'Connected to GitHub.' + : 'Not connected to GitHub.', + style: TextStyle( + fontSize: 16, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + const SizedBox(height: 12), + if (!isConnected) + ElevatedButton.icon( + onPressed: _busy ? null : _connectGitHub, + icon: const Icon(Icons.link), + label: const Text('Connect GitHub'), + ) + else + OutlinedButton.icon( + onPressed: _busy ? null : _disconnect, + icon: const Icon(Icons.link_off), + label: const Text('Disconnect GitHub'), + ), + const SizedBox(height: 16), + Text( + 'PC config — the PC reads Firebase first and falls back ' + 'to GitHub. For the GitHub fallback, create:\n' + '~/.config/wake_alarm/sync.json\n\n' + '{ "token": "" }\n\n' + 'The token needs "repo" scope so the PC can read ' + 'kuhyx/syncs.', + style: TextStyle( + fontSize: 16, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + if (_status.isNotEmpty) ...[ + const SizedBox(height: 16), + Text( + _status, + style: TextStyle( + color: _status.startsWith('Error') + ? Theme.of(context).colorScheme.error + : null, + ), + ), + ], + ], + ), + ), + ), + ); + } +} + +/// Dialog shown while waiting for the user to authorize on GitHub. +class _DeviceCodeDialog extends StatefulWidget { + const _DeviceCodeDialog({required this.device, required this.auth}); + + final DeviceCodeResponse device; + final GitHubDeviceAuth auth; + + @override + State<_DeviceCodeDialog> createState() => _DeviceCodeDialogState(); +} + +class _DeviceCodeDialogState extends State<_DeviceCodeDialog> { + String? _error; + + @override + void initState() { + super.initState(); + unawaited(_poll()); + } + + Future _poll() async { + try { + final token = await widget.auth.pollForToken(widget.device); + if (mounted) Navigator.of(context).pop(token); + } on Exception catch (e) { + if (mounted) setState(() => _error = '$e'); + } + } + + Future _openPage() async { + await Clipboard.setData(ClipboardData(text: widget.device.userCode)); + await launchUrl( + Uri.parse(widget.device.verificationUri), + mode: LaunchMode.externalApplication, + ); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Authorize on GitHub'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Enter this code on GitHub:'), + const SizedBox(height: 8), + SelectableText( + widget.device.userCode, + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 16), + if (_error == null) + const Row( + children: [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + SizedBox(width: 12), + Expanded(child: Text('Waiting for authorization…')), + ], + ) + else + Text( + _error!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton.icon( + onPressed: _openPage, + icon: const Icon(Icons.open_in_new), + label: const Text('Open GitHub & copy code'), + ), + ], + ); + } +} diff --git a/phone_app/lib/screens/settings_screen.dart b/phone_app/lib/screens/settings_screen.dart index 9da395b..627f9bf 100644 --- a/phone_app/lib/screens/settings_screen.dart +++ b/phone_app/lib/screens/settings_screen.dart @@ -1,527 +1,56 @@ -import 'dart:async'; - -import 'package:crdt_sync/crdt_sync.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:url_launcher/url_launcher.dart'; +import 'package:sync_settings_ui/sync_settings_ui.dart'; +import 'package:wake_alarm_sync/screens/github_mirror_screen.dart'; import 'package:wake_alarm_sync/services/firebase_backend.dart'; -import 'package:wake_alarm_sync/services/github_device_auth.dart'; import 'package:wake_alarm_sync/services/google_sign_in_backend.dart'; -import 'package:wake_alarm_sync/services/sync_settings.dart'; -import 'package:wake_alarm_sync/ui/theme.dart'; -/// Settings screen for the backends the alarm syncs through. +/// Settings screen: links to the two sync surfaces. /// -/// Firebase is the primary path: sign in once with the shared sync account, -/// whose password is kept in the OS keystore. GitHub is only the cutover -/// mirror, so its device-flow connection sits under "Advanced" rather than -/// competing with Firebase as a visible choice. -class SettingsScreen extends StatefulWidget { +/// "Sync settings" is the shared `sync_settings_ui` package (Firebase, +/// identical in shape to every other kuhy app's Sync settings screen). +/// "Advanced sync (GitHub)" stays app-local ([GitHubMirrorScreen]). +class SettingsScreen extends StatelessWidget { /// Creates a [SettingsScreen]. - const SettingsScreen({ - super.key, - this.googleFirebaseFactory, - this.googleAvailable, - this.accountLoader, - this.sessionProbe, - }); - - /// Builds the Firebase backend via Google sign-in. Injected so tests need - /// no platform channel -- the plugin reaches the OS account picker. - final Future Function()? googleFirebaseFactory; - - /// Whether to offer the Google button. Defaults to what the platform - /// supports; injected by tests, whose host reports unsupported. - final bool? googleAvailable; - - /// Reads the stored Firebase account. Injected for the same reason as - /// [googleFirebaseFactory]: the keystore is a platform channel. - final Future Function()? accountLoader; - - /// Whether a Firebase session is stored. See [accountLoader]. - /// - /// Separate from [accountLoader] because the two answer different - /// questions: the account marker is bookkeeping, the session is the - /// credential. A device can hold the second without the first, and - /// reporting only the first is what made a syncing phone read as - /// "not connected". - final Future Function()? sessionProbe; - - @override - State createState() => _SettingsScreenState(); -} - -class _SettingsScreenState extends State { - SyncSettings _settings = const SyncSettings(token: ''); - final _emailController = TextEditingController(); - final _passwordController = TextEditingController(); - bool _firebaseConnected = false; - bool _busy = false; - String _status = ''; - - @override - void initState() { - super.initState(); - unawaited(_load()); - } - - @override - void dispose() { - _emailController.dispose(); - _passwordController.dispose(); - super.dispose(); - } - - Future _load() async { - final s = await SyncSettings.load(); - // 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 connected = await (widget.sessionProbe ?? isFirebaseConfigured)(); - if (!mounted) return; - if (account != null) _emailController.text = account.email; - setState(() { - _settings = s; - _firebaseConnected = connected; - }); - } - - /// Stores the typed account and signs in immediately, so a typo surfaces - /// here rather than as a silent background failure at push time. - - /// Signs in by picking a Google account -- the one-tap path. - /// - /// A dismissed picker is not an error; a wrong-account sign-in reports why, - /// because that is the failure that otherwise looks like a working sync - /// which silently never syncs. - Future _connectGoogle() async { - setState(() { - _busy = true; - _status = 'Signing in...'; - }); - try { - final client = - await (widget.googleFirebaseFactory ?? openFirebaseWithGoogle)(); - if (!mounted) return; - if (client == null) { - setState(() { - _busy = false; - _status = 'Google sign-in was cancelled.'; - }); - return; - } - // openFirebaseWithGoogle stored the account under the email Firebase - // reported; reflect it rather than reading the (empty) form field. - final account = await (widget.accountLoader ?? storedAccount)(); - // Report the persisted state, not the fact that the call returned a - // client: a non-null client only means sign-in succeeded in that - // moment, which is how four apps claimed "Connected" and then synced - // over GitHub after the next restart. - final connected = await (widget.sessionProbe ?? isFirebaseConfigured)(); - if (!mounted) return; - if (account != null) _emailController.text = account.email; - setState(() { - _busy = false; - _firebaseConnected = connected; - _status = connected - ? '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; - _firebaseConnected = false; - _status = error.message; - }); - } on Object catch (error) { - // Broader than Exception on purpose: a missing platform binding raises - // an Error, and anything escaping here leaves the button disabled and - // the screen stuck on "Signing in..." forever -- which is exactly what - // happened on the phone before storedAccount() replaced loadAccount(). - if (!mounted) return; - setState(() { - _busy = false; - _firebaseConnected = false; - _status = 'Google sign-in failed: $error'; - }); - } - } - - Future _connectFirebase() async { - final email = _emailController.text.trim(); - final password = _passwordController.text; - if (email.isEmpty || password.isEmpty) { - setState(() => _status = 'Enter the sync account email and password.'); - return; - } - setState(() { - _busy = true; - _status = 'Signing in…'; - }); - await saveAccount(FirebaseAccount(email: email, password: password)); - final client = await openFirebase(); - if (!mounted) return; - if (client == null) { - await clearAccount(); - setState(() { - _busy = false; - _firebaseConnected = false; - _status = 'Error: Firebase rejected that account.'; - }); - return; - } - _passwordController.clear(); - setState(() { - _busy = false; - _firebaseConnected = true; - _status = 'Connected to Firebase.'; - }); - } - - Future _disconnectFirebase() async { - await clearAccount(); - if (!mounted) return; - _emailController.clear(); - _passwordController.clear(); - setState(() { - _firebaseConnected = false; - _status = 'Firebase disconnected.'; - }); - } - - Future _connectGitHub() async { - final auth = GitHubDeviceAuth(clientId: SyncSettings.defaultClientId); - try { - final device = await auth.requestDeviceCode(); - if (!mounted) return; - final token = await showDialog( - context: context, - barrierDismissible: false, - builder: (_) => _DeviceCodeDialog(device: device, auth: auth), - ); - if (token == null || token.isEmpty) return; - - setState(() { - _busy = true; - _status = 'Connecting…'; - }); - final updated = SyncSettings(token: token); - await updated.save(); - if (mounted) { - setState(() { - _settings = updated; - _busy = false; - _status = 'Connected! Alarm will sync to kuhyx/syncs.'; - }); - } - } on Exception catch (e) { - if (mounted) { - setState(() { - _busy = false; - _status = 'Error: $e'; - }); - } - } finally { - auth.close(); - } - } - - Future _disconnect() async { - await SyncSettings.clear(); - if (mounted) { - setState(() { - _settings = const SyncSettings(token: ''); - _status = 'Disconnected.'; - }); - } - } + const SettingsScreen({super.key}); @override Widget build(BuildContext context) { - final isConnected = _settings.isConfigured; return Scaffold( appBar: AppBar(title: const Text('Settings')), - // Scrollable: this screen carries a five-line explanatory paragraph and - // an unbounded `'Error: \$e'`, inside a Column that could not scroll. - body: SafeArea( - child: SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _StatusCard(connected: _firebaseConnected), - const SizedBox(height: 16), - Text( - 'Firebase sync', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 8), - Text( - _firebaseConnected - ? 'Connected. The alarm goes to Firebase first, and still ' - 'mirrors 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: TextStyle( - fontSize: 16, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 12), - // Once connected the account is shown as read-only text: an - // editable email box beside an empty password box reads as - // "you still have to enter this", making a connected device - // look unconfigured. - if (_firebaseConnected) - Row( - children: [ - const Icon(Icons.cloud_done, size: 20), - const SizedBox(width: 8), - Expanded(child: Text(_emailController.text)), - TextButton( - onPressed: _busy ? null : _disconnectFirebase, - child: const Text('Disconnect'), - ), - ], - ) - else ...[ - TextField( - controller: _emailController, - keyboardType: TextInputType.emailAddress, - autocorrect: false, - decoration: const InputDecoration( - labelText: 'Sync account email', - ), - ), - const SizedBox(height: 8), - TextField( - controller: _passwordController, - obscureText: true, - autocorrect: false, - decoration: const InputDecoration( - labelText: 'Sync account password', - ), - ), - const SizedBox(height: 12), - FilledButton.icon( - onPressed: _busy ? null : _connectFirebase, - icon: _busy - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : 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: 16), - if (_status.isNotEmpty) - Text( - _status, - style: TextStyle( - color: _status.contains('Error') - ? Theme.of(context).colorScheme.error - : Theme.of( - context, - ).extension()!.success, - ), - ), - const Divider(height: 32), - // GitHub is the cutover mirror, not a choice: everything about - // it lives here so the primary path stays a single decision. - ExpansionTile( - title: const Text('Advanced (GitHub mirror)'), - tilePadding: EdgeInsets.zero, - childrenPadding: const EdgeInsets.only(bottom: 8), - children: [ - Align( - alignment: Alignment.centerLeft, - child: Text( - isConnected - ? 'Connected to GitHub.' - : 'Not connected to GitHub.', - style: TextStyle( - fontSize: 16, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - const SizedBox(height: 12), - if (!isConnected) - ElevatedButton.icon( - onPressed: _busy ? null : _connectGitHub, - icon: const Icon(Icons.link), - label: const Text('Connect GitHub'), - ) - else - OutlinedButton.icon( - onPressed: _busy ? null : _disconnect, - icon: const Icon(Icons.link_off), - label: const Text('Disconnect GitHub'), - ), - const SizedBox(height: 16), - Text( - 'PC config — the PC reads Firebase first and falls back ' - 'to GitHub. For the GitHub fallback, create:\n' - '~/.config/wake_alarm/sync.json\n\n' - '{ "token": "" }\n\n' - 'The token needs "repo" scope so the PC can read ' - 'kuhyx/syncs.', - // Was 12px/Colors.grey — below the 16px reading floor - // (rule 20) and off-palette (rule 4); this is real setup - // instructions, not decorative chrome. - style: TextStyle( - fontSize: 16, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ], - ), - ), - ), - ); - } -} - -class _StatusCard extends StatelessWidget { - const _StatusCard({required this.connected}); - - final bool connected; - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final success = Theme.of(context).extension()!.success; - return Card( - child: Padding( + body: ListView( padding: const EdgeInsets.all(16), - child: Row( - children: [ - Icon( - connected ? Icons.check_circle : Icons.cancel, - // Dimmed relative to the adjacent label text (rule 28). - color: (connected ? success : colorScheme.error).withValues( - alpha: 0.8, - ), - ), - const SizedBox(width: 8), - Expanded( - child: Text( - connected ? 'Connected to Firebase' : 'Not connected', - ), - ), - ], - ), - ), - ); - } -} - -/// Dialog shown while waiting for the user to authorize on GitHub. -class _DeviceCodeDialog extends StatefulWidget { - const _DeviceCodeDialog({required this.device, required this.auth}); - - final DeviceCodeResponse device; - final GitHubDeviceAuth auth; - - @override - State<_DeviceCodeDialog> createState() => _DeviceCodeDialogState(); -} - -class _DeviceCodeDialogState extends State<_DeviceCodeDialog> { - String? _error; - - @override - void initState() { - super.initState(); - unawaited(_poll()); - } - - Future _poll() async { - try { - final token = await widget.auth.pollForToken(widget.device); - if (mounted) Navigator.of(context).pop(token); - } on Exception catch (e) { - if (mounted) setState(() => _error = '$e'); - } - } - - Future _openPage() async { - await Clipboard.setData(ClipboardData(text: widget.device.userCode)); - await launchUrl( - Uri.parse(widget.device.verificationUri), - mode: LaunchMode.externalApplication, - ); - } - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: const Text('Authorize on GitHub'), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('Enter this code on GitHub:'), - const SizedBox(height: 8), - SelectableText( - widget.device.userCode, - style: Theme.of(context).textTheme.headlineSmall, - ), - const SizedBox(height: 16), - if (_error == null) - const Row( - children: [ - SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), + ListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Sync settings'), + subtitle: const Text('Firebase sync'), + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => SyncSettingsScreen( + accountLoader: loadAccount, + accountSaver: saveAccount, + accountClearer: clearAccount, + sessionProbe: isFirebaseConfigured, + firebaseFactory: openFirebase, + googleFirebaseFactory: openFirebaseWithGoogle, + googleAvailable: googleSignInSupported, ), - SizedBox(width: 12), - Expanded(child: Text('Waiting for authorization…')), - ], - ) - else - Text( - _error!, - style: TextStyle(color: Theme.of(context).colorScheme.error), + ), ), + ), + ListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Advanced sync (GitHub)'), + subtitle: const Text('Cutover mirror — not recommended'), + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const GitHubMirrorScreen()), + ), + ), ], ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - FilledButton.icon( - onPressed: _openPage, - icon: const Icon(Icons.open_in_new), - label: const Text('Open GitHub & copy code'), - ), - ], ); } } diff --git a/phone_app/pubspec.lock b/phone_app/pubspec.lock index f602371..42904c2 100644 --- a/phone_app/pubspec.lock +++ b/phone_app/pubspec.lock @@ -598,6 +598,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" term_glyph: dependency: transitive description: diff --git a/phone_app/pubspec.yaml b/phone_app/pubspec.yaml index 3a4842d..db0ba32 100644 --- a/phone_app/pubspec.yaml +++ b/phone_app/pubspec.yaml @@ -26,6 +26,15 @@ dependencies: path: ^1.9.0 path_provider: ^2.1.0 shared_preferences: ^2.3.0 + + # 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). + 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.0 uuid: ^4.6.0 diff --git a/phone_app/test/settings_screen_test.dart b/phone_app/test/settings_screen_test.dart new file mode 100644 index 0000000..2ec5017 --- /dev/null +++ b/phone_app/test/settings_screen_test.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:wake_alarm_sync/screens/github_mirror_screen.dart'; +import 'package:wake_alarm_sync/screens/settings_screen.dart'; + +void main() { + testWidgets('shows the two sync links and navigates to each', ( + tester, + ) async { + await tester.pumpWidget( + const MaterialApp(home: SettingsScreen()), + ); + + expect(find.text('Sync settings'), findsOneWidget); + expect(find.text('Advanced sync (GitHub)'), findsOneWidget); + + await tester.tap(find.text('Advanced sync (GitHub)')); + await tester.pumpAndSettle(); + + expect(find.byType(GitHubMirrorScreen), findsOneWidget); + }); +}