diff --git a/lib/cli/cli_args.dart b/lib/cli/cli_args.dart index d40475d..1ba53f9 100644 --- a/lib/cli/cli_args.dart +++ b/lib/cli/cli_args.dart @@ -4,49 +4,12 @@ /// exact-pinned dependency under the freshness gate. library; +import 'package:todo/cli/cli_mode.dart'; import 'package:todo/data/note.dart'; +export 'package:todo/cli/cli_mode.dart'; export 'package:todo/cli/cli_usage.dart'; -/// What the CLI was asked to do. -enum CliMode { - /// Create a note and push it. - add, - - /// Print every live note, one per line. - list, - - /// Print one note in full. - get, - - /// Tombstone one note and push the deletion. - delete, - - /// Change one note's status and push it. - setStatus, - - /// Set a task done with evidence and push it. - close, - - /// Print the notes matching a query, as [list] does. - search, - - /// Print usage. - help, -} - -/// Thrown for a malformed command line; maps to exit code 2. -class UsageError implements Exception { - /// Creates a [UsageError] carrying [message]. - UsageError(this.message); - - /// What was wrong with the arguments. - final String message; - - @override - String toString() => message; -} - /// The parsed command line. class CliArgs { /// Creates parsed arguments; see [parse]. @@ -58,6 +21,7 @@ class CliArgs { this.status, this.dataDir, this.newNote = false, + this.dryRun = false, this.opts = ( repo: null, query: null, @@ -89,6 +53,10 @@ class CliArgs { opts[arg] = value(); case '--list': modes.add(CliMode.list); + case '--backfill-created-by': + modes.add(CliMode.backfill); + case '--dry-run': + opts[arg] = 'true'; case '--search': modes.add(CliMode.search); opts[arg] = value(); @@ -106,8 +74,8 @@ class CliArgs { if (modes.contains(CliMode.help)) return const CliArgs(mode: CliMode.help); if (modes.length > 1) { throw UsageError( - '--list, --search, --get, --delete, --set-status and --close are ' - 'mutually exclusive', + '--list, --search, --get, --delete, --set-status, --close and ' + '--backfill-created-by are mutually exclusive', ); } final mode = modes.isEmpty ? CliMode.add : modes.single; @@ -128,6 +96,7 @@ class CliArgs { opts.containsKey('--new-note') && (opts['--repo'] != null || (throw UsageError('--new-note needs --repo'))), + dryRun: opts.containsKey('--dry-run'), opts: ( repo: _repo(opts['--repo']), query: _nonBlank(opts['--search'], '--search'), @@ -159,6 +128,9 @@ class CliArgs { /// `--new-note`: the `--repo` task gets its own note. final bool newNote; + /// Whether `--dry-run` was given: report what would change, change nothing. + final bool dryRun; + /// `--repo`, `--search`, `--done-check`, `--by` and `--evidence` values. final ({ String? repo, @@ -183,6 +155,7 @@ class CliArgs { ), '--evidence': ('to --close', {CliMode.close}), '--new-note': (_adding, {CliMode.add}), + '--dry-run': ('to --backfill-created-by', {CliMode.backfill}), }; static void _checkScopes(Iterable flags, CliMode mode) { diff --git a/lib/cli/cli_commands.dart b/lib/cli/cli_commands.dart index f0df335..a00ec68 100644 --- a/lib/cli/cli_commands.dart +++ b/lib/cli/cli_commands.dart @@ -38,6 +38,7 @@ class CliCommands { repo: repo, doneCheck: args.opts.doneCheck, by: args.opts.by, + createdBy: 'claude', ); await session.repository.upsert(note); if (!await _push('note')) return 1; @@ -57,6 +58,7 @@ class CliCommands { doneCheck: args.opts.doneCheck, priority: args.priority, by: args.opts.by, + createdBy: 'claude', ); await _save(added.note); if (!await _push('note')) return 1; @@ -79,6 +81,7 @@ class CliCommands { doneCheck: args.opts.doneCheck, priority: args.priority, by: args.opts.by, + createdBy: 'claude', ); await _save(added.note); if (!await _push('note')) return 1; @@ -142,6 +145,35 @@ class CliCommands { return 0; } + /// Sets `created_by` where unset (see [NoteRepository.backfillCreatedBy]). + /// Prints one `\t\t` line per note, then the totals. With + /// [dryRun] nothing is written or pushed, and peers are only read. + Future<int> backfillCreatedBy( + Set<String> claudeNodes, { + required bool dryRun, + }) async { + _report(await session.sync(push: !dryRun)); + final plan = await session.repository.backfillCreatedBy( + claudeNodes, + dryRun: dryRun, + ); + final titles = { + for (final n in await session.repository.listNotes()) n.id: n, + }; + for (final MapEntry(:key, :value) in plan.entries) { + final note = titles[key]; + final title = note == null ? '' : note.text.split('\n').first; + _out.writeln( + '${key.substring(0, key.length.clamp(0, 8))}\t$value\t$title', + ); + } + final claude = plan.values.where((v) => v == 'claude').length; + final kuhy = plan.length - claude; + _out.writeln('${dryRun ? 'dry run' : 'set'}: claude $claude, kuhy $kuhy'); + if (dryRun || plan.isEmpty) return 0; + return await _push('backfill') ? 0 : 1; + } + Future<Note> _pullAndFind(String ref) async { _report(await session.sync(push: true)); return findNote(await session.repository.listNotes(), ref); diff --git a/lib/cli/cli_mode.dart b/lib/cli/cli_mode.dart new file mode 100644 index 0000000..b1671fa --- /dev/null +++ b/lib/cli/cli_mode.dart @@ -0,0 +1,44 @@ +/// What the CLI can be asked to do, and how a bad command line is reported. +library; + +/// What the CLI was asked to do. +enum CliMode { + /// Create a note and push it. + add, + + /// Print every live note, one per line. + list, + + /// Print one note in full. + get, + + /// Tombstone one note and push the deletion. + delete, + + /// Change one note's status and push it. + setStatus, + + /// Set a task done with evidence and push it. + close, + + /// Print the notes matching a query, as [list] does. + search, + + /// Set `created_by` on notes that lack it. + backfill, + + /// Print usage. + help, +} + +/// Thrown for a malformed command line; maps to exit code 2. +class UsageError implements Exception { + /// Creates a [UsageError] carrying [message]. + UsageError(this.message); + + /// What was wrong with the arguments. + final String message; + + @override + String toString() => message; +} diff --git a/lib/cli/cli_runner.dart b/lib/cli/cli_runner.dart index 15c165d..d29af6f 100644 --- a/lib/cli/cli_runner.dart +++ b/lib/cli/cli_runner.dart @@ -80,6 +80,10 @@ Future<int> runCli( CliMode.get => await cli.get(args.id!), CliMode.setStatus => await cli.setStatus(args.id!, args.status!), CliMode.close => await cli.close(args.id!, args.opts.evidence!), + CliMode.backfill => await cli.backfillCreatedBy( + claudeNodeIds(env), + dryRun: args.dryRun, + ), // Only delete is left: help returned above. _ => await cli.delete(args.id!), }; diff --git a/lib/cli/cli_session.dart b/lib/cli/cli_session.dart index 3c76ec9..4ecfac4 100644 --- a/lib/cli/cli_session.dart +++ b/lib/cli/cli_session.dart @@ -29,6 +29,19 @@ String defaultDataDir(Map<String, String> env) { return p.join(base, 'todo-cli'); } +/// Node ids of the installs Claude files notes through: the MCP server and +/// the CLI under the same data root as [defaultDataDir]. Missing ones are +/// skipped. +Set<String> claudeNodeIds(Map<String, String> env) { + final cli = defaultDataDir(env); + return { + for (final dir in [cli, p.join(p.dirname(cli), 'todo-mcp')]) + if (File(p.join(dir, CliSession.nodeIdFileName)) case final f + when f.existsSync()) + f.readAsStringSync().trim(), + }..remove(''); +} + /// The open state of one CLI invocation. Call [close] when done. class CliSession { /// Wraps already-open parts; [open] is the normal way in. Public so a test diff --git a/lib/cli/cli_usage.dart b/lib/cli/cli_usage.dart index 7ea303a..f02f673 100644 --- a/lib/cli/cli_usage.dart +++ b/lib/cli/cli_usage.dart @@ -25,6 +25,9 @@ A note with --repo is a repo task; one without is a plain idea. --close <id> --evidence <s> pull, set status done + evidence (commit sha + done check exit code), push; refused without --evidence + --backfill-created-by [--dry-run] + pull, set created_by (claude|kuhy) on every note that + has none, push; --dry-run prints the plan only --data-dir <dir> local log + node id (default $XDG_DATA_HOME/todo-cli or ~/.local/share/todo-cli) -h, --help this text diff --git a/lib/cli/note_lookup.dart b/lib/cli/note_lookup.dart index 46acdf9..ce6a3ee 100644 --- a/lib/cli/note_lookup.dart +++ b/lib/cli/note_lookup.dart @@ -32,7 +32,7 @@ Note findNote(Iterable<Note> notes, String ref) { return matches.single; } -/// `<8-char id>\t<status>\t<priority>\t<repo or ->\t<title>`. +/// `<8-char id>\t<status>\t<priority>\t<repo or ->\t<title>\t<created_by>`. String listLine(Note note) => [ if (note.id.length <= _minPrefix) note.id @@ -42,6 +42,7 @@ String listLine(Note note) => [ note.priority.name, note.repo ?? '-', _title(note), + note.createdBy ?? 'kuhy', ].join('\t'); String _title(Note note) => note.text.split('\n').first; @@ -57,6 +58,7 @@ String noteDetail(Note note) => [ if (note.doneCheck case final v?) 'done_check: $v', if (note.by case final v?) 'by: $v', if (note.evidence case final v?) 'evidence: $v', + 'created_by: ${note.createdBy ?? 'kuhy'}', '---', note.text, ].join('\n'); diff --git a/lib/data/app_settings.dart b/lib/data/app_settings.dart index bf289d5..6802dfe 100644 --- a/lib/data/app_settings.dart +++ b/lib/data/app_settings.dart @@ -19,10 +19,14 @@ import 'package:shared_preferences/shared_preferences.dart'; /// sibling to `changesets/<nodeId>.json`. const String kAdvancedModeRemotePath = 'settings/advancedMode'; -/// App-wide, non-sync preferences (currently just `advancedMode`). +/// App-wide, non-sync preferences (`advancedMode`, `showClaudeNotes`). class AppSettings { /// Creates an [AppSettings] from its stored fields. - const AppSettings({required this.advancedMode, this.advancedModeUpdatedAt}); + 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 @@ -35,6 +39,11 @@ class AppSettings { /// 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'; @@ -44,6 +53,7 @@ class AppSettings { final updatedAtMillis = prefs.getInt(_kAdvancedModeUpdatedAt); return AppSettings( advancedMode: prefs.getBool(_kAdvancedMode) ?? false, + showClaudeNotes: prefs.getBool(_kShowClaude) ?? false, advancedModeUpdatedAt: updatedAtMillis == null ? null : DateTime.fromMillisecondsSinceEpoch(updatedAtMillis), @@ -71,6 +81,7 @@ class AppSettings { final updated = AppSettings( advancedMode: value, advancedModeUpdatedAt: updatedAt, + showClaudeNotes: showClaudeNotes, ); if (client != null) { try { @@ -89,6 +100,17 @@ class AppSettings { 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 @@ -100,9 +122,7 @@ class AppSettings { try { final remote = await client.getStringMap(kAdvancedModeRemotePath); if (!remote.containsKey('value')) return this; - final remoteUpdatedMillis = int.tryParse( - remote['updatedAtMillis'] ?? '', - ); + final remoteUpdatedMillis = int.tryParse(remote['updatedAtMillis'] ?? ''); if (remoteUpdatedMillis == null) return this; final remoteUpdatedAt = DateTime.fromMillisecondsSinceEpoch( remoteUpdatedMillis, @@ -110,6 +130,7 @@ class AppSettings { final candidate = AppSettings( advancedMode: remote['value'] == 'true', advancedModeUpdatedAt: remoteUpdatedAt, + showClaudeNotes: showClaudeNotes, ); final adopted = adopt(candidate); if (identical(adopted, this)) return this; diff --git a/lib/data/created_by_backfill.dart b/lib/data/created_by_backfill.dart new file mode 100644 index 0000000..8cacbd7 --- /dev/null +++ b/lib/data/created_by_backfill.dart @@ -0,0 +1,44 @@ +/// Who filed a note, for notes written before `created_by` existed. +/// +/// The `created_at` field's clock names the node that last wrote it. The MCP +/// and the CLI are the only nodes Claude writes through, so a note created +/// there carries one of their node ids; anything else came from kuhy's own +/// devices. +library; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:todo/data/note_record.dart'; + +const _createdAt = 'created_at'; + +/// `claude` when [record]'s `created_at` was written by one of [claudeNodes], +/// else `kuhy`. +String classifyCreatedBy(Record record, Set<String> claudeNodes) { + final node = record.fields[_createdAt]?.$2.nodeId; + return node != null && claudeNodes.contains(node) ? 'claude' : 'kuhy'; +} + +/// Live records of [log] with no `created_by` yet, mapped to their value. +Map<String, String> planCreatedByBackfill(Log log, Set<String> claudeNodes) => { + for (final record in log.values) + if (!record.deleted && + ((record.fields[kCreatedByField]?.$1 as String?) ?? '').isEmpty) + record.id: classifyCreatedBy(record, claudeNodes), +}; + +/// [incoming], carrying [existing]'s `created_by` when [incoming] has none. +/// +/// A store replaces a record wholesale, so an edit that does not know the +/// field (an import of an old backup) would otherwise drop it locally. +Record keepCreatedBy(Record incoming, Record? existing) { + final kept = existing?.fields[kCreatedByField]; + if (kept == null || incoming.fields.containsKey(kCreatedByField)) { + return incoming; + } + return Record( + id: incoming.id, + fields: {...incoming.fields, kCreatedByField: kept}, + deleted: incoming.deleted, + deletedHlc: incoming.deletedHlc, + ); +} diff --git a/lib/data/note.dart b/lib/data/note.dart index a15969d..71452a4 100644 --- a/lib/data/note.dart +++ b/lib/data/note.dart @@ -76,7 +76,7 @@ enum Status { } } -/// The values the optional `by` field may hold: who owns a repo task. +/// The values the optional `by` and `created_by` fields may hold. const Set<String> kNoteAuthors = {'claude', 'kuhy'}; /// An immutable idea/note record. @@ -99,6 +99,7 @@ class Note { this.doneCheck, this.by, this.evidence, + this.createdBy, }); /// Builds a [Note] from a raw CRDT query row. @@ -146,6 +147,12 @@ class Note { /// Proof the task was closed (commit sha + the done check's exit code). final String? evidence; + /// Who filed the note: one of [kNoteAuthors]; null (older notes) means kuhy. + final String? createdBy; + + /// Whether Claude filed this note (hidden by default, sorted last). + bool get isClaudeMade => createdBy == 'claude'; + /// Whether this note is a repo task rather than a plain idea. bool get isRepoTask => repo != null; @@ -162,6 +169,7 @@ class Note { String? doneCheck, String? by, String? evidence, + String? createdBy, }) { return Note( id: id, @@ -174,6 +182,7 @@ class Note { doneCheck: doneCheck ?? this.doneCheck, by: by ?? this.by, evidence: evidence ?? this.evidence, + createdBy: createdBy ?? this.createdBy, ); } } diff --git a/lib/data/note_query.dart b/lib/data/note_query.dart index 443a897..ea40748 100644 --- a/lib/data/note_query.dart +++ b/lib/data/note_query.dart @@ -37,6 +37,7 @@ class NoteFilter { this.statuses = const {}, this.repo, this.repoTasksOnly = false, + this.hideClaude = false, this.createdFrom, this.createdTo, this.updatedFrom, @@ -58,6 +59,10 @@ class NoteFilter { /// Only notes that have a repo (repo tasks). Ideas are excluded. final bool repoTasksOnly; + /// Leave out notes Claude filed. A view preference rather than a user + /// facet, so it never counts toward [isEmpty] or [activeCount]. + final bool hideClaude; + /// Inclusive lower bound (by calendar day) on the creation date. final DateTime? createdFrom; @@ -104,6 +109,7 @@ class NoteFilter { Set<Status>? statuses, String? repo, bool? repoTasksOnly, + bool? hideClaude, DateTime? createdFrom, DateTime? createdTo, DateTime? updatedFrom, @@ -117,6 +123,7 @@ class NoteFilter { statuses: statuses ?? this.statuses, repo: repo ?? this.repo, repoTasksOnly: repoTasksOnly ?? this.repoTasksOnly, + hideClaude: hideClaude ?? this.hideClaude, createdFrom: clearCreated ? null : (createdFrom ?? this.createdFrom), createdTo: clearCreated ? null : (createdTo ?? this.createdTo), updatedFrom: clearUpdated ? null : (updatedFrom ?? this.updatedFrom), @@ -138,6 +145,7 @@ bool matchesFilter(Note note, NoteFilter filter) { } if (filter.repo != null && note.repo != filter.repo) return false; if (filter.repoTasksOnly && note.repo == null) return false; + if (filter.hideClaude && note.isClaudeMade) return false; if (filter.priorities.isNotEmpty && !filter.priorities.contains(note.priority)) { return false; @@ -165,8 +173,17 @@ bool _withinDays(DateTime value, DateTime? from, DateTime? to) { return true; } -/// The comparator implementing [sort]. +/// The comparator implementing [sort]: human-made notes first, Claude-made +/// ones after, and [sort] orders each group. Comparator<Note> comparatorFor(NoteSort sort) { + final within = _within(sort); + return (a, b) { + if (a.isClaudeMade != b.isClaudeMade) return a.isClaudeMade ? 1 : -1; + return within(a, b); + }; +} + +Comparator<Note> _within(NoteSort sort) { switch (sort) { case NoteSort.createdDesc: return (a, b) => b.createdAt.compareTo(a.createdAt); diff --git a/lib/data/note_record.dart b/lib/data/note_record.dart index 0717379..34be2ff 100644 --- a/lib/data/note_record.dart +++ b/lib/data/note_record.dart @@ -14,6 +14,9 @@ const _doneCheck = 'done_check'; const _by = 'by'; const _evidence = 'evidence'; +/// Record field naming who filed the note (`kuhy` or `claude`). +const kCreatedByField = 'created_by'; + /// Record fields for [note], all at [hlc]. Unset task fields are omitted, so /// an idea's record is exactly the v3 field set (and cannot clear a peer's). Map<String, Field> noteFields(Note note, Hlc hlc) => { @@ -26,6 +29,7 @@ Map<String, Field> noteFields(Note note, Hlc hlc) => { if (note.doneCheck case final v?) _doneCheck: (v, hlc), if (note.by case final v?) _by: (v, hlc), if (note.evidence case final v?) _evidence: (v, hlc), + if (note.createdBy case final v?) kCreatedByField: (v, hlc), }; /// Reads [record] as a [Note], ignoring unknown fields. @@ -48,6 +52,7 @@ Note noteFromRecord(Record record) { doneCheck: task(_doneCheck), by: task(_by), evidence: task(_evidence), + createdBy: task(kCreatedByField), ); } diff --git a/lib/data/note_repository.dart b/lib/data/note_repository.dart index 42afff5..aa27015 100644 --- a/lib/data/note_repository.dart +++ b/lib/data/note_repository.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:crdt_sync/crdt_sync.dart'; +import 'package:todo/data/created_by_backfill.dart'; import 'package:todo/data/note.dart'; import 'package:todo/data/note_import.dart'; import 'package:todo/data/note_query.dart'; @@ -114,9 +115,32 @@ class NoteRepository { /// character-by-character autosave: it is cheap and idempotent. All fields /// share one fresh monotonic clock (they are written together). Future<void> upsert(Note note) async { - await _store.upsert( - Record(id: note.id, fields: noteFields(note, _store.nextHlc())), + await _put(Record(id: note.id, fields: noteFields(note, _store.nextHlc()))); + } + + Future<void> _put(Record record) => + _store.upsert(keepCreatedBy(record, _store.get(record.id))); + + /// Sets `created_by` on every live note that has none, from the node that + /// wrote its `created_at` (see [classifyCreatedBy]); returns id -> value. + /// + /// Writes only that field, at a fresh clock, so no other field's clock + /// moves (a rewrite of `created_at` would erase the signal itself). With + /// [dryRun] nothing is written. + Future<Map<String, String>> backfillCreatedBy( + Set<String> claudeNodes, { + bool dryRun = false, + }) async { + final plan = planCreatedByBackfill(_store.snapshot(), claudeNodes); + if (dryRun || plan.isEmpty) return plan; + final hlc = _store.nextHlc(); + await _store.replaceAll( + mergeLogs(_store.snapshot(), { + for (final e in plan.entries) + e.key: Record(id: e.key, fields: {kCreatedByField: (e.value, hlc)}), + }), ); + return plan; } /// Soft-deletes a note. The log keeps a sticky tombstone so the deletion @@ -142,10 +166,10 @@ class NoteRepository { // the import wins locally; the clock decides whether it wins on *other* // devices, and an import must not win there simply by being recent. if (local == null) { - await _store.upsert(recordAtNoteTime(note, _nodeId)); + await _put(recordAtNoteTime(note, _nodeId)); added++; } else if (note.updatedAt.isAfter(local.updatedAt)) { - await _store.upsert(recordAtNoteTime(note, _nodeId)); + await _put(recordAtNoteTime(note, _nodeId)); updated++; } else { skipped++; diff --git a/lib/data/repo_note.dart b/lib/data/repo_note.dart index 2271542..5987f77 100644 --- a/lib/data/repo_note.dart +++ b/lib/data/repo_note.dart @@ -71,6 +71,7 @@ Future<RepoAdd> addRepoTask( String? doneCheck, Priority priority = Priority.medium, String? by, + String? createdBy, }) async { final section = repoSection(title, body); final open = (await repository.listNotes( @@ -99,6 +100,7 @@ Future<RepoAdd> addRepoTask( repo: repo, doneCheck: composeDoneCheck(null, doneCheck), by: by, + createdBy: createdBy, ), appended: false, ); diff --git a/lib/data/repo_note_split.dart b/lib/data/repo_note_split.dart index f45b13e..4aa03be 100644 --- a/lib/data/repo_note_split.dart +++ b/lib/data/repo_note_split.dart @@ -77,6 +77,7 @@ Future<SplitAdd> addSeparateTask( String? doneCheck, Priority priority = Priority.medium, String? by, + String? createdBy, Future<bool> Function(Note note)? busy, }) async { final section = repoSection(title, body); @@ -113,6 +114,7 @@ Future<SplitAdd> addSeparateTask( repo: repo, doneCheck: composeDoneCheck(null, doneCheck), by: by, + createdBy: createdBy, ), kind: why == null ? SplitKind.added : SplitKind.fellBack, why: why, diff --git a/lib/mcp/task_service.dart b/lib/mcp/task_service.dart index 8f23e0f..68292f7 100644 --- a/lib/mcp/task_service.dart +++ b/lib/mcp/task_service.dart @@ -76,6 +76,7 @@ class TaskService { doneCheck: a.optional('done_check'), priority: a.priority() ?? Priority.medium, by: 'claude', + createdBy: 'claude', ); final verb = added.appended ? 'appended to' : 'added'; return (reply: await _save(added.note, verb), start: null); diff --git a/lib/mcp/task_split.dart b/lib/mcp/task_split.dart index 9cfef54..d7a14f5 100644 --- a/lib/mcp/task_split.dart +++ b/lib/mcp/task_split.dart @@ -35,6 +35,7 @@ Future<SplitReply> addSplit( doneCheck: doneCheck, priority: priority, by: 'claude', + createdBy: 'claude', busy: board == null ? null : (n) => board.running(n.id), ); if (added.kind != SplitKind.merged) { diff --git a/lib/mcp/todo_mcp_server.dart b/lib/mcp/todo_mcp_server.dart index 6101fa5..6e9a6d5 100644 --- a/lib/mcp/todo_mcp_server.dart +++ b/lib/mcp/todo_mcp_server.dart @@ -116,7 +116,8 @@ McpServer _server(TaskService tasks) { tool( 'list_tasks', 'List repo tasks, one line each: 8-char id, status, priority, repo, ' - 'title. Default status "open" (todo + in_progress).', + 'title, created_by (kuhy|claude). Default status "open" ' + '(todo + in_progress).', { 'repo': repo, 'status': JsonSchema.string( diff --git a/lib/sync/notes_markdown.dart b/lib/sync/notes_markdown.dart index 76d4a65..5f27265 100644 --- a/lib/sync/notes_markdown.dart +++ b/lib/sync/notes_markdown.dart @@ -92,6 +92,7 @@ class NotesMarkdown { doneCheck: attrs['done_check'], by: attrs['by'], evidence: attrs['evidence'], + createdBy: attrs['created_by'], ), ); } @@ -113,6 +114,7 @@ class NotesMarkdown { ('done_check', note.doneCheck), ('by', note.by), ('evidence', note.evidence), + ('created_by', note.createdBy), ]) if (value != null) ' $name="${_escape(value)}"', ].join(); diff --git a/lib/ui/capture_draft.dart b/lib/ui/capture_draft.dart index 7b30262..b381d2a 100644 --- a/lib/ui/capture_draft.dart +++ b/lib/ui/capture_draft.dart @@ -102,6 +102,7 @@ class CaptureDraft { status: status, createdAt: _createdAt!, updatedAt: now, + createdBy: 'kuhy', ), ); if (live()) lastSavedAt.value = now; diff --git a/lib/ui/notes_list_screen.dart b/lib/ui/notes_list_screen.dart index e22318c..4565629 100644 --- a/lib/ui/notes_list_screen.dart +++ b/lib/ui/notes_list_screen.dart @@ -56,7 +56,7 @@ class _NotesListScreenState extends State<NotesListScreen> { @override void initState() { super.initState(); - _stream = widget.repository.watchNotes(sort: _sort, filter: _filter); + _stream = _watch(); } @override @@ -69,9 +69,26 @@ class _NotesListScreenState extends State<NotesListScreen> { /// Rebuilds the watch stream for the current sort + filter. Call only /// from handlers that change those, never from [build]. void _applyState() { - setState(() { - _stream = widget.repository.watchNotes(sort: _sort, filter: _filter); - }); + setState(() => _stream = _watch()); + } + + /// The query for the current sort + filter, minus Claude-made notes unless + /// this device's eye toggle is on. + Stream<List<Note>> _watch() => widget.repository.watchNotes( + sort: _sort, + filter: _filter.copyWith( + hideClaude: !widget.appSettings.value.showClaudeNotes, + ), + ); + + Future<void> _toggleClaudeNotes() async { + final settings = widget.appSettings.value; + final next = await settings.withShowClaudeNotes( + value: !settings.showClaudeNotes, + ); + if (!mounted) return; + widget.appSettings.value = next; + _applyState(); } void _onSearchChanged(String value) { @@ -110,6 +127,16 @@ class _NotesListScreenState extends State<NotesListScreen> { appBar: AppBar( title: const Text('Notes'), actions: [ + // Eye, no label: open = Claude-made notes shown, slashed = hidden. + IconButton( + icon: Icon( + widget.appSettings.value.showClaudeNotes + ? Icons.visibility + : Icons.visibility_off, + semanticLabel: 'Claude notes', + ), + onPressed: _toggleClaudeNotes, + ), PopupMenuButton<NoteSort>( tooltip: 'Sort', icon: const Icon(Icons.sort), diff --git a/mcp/README.md b/mcp/README.md index 58bd20a..f409016 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -10,6 +10,12 @@ The server is one more sync node: it reuses `lib/cli` (`CliSession`, `todo-sync/notes/<node id>.json` plus `todo-sync/revs/<node id>`. Code: `lib/mcp/`, entrypoint `mcp/todo_mcp.dart`, tests `test/mcp/`. +Output shapes: `list_tasks` and `search_tasks` print one tab-separated line per +task (`id8 status priority repo title created_by`); `get_task` prints +`key: value` lines (including `created_by`) then `---` and the text. +`created_by` is `claude` for notes filed through `add_task` or the CLI and +`kuhy` for everything else (a missing value reads as `kuhy`). + | What | Where | |---|---| | Firebase seed (read once) | `~/.config/todo-mcp/firebase_auth.json` | diff --git a/pubspec.yaml b/pubspec.yaml index 2838e2e..0122543 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.65+1 +version: 1.0.66+1 environment: sdk: ^3.12.2 diff --git a/test/capture_screen_created_by_test.dart b/test/capture_screen_created_by_test.dart new file mode 100644 index 0000000..0b6a286 --- /dev/null +++ b/test/capture_screen_created_by_test.dart @@ -0,0 +1,21 @@ +// A note typed into the app is stamped created_by kuhy. +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'capture_screen_harness.dart'; + +void main() { + testWidgets('a captured note is stamped kuhy and keeps it on edit', ( + tester, + ) async { + final repo = await pumpCapture(tester); + await tester.enterText(find.byType(TextField).first, 'First line'); + await tester.pump(); + await tester.enterText(find.byType(TextField).first, 'First line, more'); + await tester.pump(); + + final saved = (await repo.listNotes()).single; + expect(saved.text, 'First line, more'); + expect(saved.createdBy, 'kuhy'); + }); +} diff --git a/test/cli/cli_created_by_test.dart b/test/cli/cli_created_by_test.dart new file mode 100644 index 0000000..9e36406 --- /dev/null +++ b/test/cli/cli_created_by_test.dart @@ -0,0 +1,85 @@ +// created_by through the CLI: stamping, output, and the backfill command. +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'cli_harness.dart'; + +void main() { + late CliHarness h; + setUp(() => h = CliHarness()); + tearDown(() => h.dispose()); + + test('a CLI add is stamped claude, repo task or idea', () async { + final idea = await h.add('Plain idea'); + final task = await h.add('Fix gate', ['--repo', 'kuhyx/todo']); + for (final id in [idea, task]) { + expect(await h.run(['--get', id, '--data-dir', h.dir('b')]), 0); + expect(h.out.toString(), contains('created_by: claude')); + } + expect(await h.run(['--list']), 0); + expect( + h.out.toString().trim().split('\n'), + everyElement(endsWith('\tclaude')), + ); + }); + + test('a --new-note add is stamped claude', () async { + final id = await h.add('Own note', ['--repo', 'kuhyx/todo', '--new-note']); + expect(await h.run(['--get', id, '--data-dir', h.dir('b')]), 0); + expect(h.out.toString(), contains('created_by: claude')); + }); + + test('a peer note without created_by reads as kuhy', () async { + h.seedPeer([fixtureNote('peer-note-1', 'From the phone')]); + expect(await h.run(['--get', 'peer-note-1']), 0); + expect(h.out.toString(), contains('created_by: kuhy')); + expect(await h.run(['--list']), 0); + expect(h.out.toString(), endsWith('From the phone\tkuhy\n')); + }); + + group('--backfill-created-by', () { + void seed() { + final mcp = Directory('${h.env['XDG_DATA_HOME']}/todo-mcp') + ..createSync(recursive: true); + File('${mcp.path}/node_id').writeAsStringSync('mcp-node\n'); + h.seedPeer([ + fixtureNote('mcp-note-0001', 'Make a text-free', repo: 'kuhyx/a'), + ], node: 'mcp-node'); + h.seedPeer([fixtureNote('idea-note-0001', 'My idea')], node: 'phone'); + } + + test('--dry-run lists the plan and writes nothing', () async { + seed(); + expect(await h.run(['--backfill-created-by', '--dry-run']), 0); + final lines = h.out.toString().trim().split('\n'); + expect(lines, contains('mcp-note\tclaude\tMake a text-free')); + expect(lines, contains('idea-not\tkuhy\tMy idea')); + expect(lines.last, 'dry run: claude 1, kuhy 1'); + expect(h.db.methods, isNot(contains('PUT')), reason: 'nothing pushed'); + }); + + test('a real run sets it once, pushes it, and is then a no-op', () async { + seed(); + expect(await h.run(['--backfill-created-by']), 0); + expect(h.out.toString().trim().split('\n').last, 'set: claude 1, kuhy 1'); + expect( + await h.run(['--get', 'mcp-note-0001', '--data-dir', h.dir('b')]), + 0, + ); + expect(h.out.toString(), contains('created_by: claude')); + expect( + await h.run(['--get', 'idea-note-0001', '--data-dir', h.dir('b')]), + 0, + ); + expect(h.out.toString(), contains('created_by: kuhy')); + expect(await h.run(['--backfill-created-by']), 0); + expect(h.out.toString().trim(), 'set: claude 0, kuhy 0'); + }); + + test('--dry-run is refused without the backfill flag', () async { + expect(await h.run(['--dry-run']), 2); + expect(h.err.toString(), contains('--dry-run only applies')); + }); + }); +} diff --git a/test/cli/cli_harness.dart b/test/cli/cli_harness.dart index 1a47e2b..aa9d1c2 100644 --- a/test/cli/cli_harness.dart +++ b/test/cli/cli_harness.dart @@ -60,10 +60,10 @@ class CliHarness { return out.toString().trim(); } - /// Publishes [notes] as another device's log. - void seedPeer(List<Note> notes) { - db.files['todo-sync/notes/peer~2Ejson'] = logToJson({ - for (final n in notes) n.id: NoteRepository.recordAtNoteTime(n, 'peer'), + /// Publishes [notes] as device [node]'s log. + void seedPeer(List<Note> notes, {String node = 'peer'}) { + db.files['todo-sync/notes/$node~2Ejson'] = logToJson({ + for (final n in notes) n.id: NoteRepository.recordAtNoteTime(n, node), }); } } diff --git a/test/cli/cli_runner_test.dart b/test/cli/cli_runner_test.dart index 410d84f..b5bce38 100644 --- a/test/cli/cli_runner_test.dart +++ b/test/cli/cli_runner_test.dart @@ -121,7 +121,7 @@ void main() { expect(await run(['--list', '--data-dir', dir('b')]), 0); expect( out.toString().trim(), - '${id.substring(0, 8)}\tin_progress\thigh\t-\tTitle', + '${id.substring(0, 8)}\tin_progress\thigh\t-\tTitle\tclaude', ); expect(await run(['--get', id, '--data-dir', dir('b')]), 0); diff --git a/test/cli/cli_tasks_test.dart b/test/cli/cli_tasks_test.dart index 7b7d3cb..f6c74eb 100644 --- a/test/cli/cli_tasks_test.dart +++ b/test/cli/cli_tasks_test.dart @@ -41,7 +41,7 @@ void main() { expect(await h.run(['--list', '--repo', 'kuhyx/todo']), 0); expect( h.out.toString(), - '${todo.substring(0, 8)}\ttodo\tmedium\tkuhyx/todo\ttodo todos\n', + '${todo.substring(0, 8)}\ttodo\tmedium\tkuhyx/todo\ttodo todos\tclaude\n', ); expect(await h.run(['--list', '--status', 'done']), 0); expect(h.out.toString(), isEmpty); diff --git a/test/cli/note_lookup_test.dart b/test/cli/note_lookup_test.dart index 72cbcaf..7a46f70 100644 --- a/test/cli/note_lookup_test.dart +++ b/test/cli/note_lookup_test.dart @@ -65,12 +65,15 @@ void main() { ); }); - test('listLine: 8-char id, status, priority, repo, title', () { + test('listLine: 8-char id, status, priority, repo, title, author', () { expect( listLine(notes[2].copyWith(status: Status.inProgress)), - '12345678\tin_progress\tmedium\t-\tFirst', + '12345678\tin_progress\tmedium\t-\tFirst\tkuhy', + ); + expect( + listLine(notes[4]), + '99999999\ttodo\tmedium\tkuhyx/todo\tOnly\tkuhy', ); - expect(listLine(notes[4]), '99999999\ttodo\tmedium\tkuhyx/todo\tOnly'); expect(listLine(fixtureNote('abc', 'Short')), startsWith('abc\t')); }); } diff --git a/test/fake_note_repository.dart b/test/fake_note_repository.dart index 0b902e9..20b27ce 100644 --- a/test/fake_note_repository.dart +++ b/test/fake_note_repository.dart @@ -104,6 +104,12 @@ class FakeNoteRepository implements NoteRepository { @override Stream<int> watchCount() => _snapshots().map((n) => n.length); + @override + Future<Map<String, String>> backfillCreatedBy( + Set<String> claudeNodes, { + bool dryRun = false, + }) async => {}; + @override String get nodeId => 'fake-node'; diff --git a/test/mcp/mcp_created_by_test.dart b/test/mcp/mcp_created_by_test.dart new file mode 100644 index 0000000..037e5cb --- /dev/null +++ b/test/mcp/mcp_created_by_test.dart @@ -0,0 +1,88 @@ +// add_task stamps created_by claude; every read tool reports it. +import 'package:flutter_test/flutter_test.dart'; +import 'package:mcp_dart/mcp_dart.dart'; +import 'package:todo/data/note.dart'; + +import 'mcp_harness.dart'; + +void main() { + late McpHarness h; + late McpClient c; + setUp(() async { + h = McpHarness(); + await h.start(); + c = await h.connect(); + }); + tearDown(() => h.dispose()); + + test('add_task is stamped claude and list/get/search show it', () async { + final added = await h.call(c, 'add_task', { + 'repo': 'kuhyx/todo', + 'title': 'Fix gate', + }); + final id = idIn(added); + final stored = (await h.session.repository.listNotes()).single; + expect(stored.createdBy, 'claude'); + + expect( + await h.call(c, 'get_task', {'id': id}), + contains('created_by: claude'), + ); + for (final listed in [ + await h.call(c, 'list_tasks', {}), + await h.call(c, 'search_tasks', {'query': 'gate'}), + ]) { + expect(listed.trim(), endsWith('\tclaude')); + } + }); + + test('a separate new_note task is stamped claude too', () async { + await h.call(c, 'add_task', { + 'repo': 'kuhyx/todo', + 'title': 'Own note', + 'new_note': true, + }); + expect((await h.session.repository.listNotes()).single.createdBy, 'claude'); + }); + + test('a record without created_by reads as kuhy', () async { + final at = DateTime.utc(2026, 11); + await h.session.repository.upsert( + Note( + id: 'human-task-0001', + text: 'Human task', + priority: Priority.medium, + status: Status.todo, + createdAt: at, + updatedAt: at, + repo: 'kuhyx/todo', + ), + ); + expect( + await h.call(c, 'get_task', {'id': 'human-task-0001'}), + contains('created_by: kuhy'), + ); + expect( + (await h.call(c, 'list_tasks', {})).trim(), + endsWith('Human task\tkuhy'), + ); + }); + + test('joining a human-made repo note keeps its author', () async { + final at = DateTime.utc(2026, 11); + await h.session.repository.upsert( + Note( + id: 'human-task-0001', + text: 'todo todos', + priority: Priority.medium, + status: Status.todo, + createdAt: at, + updatedAt: at, + repo: 'kuhyx/todo', + createdBy: 'kuhy', + ), + ); + await h.call(c, 'add_task', {'repo': 'kuhyx/todo', 'title': 'More'}); + expect((await h.session.repository.listNotes()).single.createdBy, 'kuhy'); + }); +} diff --git a/test/note_created_by_test.dart b/test/note_created_by_test.dart new file mode 100644 index 0000000..e0d92b2 --- /dev/null +++ b/test/note_created_by_test.dart @@ -0,0 +1,181 @@ +// created_by: record mapping, merge safety against older builds, list order, +// the hide filter and backfill classification. +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:todo/data/created_by_backfill.dart'; +import 'package:todo/data/note.dart'; +import 'package:todo/data/note_repository.dart'; + +Note _note( + String id, { + String? createdBy, + Priority priority = Priority.medium, + DateTime? updated, +}) { + final at = updated ?? DateTime.utc(2026, 11); + return Note( + id: id, + text: 'note $id', + priority: priority, + status: Status.todo, + createdAt: at, + updatedAt: at, + createdBy: createdBy, + ); +} + +/// What an older build writes: no created_by, replacing the record locally. +Map<String, Field> _oldFields(Note n, Hlc hlc) => { + 'text': (n.text, hlc), + 'priority': (n.priority.value, hlc), + 'status': (n.status.value, hlc), + 'created_at': (n.createdAt.toIso8601String(), hlc), + 'updated_at': (n.updatedAt.toIso8601String(), hlc), +}; + +Log _overTheWire(Log log) => logFromJson(logToJson(log)); + +void main() { + group('record mapping', () { + test('created_by is written only when set and read back', () { + final hlc = Hlc(wallTimeMs: 1, counter: 0, nodeId: 'n'); + expect( + noteFields(_note('a'), hlc).containsKey('created_by'), + isFalse, + reason: 'an unset field keeps the record wire-identical', + ); + final fields = noteFields(_note('a', createdBy: 'claude'), hlc); + expect(fields['created_by']!.$1, 'claude'); + final back = noteFromRecord(Record(id: 'a', fields: fields)); + expect(back.createdBy, 'claude'); + expect(back.isClaudeMade, isTrue); + expect(_note('b', createdBy: 'kuhy').isClaudeMade, isFalse); + expect(_note('c').isClaudeMade, isFalse); + }); + + test('copyWith keeps createdBy unless replaced', () { + final n = _note('a', createdBy: 'claude'); + expect(n.copyWith(text: 'x').createdBy, 'claude'); + expect(n.copyWith(createdBy: 'kuhy').createdBy, 'kuhy'); + }); + }); + + group('merge safety', () { + test( + 'an older build upsert (no created_by) does not wipe a peer value', + () async { + final current = await NoteRepository.openInMemory(nodeId: 'new'); + await current.upsert(_note('a', createdBy: 'claude')); + + final old = LogStore(persistence: MemoryPersistence(), nodeId: 'old'); + await old.load(); + await old.replaceAll( + mergeLogs(old.snapshot(), _overTheWire(current.exportLog())), + ); + final edited = _note('a', updated: DateTime.utc(2026, 11, 2)); + await old.upsert( + Record(id: 'a', fields: _oldFields(edited, old.nextHlc())), + ); + expect(old.snapshot()['a']!.fields.containsKey('created_by'), isFalse); + + await current.importLog(_overTheWire(old.snapshot())); + expect((await current.listNotes()).single.createdBy, 'claude'); + + // Pulling the other way restores it on the older device too. + await old.replaceAll( + mergeLogs(old.snapshot(), _overTheWire(current.exportLog())), + ); + expect(old.snapshot()['a']!.fields['created_by']!.$1, 'claude'); + }, + ); + + test('upserting a note without createdBy keeps the stored value', () async { + final repo = await NoteRepository.openInMemory(); + await repo.upsert(_note('a', createdBy: 'claude')); + await repo.upsert(_note('a', updated: DateTime.utc(2026, 11, 3))); + expect((await repo.listNotes()).single.createdBy, 'claude'); + await repo.importNotes([_note('a', updated: DateTime.utc(2027))]); + expect((await repo.listNotes()).single.createdBy, 'claude'); + }); + }); + + group('order and hiding', () { + final claudeHigh = _note('c', createdBy: 'claude', priority: Priority.high); + final humanLow = _note('h', createdBy: 'kuhy', priority: Priority.low); + final legacy = _note('l', priority: Priority.medium); + + test( + 'human notes come first under every sort, then the old order', + () async { + final repo = await NoteRepository.openInMemory(); + for (final n in [claudeHigh, humanLow, legacy]) { + await repo.upsert(n); + } + for (final sort in NoteSort.values) { + final ids = [for (final n in await repo.listNotes(sort: sort)) n.id]; + expect(ids.last, 'c', reason: '$sort'); + } + final byPriority = await repo.listNotes(); + expect([for (final n in byPriority) n.id], ['l', 'h', 'c']); + }, + ); + + test('hideClaude drops Claude-made notes and is not a user facet', () { + const filter = NoteFilter(hideClaude: true); + expect(matchesFilter(claudeHigh, filter), isFalse); + expect(matchesFilter(humanLow, filter), isTrue); + expect(matchesFilter(legacy, filter), isTrue); + expect(matchesFilter(claudeHigh, const NoteFilter()), isTrue); + expect(filter.isEmpty, isTrue); + expect(filter.activeCount, 0); + expect(filter.copyWith(query: 'x').hideClaude, isTrue); + expect(filter.copyWith(hideClaude: false).hideClaude, isFalse); + }); + }); + + group('backfill', () { + Record rec(String id, String node, {String? createdBy, bool dead = false}) { + final hlc = Hlc(wallTimeMs: 5, counter: 0, nodeId: node); + return Record( + id: id, + fields: {...noteFields(_note(id, createdBy: createdBy), hlc)}, + deleted: dead, + deletedHlc: dead ? hlc : null, + ); + } + + test('classifies by the node that wrote created_at', () { + const claude = {'mcp', 'cli'}; + expect(classifyCreatedBy(rec('a', 'mcp'), claude), 'claude'); + expect(classifyCreatedBy(rec('a', 'cli'), claude), 'claude'); + expect(classifyCreatedBy(rec('a', 'phone'), claude), 'kuhy'); + const bare = Record(id: 'x', fields: {}); + expect(classifyCreatedBy(bare, claude), 'kuhy'); + }); + + test('writes only unset, live records, and only that field', () async { + final repo = await NoteRepository.openInMemory(nodeId: 'cli'); + await repo.importLog({ + 'a': rec('a', 'mcp'), + 'b': rec('b', 'phone'), + 'c': rec('c', 'phone', createdBy: 'claude'), + 'd': rec('d', 'mcp', dead: true), + }); + const claude = {'mcp', 'cli'}; + final dry = await repo.backfillCreatedBy(claude, dryRun: true); + expect(dry, {'a': 'claude', 'b': 'kuhy'}); + expect(repo.exportLog()['a']!.fields.containsKey('created_by'), isFalse); + + final done = await repo.backfillCreatedBy(claude); + expect(done, dry); + final log = repo.exportLog(); + expect(log['a']!.fields['created_by']!.$1, 'claude'); + expect(log['b']!.fields['created_by']!.$1, 'kuhy'); + expect(log['c']!.fields['created_by']!.$1, 'claude'); + expect(log['d']!.fields.containsKey('created_by'), isFalse); + // created_at keeps its original clock: the signal is not destroyed. + expect(log['b']!.fields['created_at']!.$2.nodeId, 'phone'); + expect(await repo.backfillCreatedBy(claude), isEmpty); + }); + }); +} diff --git a/test/notes_list_claude_toggle_test.dart b/test/notes_list_claude_toggle_test.dart new file mode 100644 index 0000000..351f327 --- /dev/null +++ b/test/notes_list_claude_toggle_test.dart @@ -0,0 +1,100 @@ +// Claude-made notes: hidden by default, an eye toggle shows them, the choice +// is remembered per device, and human notes always sort first. +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:todo/data/app_settings.dart'; +import 'package:todo/data/note.dart'; +import 'package:todo/ui/notes_list_screen.dart'; + +import 'fake_note_repository.dart'; +import 'notes_list_harness.dart'; + +final _seed = [ + note( + 'c', + 'Claude chore', + priority: Priority.high, + ).copyWith(createdBy: 'claude'), + note('h', 'Human idea', priority: Priority.low).copyWith(createdBy: 'kuhy'), + note('l', 'Legacy idea'), +]; + +Future<ValueNotifier<AppSettings>> _pump( + WidgetTester tester, { + AppSettings settings = const AppSettings(advancedMode: true), +}) async { + final repo = FakeNoteRepository(_seed, true); + addTearDown(repo.close); + final notifier = ValueNotifier(settings); + addTearDown(notifier.dispose); + await tester.pumpWidget( + MaterialApp( + home: NotesListScreen(repository: repo, appSettings: notifier), + ), + ); + await tester.pump(); + return notifier; +} + +Future<void> _tap(WidgetTester tester, IconData icon) async { + await tester.tap(find.byIcon(icon)); + await tester.pump(); + await tester.pump(); +} + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('Claude-made notes are hidden until the eye is tapped', ( + tester, + ) async { + await _pump(tester); + expect(find.text('Claude chore'), findsNothing); + expect(find.text('Human idea'), findsOneWidget); + expect(find.text('Legacy idea'), findsOneWidget); + expect(find.byIcon(Icons.visibility_off), findsOneWidget); + + await _tap(tester, Icons.visibility_off); + expect(find.text('Claude chore'), findsOneWidget); + expect(find.byIcon(Icons.visibility), findsOneWidget); + + await _tap(tester, Icons.visibility); + expect(find.text('Claude chore'), findsNothing); + }); + + testWidgets('shown, Claude notes sort below every human note', ( + tester, + ) async { + await _pump( + tester, + settings: const AppSettings(advancedMode: true, showClaudeNotes: true), + ); + double y(String t) => tester.getTopLeft(find.text(t)).dy; + // The default 800x600 view lists one note per row, in display order; the + // high-priority Claude note still comes last. + expect(y('Claude chore'), greaterThan(y('Human idea'))); + expect(y('Claude chore'), greaterThan(y('Legacy idea'))); + }); + + testWidgets('the choice is persisted per device and reloaded', ( + tester, + ) async { + final notifier = await _pump(tester); + await _tap(tester, Icons.visibility_off); + expect(notifier.value.showClaudeNotes, isTrue); + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getBool('app.showClaudeNotes'), isTrue); + expect((await AppSettings.load()).showClaudeNotes, isTrue); + }); + + test('advancedMode and the toggle do not overwrite each other', () async { + final shown = await const AppSettings( + advancedMode: false, + ).withShowClaudeNotes(value: true); + expect(shown.advancedMode, isFalse); + final both = await shown.withAdvancedMode(value: true); + expect(both.showClaudeNotes, isTrue); + expect(both.advancedMode, isTrue); + }); +}