diff --git a/lib/analytics/analytics_event.dart b/lib/analytics/analytics_event.dart new file mode 100644 index 0000000..3c0207e --- /dev/null +++ b/lib/analytics/analytics_event.dart @@ -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 params; + + /// Serializes to a JSON-encodable map for local buffering and the + /// Firebase flush. + Map 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.from(params) + : const {}, + ); + } +} diff --git a/lib/analytics/analytics_service.dart b/lib/analytics/analytics_service.dart new file mode 100644 index 0000000..c0ae41f --- /dev/null +++ b/lib/analytics/analytics_service.dart @@ -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/.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 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 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 _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 _writeBuffer( + SharedPreferences prefs, + List events, + ) async { + if (events.isEmpty) { + await prefs.remove(_kBuffer); + return; + } + await prefs.setString( + _kBuffer, + jsonEncode([for (final e in events) e.toJson()]), + ); + } +} diff --git a/lib/data/app_settings.dart b/lib/data/app_settings.dart new file mode 100644 index 0000000..bf289d5 --- /dev/null +++ b/lib/data/app_settings.dart @@ -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/.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 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 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 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; + } +} diff --git a/lib/data/note_repository.dart b/lib/data/note_repository.dart index 567f12a..5e77457 100644 --- a/lib/data/note_repository.dart +++ b/lib/data/note_repository.dart @@ -194,14 +194,14 @@ class NoteRepository { /// Returns the live notes matching [filter], ordered by [sort]. Future> 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> watchNotes({ - NoteSort sort = NoteSort.modifiedDesc, + NoteSort sort = NoteSort.priorityDesc, NoteFilter filter = const NoteFilter(), }) => _watch(() => _query(sort, filter)); diff --git a/lib/main.dart b/lib/main.dart index a50d1fe..a08a029 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 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; + + /// 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, + ), ); } } diff --git a/lib/sync/run_sync.dart b/lib/sync/run_sync.dart index da933eb..b12214e 100644 --- a/lib/sync/run_sync.dart +++ b/lib/sync/run_sync.dart @@ -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 runSync( +Future runSync( NoteRepository repository, SyncSettings settings, { + AppSettings? appSettings, + AnalyticsService? analytics, http.Client? httpClient, Future Function()? firebaseFactory, SyncStateStore? stateStore, @@ -53,7 +78,11 @@ Future 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. diff --git a/lib/ui/capture_screen.dart b/lib/ui/capture_screen.dart index 45ed2e1..8679908 100644 --- a/lib/ui/capture_screen.dart +++ b/lib/ui/capture_screen.dart @@ -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; + + /// 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 Function()? firebaseFactory; @@ -117,9 +132,26 @@ class _CaptureScreenState extends State /// (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 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 @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 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 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 _openSettings() async { + _logEvent('screen_view', params: {'screen': 'settings'}); final current = _settings ?? await SyncSettings.load(); if (!mounted) return; await Navigator.of(context).push( @@ -275,6 +339,8 @@ class _CaptureScreenState extends State builder: (_) => SettingsScreen( initial: current, repository: widget.repository, + appSettings: widget.appSettings, + analytics: widget.analytics, httpClient: widget.httpClient, ), ), @@ -288,6 +354,7 @@ class _CaptureScreenState extends State } void _openList() { + _logEvent('screen_view', params: {'screen': 'notes_list'}); unawaited( Navigator.of(context).push( MaterialPageRoute( @@ -307,18 +374,26 @@ class _CaptureScreenState extends State } 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 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 /// Applies a new priority to the draft, persisting immediately if a note /// row already exists (otherwise it is applied on the first keystroke). Future _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 /// Applies a new status to the draft, persisting immediately if a note /// row already exists. Future _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 } /// 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 _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( + 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( - 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( - 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( + 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( - 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( - 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( + label: 'Priority', + value: _draftPriority, + values: Priority.values, + labelOf: (p) => p.label, + onChanged: _setPriority, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _MetaDropdown( + label: 'Status', + value: _draftStatus, + values: Status.values, + labelOf: (s) => s.label, + onChanged: _setStatus, + ), + ), + ], ), - ValueListenableBuilder( - 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( + 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( + 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, + ), + ); + }, ); } diff --git a/lib/ui/note_editor.dart b/lib/ui/note_editor.dart index 40e3345..61a42d0 100644 --- a/lib/ui/note_editor.dart +++ b/lib/ui/note_editor.dart @@ -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 createState() => _NoteEditorState(); } @@ -179,8 +190,11 @@ class _NoteEditorState extends State { 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 { // 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: [ diff --git a/lib/ui/notes_list_screen.dart b/lib/ui/notes_list_screen.dart index 297aa7c..a7200f8 100644 --- a/lib/ui/notes_list_screen.dart +++ b/lib/ui/notes_list_screen.dart @@ -38,7 +38,7 @@ class _NotesListScreenState extends State { 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. diff --git a/lib/ui/settings_screen.dart b/lib/ui/settings_screen.dart index 2b591e1..edb7c23 100644 --- a/lib/ui/settings_screen.dart +++ b/lib/ui/settings_screen.dart @@ -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; + + /// 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 Function()? firebaseFactory; @@ -310,18 +322,24 @@ class _SettingsScreenState extends State { Future _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 { 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 _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 { @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( + 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 { ), ], 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 { ), ], ), - 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), diff --git a/pubspec.yaml b/pubspec.yaml index fe7c25f..b9b7994 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 diff --git a/test/analytics_service_test.dart b/test/analytics_service_test.dart new file mode 100644 index 0000000..c9db7c8 --- /dev/null +++ b/test/analytics_service_test.dart @@ -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 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': {}, + }, + ]), + }); + 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 = []; + 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)); + }, + ); + }); +} diff --git a/test/app_settings_test.dart b/test/app_settings_test.dart new file mode 100644 index 0000000..47b0476 --- /dev/null +++ b/test/app_settings_test.dart @@ -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 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 = []; + 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); + }, + ); + }); +} diff --git a/test/capture_screen_test.dart b/test/capture_screen_test.dart index f3c6f8c..0d757f3 100644 --- a/test/capture_screen_test.dart +++ b/test/capture_screen_test.dart @@ -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 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, + 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(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 = []; 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 = []; - 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 = []; + 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 diff --git a/test/note_repository_test.dart b/test/note_repository_test.dart index cda1a2a..b7c51a6 100644 --- a/test/note_repository_test.dart +++ b/test/note_repository_test.dart @@ -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); diff --git a/test/run_sync_test.dart b/test/run_sync_test.dart index d6d7d1f..73ef40e 100644 --- a/test/run_sync_test.dart +++ b/test/run_sync_test.dart @@ -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 = []; + 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', + ); + }, + ); } diff --git a/test/settings_screen_test.dart b/test/settings_screen_test.dart index 9b7e4fc..56075e8 100644 --- a/test/settings_screen_test.dart +++ b/test/settings_screen_test.dart @@ -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 Function(FirebaseAccount)? accountSaver, Future Function()? accountClearer, Future Function()? sessionProbe, + ValueNotifier? 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(), ), diff --git a/test/wrapper_server_test.dart b/test/wrapper_server_test.dart index 6d05123..5b23b7d 100644 --- a/test/wrapper_server_test.dart +++ b/test/wrapper_server_test.dart @@ -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);