mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-10 20:27:01 +01:00
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:
parent
d316563015
commit
ce2ea5b410
51
lib/analytics/analytics_event.dart
Normal file
51
lib/analytics/analytics_event.dart
Normal 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?>{},
|
||||
);
|
||||
}
|
||||
}
|
||||
117
lib/analytics/analytics_service.dart
Normal file
117
lib/analytics/analytics_service.dart
Normal 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
140
lib/data/app_settings.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
|
||||
|
||||
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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: [
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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
|
||||
|
||||
300
test/analytics_service_test.dart
Normal file
300
test/analytics_service_test.dart
Normal 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
231
test/app_settings_test.dart
Normal 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);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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',
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
),
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user