mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 17:47:24 +01:00
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:
parent
92f55cee5a
commit
23c693da4e
36
.github/workflows/sync-settings-ui-tests.yml
vendored
Normal file
36
.github/workflows/sync-settings-ui-tests.yml
vendored
Normal 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
|
||||
22
README.md
22
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
|
||||
|
||||
@ -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 "$@"
|
||||
|
||||
6
sync_settings_ui/.gitignore
vendored
Normal file
6
sync_settings_ui/.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
.dart_tool/
|
||||
.packages
|
||||
build/
|
||||
pubspec.lock
|
||||
coverage/
|
||||
*.iml
|
||||
61
sync_settings_ui/.pre-commit-config.yaml
Normal file
61
sync_settings_ui/.pre-commit-config.yaml
Normal 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]
|
||||
32
sync_settings_ui/analysis_options.yaml
Normal file
32
sync_settings_ui/analysis_options.yaml
Normal 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
|
||||
30
sync_settings_ui/lib/src/backup_slot.dart
Normal file
30
sync_settings_ui/lib/src/backup_slot.dart
Normal 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;
|
||||
}
|
||||
205
sync_settings_ui/lib/src/firebase_sync_controller.dart
Normal file
205
sync_settings_ui/lib/src/firebase_sync_controller.dart
Normal 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();
|
||||
}
|
||||
382
sync_settings_ui/lib/src/sync_settings_screen.dart
Normal file
382
sync_settings_ui/lib/src/sync_settings_screen.dart
Normal 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}'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
16
sync_settings_ui/lib/sync_settings_ui.dart
Normal file
16
sync_settings_ui/lib/sync_settings_ui.dart
Normal 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';
|
||||
27
sync_settings_ui/pubspec.yaml
Normal file
27
sync_settings_ui/pubspec.yaml
Normal 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:
|
||||
63
sync_settings_ui/scripts/check_coverage.sh
Executable file
63
sync_settings_ui/scripts/check_coverage.sh
Executable 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
|
||||
19
sync_settings_ui/test/backup_slot_test.dart
Normal file
19
sync_settings_ui/test/backup_slot_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
218
sync_settings_ui/test/firebase_sync_controller_test.dart
Normal file
218
sync_settings_ui/test/firebase_sync_controller_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
450
sync_settings_ui/test/sync_settings_screen_test.dart
Normal file
450
sync_settings_ui/test/sync_settings_screen_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user