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
This commit is contained in:
Krzysztof Rudnicki 2026-08-12 23:10:17 +02:00
parent 92f55cee5a
commit 23c693da4e
15 changed files with 1620 additions and 33 deletions

View File

@ -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

View File

@ -18,10 +18,30 @@ dependencies:
crdt_sync: crdt_sync:
git: git:
url: https://github.com/kuhyx/utils url: https://github.com/kuhyx/utils
ref: crdt_sync_dart-v0.2.0 ref: crdt_sync_dart-v0.10.0
path: crdt_sync_dart 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/ ## crdt-sync/
Python CRDT sync library (package: `crdt_sync`, not published to PyPI). Same Python CRDT sync library (package: `crdt_sync`, not published to PyPI). Same

View File

@ -1,40 +1,45 @@
#!/bin/bash #!/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 # The apps depend on packages in this repo (crdt_sync, sync_settings_ui) as
# CI builds is whatever that tag points at. A `dependency_overrides:` entry # a *git dependency pinned to a tag*, so what CI builds is whatever that tag
# pointing at ~/utils/crdt_sync_dart is invaluable while developing an unreleased # points at. A `dependency_overrides:` entry pointing at a local checkout is
# library change -- and catastrophic if it survives the commit: local tests go # invaluable while developing an unreleased library change -- and
# green against code CI has never seen, and the failure is silent in both # catastrophic if it survives the commit: local tests go green against code
# directions. # CI has never seen, and the failure is silent in both directions.
# #
# A comment in the pubspec asking someone to remember is deployment hygiene, # A comment in the pubspec asking someone to remember is deployment hygiene,
# not a fix. This is the gate. # not a fix. This is the gate.
# #
# Usage: check_no_crdt_sync_override.sh [pubspec.yaml ...] # Usage: check_no_crdt_sync_override.sh [-p package_name] [pubspec.yaml ...]
# With no arguments, checks ./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 set -euo pipefail
SCRIPT_NAME="$(basename "$0")" SCRIPT_NAME="$(basename "$0")"
readonly SCRIPT_NAME readonly SCRIPT_NAME
DEFAULT_PACKAGES=(crdt_sync sync_settings_ui)
usage() { usage() {
echo "Usage: $SCRIPT_NAME [pubspec.yaml ...]" echo "Usage: $SCRIPT_NAME [-p package_name ...] [pubspec.yaml ...]"
echo " Fails when a pubspec overrides crdt_sync with a local path." echo " Fails when a pubspec overrides a utils git dependency with a local path."
echo " Default packages checked: ${DEFAULT_PACKAGES[*]}"
exit 0 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: # dependency_overrides. Deliberately a small state machine rather than a grep:
# `path:` appears under plenty of other keys, and a false positive here blocks # `path:` appears under plenty of other keys, and a false positive here blocks
# a legitimate commit. # a legitimate commit.
has_override() { has_override() {
local file="$1" local file="$1" package="$2"
awk ' awk -v package="$package" '
# Track indent depth explicitly. An earlier version used an awk # Track indent depth explicitly. An earlier version used an awk
# interval expression ({1,4}) to spot a sibling key, which plain 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 # does not enable by default -- so the check silently never fired and
@ -47,23 +52,42 @@ has_override() {
/^[[:space:]]*#/ { next } # comments /^[[:space:]]*#/ { next } # comments
/^[[:space:]]*$/ { next } # blank lines /^[[:space:]]*$/ { next } # blank lines
/^dependency_overrides:[[:space:]]*$/ { in_overrides = 1; next } /^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 { in_overrides {
if ($0 ~ /^[[:space:]]+crdt_sync:[[:space:]]*$/) { if ($0 ~ ("^[[:space:]]+" package ":[[:space:]]*$")) {
in_crdt = 1 in_pkg = 1
crdt_indent = indent($0) pkg_indent = indent($0)
next next
} }
# Any key at or above crdt_sync{s} own indent ends its block. # Any key at or above the package own indent ends its block.
if (in_crdt && indent($0) <= crdt_indent) { in_crdt = 0 } if (in_pkg && indent($0) <= pkg_indent) { in_pkg = 0 }
if (in_crdt && $0 ~ /^[[:space:]]+path:/) { found = 1 } if (in_pkg && $0 ~ /^[[:space:]]+path:/) { found = 1 }
} }
END { exit(found ? 0 : 1) } END { exit(found ? 0 : 1) }
' "$file" ' "$file"
} }
main() { 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 if [[ ${#files[@]} -eq 0 ]]; then
files=("pubspec.yaml") files=("pubspec.yaml")
fi fi
@ -71,18 +95,16 @@ main() {
local failed=0 local failed=0
for file in "${files[@]}"; do for file in "${files[@]}"; do
[[ -f "$file" ]] || continue [[ -f "$file" ]] || continue
if has_override "$file"; then for package in "${packages[@]}"; do
echo "Error: $file still has a local crdt_sync dependency_override." >&2 if has_override "$file" "$package"; then
echo " Remove it and bump the git 'ref:' to the released tag" >&2 echo "Error: $file still has a local $package dependency_override." >&2
echo " before committing, or CI will build different code." >&2 echo " Remove it and bump the git 'ref:' to the released tag" >&2
failed=1 echo " before committing, or CI will build different code." >&2
fi failed=1
fi
done
done done
return "$failed" return "$failed"
} }
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
fi
main "$@" main "$@"

6
sync_settings_ui/.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
.dart_tool/
.packages
build/
pubspec.lock
coverage/
*.iml

View File

@ -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]

View File

@ -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

View File

@ -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<void> Function() export;
/// Reads records back from wherever [export] wrote them.
final Future<void> Function() import;
}

View File

@ -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<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();
}

View File

@ -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<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. Injected so tests
/// can supply a fake.
final Future<FirebaseRestClient?> 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<FirebaseRestClient?> 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<SyncSettingsScreen> createState() => _SyncSettingsScreenState();
}
class _SyncSettingsScreenState extends State<SyncSettingsScreen> {
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<void> _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<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…';
});
final result = await _firebase.connectWithPassword(
email: email,
password: password,
);
if (!mounted) return;
_applyConnectResult(result, clearPasswordOnSuccess: true);
}
Future<void> _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<void> _disconnectFirebase() async {
await _firebase.disconnect();
if (!mounted) return;
_emailController.clear();
_passwordController.clear();
setState(() {
_firebaseConnected = false;
_status = 'Firebase disconnected.';
});
}
Future<void> _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<void> _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}'),
),
),
],
);
}
}

View File

@ -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';

View File

@ -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:

View File

@ -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 <lcov.info> <min_percent>
# ============================================================================
set -euo pipefail
SCRIPT_NAME="$(basename "$0")"
readonly SCRIPT_NAME
usage() {
echo "Usage: $SCRIPT_NAME <lcov.info> <min_percent>"
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

View File

@ -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);
});
}

View File

@ -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);
});
});
}

View File

@ -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<FirebaseAccount?>();
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);
});
}