utils/sync_settings_ui/lib/src/firebase_sync_controller.dart
Krzysztof kuhy Rudnicki 23c693da4e Add sync_settings_ui: shared Firebase-only sync settings screen
Extracts the near-identical "Sync settings" UI duplicated across 5 apps
(todo, diet_guard_app, workout_app, wake_alarm/phone_app, home_inventory)
into a single tested package. Firebase sync (primary) + optional local
backup slot; GitHub mirror UI was dropped per kuhy's explicit decision
mid-build in favor of Firebase-only, so existing apps keep their local
GitHub code untouched, just disconnected from this shared screen.

100% line coverage, flutter analyze clean under very_good_analysis.
Generalized check_no_crdt_sync_override.sh to guard any utils git
dependency (crdt_sync, sync_settings_ui), not just crdt_sync by name.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KdSp6Nbrs9xvsWkCMDN9a1
2026-08-12 23:10:17 +02:00

206 lines
8.0 KiB
Dart

import 'package:crdt_sync/crdt_sync.dart';
/// Outcome of a Firebase connect attempt, distinguishing why it did or did
/// not end connected -- the UI needs different copy for each case, and
/// collapsing them lost that distinction in the app-local copies this
/// generalizes.
enum FirebaseConnectOutcome {
/// Signed in and the session is confirmed stored.
connected,
/// The client rejected the given account (bad password / wrong account).
rejected,
/// The user dismissed the Google picker. Not an error.
cancelled,
/// Google signed in, but this device did not end up with a stored
/// session -- the client came back non-null yet [FirebaseSyncController]
/// re-reads state rather than trusting that, and found nothing durable.
signedInButNotPersisted,
/// Google succeeded but resolved to a uid the security rules do not pin
/// (wrong account entirely).
wrongAccount,
/// Any other failure (network, unreachable keystore, etc).
failed,
}
/// Result of a [FirebaseSyncController.connectWithPassword] or
/// [FirebaseSyncController.connectWithGoogle] call.
class FirebaseConnectResult {
/// Creates a [FirebaseConnectResult].
const FirebaseConnectResult({
required this.outcome,
this.email,
this.message,
});
/// What happened.
final FirebaseConnectOutcome outcome;
/// The connected account's email, when known.
final String? email;
/// Human-readable detail for [FirebaseConnectOutcome.failed] and
/// [FirebaseConnectOutcome.wrongAccount].
final String? message;
}
/// Sequences Firebase connect/disconnect/status-probe over injected
/// closures, generalized from the near-identical `_connectFirebase` /
/// `_connectGoogle` / `_disconnectFirebase` / `_load` logic duplicated
/// across every app's settings screen.
///
/// Deliberately holds no keystore or `google_sign_in` reference itself --
/// every constructor parameter is the same closure each app's
/// `SettingsScreen` already built around its own `firebase_backend.dart` /
/// `google_sign_in_backend.dart`. That keeps this class pure Dart and fully
/// unit-testable with fakes, with the actual platform-channel adapters
/// staying in the app where they were already `// coverage:ignore`d for
/// being unreachable from `flutter test`.
class FirebaseSyncController {
/// Creates a [FirebaseSyncController] over the app's injected Firebase
/// wiring. See each field's doc for what it does; [googleFirebaseFactory]
/// is optional because Google sign-in itself is optional per
/// app/platform (see `googleAvailable` on `SyncSettingsScreen`) --
/// [connectWithGoogle] must not be called when it is null.
FirebaseSyncController({
required this.accountLoader,
required this.accountSaver,
required this.accountClearer,
required this.sessionProbe,
required this.firebaseFactory,
this.googleFirebaseFactory,
});
/// Reads the stored Firebase account, or null when sync has not been set
/// up on this device.
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.
///
/// 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.
final Future<FirebaseRestClient?> Function() firebaseFactory;
/// Builds the Firebase backend via Google sign-in. Optional because
/// Google sign-in itself is optional per app/platform (see
/// `googleAvailable` on `SyncSettingsScreen`); [connectWithGoogle] must
/// not be called when this is null.
final Future<FirebaseRestClient?> Function()? googleFirebaseFactory;
/// Whether [connectWithGoogle] can be called.
bool get supportsGoogle => googleFirebaseFactory != null;
/// Reads whether this device currently reports a connected Firebase
/// session, and the account email if any is stored.
///
/// Deliberately trusts the session probe (the stored session), never a
/// client's mere non-null return value, for "connected": the invariant
/// every app's `firebase_backend.dart` documents as
/// "read back the stored session, don't trust the returned client" --
/// a session probe built on `hasSession()` treats a live refresh token as
/// connected even with no account marker beside it, while a marker with no
/// session behind it (a revoked token) is the stale half and must read as
/// *not* connected. Trusting a freshly-returned client instead is exactly
/// what let a signed-in-but-not-actually-persisted device claim
/// "Connected" and then sync over the GitHub mirror and 401 forever.
Future<({bool connected, String? email})> loadStatus() async {
final account = await accountLoader();
final connected = await sessionProbe();
return (connected: connected, email: account?.email);
}
/// Stores [email]/[password] and signs in immediately, so a typo surfaces
/// here instead of as a silent background failure on the next sync tick.
Future<FirebaseConnectResult> connectWithPassword({
required String email,
required String password,
}) async {
await accountSaver(FirebaseAccount(email: email, password: password));
final client = await firebaseFactory();
if (client == null) {
await accountClearer();
return const FirebaseConnectResult(
outcome: FirebaseConnectOutcome.rejected,
);
}
return FirebaseConnectResult(
outcome: FirebaseConnectOutcome.connected,
email: email,
);
}
/// Signs in by picking a Google account -- the one-tap path. Callers must
/// check [supportsGoogle] first.
///
/// Distinguishes outcomes the same way the app-local copies did: a
/// dismissed picker is not an error; a wrong-account sign-in reports why,
/// because that failure would otherwise look like a working sync that
/// never actually syncs; anything else not caught by [FirebaseAuthError]
/// is a plain failure. Reports the persisted state via the session probe
/// after the call, not the fact that a client came back -- see
/// [loadStatus]'s doc for why that distinction matters.
Future<FirebaseConnectResult> connectWithGoogle() async {
final googleFactory = googleFirebaseFactory;
if (googleFactory == null) {
throw StateError(
'connectWithGoogle() called without a googleFirebaseFactory; '
'check supportsGoogle first.',
);
}
try {
final client = await googleFactory();
if (client == null) {
return const FirebaseConnectResult(
outcome: FirebaseConnectOutcome.cancelled,
);
}
final account = await accountLoader();
final connected = await sessionProbe();
if (!connected) {
return FirebaseConnectResult(
outcome: FirebaseConnectOutcome.signedInButNotPersisted,
email: account?.email,
);
}
return FirebaseConnectResult(
outcome: FirebaseConnectOutcome.connected,
email: account?.email,
);
} on FirebaseAuthError catch (error) {
return FirebaseConnectResult(
outcome: FirebaseConnectOutcome.wrongAccount,
message: error.message,
);
// Broader than Exception on purpose, matching every app-local copy:
// a missing platform binding raises a FlutterError, which is an
// Error, not an Exception -- letting it escape left the button
// disabled and the screen stuck on "Signing in..." forever on the
// phone.
} on Object catch (error) {
return FirebaseConnectResult(
outcome: FirebaseConnectOutcome.failed,
message: '$error',
);
}
}
/// Forgets the account and any cached session.
Future<void> disconnect() => accountClearer();
}