Simplify capture UI behind an advanced-mode toggle, add usage analytics

Hide priority/status/template/mode chrome and routine sync-status text
behind a Firebase-synced "Enable advanced" setting (default off); remove
the Capture title and Save button in favor of autosave + a plain reset
icon; sort notes by priority then recency; add interaction-only usage
analytics buffered locally and flushed to Firebase alongside note sync.

Code review (self + follow-up verification pass) found and fixed 3 real
bugs during this work: a widget-key placement bug that discarded an
in-progress draft when advancedMode was toggled mid-typing, a data-loss
bug in the analytics buffer's flush-and-clear logic under concurrent
cap-eviction, and two spots where an expired/wrong-account Firebase
session (FirebaseAuthError) escaped a narrower catch clause and would
have failed an otherwise-successful sync pass.

Also fixes an unrelated pre-existing test-isolation bug in
wrapper_server_test.dart, which was reading the real
~/.config/todo/firebase_auth.json instead of a test-scoped path.

320 tests, 100% line coverage on all touched files (one pre-existing,
out-of-scope 5-line gap in settings_screen.dart unrelated to this
change).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-08-12 21:30:40 +02:00
parent d316563015
commit ce2ea5b410
18 changed files with 1660 additions and 236 deletions

View File

@ -0,0 +1,51 @@
/// A single, structured analytics event.
///
/// Interaction-only by construction: [params] holds enum values, counts,
/// durations and booleans — never note text or other free-form user input.
/// Callers are trusted to uphold that; see `analytics_service.dart` for the
/// full policy.
class AnalyticsEvent {
/// Creates an [AnalyticsEvent] with the given [name], [params] and
/// [timestamp].
const AnalyticsEvent({
required this.name,
required this.timestamp,
this.params = const {},
});
/// Short, stable event identifier, e.g. `'screen_view'`, `'sort_changed'`.
final String name;
/// When the event happened.
final DateTime timestamp;
/// Structured parameters. Values must be JSON-encodable primitives
/// (String, num, bool, or null) — never note text.
final Map<String, Object?> params;
/// Serializes to a JSON-encodable map for local buffering and the
/// Firebase flush.
Map<String, Object?> toJson() => {
'name': name,
'timestamp': timestamp.toIso8601String(),
'params': params,
};
/// Reconstructs an [AnalyticsEvent] from [toJson]'s output. Returns null
/// for a malformed entry (e.g. a buffer corrupted by a previous crash)
/// rather than throwing — analytics must never crash capture.
static AnalyticsEvent? tryFromJson(Object? json) {
if (json is! Map) return null;
final name = json['name'];
final timestamp = DateTime.tryParse('${json['timestamp']}');
final params = json['params'];
if (name is! String || timestamp == null) return null;
return AnalyticsEvent(
name: name,
timestamp: timestamp,
params: params is Map
? Map<String, Object?>.from(params)
: const <String, Object?>{},
);
}
}

View File

@ -0,0 +1,117 @@
/// Interaction-only usage analytics: what was tapped, selected, opened, for
/// how long — never note text or other free-form input.
///
/// Buffered in `SharedPreferences` (a JSON-encoded list, capped so a device
/// that never reaches Firebase can't grow it without bound) and flushed to
/// the shared `kuhy-syncs` Firebase RTDB project during the same sync pass
/// that already opens an authenticated client for notes and settings — see
/// `run_sync.dart`.
///
/// `SharedPreferences` rather than a new on-disk file or a new table in the
/// notes CRDT database: the desktop build has no filesystem (see
/// `local_backup_factory_web.dart`, which routes through the local wrapper
/// server for that reason), and a schema migration on the notes database
/// carries blast radius onto live user data for a feature that is allowed to
/// be lossy. `SharedPreferences` is already trusted for the sync-status
/// fields in `capture_screen.dart`.
library;
import 'dart:convert';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:todo/analytics/analytics_event.dart';
/// Where a flushed batch lands in the shared RTDB project, sibling to
/// `changesets/<nodeId>.json` and `settings/advancedMode`.
String analyticsRemotePath(String nodeId, String batchId) =>
'analytics/$nodeId/$batchId';
/// Buffers and flushes [AnalyticsEvent]s.
class AnalyticsService {
/// Creates an [AnalyticsService] for the device identified by [nodeId].
const AnalyticsService({required this.nodeId});
/// This device's id, matching the one `SyncService` uses for its own
/// changeset file — so a batch's origin is traceable without carrying any
/// user-identifying data beyond what already syncs.
final String nodeId;
static const _kBuffer = 'analytics.buffer';
/// Buffer cap: a device that never reaches Firebase must not grow this
/// list without bound (`SharedPreferences` loads synchronously into memory
/// at startup on Android). Oldest events are dropped first — analytics is
/// lossy by nature, and a stalled flush must never slow app launch.
static const maxBufferedEvents = 500;
/// Appends [event] to the local buffer, dropping the oldest event first
/// if already at [maxBufferedEvents].
Future<void> logEvent(AnalyticsEvent event) async {
final prefs = await SharedPreferences.getInstance();
final buffered = _readBuffer(prefs)..add(event);
final trimmed = buffered.length > maxBufferedEvents
? buffered.sublist(buffered.length - maxBufferedEvents)
: buffered;
await _writeBuffer(prefs, trimmed);
}
/// Flushes the current buffer to [client] as one batch.
///
/// Claims the buffer (clears it locally) before the network call, rather
/// than sending then subtracting by count: subtracting assumes the buffer
/// only ever grows during the push, which [logEvent]'s cap-eviction breaks
/// (it can trim from the *front*), so a mid-flush event could be counted
/// as already sent and silently dropped. Claim-then-restore has a simpler
/// failure mode instead: an event logged mid-flush lands in a buffer this
/// call no longer owns and survives untouched; a failed push puts the
/// claimed batch back in front of it, in original order.
///
/// A push failure (offline, not signed in, expired session) leaves the
/// buffer as if nothing happened; the next `runSync()` pass retries.
/// Never throws.
Future<void> flush(FirebaseRestClient? client) async {
if (client == null) return;
final prefs = await SharedPreferences.getInstance();
final pending = _readBuffer(prefs);
if (pending.isEmpty) return;
await _writeBuffer(prefs, const []);
final batchId = DateTime.now().toUtc().millisecondsSinceEpoch.toString();
try {
await client.putFileText(
analyticsRemotePath(nodeId, batchId),
jsonEncode([for (final e in pending) e.toJson()]),
message: 'crdt_sync: analytics batch',
);
} on RemoteSyncError {
// Catches FirebaseSyncError (network) and FirebaseAuthError (session
// expired) alike — both are RemoteSyncError, and only catching the
// narrower sibling type used to let an auth failure escape and turn
// an otherwise-successful sync pass into a reported failure.
final since = _readBuffer(prefs);
await _writeBuffer(prefs, [...pending, ...since]);
}
}
List<AnalyticsEvent> _readBuffer(SharedPreferences prefs) {
final raw = prefs.getString(_kBuffer);
if (raw == null || raw.isEmpty) return [];
final decoded = jsonDecode(raw);
if (decoded is! List) return [];
return [for (final entry in decoded) ?AnalyticsEvent.tryFromJson(entry)];
}
Future<void> _writeBuffer(
SharedPreferences prefs,
List<AnalyticsEvent> events,
) async {
if (events.isEmpty) {
await prefs.remove(_kBuffer);
return;
}
await prefs.setString(
_kBuffer,
jsonEncode([for (final e in events) e.toJson()]),
);
}
}

140
lib/data/app_settings.dart Normal file
View File

