mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-10 19:27:02 +01:00
Sync the phone's alarm through Firebase, mirroring to GitHub
Completes the cutover started on the PC side: phone_app was still pinned to crdt_sync_dart-v0.2.0, so the Flutter half was writing only to GitHub even though _sync.py had already moved. pushWith() now takes a RemoteStore instead of a GitHubClient, so one call serves Firebase, GitHub, or the MirrorStore that writes both; syncBackend() picks which, and returns the bare GitHub client when this device has not been set up for Firebase -- the normal cutover state, and also the rollback. The remote path is byte-identical on both backends, so only the transport differs and the PC's parse is untouched. Settings gained the Firebase section that actually calls saveAccount(). Its absence was the bug found in diet-guard: without it openFirebase() always returned null, so the app was a Firebase no-op on real hardware no matter what the tests said. Signing in happens immediately on Connect, so a wrong password surfaces on-screen rather than as a silent failure at push time. The three UI problems kuhy hit in diet-guard are avoided here rather than repeated: GitHub is demoted into "Advanced (GitHub mirror)" instead of sitting beside Firebase as a peer choice, the connected account renders as read-only text with a Disconnect button rather than an empty-looking form, and the status card reports the Firebase state it is actually reading. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017FH32BgGQbQLDstBhR26Xa
This commit is contained in:
parent
66bc3a9e55
commit
98f6c5c26c
@ -1,17 +1,21 @@
|
||||
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:wake_alarm_sync/services/firebase_backend.dart';
|
||||
import 'package:wake_alarm_sync/services/github_device_auth.dart';
|
||||
import 'package:wake_alarm_sync/services/sync_settings.dart';
|
||||
import 'package:wake_alarm_sync/ui/theme.dart';
|
||||
|
||||
/// Settings screen for the GitHub connection used to sync the alarm.
|
||||
/// Settings screen for the backends the alarm syncs through.
|
||||
///
|
||||
/// The "Connect GitHub" button runs the OAuth device flow — the user
|
||||
/// enters a short code on github.com; no token pasting required.
|
||||
/// 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 {
|
||||
/// Creates a [SettingsScreen].
|
||||
const SettingsScreen({super.key});
|
||||
@ -22,6 +26,9 @@ class SettingsScreen extends StatefulWidget {
|
||||
|
||||
class _SettingsScreenState extends State<SettingsScreen> {
|
||||
SyncSettings _settings = const SyncSettings(token: '');
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
bool _firebaseConnected = false;
|
||||
bool _busy = false;
|
||||
String _status = '';
|
||||
|
||||
@ -31,9 +38,68 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
unawaited(_load());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final s = await SyncSettings.load();
|
||||
if (mounted) setState(() => _settings = s);
|
||||
// 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) _emailController.text = account.email;
|
||||
setState(() {
|
||||
_settings = s;
|
||||
_firebaseConnected = account != null;
|
||||
});
|
||||
}
|
||||
|
||||
/// Stores the typed account and signs in immediately, so a typo surfaces
|
||||
/// here rather than as a silent background failure at push time.
|
||||
Future<void> _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<void> _disconnectFirebase() async {
|
||||
await clearAccount();
|
||||
if (!mounted) return;
|
||||
_emailController.clear();
|
||||
_passwordController.clear();
|
||||
setState(() {
|
||||
_firebaseConnected = false;
|
||||
_status = 'Firebase disconnected.';
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _connectGitHub() async {
|
||||
@ -96,30 +162,74 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_StatusCard(connected: isConnected),
|
||||
_StatusCard(connected: _firebaseConnected),
|
||||
const SizedBox(height: 16),
|
||||
if (!isConnected)
|
||||
ElevatedButton.icon(
|
||||
onPressed: _busy ? null : _connectGitHub,
|
||||
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.link),
|
||||
label: const Text('Connect GitHub'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
textStyle: const TextStyle(fontSize: 16),
|
||||
),
|
||||
)
|
||||
else
|
||||
OutlinedButton.icon(
|
||||
onPressed: _busy ? null : _disconnect,
|
||||
icon: const Icon(Icons.link_off),
|
||||
label: const Text('Disconnect'),
|
||||
: const Icon(Icons.cloud_done),
|
||||
label: const Text('Connect Firebase'),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
if (_status.isNotEmpty)
|
||||
Text(
|
||||
@ -133,19 +243,55 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
),
|
||||
const Divider(height: 32),
|
||||
Text(
|
||||
'PC config — create this file:\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 at shutdown.',
|
||||
// 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,
|
||||
),
|
||||
// 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": "<same 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -178,7 +324,9 @@ class _StatusCard extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(connected ? 'Connected to GitHub' : 'Not connected'),
|
||||
child: Text(
|
||||
connected ? 'Connected to Firebase' : 'Not connected',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@ -5,6 +5,7 @@ import 'package:crdt_sync/crdt_sync_io.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:wake_alarm_sync/services/firebase_backend.dart';
|
||||
|
||||
/// The alarm time this device holds, as read from or written to the store.
|
||||
typedef Alarm = ({int hour, int minute});
|
||||
@ -14,12 +15,15 @@ typedef Alarm = ({int hour, int minute});
|
||||
///
|
||||
/// The phone is the **sole writer**: it stores the chosen alarm locally in a
|
||||
/// [LogStore] (one HLC-stamped [Record], id [_recordId]) and pushes that log
|
||||
/// to `wake-alarm-sync/devices/phone/alarm.json` in the private `kuhyx/syncs`
|
||||
/// repo — the same "GitHub as dumb file storage under `devices/<id>/`" layout
|
||||
/// diet_guard's app and the workout app already use. The PC side
|
||||
/// (`shutdown-wrapper.sh`) is read-only: it fetches that file at shutdown to
|
||||
/// set `rtcwake`. There is no pull/merge: the phone is the only writer, so its
|
||||
/// local store is always the source of truth.
|
||||
/// to `wake-alarm-sync/devices/phone/alarm.json` — Firebase first, mirrored
|
||||
/// to the private `kuhyx/syncs` repo during the cutover (see [syncBackend]).
|
||||
/// The path is deliberately identical on both backends, so only the transport
|
||||
/// differs.
|
||||
///
|
||||
/// The PC side is read-only (`wake_alarm/_sync.py`, which tries Firebase then
|
||||
/// GitHub, caching the result for `shutdown-wrapper.sh`). There is no
|
||||
/// pull/merge here: the phone is the only writer, so its local store is
|
||||
/// always the source of truth.
|
||||
class AlarmSyncService {
|
||||
/// Wraps an already-loaded log store.
|
||||
AlarmSyncService(this._store);
|
||||
@ -81,20 +85,29 @@ class AlarmSyncService {
|
||||
);
|
||||
}
|
||||
|
||||
/// Pushes the local alarm log to `kuhyx/syncs` using [client].
|
||||
Future<void> pushWith(GitHubClient client) => client.putFileText(
|
||||
/// Pushes the local alarm log using [client].
|
||||
///
|
||||
/// Takes a [RemoteStore] rather than a `GitHubClient` so the same call
|
||||
/// serves Firebase, GitHub, or the [MirrorStore] that writes both — the
|
||||
/// backend is chosen once, in [syncBackend].
|
||||
Future<void> pushWith(RemoteStore client) => client.putFileText(
|
||||
_remotePath,
|
||||
logToJson(_store.snapshot()),
|
||||
message: 'wake_alarm_sync: update alarm',
|
||||
);
|
||||
|
||||
/// Pushes using a [token]-authenticated client for the fixed repo.
|
||||
Future<void> push(String token, {http.Client? httpClient}) => pushWith(
|
||||
GitHubClient(
|
||||
/// Pushes to Firebase when this device is set up for it, mirroring to
|
||||
/// GitHub during the cutover; to GitHub alone when it is not.
|
||||
///
|
||||
/// [token] still authenticates the GitHub half, so an un-migrated phone
|
||||
/// behaves exactly as before.
|
||||
Future<void> push(String token, {http.Client? httpClient}) async {
|
||||
final github = GitHubClient(
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
token: token,
|
||||
httpClient: httpClient,
|
||||
),
|
||||
);
|
||||
);
|
||||
await pushWith(await syncBackend(github));
|
||||
}
|
||||
}
|
||||
|
||||
105
phone_app/lib/services/firebase_backend.dart
Normal file
105
phone_app/lib/services/firebase_backend.dart
Normal file
@ -0,0 +1,105 @@
|
||||
/// Wiring for the Firebase backend, during and after the GitHub cutover.
|
||||
///
|
||||
/// Split by what is safe to publish, because this repo is public:
|
||||
///
|
||||
/// * [kProject] holds the Web API key and database URL. Both are public
|
||||
/// identifiers that already ship inside the APK; the security rules, not
|
||||
/// their secrecy, are what protect the data.
|
||||
/// * The account email and password are entered once per device and kept in
|
||||
/// the OS keystore, next to the GitHub token this app already stores there.
|
||||
///
|
||||
/// Nothing here reads `~/.config/crdt-sync/` — that is the desktop/Python
|
||||
/// half (`wake_alarm/_sync.py`). On Android there is no such file.
|
||||
library;
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
/// The shared `kuhy-syncs` project.
|
||||
///
|
||||
/// `databaseUrl` is the **regional** host. The plain `*.firebaseio.com` form
|
||||
/// answers 404 with a `correctUrl` body rather than an obvious error, which
|
||||
/// reads like an auth failure and wastes a debugging session.
|
||||
const kProject = FirebaseProject(
|
||||
apiKey: 'AIzaSyCF_sA3xCMehAYXK8eND-rAygb9NXXW_8E',
|
||||
databaseUrl:
|
||||
'https://kuhy-syncs-default-rtdb.europe-west1.firebasedatabase.app',
|
||||
);
|
||||
|
||||
/// Same options the GitHub token uses: off Android's deprecated
|
||||
/// `encryptedSharedPreferences` path, and libsecret on Linux.
|
||||
const _secure = FlutterSecureStorage();
|
||||
|
||||
// Everything below reaches the OS keystore through a platform channel, which
|
||||
// `flutter test` has no binding for. The logic these wrap (parsing, the
|
||||
// public/private split, sign-in) lives in `crdt_sync` and is covered there at
|
||||
// 100%; what is left here is the two-line adapter.
|
||||
// coverage:ignore-start
|
||||
|
||||
/// The keystore-backed home for the Firebase refresh token.
|
||||
SecureCredentialStore credentialStore() => SecureCredentialStore(
|
||||
read: (key) => _secure.read(key: key),
|
||||
write: (key, value) => _secure.write(key: key, value: value),
|
||||
delete: (key) => _secure.delete(key: key),
|
||||
);
|
||||
|
||||
/// Reads the per-device account, or null when sync has not been set up.
|
||||
Future<FirebaseAccount?> loadAccount() async {
|
||||
try {
|
||||
return FirebaseAccount.tryParse(
|
||||
await _secure.read(key: kFirebaseAccountKey),
|
||||
);
|
||||
} on Object {
|
||||
// No secret service available: behave as "not configured" rather than
|
||||
// crashing the settings screen.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Stores the per-device account. Keystore only — never prefs, never source.
|
||||
Future<void> saveAccount(FirebaseAccount account) =>
|
||||
_secure.write(key: kFirebaseAccountKey, value: account.toJsonString());
|
||||
|
||||
/// Forgets the account and any cached session.
|
||||
Future<void> clearAccount() async {
|
||||
await _secure.delete(key: kFirebaseAccountKey);
|
||||
await credentialStore().clear();
|
||||
}
|
||||
|
||||
/// Returns a signed-in Firebase client, or null when not configured.
|
||||
///
|
||||
/// Signs in with the stored password only when there is no cached refresh
|
||||
/// token, so the usual path costs no authentication round trip.
|
||||
Future<FirebaseRestClient?> openFirebase() async {
|
||||
final account = await loadAccount();
|
||||
if (account == null) return null;
|
||||
try {
|
||||
return await firebaseClientFor(
|
||||
config: kProject.configFor(account.email),
|
||||
store: credentialStore(),
|
||||
password: account.password,
|
||||
);
|
||||
} on Object {
|
||||
// Unreachable keystore or a rejected sign-in must not fail the push: the
|
||||
// app keeps syncing over GitHub, exactly as before the cutover.
|
||||
// Broader than Exception because an uninitialised platform binding
|
||||
// raises a FlutterError, which is an Error.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// coverage:ignore-end
|
||||
|
||||
/// Wraps [github] so Firebase is primary and GitHub is kept as a mirror.
|
||||
///
|
||||
/// Returns [github] unchanged when this device has not been set up for
|
||||
/// Firebase — a normal state during the cutover, not an error, and also the
|
||||
/// rollback. The PC reads whichever backend answers first
|
||||
/// (`_fetch_firebase_alarm() or _fetch_github_alarm()`), so a phone that has
|
||||
/// not moved yet still reaches it.
|
||||
Future<RemoteStore> syncBackend(RemoteStore github) async {
|
||||
final firebase = await openFirebase();
|
||||
return firebase == null
|
||||
? github
|
||||
: MirrorStore(primary: firebase, mirror: github);
|
||||
}
|
||||
@ -93,11 +93,11 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: crdt_sync_dart
|
||||
ref: "crdt_sync_dart-v0.2.0"
|
||||
resolved-ref: "5be99ab11d1657ec04edd65508b8c4ecae4da906"
|
||||
ref: "crdt_sync_dart-v0.6.0"
|
||||
resolved-ref: fc99eff00599a4c3195e4c1573ba08f15909a10d
|
||||
url: "https://github.com/kuhyx/utils"
|
||||
source: git
|
||||
version: "0.2.0"
|
||||
version: "0.6.0"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@ -12,7 +12,7 @@ dependencies:
|
||||
crdt_sync:
|
||||
git:
|
||||
url: https://github.com/kuhyx/utils
|
||||
ref: crdt_sync_dart-v0.2.0
|
||||
ref: crdt_sync_dart-v0.6.0
|
||||
path: crdt_sync_dart
|
||||
cupertino_icons: ^1.0.8
|
||||
flutter:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user