diff --git a/.github/workflows/sync-settings-ui-tests.yml b/.github/workflows/sync-settings-ui-tests.yml new file mode 100644 index 0000000..be44f65 --- /dev/null +++ b/.github/workflows/sync-settings-ui-tests.yml @@ -0,0 +1,36 @@ +name: sync_settings_ui tests + +on: + push: + branches: [main] + paths: ['sync_settings_ui/**'] + pull_request: + branches: [main] + paths: ['sync_settings_ui/**'] + +jobs: + test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: sync_settings_ui + + steps: + - uses: actions/checkout@v4 + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + + - name: Install dependencies + run: flutter pub get + + - name: Analyze + run: flutter analyze + + - name: Run tests with coverage + run: flutter test --coverage + + - name: Enforce 100% line coverage + run: scripts/check_coverage.sh coverage/lcov.info 100 diff --git a/README.md b/README.md index 218b448..9063578 100644 --- a/README.md +++ b/README.md @@ -18,10 +18,30 @@ dependencies: crdt_sync: git: url: https://github.com/kuhyx/utils - ref: crdt_sync_dart-v0.2.0 + ref: crdt_sync_dart-v0.10.0 path: crdt_sync_dart ``` +## sync_settings_ui/ + +Shared Flutter "Sync settings" screen (pub package name: `sync_settings_ui`, +not published to pub.dev). Firebase sync (primary) plus an optional local +backup slot — no GitHub mirror UI. Pure UI + a `FirebaseSyncController` +service layer over injected closures (keystore/Google-plugin access stays +in each consuming app, exactly as before this package existed). Consumed by +`todo`, `diet_guard_app`, `workout_app`, `wake_alarm/phone_app`, and +`home_inventory`'s dedicated "Sync settings" route. + +Consume via: +```yaml +dependencies: + sync_settings_ui: + git: + url: https://github.com/kuhyx/utils + ref: sync_settings_ui-v0.1.0 + path: sync_settings_ui +``` + ## crdt-sync/ Python CRDT sync library (package: `crdt_sync`, not published to PyPI). Same diff --git a/scripts/check_no_crdt_sync_override.sh b/scripts/check_no_crdt_sync_override.sh index 967e6c8..df506b5 100755 --- a/scripts/check_no_crdt_sync_override.sh +++ b/scripts/check_no_crdt_sync_override.sh @@ -1,40 +1,45 @@ #!/bin/bash # ============================================================================ -# Fail if a pubspec still overrides crdt_sync with a local path. +# Fail if a pubspec still overrides a git-dependency utils package with a +# local path. # -# The apps depend on crdt_sync as a *git dependency pinned to a tag*, so what -# CI builds is whatever that tag points at. A `dependency_overrides:` entry -# pointing at ~/utils/crdt_sync_dart is invaluable while developing an unreleased -# library change -- and catastrophic if it survives the commit: local tests go -# green against code CI has never seen, and the failure is silent in both -# directions. +# The apps depend on packages in this repo (crdt_sync, sync_settings_ui) as +# a *git dependency pinned to a tag*, so what CI builds is whatever that tag +# points at. A `dependency_overrides:` entry pointing at a local checkout is +# invaluable while developing an unreleased library change -- and +# catastrophic if it survives the commit: local tests go green against code +# CI has never seen, and the failure is silent in both directions. # # A comment in the pubspec asking someone to remember is deployment hygiene, # not a fix. This is the gate. # -# Usage: check_no_crdt_sync_override.sh [pubspec.yaml ...] -# With no arguments, checks ./pubspec.yaml. +# Usage: check_no_crdt_sync_override.sh [-p package_name] [pubspec.yaml ...] +# -p package_name Check this package instead of the default set. May be +# repeated. Default: crdt_sync sync_settings_ui. +# With no pubspec.yaml arguments, checks ./pubspec.yaml. # ============================================================================ set -euo pipefail SCRIPT_NAME="$(basename "$0")" readonly SCRIPT_NAME +DEFAULT_PACKAGES=(crdt_sync sync_settings_ui) usage() { - echo "Usage: $SCRIPT_NAME [pubspec.yaml ...]" - echo " Fails when a pubspec overrides crdt_sync with a local path." + echo "Usage: $SCRIPT_NAME [-p package_name ...] [pubspec.yaml ...]" + echo " Fails when a pubspec overrides a utils git dependency with a local path." + echo " Default packages checked: ${DEFAULT_PACKAGES[*]}" exit 0 } -# Returns 0 (found) when the file has a crdt_sync entry under +# Returns 0 (found) when the file has a $package entry under # dependency_overrides. Deliberately a small state machine rather than a grep: # `path:` appears under plenty of other keys, and a false positive here blocks # a legitimate commit. has_override() { - local file="$1" - awk ' + local file="$1" package="$2" + awk -v package="$package" ' # Track indent depth explicitly. An earlier version used an awk # interval expression ({1,4}) to spot a sibling key, which plain awk # does not enable by default -- so the check silently never fired and @@ -47,23 +52,42 @@ has_override() { /^[[:space:]]*#/ { next } # comments /^[[:space:]]*$/ { next } # blank lines /^dependency_overrides:[[:space:]]*$/ { in_overrides = 1; next } - /^[^[:space:]]/ { in_overrides = 0; in_crdt = 0; next } + /^[^[:space:]]/ { in_overrides = 0; in_pkg = 0; next } in_overrides { - if ($0 ~ /^[[:space:]]+crdt_sync:[[:space:]]*$/) { - in_crdt = 1 - crdt_indent = indent($0) + if ($0 ~ ("^[[:space:]]+" package ":[[:space:]]*$")) { + in_pkg = 1 + pkg_indent = indent($0) next } - # Any key at or above crdt_sync{s} own indent ends its block. - if (in_crdt && indent($0) <= crdt_indent) { in_crdt = 0 } - if (in_crdt && $0 ~ /^[[:space:]]+path:/) { found = 1 } + # Any key at or above the package own indent ends its block. + if (in_pkg && indent($0) <= pkg_indent) { in_pkg = 0 } + if (in_pkg && $0 ~ /^[[:space:]]+path:/) { found = 1 } } END { exit(found ? 0 : 1) } ' "$file" } main() { - local -a files=("$@") + local -a packages=() files=() + while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) + usage + ;; + -p) + packages+=("$2") + shift 2 + ;; + *) + files+=("$1") + shift + ;; + esac + done + + if [[ ${#packages[@]} -eq 0 ]]; then + packages=("${DEFAULT_PACKAGES[@]}") + fi if [[ ${#files[@]} -eq 0 ]]; then files=("pubspec.yaml") fi @@ -71,18 +95,16 @@ main() { local failed=0 for file in "${files[@]}"; do [[ -f "$file" ]] || continue - if has_override "$file"; then - echo "Error: $file still has a local crdt_sync dependency_override." >&2 - echo " Remove it and bump the git 'ref:' to the released tag" >&2 - echo " before committing, or CI will build different code." >&2 - failed=1 - fi + for package in "${packages[@]}"; do + if has_override "$file" "$package"; then + echo "Error: $file still has a local $package dependency_override." >&2 + echo " Remove it and bump the git 'ref:' to the released tag" >&2 + echo " before committing, or CI will build different code." >&2 + failed=1 + fi + done done return "$failed" } -if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then - usage -fi - main "$@" diff --git a/sync_settings_ui/.gitignore b/sync_settings_ui/.gitignore new file mode 100644 index 0000000..08e2419 --- /dev/null +++ b/sync_settings_ui/.gitignore @@ -0,0 +1,6 @@ +.dart_tool/ +.packages +build/ +pubspec.lock +coverage/ +*.iml diff --git a/sync_settings_ui/.pre-commit-config.yaml b/sync_settings_ui/.pre-commit-config.yaml new file mode 100644 index 0000000..fea87f1 --- /dev/null +++ b/sync_settings_ui/.pre-commit-config.yaml @@ -0,0 +1,61 @@ +# Pre-commit Configuration for sync_settings_ui +# Install: pre-commit install && pre-commit install --hook-type pre-push +# Run: pre-commit run --all-files + +# NOTE: this subproject lives inside the ~/utils monorepo, whose git root is +# one level up. pre-commit always runs hooks from the git root, so every path +# below is written relative to THAT, not to this directory, and the top-level +# `files` key keeps these hooks off the sibling subprojects. CI invokes this +# config exactly the same way: +# pre-commit run --config sync_settings_ui/.pre-commit-config.yaml --all-files +files: ^sync_settings_ui/ + +default_stages: [pre-commit] +fail_fast: false + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + args: [--markdown-linebreak-ext=md] + - id: end-of-file-fixer + - id: check-yaml + - id: check-merge-conflict + - id: mixed-line-ending + args: [--fix=lf] + + - repo: local + hooks: + - id: subproject-scope + name: subproject scope regex still matches + entry: scripts/check_subproject_scope.sh sync_settings_ui + language: script + # always_run so the top-level `files` filter cannot skip the one hook + # whose job is to notice that filter has stopped matching anything. + always_run: true + pass_filenames: false + - id: no-crdt-sync-override + name: no local dependency_overrides on a utils git dependency + entry: scripts/check_no_crdt_sync_override.sh sync_settings_ui/pubspec.yaml + language: script + pass_filenames: false + - id: dart-format + name: dart format --set-exit-if-changed + entry: bash -c 'cd sync_settings_ui && dart format --set-exit-if-changed lib test' + language: system + pass_filenames: false + - id: flutter-analyze + name: flutter analyze + entry: bash -c 'cd sync_settings_ui && flutter analyze' + language: system + pass_filenames: false + - id: flutter-test-coverage + name: flutter test --coverage with 100% line-coverage gate + entry: >- + bash -c 'cd sync_settings_ui && flutter test --coverage && + scripts/check_coverage.sh coverage/lcov.info 100' + language: system + pass_filenames: false + require_serial: true + stages: [pre-push] diff --git a/sync_settings_ui/analysis_options.yaml b/sync_settings_ui/analysis_options.yaml new file mode 100644 index 0000000..bac0810 --- /dev/null +++ b/sync_settings_ui/analysis_options.yaml @@ -0,0 +1,32 @@ +include: package:very_good_analysis/analysis_options.yaml + +analyzer: + language: + strict-casts: true + strict-inference: true + strict-raw-types: true + errors: + # Promote key lints to errors so they fail CI, not just warn. + missing_required_param: error + unnecessary_null_comparison: error + dead_code: error + invalid_annotation_target: error + exclude: + - build/** + - "**/*.g.dart" + - "**/*.freezed.dart" + - "test/**" + +linter: + rules: + # very_good_analysis enables most rules; add extras it doesn't include. + always_use_package_imports: true + avoid_print: true + avoid_relative_lib_imports: true + cancel_subscriptions: true + close_sinks: true + comment_references: false # conflicts with typical Flutter API docs style + directives_ordering: true + lines_longer_than_80_chars: true + public_member_api_docs: true + unawaited_futures: true diff --git a/sync_settings_ui/lib/src/backup_slot.dart b/sync_settings_ui/lib/src/backup_slot.dart new file mode 100644 index 0000000..90c98b4 --- /dev/null +++ b/sync_settings_ui/lib/src/backup_slot.dart @@ -0,0 +1,30 @@ +/// An injectable local export/import delegate for [SyncSettingsScreen]'s +/// optional "Backup" section. +/// +/// Deliberately not a shared implementation: the actual export/import format +/// differs per app (todo: Markdown notes; home_inventory: inventory export; +/// workout_app: a permission-gated `BackupService`), so this package only +/// carries the label and the two callbacks. Passing `null` for +/// `SyncSettingsScreen.backup` omits the section entirely (diet_guard, +/// wake_alarm have no local backup at all). +class BackupSlot { + /// Creates a [BackupSlot] labelled [label] (e.g. `'notes'`, + /// `'inventory'`), backed by [export] and [import]. + const BackupSlot({ + required this.label, + required this.export, + required this.import, + }); + + /// Noun describing what this exports/imports, used in the section's + /// button text ("Export $label" / "Import $label"). + final String label; + + /// Writes every local record to the app's chosen destination (a share + /// sheet on mobile, a fixed file path on desktop -- the app's choice, not + /// this package's). + final Future Function() export; + + /// Reads records back from wherever [export] wrote them. + final Future Function() import; +} diff --git a/sync_settings_ui/lib/src/firebase_sync_controller.dart b/sync_settings_ui/lib/src/firebase_sync_controller.dart new file mode 100644 index 0000000..c870779 --- /dev/null +++ b/sync_settings_ui/lib/src/firebase_sync_controller.dart @@ -0,0 +1,205 @@ +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 Function() accountLoader; + + /// Persists the account. See [accountLoader]. + final Future Function(FirebaseAccount) accountSaver; + + /// Forgets the account and any cached session. See [accountLoader]. + final Future 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 Function() sessionProbe; + + /// Builds the Firebase backend from the stored account. + final Future 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 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 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 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 disconnect() => accountClearer(); +} diff --git a/sync_settings_ui/lib/src/sync_settings_screen.dart b/sync_settings_ui/lib/src/sync_settings_screen.dart new file mode 100644 index 0000000..9fd3a64 --- /dev/null +++ b/sync_settings_ui/lib/src/sync_settings_screen.dart @@ -0,0 +1,382 @@ +import 'dart:async'; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:flutter/material.dart'; +import 'package:sync_settings_ui/src/backup_slot.dart'; +import 'package:sync_settings_ui/src/firebase_sync_controller.dart'; + +/// The shared "Sync settings" screen: Firebase sync (primary, shown by +/// default) and an optional local backup section. +/// +/// Generalized from the near-identical settings screens duplicated across +/// the todo app, diet_guard_app, workout_app, wake_alarm/phone_app and +/// home_inventory. Only the sync-related fields migrate here -- app-specific +/// settings (kcal goals, exercise thresholds, notifications, the +/// battery-exemption toggle) stay behind in each app's own Settings screen, +/// reached via a `ListTile` link to this one. +class SyncSettingsScreen extends StatefulWidget { + /// Creates a [SyncSettingsScreen]. + const SyncSettingsScreen({ + required this.accountLoader, + required this.accountSaver, + required this.accountClearer, + required this.sessionProbe, + required this.firebaseFactory, + this.googleFirebaseFactory, + this.googleAvailable, + this.backup, + super.key, + }); + + /// Reads the stored Firebase account. Injected because the keystore is a + /// platform channel `flutter test` has no binding for. + final Future Function() accountLoader; + + /// Persists the account. See [accountLoader]. + final Future Function(FirebaseAccount) accountSaver; + + /// Forgets the account and any cached session. See [accountLoader]. + final Future 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 Function() sessionProbe; + + /// Builds the Firebase backend from the stored account. Injected so tests + /// can supply a fake. + final Future Function() firebaseFactory; + + /// Builds the Firebase backend via Google sign-in. Separate from + /// [firebaseFactory] because it reaches the Google plugin's platform + /// channel. Null hides the "Sign in with Google" button entirely. + final Future Function()? googleFirebaseFactory; + + /// Narrows whether to offer the Google button. Can only turn it off, never + /// on: the button always requires a non-null [googleFirebaseFactory] + /// regardless of this value, so a host that sets this true without also + /// wiring the factory does not get a button that can never succeed. Tests + /// pass `false` to model a platform that reports Google sign-in + /// unsupported even though a factory is wired. + final bool? googleAvailable; + + /// Optional local export/import section. Null omits the "Backup" section + /// entirely (diet_guard, wake_alarm have no local backup format). + final BackupSlot? backup; + + @override + State createState() => _SyncSettingsScreenState(); +} + +class _SyncSettingsScreenState extends State { + late final FirebaseSyncController _firebase = FirebaseSyncController( + accountLoader: widget.accountLoader, + accountSaver: widget.accountSaver, + accountClearer: widget.accountClearer, + sessionProbe: widget.sessionProbe, + firebaseFactory: widget.firebaseFactory, + googleFirebaseFactory: widget.googleFirebaseFactory, + ); + + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + + bool _loading = true; + bool _firebaseConnected = false; + bool _busy = false; + String? _status; + + @override + void initState() { + super.initState(); + unawaited(_load()); + } + + Future _load() async { + final status = await _firebase.loadStatus(); + if (!mounted) return; + setState(() { + _firebaseConnected = status.connected; + _emailController.text = status.email ?? ''; + _loading = false; + }); + } + + // supportsGoogle gates on the factory being non-null; widget.googleAvailable + // can only narrow further (e.g. a test host reporting the plugin + // unsupported), never widen. Without the `&&`, `googleAvailable: true` + // with no googleFirebaseFactory renders a live button whose tap hits + // FirebaseSyncController's StateError -- a visible control that can never + // succeed, which is exactly what this gate exists to prevent. + bool get _googleAvailable => + _firebase.supportsGoogle && (widget.googleAvailable ?? true); + + 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…'; + }); + final result = await _firebase.connectWithPassword( + email: email, + password: password, + ); + if (!mounted) return; + _applyConnectResult(result, clearPasswordOnSuccess: true); + } + + Future _connectGoogle() async { + setState(() { + _busy = true; + _status = 'Signing in…'; + }); + final result = await _firebase.connectWithGoogle(); + if (!mounted) return; + _applyConnectResult(result, clearPasswordOnSuccess: false); + } + + void _applyConnectResult( + FirebaseConnectResult result, { + required bool clearPasswordOnSuccess, + }) { + if (result.email != null) _emailController.text = result.email!; + if (result.outcome == FirebaseConnectOutcome.connected && + clearPasswordOnSuccess) { + _passwordController.clear(); + } + setState(() { + _busy = false; + _firebaseConnected = result.outcome == FirebaseConnectOutcome.connected; + _status = switch (result.outcome) { + FirebaseConnectOutcome.connected => 'Connected to Firebase.', + FirebaseConnectOutcome.rejected => 'Firebase rejected that account.', + FirebaseConnectOutcome.cancelled => 'Google sign-in was cancelled.', + FirebaseConnectOutcome.signedInButNotPersisted => + 'Signed in, but this device did not save the session - it will ' + 'sync over GitHub after a restart. Try connecting again.', + FirebaseConnectOutcome.wrongAccount => result.message, + FirebaseConnectOutcome.failed => + 'Google sign-in failed: ${result.message}', + }; + }); + } + + Future _disconnectFirebase() async { + await _firebase.disconnect(); + if (!mounted) return; + _emailController.clear(); + _passwordController.clear(); + setState(() { + _firebaseConnected = false; + _status = 'Firebase disconnected.'; + }); + } + + Future _exportBackup(BackupSlot backup) async { + try { + await backup.export(); + if (mounted) setState(() => _status = 'Exported ${backup.label}.'); + } on Exception catch (error) { + if (mounted) setState(() => _status = 'Export failed: $error'); + } + } + + Future _importBackup(BackupSlot backup) async { + try { + await backup.import(); + if (mounted) setState(() => _status = 'Imported ${backup.label}.'); + } on Exception catch (error) { + if (mounted) setState(() => _status = 'Import failed: $error'); + } + } + + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (_loading) { + return Scaffold( + appBar: AppBar(title: const Text('Sync settings')), + body: const Center(child: CircularProgressIndicator()), + ); + } + final backup = widget.backup; + final textTheme = Theme.of(context).textTheme; + return Scaffold( + appBar: AppBar(title: const Text('Sync settings')), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text('Firebase sync', style: textTheme.titleMedium), + const SizedBox(height: 8), + _FirebaseSection( + connected: _firebaseConnected, + busy: _busy, + googleAvailable: _googleAvailable, + emailController: _emailController, + passwordController: _passwordController, + onConnectPassword: _connectFirebase, + onConnectGoogle: _connectGoogle, + onDisconnect: _disconnectFirebase, + ), + if (backup != null) ...[ + const SizedBox(height: 24), + const Divider(), + const SizedBox(height: 8), + Text('Backup', style: textTheme.titleMedium), + const SizedBox(height: 8), + _BackupSection( + backup: backup, + onExport: () => _exportBackup(backup), + onImport: () => _importBackup(backup), + ), + ], + if (_status != null) ...[ + const SizedBox(height: 16), + Text(_status!, style: textTheme.bodyMedium), + ], + ], + ), + ); + } +} + +class _FirebaseSection extends StatelessWidget { + const _FirebaseSection({ + required this.connected, + required this.busy, + required this.googleAvailable, + required this.emailController, + required this.passwordController, + required this.onConnectPassword, + required this.onConnectGoogle, + required this.onDisconnect, + }); + + final bool connected; + final bool busy; + final bool googleAvailable; + final TextEditingController emailController; + final TextEditingController passwordController; + final VoidCallback onConnectPassword; + final VoidCallback onConnectGoogle; + final VoidCallback onDisconnect; + + @override + Widget build(BuildContext context) { + if (connected) { + return Row( + children: [ + const Icon(Icons.cloud_done, size: 20), + const SizedBox(width: 8), + Expanded(child: Text(emailController.text)), + TextButton( + onPressed: busy ? null : onDisconnect, + child: const Text('Disconnect'), + ), + ], + ); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (googleAvailable) ...[ + Align( + alignment: Alignment.centerLeft, + child: FilledButton.icon( + onPressed: busy ? null : onConnectGoogle, + icon: const Icon(Icons.account_circle), + label: const Text('Sign in with Google'), + ), + ), + const SizedBox(height: 12), + ], + ExpansionTile( + initiallyExpanded: !googleAvailable, + title: const Text('Use the account password instead'), + tilePadding: EdgeInsets.zero, + childrenPadding: const EdgeInsets.only(bottom: 8), + children: [ + TextField( + controller: emailController, + keyboardType: TextInputType.emailAddress, + autocorrect: false, + decoration: const InputDecoration( + labelText: 'Sync account email', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + TextField( + controller: passwordController, + obscureText: true, + autocorrect: false, + decoration: const InputDecoration( + labelText: 'Sync account password', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + Align( + alignment: Alignment.centerLeft, + child: FilledButton.icon( + onPressed: busy ? null : onConnectPassword, + icon: const Icon(Icons.cloud_done), + label: const Text('Connect Firebase'), + ), + ), + ], + ), + ], + ); + } +} + +class _BackupSection extends StatelessWidget { + const _BackupSection({ + required this.backup, + required this.onExport, + required this.onImport, + }); + + final BackupSlot backup; + final VoidCallback onExport; + final VoidCallback onImport; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: onExport, + icon: const Icon(Icons.upload), + label: Text('Export ${backup.label}'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: OutlinedButton.icon( + onPressed: onImport, + icon: const Icon(Icons.download), + label: Text('Import ${backup.label}'), + ), + ), + ], + ); + } +} diff --git a/sync_settings_ui/lib/sync_settings_ui.dart b/sync_settings_ui/lib/sync_settings_ui.dart new file mode 100644 index 0000000..101237f --- /dev/null +++ b/sync_settings_ui/lib/sync_settings_ui.dart @@ -0,0 +1,16 @@ +/// Shared "Sync settings" UI: Firebase (primary) and an optional per-app +/// local backup slot. +/// +/// This package deliberately never touches `flutter_secure_storage` or +/// `google_sign_in` itself -- every keystore read/write and every platform +/// sign-in call arrives here as an already-built closure, exactly as each +/// app's own `SettingsScreen` already injected them before this package +/// existed. That keeps the controller pure Dart and 100%-coverable without +/// a plugin platform-interface fake, and keeps each app's three-line +/// keystore adapter where it already lived (and was already +/// `// coverage:ignore`d for the same platform-channel reason). +library; + +export 'src/backup_slot.dart'; +export 'src/firebase_sync_controller.dart'; +export 'src/sync_settings_screen.dart'; diff --git a/sync_settings_ui/pubspec.yaml b/sync_settings_ui/pubspec.yaml new file mode 100644 index 0000000..ea9ba8d --- /dev/null +++ b/sync_settings_ui/pubspec.yaml @@ -0,0 +1,27 @@ +name: sync_settings_ui +description: >- + Shared "Sync settings" screen (Firebase primary, optional local backup) + reused across kuhy's Flutter apps. Pure UI + sync service layer over + injected closures -- no keystore or platform plugin access lives in this + package itself. +publish_to: 'none' +version: 0.1.0 + +environment: + sdk: ^3.12.2 + +dependencies: + crdt_sync: + git: + url: https://github.com/kuhyx/utils + ref: crdt_sync_dart-v0.10.0 + path: crdt_sync_dart + flutter: + sdk: flutter + +dev_dependencies: + flutter_test: + sdk: flutter + very_good_analysis: ^10.2.0 + +flutter: diff --git a/sync_settings_ui/scripts/check_coverage.sh b/sync_settings_ui/scripts/check_coverage.sh new file mode 100755 index 0000000..2c0ee6f --- /dev/null +++ b/sync_settings_ui/scripts/check_coverage.sh @@ -0,0 +1,63 @@ +#!/bin/bash + +# ============================================================================ +# Fail if lcov line coverage drops below a threshold. +# +# No Dart package in this repo has coverage enforced in CI yet -- crdt_sync_dart +# sits at 100% by discipline, not a gate. This script is that gate for +# sync_settings_ui, which ships as a 100%-covered shared UI package by +# requirement, not by convention. +# +# Usage: check_coverage.sh +# ============================================================================ + +set -euo pipefail + +SCRIPT_NAME="$(basename "$0")" +readonly SCRIPT_NAME + +usage() { + echo "Usage: $SCRIPT_NAME " + echo " Fails when line coverage in lcov.info is below min_percent." + exit 0 +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage +fi + +if [[ $# -ne 2 ]]; then + usage +fi + +lcov_file="$1" +min_percent="$2" + +if [[ ! -f "$lcov_file" ]]; then + echo "Error: $lcov_file not found. Run 'flutter test --coverage' first." >&2 + exit 1 +fi + +# Sum LH (lines hit) and LF (lines found) across every SF (source file) +# block rather than trusting a single top-level total -- lcov.info has no +# such total line, only per-file LH/LF pairs. +read -r hit found <<< "$(awk ' + /^LH:/ { hit += substr($0, 4) } + /^LF:/ { found += substr($0, 4) } + END { print hit, found } +' "$lcov_file")" + +if [[ "$found" -eq 0 ]]; then + echo "Error: $lcov_file reports zero coverable lines." >&2 + exit 1 +fi + +percent=$(awk -v h="$hit" -v f="$found" 'BEGIN { printf "%.2f", (h / f) * 100 }') +meets_threshold=$(awk -v p="$percent" -v m="$min_percent" 'BEGIN { print (p + 0 >= m + 0) ? 1 : 0 }') + +echo "Line coverage: $hit/$found ($percent%), threshold: $min_percent%" + +if [[ "$meets_threshold" -ne 1 ]]; then + echo "Error: coverage $percent% is below the required $min_percent%." >&2 + exit 1 +fi diff --git a/sync_settings_ui/test/backup_slot_test.dart b/sync_settings_ui/test/backup_slot_test.dart new file mode 100644 index 0000000..6754c6e --- /dev/null +++ b/sync_settings_ui/test/backup_slot_test.dart @@ -0,0 +1,19 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sync_settings_ui/sync_settings_ui.dart'; + +void main() { + test('carries the label and invokes the injected callbacks', () async { + var exported = false; + var imported = false; + final slot = BackupSlot( + label: 'notes', + export: () async => exported = true, + import: () async => imported = true, + ); + expect(slot.label, 'notes'); + await slot.export(); + await slot.import(); + expect(exported, isTrue); + expect(imported, isTrue); + }); +} diff --git a/sync_settings_ui/test/firebase_sync_controller_test.dart b/sync_settings_ui/test/firebase_sync_controller_test.dart new file mode 100644 index 0000000..bc42db2 --- /dev/null +++ b/sync_settings_ui/test/firebase_sync_controller_test.dart @@ -0,0 +1,218 @@ +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sync_settings_ui/sync_settings_ui.dart'; + +FirebaseRestClient _fakeClient() => FirebaseRestClient( + databaseUrl: 'https://example.firebasedatabase.app', + auth: FirebaseTokenProvider( + apiKey: 'test-key', + store: InMemoryCredentialStore(), + ), +); + +void main() { + group('loadStatus', () { + test('reports connected with the stored email', () async { + final controller = FirebaseSyncController( + accountLoader: () async => + const FirebaseAccount(email: 'a@b.com', password: 'x'), + accountSaver: (_) async {}, + accountClearer: () async {}, + sessionProbe: () async => true, + firebaseFactory: () async => null, + ); + final status = await controller.loadStatus(); + expect(status.connected, isTrue); + expect(status.email, 'a@b.com'); + }); + + test( + 'reports not connected with a null email when nothing stored', + () async { + final controller = FirebaseSyncController( + accountLoader: () async => null, + accountSaver: (_) async {}, + accountClearer: () async {}, + sessionProbe: () async => false, + firebaseFactory: () async => null, + ); + final status = await controller.loadStatus(); + expect(status.connected, isFalse); + expect(status.email, isNull); + }, + ); + + test( + 'trusts the session probe over account presence -- a marker with no ' + 'live session must read as not connected', + () async { + final controller = FirebaseSyncController( + accountLoader: () async => + const FirebaseAccount(email: 'stale@b.com', password: 'x'), + accountSaver: (_) async {}, + accountClearer: () async {}, + sessionProbe: () async => false, + firebaseFactory: () async => null, + ); + final status = await controller.loadStatus(); + expect(status.connected, isFalse); + }, + ); + }); + + group('connectWithPassword', () { + test('saves the account and reports connected on success', () async { + FirebaseAccount? saved; + final controller = FirebaseSyncController( + accountLoader: () async => null, + accountSaver: (account) async => saved = account, + accountClearer: () async {}, + sessionProbe: () async => true, + firebaseFactory: () async => _fakeClient(), + ); + final result = await controller.connectWithPassword( + email: 'a@b.com', + password: 'pw', + ); + expect(result.outcome, FirebaseConnectOutcome.connected); + expect(result.email, 'a@b.com'); + expect(saved?.email, 'a@b.com'); + }); + + test( + 'clears the account and reports rejected when the client is null', + () async { + var cleared = false; + final controller = FirebaseSyncController( + accountLoader: () async => null, + accountSaver: (_) async {}, + accountClearer: () async => cleared = true, + sessionProbe: () async => false, + firebaseFactory: () async => null, + ); + final result = await controller.connectWithPassword( + email: 'a@b.com', + password: 'wrong', + ); + expect(result.outcome, FirebaseConnectOutcome.rejected); + expect(cleared, isTrue); + }, + ); + }); + + group('connectWithGoogle', () { + test('throws StateError when no googleFirebaseFactory is wired', () async { + final controller = FirebaseSyncController( + accountLoader: () async => null, + accountSaver: (_) async {}, + accountClearer: () async {}, + sessionProbe: () async => false, + firebaseFactory: () async => null, + ); + expect(controller.supportsGoogle, isFalse); + expect(controller.connectWithGoogle, throwsStateError); + }); + + test('reports cancelled when the picker returns null', () async { + final controller = FirebaseSyncController( + accountLoader: () async => null, + accountSaver: (_) async {}, + accountClearer: () async {}, + sessionProbe: () async => false, + firebaseFactory: () async => null, + googleFirebaseFactory: () async => null, + ); + expect(controller.supportsGoogle, isTrue); + final result = await controller.connectWithGoogle(); + expect(result.outcome, FirebaseConnectOutcome.cancelled); + }); + + test('reports connected and the reported email on success', () async { + final controller = FirebaseSyncController( + accountLoader: () async => + const FirebaseAccount(email: 'g@b.com', password: ''), + accountSaver: (_) async {}, + accountClearer: () async {}, + sessionProbe: () async => true, + firebaseFactory: () async => null, + googleFirebaseFactory: () async => _fakeClient(), + ); + final result = await controller.connectWithGoogle(); + expect(result.outcome, FirebaseConnectOutcome.connected); + expect(result.email, 'g@b.com'); + }); + + test( + 'reports signedInButNotPersisted with the stale account email when ' + 'the client came back non-null but no session actually stuck -- the ' + 'invariant this package exists to centralize', + () async { + final controller = FirebaseSyncController( + accountLoader: () async => + const FirebaseAccount(email: 'stale@b.com', password: ''), + accountSaver: (_) async {}, + accountClearer: () async {}, + sessionProbe: () async => false, + firebaseFactory: () async => null, + googleFirebaseFactory: () async => _fakeClient(), + ); + final result = await controller.connectWithGoogle(); + expect( + result.outcome, + FirebaseConnectOutcome.signedInButNotPersisted, + ); + expect(result.email, 'stale@b.com'); + }, + ); + + test( + 'reports wrongAccount with the error message on FirebaseAuthError', + () async { + final controller = FirebaseSyncController( + accountLoader: () async => null, + accountSaver: (_) async {}, + accountClearer: () async {}, + sessionProbe: () async => false, + firebaseFactory: () async => null, + googleFirebaseFactory: () async => + throw FirebaseAuthError('wrong uid'), + ); + final result = await controller.connectWithGoogle(); + expect(result.outcome, FirebaseConnectOutcome.wrongAccount); + expect(result.message, 'wrong uid'); + }, + ); + + test( + 'reports failed with the stringified error on any other failure', + () async { + final controller = FirebaseSyncController( + accountLoader: () async => null, + accountSaver: (_) async {}, + accountClearer: () async {}, + sessionProbe: () async => false, + firebaseFactory: () async => null, + googleFirebaseFactory: () async => throw StateError('boom'), + ); + final result = await controller.connectWithGoogle(); + expect(result.outcome, FirebaseConnectOutcome.failed); + expect(result.message, contains('boom')); + }, + ); + }); + + group('disconnect', () { + test('calls the account clearer', () async { + var cleared = false; + final controller = FirebaseSyncController( + accountLoader: () async => null, + accountSaver: (_) async {}, + accountClearer: () async => cleared = true, + sessionProbe: () async => false, + firebaseFactory: () async => null, + ); + await controller.disconnect(); + expect(cleared, isTrue); + }); + }); +} diff --git a/sync_settings_ui/test/sync_settings_screen_test.dart b/sync_settings_ui/test/sync_settings_screen_test.dart new file mode 100644 index 0000000..403c9e7 --- /dev/null +++ b/sync_settings_ui/test/sync_settings_screen_test.dart @@ -0,0 +1,450 @@ +import 'dart:async'; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sync_settings_ui/sync_settings_ui.dart'; + +FirebaseRestClient _fakeClient() => FirebaseRestClient( + databaseUrl: 'https://example.firebasedatabase.app', + auth: FirebaseTokenProvider( + apiKey: 'test-key', + store: InMemoryCredentialStore(), + ), +); + +Widget _wrap(Widget child) => MaterialApp(home: child); + +void main() { + // Every test expands the Google/password ExpansionTile, which pushes the + // form fields well past the default 800x600 test surface. A taller + // surface keeps every button reachable without scrolling -- scrolling a + // plain (non-.builder) ListView far enough still unmounts sliver children + // beyond its cache extent, which made an early version of these tests + // lose the Firebase section from the tree entirely. + setUp(() { + final binding = TestWidgetsFlutterBinding.ensureInitialized(); + binding.platformDispatcher.views.first.physicalSize = const Size( + 800, + 2000, + ); + binding.platformDispatcher.views.first.devicePixelRatio = 1.0; + addTearDown(binding.platformDispatcher.views.first.resetPhysicalSize); + addTearDown(binding.platformDispatcher.views.first.resetDevicePixelRatio); + }); + + group('Firebase section', () { + testWidgets('shows connected state with email and Disconnect', ( + tester, + ) async { + await tester.pumpWidget( + _wrap( + SyncSettingsScreen( + accountLoader: () async => + const FirebaseAccount(email: 'a@b.com', password: 'x'), + accountSaver: (_) async {}, + accountClearer: () async {}, + sessionProbe: () async => true, + firebaseFactory: () async => null, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('a@b.com'), findsOneWidget); + expect(find.text('Disconnect'), findsOneWidget); + }); + + testWidgets('disconnect clears the account and shows status', ( + tester, + ) async { + var cleared = false; + await tester.pumpWidget( + _wrap( + SyncSettingsScreen( + accountLoader: () async => + const FirebaseAccount(email: 'a@b.com', password: 'x'), + accountSaver: (_) async {}, + accountClearer: () async => cleared = true, + sessionProbe: () async => true, + firebaseFactory: () async => null, + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Disconnect')); + await tester.pumpAndSettle(); + + expect(cleared, isTrue); + expect(find.text('Firebase disconnected.'), findsOneWidget); + expect(find.text('Connect Firebase'), findsOneWidget); + }); + + testWidgets('connect with password shows an error when empty', ( + tester, + ) async { + await tester.pumpWidget( + _wrap( + SyncSettingsScreen( + accountLoader: () async => null, + accountSaver: (_) async {}, + accountClearer: () async {}, + sessionProbe: () async => false, + firebaseFactory: () async => null, + ), + ), + ); + await tester.pumpAndSettle(); + + // Google unavailable by default -> password form starts expanded. + await tester.tap(find.text('Connect Firebase')); + await tester.pumpAndSettle(); + + expect( + find.text('Enter the sync account email and password.'), + findsOneWidget, + ); + }); + + testWidgets('connect with password succeeds and shows Connected', ( + tester, + ) async { + await tester.pumpWidget( + _wrap( + SyncSettingsScreen( + accountLoader: () async => null, + accountSaver: (_) async {}, + accountClearer: () async {}, + sessionProbe: () async => false, + firebaseFactory: () async => _fakeClient(), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.enterText( + find.widgetWithText(TextField, 'Sync account email'), + 'a@b.com', + ); + await tester.enterText( + find.widgetWithText(TextField, 'Sync account password'), + 'pw', + ); + await tester.tap(find.text('Connect Firebase')); + await tester.pumpAndSettle(); + + expect(find.text('Connected to Firebase.'), findsOneWidget); + expect(find.text('a@b.com'), findsOneWidget); + }); + + testWidgets('connect with password reports rejection', (tester) async { + await tester.pumpWidget( + _wrap( + SyncSettingsScreen( + accountLoader: () async => null, + accountSaver: (_) async {}, + accountClearer: () async {}, + sessionProbe: () async => false, + firebaseFactory: () async => null, + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.enterText( + find.widgetWithText(TextField, 'Sync account email'), + 'a@b.com', + ); + await tester.enterText( + find.widgetWithText(TextField, 'Sync account password'), + 'wrong', + ); + await tester.tap(find.text('Connect Firebase')); + await tester.pumpAndSettle(); + + expect(find.text('Firebase rejected that account.'), findsOneWidget); + }); + + testWidgets( + 'shows the Google button when googleAvailable, and connecting ' + 'succeeds', + (tester) async { + var sessionStored = false; + await tester.pumpWidget( + _wrap( + SyncSettingsScreen( + accountLoader: () async => null, + accountSaver: (_) async {}, + accountClearer: () async {}, + sessionProbe: () async => sessionStored, + firebaseFactory: () async => null, + googleFirebaseFactory: () async { + sessionStored = true; + return _fakeClient(); + }, + googleAvailable: true, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Sign in with Google'), findsOneWidget); + await tester.tap(find.text('Sign in with Google')); + await tester.pumpAndSettle(); + + expect(find.text('Connected to Firebase.'), findsOneWidget); + }, + ); + + testWidgets('Google sign-in cancelled shows the cancellation message', ( + tester, + ) async { + await tester.pumpWidget( + _wrap( + SyncSettingsScreen( + accountLoader: () async => null, + accountSaver: (_) async {}, + accountClearer: () async {}, + sessionProbe: () async => false, + firebaseFactory: () async => null, + googleFirebaseFactory: () async => null, + googleAvailable: true, + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Sign in with Google')); + await tester.pumpAndSettle(); + + expect(find.text('Google sign-in was cancelled.'), findsOneWidget); + }); + + testWidgets( + 'Google sign-in that does not persist a session reports the retry ' + 'message', + (tester) async { + await tester.pumpWidget( + _wrap( + SyncSettingsScreen( + accountLoader: () async => null, + accountSaver: (_) async {}, + accountClearer: () async {}, + sessionProbe: () async => false, + firebaseFactory: () async => null, + googleFirebaseFactory: () async => _fakeClient(), + googleAvailable: true, + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Sign in with Google')); + await tester.pumpAndSettle(); + + expect( + find.textContaining('did not save the session'), + findsOneWidget, + ); + }, + ); + + testWidgets('Google sign-in wrong-account error surfaces the message', ( + tester, + ) async { + await tester.pumpWidget( + _wrap( + SyncSettingsScreen( + accountLoader: () async => null, + accountSaver: (_) async {}, + accountClearer: () async {}, + sessionProbe: () async => false, + firebaseFactory: () async => null, + googleFirebaseFactory: () async => + throw FirebaseAuthError('wrong uid'), + googleAvailable: true, + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Sign in with Google')); + await tester.pumpAndSettle(); + + expect(find.text('wrong uid'), findsOneWidget); + }); + + testWidgets('Google sign-in generic failure surfaces the error text', ( + tester, + ) async { + await tester.pumpWidget( + _wrap( + SyncSettingsScreen( + accountLoader: () async => null, + accountSaver: (_) async {}, + accountClearer: () async {}, + sessionProbe: () async => false, + firebaseFactory: () async => null, + googleFirebaseFactory: () async => throw StateError('boom'), + googleAvailable: true, + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Sign in with Google')); + await tester.pumpAndSettle(); + + expect(find.textContaining('Google sign-in failed'), findsOneWidget); + }); + + testWidgets( + 'hides the Google button when googleAvailable is true but no ' + 'googleFirebaseFactory is wired -- a visible button that can never ' + 'succeed must not render', + (tester) async { + await tester.pumpWidget( + _wrap( + SyncSettingsScreen( + accountLoader: () async => null, + accountSaver: (_) async {}, + accountClearer: () async {}, + sessionProbe: () async => false, + firebaseFactory: () async => null, + googleAvailable: true, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Sign in with Google'), findsNothing); + }, + ); + }); + + group('Backup section', () { + testWidgets('omitted entirely when backup is null', (tester) async { + await tester.pumpWidget( + _wrap( + SyncSettingsScreen( + accountLoader: () async => null, + accountSaver: (_) async {}, + accountClearer: () async {}, + sessionProbe: () async => false, + firebaseFactory: () async => null, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Backup'), findsNothing); + }); + + testWidgets('export and import call the injected callbacks', ( + tester, + ) async { + var exported = false; + var imported = false; + await tester.pumpWidget( + _wrap( + SyncSettingsScreen( + accountLoader: () async => null, + accountSaver: (_) async {}, + accountClearer: () async {}, + sessionProbe: () async => false, + firebaseFactory: () async => null, + backup: BackupSlot( + label: 'notes', + export: () async => exported = true, + import: () async => imported = true, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Backup'), findsOneWidget); + await tester.tap(find.text('Export notes')); + await tester.pumpAndSettle(); + expect(exported, isTrue); + expect(find.text('Exported notes.'), findsOneWidget); + + await tester.tap(find.text('Import notes')); + await tester.pumpAndSettle(); + expect(imported, isTrue); + expect(find.text('Imported notes.'), findsOneWidget); + }); + + testWidgets('export failure reports the error', (tester) async { + await tester.pumpWidget( + _wrap( + SyncSettingsScreen( + accountLoader: () async => null, + accountSaver: (_) async {}, + accountClearer: () async {}, + sessionProbe: () async => false, + firebaseFactory: () async => null, + backup: BackupSlot( + label: 'notes', + export: () async => throw Exception('disk full'), + import: () async {}, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Export notes')); + await tester.pumpAndSettle(); + expect(find.textContaining('Export failed'), findsOneWidget); + }); + + testWidgets('import failure reports the error', (tester) async { + await tester.pumpWidget( + _wrap( + SyncSettingsScreen( + accountLoader: () async => null, + accountSaver: (_) async {}, + accountClearer: () async {}, + sessionProbe: () async => false, + firebaseFactory: () async => null, + backup: BackupSlot( + label: 'notes', + export: () async {}, + import: () async => throw Exception('corrupt file'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Import notes')); + await tester.pumpAndSettle(); + expect(find.textContaining('Import failed'), findsOneWidget); + }); + }); + + testWidgets('shows a loading indicator until initial load completes', ( + tester, + ) async { + final completer = Completer(); + await tester.pumpWidget( + _wrap( + SyncSettingsScreen( + accountLoader: () => completer.future, + accountSaver: (_) async {}, + accountClearer: () async {}, + sessionProbe: () async => false, + firebaseFactory: () async => null, + ), + ), + ); + await tester.pump(); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + completer.complete(null); + await tester.pumpAndSettle(); + expect(find.byType(CircularProgressIndicator), findsNothing); + }); +}