@ -0,0 +1,140 @@
/// App-wide, non-sync preferences (currently just `advancedMode`).
///
/// Mirrors [SyncSettings]'s shape: `SharedPreferences` is the source of
/// truth for instant, offline reads; a Firebase mirror lets the same choice
/// follow the user to their other devices without blocking the UI on a
/// network round trip.
///
/// Unlike [SyncSettings], the remote calls here take an already-open
/// [FirebaseRestClient] rather than opening their own: `runSync()` in
/// `run_sync.dart` is the one place that owns a Firebase session per sync
/// pass, and reconciling this flag rides along on that same client instead
/// of paying for a second sign-in/token-refresh round trip.
library;
import 'package:crdt_sync/crdt_sync.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Where the mirrored value lives in the shared `kuhy-syncs` RTDB project,
/// sibling to `changesets/<nodeId>.json`.
const String kAdvancedModeRemotePath = 'settings/advancedMode';
/// App-wide, non-sync preferences (currently just `advancedMode`).
class AppSettings {
/// Creates an [AppSettings] from its stored fields.
const AppSettings({required this.advancedMode, this.advancedModeUpdatedAt});
/// Whether the priority/status/template/mode chrome and the routine
/// save/sync status text are shown. Off by default: casual capture needs
/// none of it. A sync *failure* is always surfaced regardless of this
/// flag — that is the one state the user might need to act on.
final bool advancedMode;
/// When [advancedMode] was last changed, used to reconcile a value written
/// on another device with the local one (most recent write wins). Null for
/// a value that has never round-tripped through Firebase.
final DateTime? advancedModeUpdatedAt;
static const _kAdvancedMode = 'app.advancedMode';
static const _kAdvancedModeUpdatedAt = 'app.advancedMode.updatedAt';
/// Loads settings, defaulting `advancedMode` to false.
static Future<AppSettings> load() async {
final prefs = await SharedPreferences.getInstance();
final updatedAtMillis = prefs.getInt(_kAdvancedModeUpdatedAt);
return AppSettings(
advancedMode: prefs.getBool(_kAdvancedMode) ?? false,
advancedModeUpdatedAt: updatedAtMillis == null
? null
: DateTime.fromMillisecondsSinceEpoch(updatedAtMillis),
);
}
/// Persists [value] locally (stamping [advancedModeUpdatedAt] to now so a
/// later reconciliation can tell which write is newer) and, when [client]
/// is a live Firebase session, best-effort mirrors it remotely. A push
/// failure — network (`FirebaseSyncError`) or an expired/wrong-account
/// session (`FirebaseAuthError`), both `RemoteSyncError` — is swallowed:
/// the local write already succeeded, and the next successful sync will
/// retry via [reconcileWithRemote].
Future<AppSettings> withAdvancedMode({
required bool value,
FirebaseRestClient? client,
}) async {
final updatedAt = DateTime.now();
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_kAdvancedMode, value);
await prefs.setInt(
_kAdvancedModeUpdatedAt,
updatedAt.millisecondsSinceEpoch,
);
final updated = AppSettings(
advancedMode: value,
advancedModeUpdatedAt: updatedAt,
);
if (client != null) {
try {
// getStringMap() only keeps entries whose JSON value is a string,
// so both fields are written as strings rather than a bool/int.
await client.patchValues(kAdvancedModeRemotePath, {
'value': value.toString(),
'updatedAtMillis': updatedAt.millisecondsSinceEpoch.toString(),
});
} on RemoteSyncError {
// Offline, a transient failure, or an expired/wrong-account session:
// the local write stands, and the next runSync() reconciliation will
// push it again.
}
}
return updated;
}
/// Pulls the remote value from [client] and, if it is newer than the
/// local one, applies it locally and returns the reconciled settings.
/// Returns this instance unchanged when [client] is null, there is
/// nothing remote, the remote value is not newer, or the fetch fails with
/// a [RemoteSyncError] — network, or an expired/wrong-account session
/// (offline sync is a normal state, not an error).
Future<AppSettings> reconcileWithRemote(FirebaseRestClient? client) async {
if (client == null) return this;
try {
final remote = await client.getStringMap(kAdvancedModeRemotePath);
if (!remote.containsKey('value')) return this;
final remoteUpdatedMillis = int.tryParse(
remote['updatedAtMillis'] ?? '',
);
if (remoteUpdatedMillis == null) return this;
final remoteUpdatedAt = DateTime.fromMillisecondsSinceEpoch(
remoteUpdatedMillis,
);
final candidate = AppSettings(
advancedMode: remote['value'] == 'true',
advancedModeUpdatedAt: remoteUpdatedAt,
);
final adopted = adopt(candidate);
if (identical(adopted, this)) return this;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_kAdvancedMode, adopted.advancedMode);
await prefs.setInt(_kAdvancedModeUpdatedAt, remoteUpdatedMillis);
return adopted;
} on RemoteSyncError {
return this;
}
}
/// Returns [candidate] if it carries a strictly newer
/// [advancedModeUpdatedAt] than this instance, otherwise returns this
/// instance unchanged. Shared by every call site that reconciles a
/// remote-or-otherwise-external value against the current in-memory
/// settings (`reconcileWithRemote` above, and the capture/settings
/// screens after a sync completes), so the "newer write wins" rule is
/// defined once instead of copied at each call site.
AppSettings adopt(AppSettings? candidate) {
if (candidate == null) return this;
final candidateAt = candidate.advancedModeUpdatedAt;
if (candidateAt == null) return this;
final currentAt = advancedModeUpdatedAt;
if (currentAt != null && !candidateAt.isAfter(currentAt)) return this;
return candidate;
}
}

View File

@ -194,14 +194,14 @@ class NoteRepository {
/// Returns the live notes matching [filter], ordered by [sort].
Future<List<Note>> listNotes({
NoteSort sort = NoteSort.modifiedDesc,
NoteSort sort = NoteSort.priorityDesc,
NoteFilter filter = const NoteFilter(),
}) async => _query(sort, filter);
/// Emits the matching, ordered note list immediately and re-emits whenever
/// the log changes, so the UI stays in sync without manual refreshes.
Stream<List<Note>> watchNotes({
NoteSort sort = NoteSort.modifiedDesc,
NoteSort sort = NoteSort.priorityDesc,
NoteFilter filter = const NoteFilter(),
}) => _watch(() => _query(sort, filter));

View File

@ -3,6 +3,8 @@
// end-to-end by running the app, not unit tests.
import 'package:flutter/material.dart';
import 'package:todo/analytics/analytics_service.dart';
import 'package:todo/data/app_settings.dart';
import 'package:todo/data/note_repository.dart';
import 'package:todo/data/repository_factory.dart';
import 'package:todo/frame_stats.dart';
@ -17,26 +19,52 @@ Future<void> main() async {
// platform-specific and resolved by a conditional import: `dart:io` cannot be
// imported at all in a web compile, and the desktop app is now a web build.
final repository = await openRepository();
final appSettings = ValueNotifier(await AppSettings.load());
final analytics = AnalyticsService(nodeId: repository.nodeId);
runApp(TodoApp(repository: repository));
runApp(
TodoApp(
repository: repository,
appSettings: appSettings,
analytics: analytics,
),
);
}
/// Root widget. Holds the single [NoteRepository] instance and hands it
/// to the screens that need it.
class TodoApp extends StatelessWidget {
/// Creates a [TodoApp] wrapping the given [repository].
const TodoApp({required this.repository, super.key});
/// Creates a [TodoApp] wrapping the given [repository], [appSettings] and
/// [analytics].
const TodoApp({
required this.repository,
required this.appSettings,
required this.analytics,
super.key,
});
/// The single, app-wide note store, injected into every screen.
final NoteRepository repository;
/// The single, app-wide preferences notifier (currently just
/// `advancedMode`), injected into every screen that reads or changes it.
final ValueNotifier<AppSettings> appSettings;
/// The single, app-wide analytics logger, injected into every screen that
/// reports interaction events.
final AnalyticsService analytics;
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'todo',
theme: buildLightTheme(),
darkTheme: buildDarkTheme(),
home: CaptureScreen(repository: repository),
home: CaptureScreen(
repository: repository,
appSettings: appSettings,
analytics: analytics,
),
);
}
}

View File

