mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-10 19:07:02 +01:00
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
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>
162 lines
6.8 KiB
Dart
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;
|
|
}
|
|
}
|