wake-alarm/phone_app/lib/screens/github_mirror_screen.dart
Krzysztof kuhy Rudnicki 90a7dafb48 phone_app: migrate Sync settings to the shared sync_settings_ui package
Settings screen becomes a thin dispatcher with two links: "Sync settings"
(the shared sync_settings_ui package, Firebase-only) and "Advanced sync
(GitHub)" (app-local, moved verbatim -- the shared package carries no
GitHub surface).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KdSp6Nbrs9xvsWkCMDN9a1
2026-08-14 11:29:32 +02:00

241 lines
7.0 KiB
Dart

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<GitHubMirrorScreen> createState() => _GitHubMirrorScreenState();
}
class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
SyncSettings _settings = const SyncSettings(token: '');
bool _busy = false;
String _status = '';
@override
void initState() {
super.initState();
unawaited(_load());
}
Future<void> _load() async {
final s = await SyncSettings.load();
if (!mounted) return;
setState(() => _settings = s);
}
Future<void> _connectGitHub() async {
final auth = GitHubDeviceAuth(clientId: SyncSettings.defaultClientId);
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.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<void> _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": "<same 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<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'),
),
],
);
}
}