@ -8,12 +8,29 @@ library;
import 'package:crdt_sync/crdt_sync.dart';
import 'package:http/http.dart' as http;
import 'package:todo/analytics/analytics_service.dart';
import 'package:todo/data/app_settings.dart';
import 'package:todo/data/note_repository.dart';
import 'package:todo/sync/firebase_backend.dart';
import 'package:todo/sync/sync_service.dart';
import 'package:todo/sync/sync_settings.dart';
import 'package:todo/sync/sync_state_factory.dart';
/// Bundles a [SyncResult] with the [AppSettings] as reconciled against the
/// same Firebase session — so a settings change made on another device is
/// picked up without a second authenticated round trip.
class SyncRunResult {
/// Creates a [SyncRunResult] from a completed run's outcomes.
const SyncRunResult({required this.syncResult, this.appSettings});
/// Outcome of the note sync itself.
final SyncResult syncResult;
/// [AppSettings] after reconciling with the remote mirror, or null when
/// the caller did not pass one in to reconcile.
final AppSettings? appSettings;
}
/// Runs one sync, choosing the backend and carrying the revision cache.
///
/// Firebase is the primary when this device has been set up for it, with
@ -21,11 +38,19 @@ import 'package:todo/sync/sync_state_factory.dart';
/// converges. Not being set up is a normal state, not an error — sync runs
/// over GitHub exactly as it did before.
///
/// [appSettings] and [analytics], when given, are reconciled/flushed
/// against the Firebase mirror using this same call's already-open client —
/// riding along on the one authenticated session this function opens,
/// rather than each concern (notes, settings, analytics) opening and
/// closing its own.
///
/// [firebaseFactory] and [stateStore] exist for tests, which must not reach
/// the OS keystore or an application-support directory.
Future<SyncResult> runSync(
Future<SyncRunResult> runSync(
NoteRepository repository,
SyncSettings settings, {
AppSettings? appSettings,
AnalyticsService? analytics,
http.Client? httpClient,
Future<FirebaseRestClient?> Function()? firebaseFactory,
SyncStateStore? stateStore,
@ -53,7 +78,11 @@ Future<SyncResult> runSync(
? github
: MirrorStore(primary: firebase, mirror: github),
);
return result.withFirebaseConnected(value: firebase != null);
await analytics?.flush(firebase);
return SyncRunResult(
syncResult: result.withFirebaseConnected(value: firebase != null),
appSettings: await appSettings?.reconcileWithRemote(firebase),
);
} finally {
// Close both: unlike the long-lived desktop callers, each screen builds
// these per run.

View File

@ -3,6 +3,9 @@ import 'package:crdt_sync/crdt_sync.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:todo/analytics/analytics_event.dart';
import 'package:todo/analytics/analytics_service.dart';
import 'package:todo/data/app_settings.dart';
import 'package:todo/data/note.dart';
import 'package:todo/data/note_repository.dart';
import 'package:todo/data/note_template.dart';
@ -22,13 +25,14 @@ import 'package:uuid/uuid.dart';
///
/// Per the product goal "no interruptions, immediate", text is persisted
/// to local storage on *every* keystroke. A note row is created lazily on
/// the first character typed, then updated in place. The explicit "Save"
/// action finalises the current idea and clears the field for the next
/// one (remote sync will hook in here later).
/// the first character typed, then updated in place. The "new note" action
/// finalises the current idea and clears the field for the next one.
class CaptureScreen extends StatefulWidget {
/// Creates a [CaptureScreen] backed by [repository].
const CaptureScreen({
required this.repository,
required this.appSettings,
this.analytics,
this.httpClient,
this.localBackup,
this.firebaseFactory,
@ -39,6 +43,17 @@ class CaptureScreen extends StatefulWidget {
/// The store the captured note is persisted to on every keystroke.
final NoteRepository repository;
/// App-wide preferences, currently just `advancedMode`: whether the
/// priority/status row and the routine save/sync status line are shown.
/// A sync failure is always surfaced regardless of this flag.
final ValueNotifier<AppSettings> appSettings;
/// Interaction-only usage analytics (taps, navigation, sync outcomes —
/// never note text). Null in tests that don't exercise it, so the prefs
/// round-trip `logEvent` does on every call never has to be awaited by a
/// test that didn't ask for it.
final AnalyticsService? analytics;
/// Builds the Firebase backend. Injected so tests can supply a fake, or
/// null to assert the pre-migration GitHub-only path still works.
final Future<FirebaseRestClient?> Function()? firebaseFactory;
@ -117,9 +132,26 @@ class _CaptureScreenState extends State<CaptureScreen>
/// (template/mode selectors) is also hidden, so the two stay in lockstep.
bool _chromeVisible = true;
/// When this session started, for the duration on the `app_close` event.
final DateTime _sessionStart = DateTime.now();
/// Logs an interaction event without blocking the caller on the prefs
/// round-trip `AnalyticsService.logEvent` does. A no-op when
/// [CaptureScreen.analytics] is null (tests that don't exercise it).
void _logEvent(String name, {Map<String, Object?> params = const {}}) {
final analytics = widget.analytics;
if (analytics == null) return;
unawaited(
analytics.logEvent(
AnalyticsEvent(name: name, timestamp: DateTime.now(), params: params),
),
);
}
@override
void initState() {
super.initState();
_logEvent('app_open');
WidgetsBinding.instance.addObserver(this);
_localBackup =
widget.localBackup ?? _platformLocalBackup(widget.repository);
@ -147,6 +179,12 @@ class _CaptureScreenState extends State<CaptureScreen>
@override
void dispose() {
_logEvent(
'app_close',
params: {
'durationMs': DateTime.now().difference(_sessionStart).inMilliseconds,
},
);
WidgetsBinding.instance.removeObserver(this);
_autoSyncDebounce?.cancel();
unawaited(_changesSub?.cancel());
@ -223,6 +261,14 @@ class _CaptureScreenState extends State<CaptureScreen>
void didChangeAppLifecycleState(AppLifecycleState state) {
// Push on background so the remote (the durable store) stays near-current.
if (state == AppLifecycleState.paused) {
// The realistic "user left the app" signal on Android: the process may
// be killed right after, so dispose() is not guaranteed to run.
_logEvent(
'app_close',
params: {
'durationMs': DateTime.now().difference(_sessionStart).inMilliseconds,
},
);
// Immediate: Android may kill the app before a debounce timer fires.
_autoSyncDebounce?.cancel();
unawaited(_autoSync());
@ -249,25 +295,43 @@ class _CaptureScreenState extends State<CaptureScreen>
if (!settings.isConfigured && await openFirebase() == null) return;
_autoSyncing = true;
try {
final result = await runSync(
final run = await runSync(
widget.repository,
settings,
appSettings: widget.appSettings.value,
analytics: widget.analytics,
httpClient: widget.httpClient,
firebaseFactory: widget.firebaseFactory,
stateStore: widget.stateStore,
);
await _recordSyncStatus(ok: true, detail: _describe(result));
_adoptReconciledSettings(run.appSettings);
final detail = _describe(run.syncResult);
await _recordSyncStatus(ok: true, detail: detail);
_logEvent(
'sync_result',
params: {'ok': true, 'auto': true, 'detail': detail},
);
} on Exception catch (e) {
// Offline or a transient GitHub error: still no snackbar (this path
// must never interrupt capture), but the status line shows it.
await _recordSyncStatus(ok: false, detail: '$e');
_logEvent('sync_result', params: {'ok': false, 'auto': true});
} finally {
_autoSyncing = false;
}
}
/// Applies a settings snapshot reconciled during a sync pass, when it is
/// newer than what this screen already holds (the toggle in Settings may
/// have written a newer value locally in between, which must win).
void _adoptReconciledSettings(AppSettings? reconciled) {
if (!mounted) return;
widget.appSettings.value = widget.appSettings.value.adopt(reconciled);
}
/// Opens the settings screen and adopts any saved configuration.
Future<void> _openSettings() async {
_logEvent('screen_view', params: {'screen': 'settings'});
final current = _settings ?? await SyncSettings.load();
if (!mounted) return;
await Navigator.of(context).push<SyncSettings>(
@ -275,6 +339,8 @@ class _CaptureScreenState extends State<CaptureScreen>
builder: (_) => SettingsScreen(
initial: current,
repository: widget.repository,
appSettings: widget.appSettings,
analytics: widget.analytics,
httpClient: widget.httpClient,
),
),
@ -288,6 +354,7 @@ class _CaptureScreenState extends State<CaptureScreen>
}
void _openList() {
_logEvent('screen_view', params: {'screen': 'notes_list'});
unawaited(
Navigator.of(context).push<void>(
MaterialPageRoute(
@ -307,18 +374,26 @@ class _CaptureScreenState extends State<CaptureScreen>
}
setState(() => _syncing = true);
try {
final result = await runSync(
final run = await runSync(
widget.repository,
settings,
appSettings: widget.appSettings.value,
analytics: widget.analytics,
httpClient: widget.httpClient,
firebaseFactory: widget.firebaseFactory,
stateStore: widget.stateStore,
);
final detail = _describe(result);
_adoptReconciledSettings(run.appSettings);
final detail = _describe(run.syncResult);
await _recordSyncStatus(ok: true, detail: detail);
_logEvent(
'sync_result',
params: {'ok': true, 'auto': false, 'detail': detail},
);
_showSnack('Synced: $detail');
} on Exception catch (e) {
await _recordSyncStatus(ok: false, detail: '$e');
_logEvent('sync_result', params: {'ok': false, 'auto': false});
_showSnack('Sync failed: $e');
} finally {
if (mounted) setState(() => _syncing = false);
@ -342,6 +417,7 @@ class _CaptureScreenState extends State<CaptureScreen>
if (text.trim().isEmpty) return;
_draftId = _uuid.v4();
_draftCreatedAt = DateTime.now();
_logEvent('note_created');
}
final now = DateTime.now();
await widget.repository.upsert(
@ -360,6 +436,10 @@ class _CaptureScreenState extends State<CaptureScreen>
/// Applies a new priority to the draft, persisting immediately if a note
/// row already exists (otherwise it is applied on the first keystroke).
Future<void> _setPriority(Priority priority) async {
_logEvent(
'priority_changed',
params: {'from': _draftPriority.name, 'to': priority.name},
);
setState(() => _draftPriority = priority);
await _persistDraftMeta();
}
@ -367,6 +447,10 @@ class _CaptureScreenState extends State<CaptureScreen>
/// Applies a new status to the draft, persisting immediately if a note
/// row already exists.
Future<void> _setStatus(Status status) async {
_logEvent(
'status_changed',
params: {'from': _draftStatus.name, 'to': status.name},
);
setState(() => _draftStatus = status);
await _persistDraftMeta();
}
@ -389,9 +473,10 @@ class _CaptureScreenState extends State<CaptureScreen>
}
/// Finalises the current idea and resets the field to a fresh template.
/// The field going blank is the only feedback needed — autosave already
/// persisted every keystroke, so there is nothing left to confirm.
void _saveAndReset() {
// A note was actually persisted only if a draft row was created.
final saved = _draftId != null;
_logEvent('new_note_reset');
setState(() {
_editorGeneration++; // recreate the editor with a fresh template
_draftText = '';
@ -402,165 +487,182 @@ class _CaptureScreenState extends State<CaptureScreen>
_chromeVisible = true;
});
_lastSavedAt.value = null;
if (saved) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Idea saved locally'),
duration: Duration(seconds: 1),
),
);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('Capture'),
actions: [
// TEMPORARY: forces continuous frame production so raster cost can
// be sampled at each window size. Armed by TODO_FRAME_STATS=1, which
// is never set under the test runner, so the branch is unreachable.
// coverage:ignore-start
if (frameStatsEnabled)
const Padding(
padding: EdgeInsets.only(right: 8),
child: Center(
child: SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
return ValueListenableBuilder<AppSettings>(
valueListenable: widget.appSettings,
builder: (context, settings, _) {
final advanced = settings.advancedMode;
return Scaffold(
appBar: AppBar(
actions: [
// TEMPORARY: forces continuous frame production so raster cost
// can be sampled at each window size. Armed by
// TODO_FRAME_STATS=1, which is never set under the test
// runner, so the branch is unreachable.
// coverage:ignore-start
if (frameStatsEnabled)
const Padding(
padding: EdgeInsets.only(right: 8),
child: Center(
child: SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
),
),
),
// coverage:ignore-end
// Live count of stored notes, proving local persistence.
StreamBuilder<int>(
stream: widget.repository.watchCount(),
builder: (context, snapshot) {
final count = snapshot.data ?? 0;
return Padding(
padding: const EdgeInsets.only(right: 4),
child: Center(child: Text('$count saved')),
);
},
),
IconButton(
tooltip: 'Sync',
onPressed: _syncing ? null : _sync,
icon: _syncing
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.sync),
),
IconButton(
tooltip: 'Notes',
onPressed: _openList,
icon: const Icon(Icons.list),
),
IconButton(
tooltip: 'Sync settings',
onPressed: _openSettings,
icon: const Icon(Icons.settings),
),
],
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Pickers sit above the editor so the bottom-right Save FAB
// never overlaps them. Hidden together with the editor's own
// chrome while the bare guided stepper or its entry wizard is up,
// so the top of the screen stays free of noise.
if (_chromeVisible) ...[
Row(
children: [
Expanded(
child: _MetaDropdown<Priority>(
label: 'Priority',
value: _draftPriority,
values: Priority.values,
labelOf: (p) => p.label,
onChanged: _setPriority,
// coverage:ignore-end
// Live count of stored notes, proving local persistence.
StreamBuilder<int>(
stream: widget.repository.watchCount(),
builder: (context, snapshot) {
final count = snapshot.data ?? 0;
return Padding(
padding: const EdgeInsets.only(right: 4),
child: Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.list_alt_outlined, size: 18),
const SizedBox(width: 4),
Text('$count'),
],
),
),
),
const SizedBox(width: 12),
Expanded(
child: _MetaDropdown<Status>(
label: 'Status',
value: _draftStatus,
values: Status.values,
labelOf: (s) => s.label,
onChanged: _setStatus,
),
),
],
);
},
),
IconButton(
tooltip: 'New note',
onPressed: _saveAndReset,
icon: const Icon(Icons.add),
),
if (advanced)
IconButton(
tooltip: 'Sync',
onPressed: _syncing ? null : _sync,
icon: _syncing
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.sync),
),
IconButton(
tooltip: 'Notes',
onPressed: _openList,
icon: const Icon(Icons.list),
),
IconButton(
tooltip: 'Settings',
onPressed: _openSettings,
icon: const Icon(Icons.settings),
),
const SizedBox(height: 12),
],
Expanded(
child: NoteEditor(
key: ValueKey(_editorGeneration),
initialTemplate: NoteTemplate.defaultTemplate,
initialMode: NoteEditorMode.raw,
priority: _draftPriority,
onPriorityChanged: _setPriority,
onChromeVisibleChanged: (visible) =>
setState(() => _chromeVisible = visible),
autofocus: true,
onChanged: _onChanged,
),
),
const SizedBox(height: 8),
// Leave room so the Save FAB doesn't cover the indicators.
Padding(
padding: const EdgeInsets.only(right: 96),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ValueListenableBuilder<DateTime?>(
valueListenable: _lastSavedAt,
builder: (context, savedAt, _) => Text(
savedAt == null
? 'Autosaves as you type'
: 'Saved locally at ${_formatTime(savedAt)}',
style: theme.textTheme.bodySmall,
),
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Hidden together with the editor's own chrome while the
// bare guided stepper or its entry wizard is up, so the top
// of the screen stays free of noise. Also hidden outright
// when advanced mode is off.
if (advanced && _chromeVisible) ...[
Row(
children: [
Expanded(
child: _MetaDropdown<Priority>(
label: 'Priority',
value: _draftPriority,
values: Priority.values,
labelOf: (p) => p.label,
onChanged: _setPriority,
),
),
const SizedBox(width: 12),
Expanded(
child: _MetaDropdown<Status>(
label: 'Status',
value: _draftStatus,
values: Status.values,
labelOf: (s) => s.label,
onChanged: _setStatus,
),
),
],
),
ValueListenableBuilder<SyncStatus?>(
valueListenable: _lastSync,
builder: (context, status, _) {
if (status == null) return const SizedBox.shrink();
return Text(
'${status.ok ? 'Synced' : 'Sync failed'} at '
'${_formatTime(status.time)} · ${status.detail}',
const SizedBox(height: 12),
],
Expanded(
// The key lives on this Expanded, not on NoteEditor itself:
// the Row above is conditionally present, which shifts
// this Expanded's position in the Column whenever
// advancedMode changes at runtime. A key one level too
// deep (previously on NoteEditor) doesn't survive that
// reposition — Flutter can't match it at the new index,
// so it tears down and remounts NoteEditor with an empty
// draft, discarding whatever the user was typing.
key: ValueKey(_editorGeneration),
child: NoteEditor(
initialTemplate: NoteTemplate.defaultTemplate,
initialMode: NoteEditorMode.raw,
advancedMode: advanced,
priority: _draftPriority,
onPriorityChanged: _setPriority,
onChromeVisibleChanged: (visible) =>
setState(() => _chromeVisible = visible),
autofocus: true,
onChanged: _onChanged,
),
),
// Sync is automatic; routine "saved"/"synced" chatter is not
// worth showing. Only a sync failure is surfaced, since that
// is the one state the user might need to act on.
ValueListenableBuilder<SyncStatus?>(
valueListenable: _lastSync,
builder: (context, status, _) {
if (status == null || status.ok) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
'Sync failed at ${_formatTime(status.time)} · '
'${status.detail}',
style: theme.textTheme.bodySmall?.copyWith(
color: status.ok ? null : theme.colorScheme.error,
color: theme.colorScheme.error,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
);
},
),
);
},
),
if (advanced)
Padding(
padding: const EdgeInsets.only(top: 4),
child: ValueListenableBuilder<DateTime?>(
valueListenable: _lastSavedAt,
builder: (context, savedAt, _) => Text(
savedAt == null
? 'Autosaves as you type'
: 'Saved locally at ${_formatTime(savedAt)}',
style: theme.textTheme.bodySmall,
),
),
),
],
),
],
),
],
),
),
floatingActionButton: _chromeVisible
? FloatingActionButton.extended(
onPressed: _saveAndReset,
icon: const Icon(Icons.check),
label: const Text('Save'),
)
: null,
),
);
},
);
}

View File

@ -51,6 +51,7 @@ class NoteEditor extends StatefulWidget {
this.initialTemplate,
this.initialMode = NoteEditorMode.guided,
this.autofocus = false,
this.advancedMode = true,
super.key,
});
@ -85,6 +86,16 @@ class NoteEditor extends StatefulWidget {
/// typing — preserving the app's instant-capture invariant.
final bool autofocus;
/// Whether the template picker and View/Guided/Raw mode toggle are shown.
/// For a freshly-mounted editor, false pins the mode to
/// [NoteEditorMode.raw] regardless of [initialMode], and Guided/Preview
/// are unreachable — casual capture needs neither. Toggling this off
/// while already mid-Guided does not itself force an immediate mode
/// change (the open mode stays until the next mode transition, which
/// then resolves to raw) — a transient, self-healing state, not a hole
/// in the gating.
final bool advancedMode;
@override
State<NoteEditor> createState() => _NoteEditorState();
}
@ -179,8 +190,11 @@ class _NoteEditorState extends State<NoteEditor> {
bool get _canOpenGuided => !_template.isFreeform && !_rawSource;
/// Picks the mode to actually display: honours [desired] unless Guided was
/// asked for when it can't be opened, in which case fall back to Raw.
/// asked for when it can't be opened (falls back to Raw), or
/// [NoteEditor.advancedMode] is off (Preview/Guided are unreachable
/// without their chrome, so Raw is the only mode shown).
NoteEditorMode _resolveMode(NoteEditorMode desired) {
if (!widget.advancedMode) return NoteEditorMode.raw;
if (desired == NoteEditorMode.guided && !_canOpenGuided) {
return NoteEditorMode.raw;
}
@ -370,6 +384,13 @@ class _NoteEditorState extends State<NoteEditor> {
// that silently collapses the inner flex space in release builds.
if (_mode == NoteEditorMode.guided) return _buildStepPage(theme);
// Advanced mode off: the template picker and mode toggle are hidden and
// _resolveMode() has already pinned _mode to raw, so skip straight to
// the body directly (not wrapped in another Expanded — the parent
// screen already wraps this whole widget in one, same as the guided
// branch above).
if (!widget.advancedMode) return _buildBody(theme);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [

View File

@ -38,7 +38,7 @@ class _NotesListScreenState extends State<NotesListScreen> {
final TextEditingController _searchController = TextEditingController();
Timer? _debounce;
NoteSort _sort = NoteSort.modifiedDesc;
NoteSort _sort = NoteSort.priorityDesc;
/// Default view hides completed/dropped work: only To do + In progress.
/// The user can widen this (or clear it) via the filter sheet.

View File

@ -5,6 +5,9 @@ import 'package:file_selector/file_selector.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:http/http.dart' as http;
import 'package:todo/analytics/analytics_event.dart';
import 'package:todo/analytics/analytics_service.dart';
import 'package:todo/data/app_settings.dart';
import 'package:todo/data/note_repository.dart';
import 'package:todo/sync/backlog_export.dart';
import 'package:todo/sync/firebase_backend.dart';
@ -30,6 +33,8 @@ class SettingsScreen extends StatefulWidget {
const SettingsScreen({
required this.initial,
required this.repository,
required this.appSettings,
this.analytics,
this.httpClient,
this.firebaseFactory,
this.googleFirebaseFactory,
@ -48,6 +53,13 @@ class SettingsScreen extends StatefulWidget {
/// The store backup export/import reads from and writes to.
final NoteRepository repository;
/// App-wide preferences, currently just `advancedMode`. The "Enable
/// advanced" switch on this screen reads and writes it directly.
final ValueNotifier<AppSettings> appSettings;
/// Interaction-only usage analytics. Null in tests that don't exercise it.
final AnalyticsService? analytics;
/// Builds the Firebase backend. Injected so tests can supply a fake, or
/// null to assert the pre-migration GitHub-only path still works.
final Future<FirebaseRestClient?> Function()? firebaseFactory;
@ -310,18 +322,24 @@ class _SettingsScreenState extends State<SettingsScreen> {
Future<void> _syncAfterConnect() async {
final s = _current;
try {
final result = await runSync(
final run = await runSync(
widget.repository,
s,
appSettings: widget.appSettings.value,
analytics: widget.analytics,
httpClient: widget.httpClient,
firebaseFactory: widget.firebaseFactory,
stateStore: widget.stateStore,
);
widget.appSettings.value = widget.appSettings.value.adopt(
run.appSettings,
);
if (mounted) {
setState(
() => _status =
'Connected and synced (merged ${result.mergedDevices} '
'device(s)). Your notes are up to date.',
'Connected and synced (merged '
'${run.syncResult.mergedDevices} device(s)). Your notes are '
'up to date.',
);
}
} on Exception catch (e) {
@ -362,6 +380,45 @@ class _SettingsScreenState extends State<SettingsScreen> {
if (mounted) Navigator.of(context).pop(s);
}
/// Persists the "Enable advanced" toggle locally and, when a Firebase
/// session is available, best-effort mirrors it so the choice follows the
/// user to their other devices.
///
/// The local write always lands — a signed-in-as-wrong-account device
/// throwing [FirebaseAuthError] out of `openFirebase()` must not take the
/// toggle down with it, so opening the client has its own try/catch,
/// separate from (and preceding) the try that guards the local write and
/// mirror push.
Future<void> _setAdvancedMode(bool value) async {
final analytics = widget.analytics;
if (analytics != null) {
unawaited(
analytics.logEvent(
AnalyticsEvent(
name: 'settings_toggle',
timestamp: DateTime.now(),
params: {'key': 'advancedMode', 'value': value},
),
),
);
}
FirebaseRestClient? client;
try {
client = await (widget.firebaseFactory ?? openFirebase)();
} on Exception {
client = null;
}
try {
final updated = await widget.appSettings.value.withAdvancedMode(
value: value,
client: client,
);
if (mounted) widget.appSettings.value = updated;
} finally {
client?.close();
}
}
/// Exports every note to a single Markdown file. On mobile this opens the
/// system share sheet; on desktop it writes the canonical `~/todo/
/// BACKLOG.md` so a future tool/agent has a stable path to read.
@ -410,10 +467,25 @@ class _SettingsScreenState extends State<SettingsScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Sync settings')),
appBar: AppBar(title: const Text('Settings')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
ValueListenableBuilder<AppSettings>(
valueListenable: widget.appSettings,
builder: (context, settings, _) => SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Enable advanced'),
subtitle: const Text(
'Priority/status, templates, view modes, and sync details '
'in the capture screen',
),
value: settings.advancedMode,
onChanged: (value) => unawaited(_setAdvancedMode(value)),
),
),
const Divider(),
const SizedBox(height: 8),
Text(
'Firebase sync',
style: Theme.of(context).textTheme.titleMedium,
@ -507,8 +579,47 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
],
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: FilledButton.icon(
onPressed: _save,
icon: const Icon(Icons.save),
label: const Text('Save'),
),
),
const SizedBox(height: 24),
const Divider(),
const SizedBox(height: 8),
Text('Backup', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 4),
Text(
'Export all notes to a single Markdown file, or import/merge a '
'file back (matching ids are merged, never duplicated).',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
Row(
children: [
OutlinedButton.icon(
onPressed: _export,
icon: const Icon(Icons.upload_file),
label: const Text('Export notes'),
),
const SizedBox(width: 12),
OutlinedButton.icon(
onPressed: _import,
icon: const Icon(Icons.download),
label: const Text('Import notes'),
),
],
),
const SizedBox(height: 24),
const Divider(),
const SizedBox(height: 8),
// GitHub is the cutover mirror, not a choice competing with
// Firebase, so its whole setup lives behind one disclosure.
// Firebase, so its whole setup lives behind one disclosure at the
// very bottom of the screen — last, not suggested as a normal
// option.
ExpansionTile(
title: const Text('Advanced (GitHub mirror)'),
tilePadding: EdgeInsets.zero,
@ -584,41 +695,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
],
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: FilledButton.icon(
onPressed: _save,
icon: const Icon(Icons.save),
label: const Text('Save'),
),
),
const SizedBox(height: 24),
const Divider(),
const SizedBox(height: 8),
Text('Backup', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 4),
Text(
'Export all notes to a single Markdown file, or import/merge a '
'file back (matching ids are merged, never duplicated).',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
Row(
children: [
OutlinedButton.icon(
onPressed: _export,
icon: const Icon(Icons.upload_file),
label: const Text('Export notes'),
),
const SizedBox(width: 12),
OutlinedButton.icon(
onPressed: _import,
icon: const Icon(Icons.download),
label: const Text('Import notes'),
),
],
),
if (_status != null) ...[
const SizedBox(height: 16),
Text(_status!, style: Theme.of(context).textTheme.bodyMedium),

View File

@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.5+1
version: 1.0.6+1
environment:
sdk: ^3.12.2

View File

@ -0,0 +1,300 @@
/// Tests for the interaction-only analytics buffer and Firebase flush.
@TestOn('vm')
library;
import 'dart:convert';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart' as http_testing;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:todo/analytics/analytics_event.dart';
import 'package:todo/analytics/analytics_service.dart';
FirebaseRestClient _fakeClient(
Future<http.Response> Function(http.Request request) handler,
) => FirebaseRestClient(
databaseUrl: 'https://x-rtdb.europe-west1.firebasedatabase.app',
auth: FirebaseTokenProvider(
apiKey: 'AIzaKey',
store: InMemoryCredentialStore(
FirebaseCredentials(
idToken: 'id',
refreshToken: 'refresh',
expiresAt: DateTime.now().add(const Duration(hours: 1)),
),
),
),
httpClient: http_testing.MockClient(handler),
);
void main() {
setUp(() => SharedPreferences.setMockInitialValues({}));
group('AnalyticsEvent', () {
test('round-trips through toJson/tryFromJson', () {
final event = AnalyticsEvent(
name: 'screen_view',
timestamp: DateTime(2026, 1, 1, 12),
params: {'screen': 'capture'},
);
final decoded = AnalyticsEvent.tryFromJson(event.toJson());
expect(decoded, isNotNull);
expect(decoded!.name, 'screen_view');
expect(decoded.timestamp, event.timestamp);
expect(decoded.params, {'screen': 'capture'});
});
test('tryFromJson returns null for a non-map value', () {
expect(AnalyticsEvent.tryFromJson('not a map'), isNull);
expect(AnalyticsEvent.tryFromJson(null), isNull);
});
test('tryFromJson returns null for a missing/invalid name', () {
expect(
AnalyticsEvent.tryFromJson({'timestamp': '2026-01-01T00:00:00.000'}),
isNull,
);
});
test('tryFromJson returns null for an unparsable timestamp', () {
expect(
AnalyticsEvent.tryFromJson({'name': 'x', 'timestamp': 'not a date'}),
isNull,
);
});
test('tryFromJson defaults params to empty when missing/non-map', () {
final decoded = AnalyticsEvent.tryFromJson({
'name': 'x',
'timestamp': DateTime(2026).toIso8601String(),
});
expect(decoded!.params, isEmpty);
});
});
group('AnalyticsService.logEvent', () {
test('appends to an initially empty buffer', () async {
const service = AnalyticsService(nodeId: 'device-a');
await service.logEvent(
AnalyticsEvent(name: 'app_open', timestamp: DateTime.now()),
);
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString('analytics.buffer');
expect(raw, isNotNull);
final decoded = jsonDecode(raw!) as List;
expect(decoded, hasLength(1));
});
test('drops the oldest event once past the cap', () async {
const service = AnalyticsService(nodeId: 'device-a');
for (var i = 0; i < AnalyticsService.maxBufferedEvents + 5; i++) {
await service.logEvent(
AnalyticsEvent(
name: 'event_$i',
timestamp: DateTime.now(),
),
);
}
final prefs = await SharedPreferences.getInstance();
final decoded = jsonDecode(prefs.getString('analytics.buffer')!) as List;
expect(decoded, hasLength(AnalyticsService.maxBufferedEvents));
// The oldest 5 events were dropped, so event_0..event_4 are gone.
final names = decoded.map((e) => (e as Map)['name']).toList();
expect(names, isNot(contains('event_0')));
expect(
names,
contains('event_${AnalyticsService.maxBufferedEvents + 4}'),
);
});
test('ignores a malformed pre-existing buffer entry on read', () async {
SharedPreferences.setMockInitialValues({
'analytics.buffer': jsonEncode([
{'not': 'an event'},
{
'name': 'ok_event',
'timestamp': DateTime(2026).toIso8601String(),
'params': <String, Object?>{},
},
]),
});
const service = AnalyticsService(nodeId: 'device-a');
await service.logEvent(
AnalyticsEvent(name: 'new_event', timestamp: DateTime.now()),
);
final prefs = await SharedPreferences.getInstance();
final decoded = jsonDecode(prefs.getString('analytics.buffer')!) as List;
// The malformed entry was dropped; only the valid + new event remain.
expect(decoded, hasLength(2));
});
});
group('AnalyticsService.flush', () {
test('does nothing when client is null', () async {
const service = AnalyticsService(nodeId: 'device-a');
await service.logEvent(
AnalyticsEvent(name: 'app_open', timestamp: DateTime.now()),
);
await service.flush(null);
final prefs = await SharedPreferences.getInstance();
expect(prefs.getString('analytics.buffer'), isNotNull);
});
test('does nothing when the buffer is empty', () async {
final requests = <String>[];
final client = _fakeClient((request) async {
requests.add(request.method);
return http.Response('{}', 200);
});
const service = AnalyticsService(nodeId: 'device-a');
await service.flush(client);
expect(requests, isEmpty);
});
test('pushes the buffer as one batch and clears it on success', () async {
String? putPath;
String? putBody;
final client = _fakeClient((request) async {
if (request.method == 'PUT') {
putPath = request.url.path;
putBody = request.body;
}
return http.Response(request.body, 200);
});
const service = AnalyticsService(nodeId: 'device-a');
await service.logEvent(
AnalyticsEvent(name: 'app_open', timestamp: DateTime(2026)),
);
await service.logEvent(
AnalyticsEvent(name: 'note_created', timestamp: DateTime(2026)),
);
await service.flush(client);
expect(putPath, startsWith('/analytics/device-a/'));
final decodedBody = jsonDecode(jsonDecode(putBody!) as String) as List;
expect(decodedBody, hasLength(2));
final prefs = await SharedPreferences.getInstance();
expect(prefs.getString('analytics.buffer'), isNull);
});
test('leaves the buffer untouched on a push failure', () async {
final client = _fakeClient((_) async => http.Response('error', 500));
const service = AnalyticsService(nodeId: 'device-a');
await service.logEvent(
AnalyticsEvent(name: 'app_open', timestamp: DateTime.now()),
);
await service.flush(client);
final prefs = await SharedPreferences.getInstance();
final decoded = jsonDecode(prefs.getString('analytics.buffer')!) as List;
expect(decoded, hasLength(1));
});
test('keeps an event logged after the batch was already claimed', () async {
// The HTTP handler runs mid-`putFileText`, strictly after flush() has
// already claimed (cleared) the buffer — so writing here lands in a
// buffer this flush() call no longer owns, unlike writing to prefs
// before calling flush() (which would just become part of the claimed
// batch itself).
final client = _fakeClient((request) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
'analytics.buffer',
jsonEncode([
AnalyticsEvent(
name: 'mid_flush_event',
timestamp: DateTime.now(),
).toJson(),
]),
);
return http.Response(request.body, 200);
});
const service = AnalyticsService(nodeId: 'device-a');
await service.logEvent(
AnalyticsEvent(name: 'app_open', timestamp: DateTime.now()),
);
await service.flush(client);
final prefs = await SharedPreferences.getInstance();
final remaining =
jsonDecode(prefs.getString('analytics.buffer')!) as List;
expect(remaining, hasLength(1));
expect((remaining.single as Map)['name'], 'mid_flush_event');
});
test(
'restores the claimed batch ahead of events logged mid-flush on '
'failure',
() async {
// The HTTP handler runs mid-`putFileText`, strictly after flush()
// has already claimed (cleared) the buffer, so writing here is a
// genuine race rather than something that could instead land before
// or after `flush()` depending on scheduling.
final client = _fakeClient((_) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
'analytics.buffer',
jsonEncode([
AnalyticsEvent(
name: 'mid_flush_event',
timestamp: DateTime(2026),
).toJson(),
]),
);
return http.Response('error', 500);
});
const service = AnalyticsService(nodeId: 'device-a');
await service.logEvent(
AnalyticsEvent(name: 'app_open', timestamp: DateTime(2026)),
);
await service.flush(client);
final prefs = await SharedPreferences.getInstance();
final remaining =
jsonDecode(prefs.getString('analytics.buffer')!) as List;
expect(remaining, hasLength(2));
expect((remaining[0] as Map)['name'], 'app_open');
expect((remaining[1] as Map)['name'], 'mid_flush_event');
},
);
test(
'a RemoteSyncError from an expired session restores the buffer',
() async {
final client = FirebaseRestClient(
databaseUrl: 'https://x-rtdb.europe-west1.firebasedatabase.app',
auth: FirebaseTokenProvider(
apiKey: 'AIzaKey',
store: InMemoryCredentialStore(null),
),
);
const service = AnalyticsService(nodeId: 'device-a');
await service.logEvent(
AnalyticsEvent(name: 'app_open', timestamp: DateTime(2026)),
);
await service.flush(client);
final prefs = await SharedPreferences.getInstance();
final decoded =
jsonDecode(prefs.getString('analytics.buffer')!) as List;
expect(decoded, hasLength(1));
},
);
});
}

231
test/app_settings_test.dart Normal file
View File

@ -0,0 +1,231 @@
/// Tests for the `advancedMode` preference: local persistence and
/// reconciliation against the Firebase mirror.
@TestOn('vm')
library;
import 'package:crdt_sync/crdt_sync.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart' as http_testing;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:todo/data/app_settings.dart';
FirebaseRestClient _fakeClient(
Future<http.Response> Function(http.Request request) handler,
) => FirebaseRestClient(
databaseUrl: 'https://x-rtdb.europe-west1.firebasedatabase.app',
auth: FirebaseTokenProvider(
apiKey: 'AIzaKey',
store: InMemoryCredentialStore(
FirebaseCredentials(
idToken: 'id',
refreshToken: 'refresh',
expiresAt: DateTime.now().add(const Duration(hours: 1)),
),
),
),
httpClient: http_testing.MockClient(handler),
);
/// A client with no stored session: any call throws [FirebaseAuthError],
/// the sibling of [FirebaseSyncError] under the shared [RemoteSyncError]
/// supertype — used to confirm the wide-plus-network catches also cover an
/// expired/wrong-account session, not just a transient network failure.
FirebaseRestClient _authFailingClient() => FirebaseRestClient(
databaseUrl: 'https://x-rtdb.europe-west1.firebasedatabase.app',
auth: FirebaseTokenProvider(
apiKey: 'AIzaKey',
store: InMemoryCredentialStore(),
),
);
void main() {
setUp(() => SharedPreferences.setMockInitialValues({}));
group('load', () {
test('defaults advancedMode to false with no updatedAt', () async {
final settings = await AppSettings.load();
expect(settings.advancedMode, isFalse);
expect(settings.advancedModeUpdatedAt, isNull);
});
test('reads back a previously persisted value', () async {
final now = DateTime(2026, 1, 1, 12);
SharedPreferences.setMockInitialValues({
'app.advancedMode': true,
'app.advancedMode.updatedAt': now.millisecondsSinceEpoch,
});
final settings = await AppSettings.load();
expect(settings.advancedMode, isTrue);
expect(settings.advancedModeUpdatedAt, now);
});
});
group('withAdvancedMode', () {
test('persists locally with no client', () async {
const initial = AppSettings(advancedMode: false);
final updated = await initial.withAdvancedMode(value: true);
expect(updated.advancedMode, isTrue);
expect(updated.advancedModeUpdatedAt, isNotNull);
final reloaded = await AppSettings.load();
expect(reloaded.advancedMode, isTrue);
});
test('mirrors to a live client via patchValues', () async {
final requests = <String>[];
final client = _fakeClient((request) async {
requests.add('${request.method} ${request.url.path}');
return http.Response(request.body, 200);
});
const initial = AppSettings(advancedMode: false);
final updated = await initial.withAdvancedMode(
value: true,
client: client,
);
expect(updated.advancedMode, isTrue);
expect(requests, contains('PATCH /settings/advancedMode.json'));
});
test('swallows a push failure and keeps the local write', () async {
final client = _fakeClient(
(_) async => http.Response('error', 500),
);
const initial = AppSettings(advancedMode: false);
final updated = await initial.withAdvancedMode(
value: true,
client: client,
);
// The local write still landed even though the mirror push failed.
expect(updated.advancedMode, isTrue);
final reloaded = await AppSettings.load();
expect(reloaded.advancedMode, isTrue);
});
test(
'swallows a FirebaseAuthError (expired session) and keeps the local '
'write',
() async {
const initial = AppSettings(advancedMode: false);
final updated = await initial.withAdvancedMode(
value: true,
client: _authFailingClient(),
);
expect(updated.advancedMode, isTrue);
final reloaded = await AppSettings.load();
expect(reloaded.advancedMode, isTrue);
},
);
});
group('reconcileWithRemote', () {
test('returns unchanged when client is null', () async {
const initial = AppSettings(advancedMode: false);
final result = await initial.reconcileWithRemote(null);
expect(identical(result, initial), isTrue);
});
test('returns unchanged when nothing is stored remotely', () async {
final client = _fakeClient((_) async => http.Response('null', 200));
const initial = AppSettings(advancedMode: false);
final result = await initial.reconcileWithRemote(client);
expect(result.advancedMode, isFalse);
expect(result.advancedModeUpdatedAt, isNull);
});
test(
'adopts the remote value when local has never round-tripped',
() async {
final remoteTime = DateTime(2026);
final client = _fakeClient(
(_) async => http.Response(
'{"value": "true", '
'"updatedAtMillis": "${remoteTime.millisecondsSinceEpoch}"}',
200,
),
);
const initial = AppSettings(advancedMode: false);
final result = await initial.reconcileWithRemote(client);
expect(result.advancedMode, isTrue);
expect(result.advancedModeUpdatedAt, remoteTime);
final reloaded = await AppSettings.load();
expect(reloaded.advancedMode, isTrue);
},
);
test('ignores a remote value that is not newer than local', () async {
final localTime = DateTime(2026, 2);
final remoteTime = DateTime(2026); // older
final client = _fakeClient(
(_) async => http.Response(
'{"value": "true", '
'"updatedAtMillis": "${remoteTime.millisecondsSinceEpoch}"}',
200,
),
);
final initial = AppSettings(
advancedMode: false,
advancedModeUpdatedAt: localTime,
);
final result = await initial.reconcileWithRemote(client);
expect(result.advancedMode, isFalse);
expect(result.advancedModeUpdatedAt, localTime);
});
test('adopts a remote value newer than local', () async {
final localTime = DateTime(2026);
final remoteTime = DateTime(2026, 2); // newer
final client = _fakeClient(
(_) async => http.Response(
'{"value": "true", '
'"updatedAtMillis": "${remoteTime.millisecondsSinceEpoch}"}',
200,
),
);
final initial = AppSettings(
advancedMode: false,
advancedModeUpdatedAt: localTime,
);
final result = await initial.reconcileWithRemote(client);
expect(result.advancedMode, isTrue);
expect(result.advancedModeUpdatedAt, remoteTime);
});
test('swallows a fetch failure and returns unchanged', () async {
final client = _fakeClient((_) async => http.Response('error', 500));
const initial = AppSettings(advancedMode: false);
final result = await initial.reconcileWithRemote(client);
expect(result.advancedMode, isFalse);
});
test(
'swallows a FirebaseAuthError (expired session) and returns unchanged',
() async {
const initial = AppSettings(advancedMode: false);
final result = await initial.reconcileWithRemote(
_authFailingClient(),
);
expect(identical(result, initial), isTrue);
},
);
});
}

View File

@ -6,6 +6,8 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:todo/analytics/analytics_service.dart';
import 'package:todo/data/app_settings.dart';
import 'package:todo/data/note.dart';
import 'package:todo/data/note_template.dart';
import 'package:todo/sync/local_backup.dart';
@ -29,6 +31,11 @@ void main() {
FakeNoteRepository? repository,
LocalBackup? localBackup,
Future<FirebaseRestClient?> Function()? firebaseFactory,
// Defaults to true: these tests exercise today's full UI (priority/status
// dropdowns, sync status text, Guided). Tests for the new default-off
// behavior pass advancedMode: false explicitly.
ValueNotifier<AppSettings>? appSettings,
AnalyticsService? analytics,
}) async {
SharedPreferences.setMockInitialValues(prefs);
installFakeSecureStorage();
@ -54,6 +61,10 @@ void main() {
MaterialApp(
home: CaptureScreen(
repository: repo,
appSettings:
appSettings ??
ValueNotifier(const AppSettings(advancedMode: true)),
analytics: analytics,
httpClient: httpClient,
// Both injected so the widget never reaches for the platform: the
// real factories want the OS keystore and an application-support
@ -83,7 +94,7 @@ void main() {
// Defaults to Raw: a single text field, no note persisted yet.
expect(find.byType(TextField), findsOneWidget);
expect(find.text('Guided'), findsOneWidget);
expect(find.text('0 saved'), findsOneWidget);
expect(find.text('0'), findsOneWidget);
// Tapping Guided on the empty draft opens the priority+template wizard
// rather than jumping straight into the stepper.
@ -108,7 +119,7 @@ void main() {
testWidgets('saving the untouched template creates no note', (tester) async {
final repo = await pumpCapture(tester);
await tester.tap(find.text('Save'));
await tester.tap(find.byTooltip('New note'));
await tester.pump();
expect(await repo.listNotes(), isEmpty);
@ -128,20 +139,15 @@ void main() {
expect(notes.single.text, contains('My idea'));
expect(notes.single.priority, Priority.medium);
expect(notes.single.status, Status.todo);
expect(find.text('1 saved'), findsOneWidget);
expect(find.text('1'), findsOneWidget);
});
testWidgets('save after editing shows a snackbar and resets the template', (
tester,
) async {
testWidgets('the new-note action resets the template', (tester) async {
final repo = await pumpCapture(tester);
await tester.enterText(find.byType(TextField).first, 'A real idea');
await tester.pump();
await tester.tap(find.text('Save'));
await tester.pump(); // build the snackbar
expect(find.text('Idea saved locally'), findsOneWidget);
await tester.tap(find.byTooltip('New note'));
await tester.pump();
expect(await repo.listNotes(), hasLength(1));
@ -296,7 +302,7 @@ void main() {
) async {
await pumpCapture(tester);
await tester.tap(find.byTooltip('Sync settings'));
await tester.tap(find.byTooltip('Settings'));
await tester.pumpAndSettle(); // route transition
expect(find.text('Firebase sync'), findsOneWidget); // settings is up
@ -312,7 +318,7 @@ void main() {
await tester.pumpAndSettle(); // save + pop transition
expect(find.text('Firebase sync'), findsNothing); // back on capture
expect(find.byTooltip('Sync settings'), findsOneWidget);
expect(find.byTooltip('Settings'), findsOneWidget);
});
// A MockClient that records request methods and answers the sync flow:
@ -325,6 +331,120 @@ void main() {
return http.Response('', 404);
});
testWidgets(
'advancedMode off shows just the text field, no chrome or status text',
(tester) async {
await pumpCapture(
tester,
appSettings: ValueNotifier(const AppSettings(advancedMode: false)),
);
await tester.enterText(find.byType(TextField), 'A plain idea');
await tester.pump();
// The editor's own template/mode chrome is gone.
expect(find.text('Guided'), findsNothing);
expect(find.text('Template'), findsNothing);
// The capture screen's priority/status row is gone.
expect(find.text('Priority'), findsNothing);
expect(find.text('Status'), findsNothing);
// Routine status chatter is gone; only a badge count remains.
expect(find.textContaining('Autosaves as you type'), findsNothing);
expect(find.text('1'), findsOneWidget);
// The note still persisted normally underneath the simplified UI.
expect(find.text('A plain idea'), findsOneWidget);
},
);
testWidgets(
'draft text survives an advancedMode toggle mid-typing',
(tester) async {
final appSettings = ValueNotifier(
const AppSettings(advancedMode: true),
);
await pumpCapture(tester, appSettings: appSettings);
await tester.enterText(
find.byType(TextField).first,
'my important idea',
);
await tester.pump();
// The Row above the editor is conditionally present, which shifts the
// editor's position in the Column whenever advancedMode changes at
// runtime — a key one level too shallow doesn't survive that
// reposition and Flutter remounts the editor with an empty draft.
appSettings.value = const AppSettings(advancedMode: false);
await tester.pump();
final field = tester.widget<TextField>(find.byType(TextField).first);
expect(field.controller?.text, contains('my important idea'));
},
);
testWidgets(
'auto-sync adopts a newer advancedMode value from Firebase',
(tester) async {
final remoteTime = DateTime(2026, 6);
final firebase = FirebaseRestClient(
databaseUrl: 'https://x-rtdb.europe-west1.firebasedatabase.app',
auth: FirebaseTokenProvider(
apiKey: 'AIzaKey',
store: InMemoryCredentialStore(
FirebaseCredentials(
idToken: 'id',
refreshToken: 'refresh',
expiresAt: DateTime.now().add(const Duration(hours: 1)),
),
),
),
httpClient: MockClient((request) async {
if (request.url.path == '/settings/advancedMode.json') {
return http.Response(
'{"value": "true", '
'"updatedAtMillis": "${remoteTime.millisecondsSinceEpoch}"}',
200,
);
}
if (request.method == 'PUT') return http.Response(request.body, 200);
return http.Response('null', 200);
}),
);
final appSettings = ValueNotifier(
AppSettings(
advancedMode: false,
// Older than remoteTime, and non-null, so the reconciliation
// guard's `currentAt != null && !reconciledAt.isAfter(currentAt)`
// check actually evaluates both operands.
advancedModeUpdatedAt: remoteTime.subtract(
const Duration(days: 1),
),
),
);
await pumpCapture(
tester,
prefs: configuredPrefs,
firebaseFactory: () async => firebase,
appSettings: appSettings,
);
await tester.pump(); // settings load → auto-sync (pull) …
await tester.pump(); // … then push, then reconcile
expect(appSettings.value.advancedMode, isTrue);
},
);
testWidgets('logs app_open when analytics is injected', (tester) async {
const analytics = AnalyticsService(nodeId: 'device-a');
await pumpCapture(tester, analytics: analytics);
await tester.pump(); // flush the unawaited logEvent's prefs write
final prefs = await SharedPreferences.getInstance();
final buffer = prefs.getString('analytics.buffer');
expect(buffer, isNotNull);
expect(buffer, contains('app_open'));
});
testWidgets('auto-syncs on launch when configured', (tester) async {
final methods = <String>[];
await pumpCapture(
@ -383,21 +503,23 @@ void main() {
expect(find.textContaining('offline'), findsOneWidget);
});
testWidgets('auto-sync success shows when and what was merged', (
tester,
) async {
final methods = <String>[];
await pumpCapture(
tester,
prefs: configuredPrefs,
httpClient: recordingMock(methods),
);
await tester.pump();
await tester.pump();
testWidgets(
'auto-sync success shows no status text — sync is automatic and silent',
(tester) async {
final methods = <String>[];
await pumpCapture(
tester,
prefs: configuredPrefs,
httpClient: recordingMock(methods),
);
await tester.pump();
await tester.pump();
expect(find.textContaining('Synced at'), findsOneWidget);
expect(find.textContaining('merged 0 device(s)'), findsOneWidget);
});
// Only a failure is worth surfacing; a routine success is silent.
expect(find.textContaining('Synced at'), findsNothing);
expect(find.textContaining('merged 0 device(s)'), findsNothing);
},
);
testWidgets('restores the last sync outcome on launch', (tester) async {
// Unconfigured (no token) so no launch auto-sync overwrites the restored

View File

@ -62,6 +62,17 @@ void main() {
expect(log['b']!.deleted, isTrue);
});
test('modifiedDesc sort orders most-recently-updated first', () async {
final repo = await NoteRepository.openInMemory();
addTearDown(repo.close);
await repo.upsert(note('a', 'older', updatedAt: DateTime(2026)));
await repo.upsert(note('b', 'newer', updatedAt: DateTime(2026, 6)));
final notes = await repo.listNotes(sort: NoteSort.modifiedDesc);
expect(notes.map((n) => n.text), ['newer', 'older']);
});
test('priority sort orders highest first', () async {
final repo = await NoteRepository.openInMemory();
addTearDown(repo.close);

View File

@ -11,6 +11,10 @@ import 'package:crdt_sync/crdt_sync.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart' as http_testing;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:todo/analytics/analytics_event.dart';
import 'package:todo/analytics/analytics_service.dart';
import 'package:todo/data/app_settings.dart';
import 'package:todo/data/note_repository.dart';
import 'package:todo/sync/run_sync.dart';
import 'package:todo/sync/sync_settings.dart';
@ -21,6 +25,7 @@ void main() {
late NoteRepository repo;
setUp(() async {
SharedPreferences.setMockInitialValues({});
repo = await NoteRepository.openInMemory(nodeId: 'me');
});
@ -40,12 +45,12 @@ void main() {
stateStore: InMemorySyncStateStore(),
);
expect(result.pushed, isTrue);
expect(result.syncResult.pushed, isTrue);
expect(paths.every((p) => p.contains('api.github.com')), isTrue);
// The status line's whole reason for existing: this must read as
// GitHub-only, not as an unqualified success indistinguishable from a
// Firebase-connected sync.
expect(result.firebaseConnected, isFalse);
expect(result.syncResult.firebaseConnected, isFalse);
});
test('makes Firebase primary and still mirrors to GitHub', () async {
@ -84,7 +89,7 @@ void main() {
stateStore: InMemorySyncStateStore(),
);
expect(result.pushed, isTrue);
expect(result.syncResult.pushed, isTrue);
expect(
firebasePaths.any((p) => p.startsWith('PUT')),
isTrue,
@ -95,6 +100,81 @@ void main() {
contains('PUT'),
reason: 'GitHub must still be mirrored during the cutover',
);
expect(result.firebaseConnected, isTrue);
expect(result.syncResult.firebaseConnected, isTrue);
});
test('leaves appSettings null when the caller passes none', () async {
final result = await runSync(
repo,
_settings,
httpClient: http_testing.MockClient((request) async {
return http.Response(jsonEncode({'content': '', 'sha': 'x'}), 200);
}),
firebaseFactory: () async => null,
stateStore: InMemorySyncStateStore(),
);
expect(result.appSettings, isNull);
});
test(
'reconciles appSettings and flushes analytics on the same client',
() async {
final requestPaths = <String>[];
final remoteTime = DateTime(2026, 6);
final firebase = FirebaseRestClient(
databaseUrl: 'https://x-rtdb.europe-west1.firebasedatabase.app',
auth: FirebaseTokenProvider(
apiKey: 'AIzaKey',
store: InMemoryCredentialStore(
FirebaseCredentials(
idToken: 'id',
refreshToken: 'refresh',
expiresAt: DateTime.now().add(const Duration(hours: 1)),
),
),
),
httpClient: http_testing.MockClient((request) async {
requestPaths.add('${request.method} ${request.url.path}');
if (request.url.path == '/settings/advancedMode.json') {
return http.Response(
'{"value": "true", '
'"updatedAtMillis": "${remoteTime.millisecondsSinceEpoch}"}',
200,
);
}
if (request.method == 'PUT') return http.Response(request.body, 200);
return http.Response('null', 200);
}),
);
const analytics = AnalyticsService(nodeId: 'me');
await analytics.logEvent(
AnalyticsEvent(name: 'app_open', timestamp: DateTime.now()),
);
final result = await runSync(
repo,
_settings,
appSettings: const AppSettings(advancedMode: false),
analytics: analytics,
httpClient: http_testing.MockClient((request) async {
return http.Response(jsonEncode({'content': '', 'sha': 'x'}), 200);
}),
firebaseFactory: () async => firebase,
stateStore: InMemorySyncStateStore(),
);
expect(result.appSettings?.advancedMode, isTrue);
expect(
requestPaths,
contains('GET /settings/advancedMode.json'),
reason: 'appSettings reconciliation used the same Firebase client',
);
expect(
requestPaths.any((p) => p.startsWith('PUT /analytics/me/')),
isTrue,
reason: 'analytics flush used the same Firebase client',
);
},
);
}

View File

@ -1,8 +1,8 @@
import 'dart:io';
import 'dart:convert';
import 'dart:io';
import 'package:file_selector_platform_interface/file_selector_platform_interface.dart';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:file_selector_platform_interface/file_selector_platform_interface.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
@ -10,9 +10,11 @@ import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:todo/sync/backlog_export_io.dart';
import 'package:todo/analytics/analytics_service.dart';
import 'package:todo/data/app_settings.dart';
import 'package:todo/data/note.dart';
import 'package:todo/data/note_repository.dart';
import 'package:todo/sync/backlog_export_io.dart';
import 'package:todo/sync/notes_markdown.dart';
import 'package:todo/sync/sync_settings.dart';
import 'package:todo/ui/settings_screen.dart';
@ -94,6 +96,8 @@ void main() {
Future<void> Function(FirebaseAccount)? accountSaver,
Future<void> Function()? accountClearer,
Future<bool> Function()? sessionProbe,
ValueNotifier<AppSettings>? appSettings,
AnalyticsService? analytics,
}) async {
SharedPreferences.setMockInitialValues({});
installFakeSecureStorage();
@ -111,6 +115,10 @@ void main() {
home: SettingsScreen(
initial: initial,
repository: repo,
appSettings:
appSettings ??
ValueNotifier(const AppSettings(advancedMode: false)),
analytics: analytics,
httpClient: httpClient,
// Both injected so the widget never reaches for the platform: the
// real factories want the OS keystore and an application-support
@ -156,6 +164,101 @@ void main() {
await tester.pumpAndSettle();
}
testWidgets(
'Enable advanced toggles and persists locally with no client',
(tester) async {
final appSettings = ValueNotifier(
const AppSettings(advancedMode: false),
);
await pumpSettings(
tester,
appSettings: appSettings,
firebaseFactory: () async => null,
);
await tester.tap(find.text('Enable advanced'));
await tester.pump();
expect(appSettings.value.advancedMode, isTrue);
final reloaded = await AppSettings.load();
expect(reloaded.advancedMode, isTrue);
},
);
testWidgets('Enable advanced mirrors the toggle to Firebase', (
tester,
) async {
String? putPath;
final firebase = FirebaseRestClient(
databaseUrl: 'https://x-rtdb.europe-west1.firebasedatabase.app',
auth: FirebaseTokenProvider(
apiKey: 'AIzaKey',
store: InMemoryCredentialStore(
FirebaseCredentials(
idToken: 'id',
refreshToken: 'refresh',
expiresAt: DateTime.now().add(const Duration(hours: 1)),
),
),
),
httpClient: MockClient((request) async {
if (request.method == 'PATCH') putPath = request.url.path;
return http.Response(request.body, 200);
}),
);
final appSettings = ValueNotifier(const AppSettings(advancedMode: false));
await pumpSettings(
tester,
appSettings: appSettings,
firebaseFactory: () async => firebase,
);
await tester.tap(find.text('Enable advanced'));
await tester.pump();
expect(appSettings.value.advancedMode, isTrue);
expect(putPath, '/settings/advancedMode.json');
});
testWidgets(
'Enable advanced still persists locally when opening Firebase throws',
(tester) async {
final appSettings = ValueNotifier(
const AppSettings(advancedMode: false),
);
await pumpSettings(
tester,
appSettings: appSettings,
firebaseFactory: () async => throw FirebaseAuthError('wrong account'),
);
await tester.tap(find.text('Enable advanced'));
await tester.pump();
// A signed-in-as-wrong-account device must not lose the local toggle.
expect(appSettings.value.advancedMode, isTrue);
},
);
testWidgets('Enable advanced logs a settings_toggle event', (
tester,
) async {
const analytics = AnalyticsService(nodeId: 'device-a');
final appSettings = ValueNotifier(const AppSettings(advancedMode: false));
await pumpSettings(
tester,
appSettings: appSettings,
analytics: analytics,
firebaseFactory: () async => null,
);
await tester.tap(find.text('Enable advanced'));
await tester.pump();
final prefs = await SharedPreferences.getInstance();
expect(prefs.getString('analytics.buffer'), contains('settings_toggle'));
});
testWidgets('renders sync fields and the backup actions', (tester) async {
await pumpSettings(tester);
// GitHub is the mirror now: hidden until the disclosure is opened. The
@ -635,6 +738,9 @@ void main() {
token: 'tok',
),
repository: repo,
appSettings: ValueNotifier(
const AppSettings(advancedMode: false),
),
firebaseFactory: () async => null,
stateStore: InMemorySyncStateStore(),
),

View File

@ -23,6 +23,16 @@ void main() {
webRoot: webRoot.path,
backlogPath: p.join(root.path, 'todo', 'BACKLOG.md'),
logPath: p.join(root.path, 'state', 'todo_notes.json'),
// Without this, the default falls back to the real
// $HOME/.config/todo/firebase_auth.json -- on a machine that has
// actually signed in (a real desktop session), that file exists, so
// the "no credentials file exists" test below observes 200 instead
// of 404. Point it at this test's own throwaway root instead.
todoCredentialsPath: p.join(
root.path,
'todo-config',
'firebase_auth.json',
),
);
// Port 0 lets the OS pick, so tests never collide with a running app.
await server.start(0);