todo-app/lib/data/app_settings.dart
Krzysztof kuhy Rudnicki 57019eec30
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Track who filed a note (created_by)
New optional note field created_by (kuhy|claude), stored like by/repo and
omitted when unset so older builds stay wire-compatible. App UI stamps kuhy;
MCP add_task and the CLI stamp claude. list/get/search output includes it.
Human notes sort above Claude's; Claude notes are hidden behind a per-device
eye toggle. CLI --backfill-created-by [--dry-run] classifies old notes by the
node that wrote created_at, writing only the created_by field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 14:18:19 +01:00

162 lines
6.8 KiB
Dart

/// 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 (`advancedMode`, `showClaudeNotes`).
class AppSettings {
/// Creates an [AppSettings] from its stored fields.
const AppSettings({
required this.advancedMode,
this.advancedModeUpdatedAt,
this.showClaudeNotes = false,
});
/// 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;
/// Whether the notes list shows notes Claude filed. Per device: it lives
/// only in this device's preferences and is never mirrored to Firebase.
final bool showClaudeNotes;
static const _kShowClaude = 'app.showClaudeNotes';
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,
showClaudeNotes: prefs.getBool(_kShowClaude) ?? 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,
showClaudeNotes: showClaudeNotes,
);
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;
}
/// Persists the per-device Claude-notes choice; nothing leaves the device.
Future<AppSettings> withShowClaudeNotes({required bool value}) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_kShowClaude, value);
return AppSettings(
advancedMode: advancedMode,
advancedModeUpdatedAt: advancedModeUpdatedAt,
showClaudeNotes: value,
);
}
/// 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,
showClaudeNotes: showClaudeNotes,
);
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;
}
}