From cbe3d9be59a989e07044ee7503e4d7af89bc680a Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 2 Nov 2026 14:11:21 +0100 Subject: [PATCH] One open note per repo, merge tool Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 2 +- bin/merge_repo_notes.dart | 16 +++ lib/cli/cli_commands.dart | 31 ++++- lib/cli/merge_repo_notes.dart | 78 ++++++++++++ lib/data/repo_note.dart | 86 ++++++++++++++ lib/data/repo_note_merge.dart | 50 ++++++++ lib/mcp/task_service.dart | 28 ++--- lib/mcp/todo_mcp_server.dart | 9 +- lib/ui/note_detail_screen.dart | 17 ++- lib/ui/note_tile.dart | 6 +- lib/ui/notes_grid.dart | 29 ++--- lib/ui/notes_list_body.dart | 46 +++----- lib/ui/notes_list_screen.dart | 23 +--- lib/ui/repo_group.dart | 70 ----------- lib/ui/repo_screen.dart | 99 ---------------- lib/ui/repo_tiles.dart | 73 ------------ pubspec.yaml | 2 +- test/cli/cli_harness.dart | 15 ++- test/cli/cli_repo_note_test.dart | 151 ++++++++++++++++++++++++ test/cli/cli_tasks_test.dart | 8 +- test/mcp/mcp_contract_test.dart | 3 +- test/mcp/mcp_repo_note_test.dart | 68 +++++++++++ test/mcp/mcp_update_test.dart | 2 +- test/note_detail_screen_test.dart | 30 +++++ test/notes_list_one_list_test.dart | 44 +++---- test/repo_note_test.dart | 183 +++++++++++++++++++++++++++++ test/repo_screen_test.dart | 129 -------------------- 27 files changed, 783 insertions(+), 515 deletions(-) create mode 100644 bin/merge_repo_notes.dart create mode 100644 lib/cli/merge_repo_notes.dart create mode 100644 lib/data/repo_note.dart create mode 100644 lib/data/repo_note_merge.dart delete mode 100644 lib/ui/repo_group.dart delete mode 100644 lib/ui/repo_screen.dart delete mode 100644 lib/ui/repo_tiles.dart create mode 100644 test/cli/cli_repo_note_test.dart create mode 100644 test/mcp/mcp_repo_note_test.dart create mode 100644 test/repo_note_test.dart delete mode 100644 test/repo_screen_test.dart diff --git a/AGENTS.md b/AGENTS.md index e1110ce..3bfd58f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,7 +85,7 @@ Three layers under `lib/`, each single-purpose: - `capture_screen.dart`: landing screen; always-focused text box pre-filled with the structured template; lazy note creation on first keystroke. - `notes_list_screen.dart`: list + search + sort + filter sheet + per-note - sheet. Notes sharing a `repo` collapse into one row (`repo_group.dart`). **Default view hides Done/Abandoned and shows no filter badge** + sheet. One open note per repo (`data/repo_note.dart`). **Default view hides Done/Abandoned and shows no filter badge** (looks unfiltered). Under 600dp wide (`kNotesGridMaxWidth`) the notes are a 2-column `NotesGrid` of compact `NoteGridTile`s (title + priority/status cues; body and dates are in the long-press sheet). The tile height is diff --git a/bin/merge_repo_notes.dart b/bin/merge_repo_notes.dart new file mode 100644 index 0000000..d558cf9 --- /dev/null +++ b/bin/merge_repo_notes.dart @@ -0,0 +1,16 @@ +// One-time: one open note per repo, holding all of that repo's todos. +// +// dart run bin/merge_repo_notes.dart # dry run, prints the map +// dart run bin/merge_repo_notes.dart --apply # does it and pushes +import 'dart:io'; + +import 'package:todo/cli/merge_repo_notes.dart'; + +Future main(List argv) async { + exitCode = await runMergeRepoNotes( + argv, + env: Platform.environment, + out: stdout, + err: stderr, + ); +} diff --git a/lib/cli/cli_commands.dart b/lib/cli/cli_commands.dart index e6536e1..ecca27d 100644 --- a/lib/cli/cli_commands.dart +++ b/lib/cli/cli_commands.dart @@ -3,6 +3,7 @@ import 'package:todo/cli/cli_session.dart'; import 'package:todo/cli/note_lookup.dart'; import 'package:todo/data/note.dart'; import 'package:todo/data/note_repository.dart'; +import 'package:todo/data/repo_note.dart'; import 'package:todo/sync/sync_service.dart'; import 'package:uuid/uuid.dart'; @@ -18,17 +19,22 @@ class CliCommands { final StringSink _out; final StringSink _err; - /// Creates a note (a repo task when `--repo` is set) and pushes it. + /// Creates a note and pushes it. A `--repo` add joins the repo's open note. Future add(CliArgs args, String text) async { + final repo = args.opts.repo; + final status = args.status ?? Status.todo; + if (repo != null && kOpenStatuses.contains(status)) { + return await _addRepoTask(args, repo, text); + } final now = DateTime.now(); final note = Note( id: const Uuid().v4(), text: text, priority: args.priority, - status: args.status ?? Status.todo, + status: status, createdAt: now, updatedAt: now, - repo: args.opts.repo, + repo: repo, doneCheck: args.opts.doneCheck, by: args.opts.by, ); @@ -38,6 +44,25 @@ class CliCommands { return 0; } + Future _addRepoTask(CliArgs args, String repo, String text) async { + _report(await session.sync(push: true)); + final (:title, :body) = splitNote(text); + final added = await addRepoTask( + session.repository, + repo: repo, + title: title, + body: body, + doneCheck: args.opts.doneCheck, + priority: args.priority, + by: args.opts.by, + ); + await _save(added.note); + if (!await _push('note')) return 1; + if (added.appended) _err.writeln('appended to the open note for $repo'); + _out.writeln(added.note.id); + return 0; + } + /// Prints [listLine] for each note matching the filters in [args]. Future list(CliArgs args) async { _report(await session.sync(push: false)); diff --git a/lib/cli/merge_repo_notes.dart b/lib/cli/merge_repo_notes.dart new file mode 100644 index 0000000..ed3b80f --- /dev/null +++ b/lib/cli/merge_repo_notes.dart @@ -0,0 +1,78 @@ +/// `bin/merge_repo_notes.dart`: collapse each repo's open notes into one. +/// Dry run unless `--apply`; stdout is the `old_idkept_idrepo` map. +library; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:path/path.dart' as p; +import 'package:todo/cli/cli_runner.dart'; +import 'package:todo/cli/cli_session.dart'; +import 'package:todo/data/repo_note_merge.dart'; + +const _usage = 'usage: merge_repo_notes [--dry-run|--apply] [--data-dir D]'; + +/// Runs the merge for [argv]; returns the exit code. +Future runMergeRepoNotes( + List argv, { + required Map env, + required StringSink out, + required StringSink err, + SessionOpener open = CliSession.open, +}) async { + var apply = false; + String? dataDir; + for (var i = 0; i < argv.length; i++) { + switch (argv[i]) { + case '--apply': + apply = true; + case '--dry-run': + apply = false; + case '--data-dir' when i + 1 < argv.length: + dataDir = argv[++i]; + default: + err.writeln(_usage); + return 2; + } + } + CliSession? session; + try { + session = await open( + dataDir ?? defaultDataDir(env), + seedCredentials: p.join( + env['HOME'] ?? '', + '.config', + 'todo', + 'firebase_auth.json', + ), + ); + await session.sync(push: apply); + final plan = planRepoMerges(await session.repository.listNotes()); + for (final (:kept, :removed) in plan) { + if (apply) { + await session.repository.upsert( + kept.copyWith(updatedAt: DateTime.now()), + ); + for (final n in removed) { + await session.repository.delete(n.id); + } + } + for (final n in removed) { + out.writeln('${n.id}\t${kept.id}\t${kept.repo}'); + } + } + final deleted = plan.fold(0, (sum, m) => sum + m.removed.length); + err.writeln( + '${apply ? 'applied' : 'dry run'}: ${plan.length} repos, ' + '$deleted notes deleted', + ); + if (apply && plan.isNotEmpty && !(await session.sync(push: true)).pushed) { + err.writeln('error: saved locally but nothing was pushed'); + return 1; + } + return 0; + } on RemoteSyncError catch (error) { + err.writeln('error: $error'); + return 1; + } finally { + await session?.close(); + } +} diff --git a/lib/data/repo_note.dart b/lib/data/repo_note.dart new file mode 100644 index 0000000..143fb14 --- /dev/null +++ b/lib/data/repo_note.dart @@ -0,0 +1,86 @@ +/// The one-open-note-per-repo invariant. Single add path for the MCP +/// `add_task` and the CLI `--repo` add; callers persist and push. +library; + +import 'package:todo/data/note.dart'; +import 'package:todo/data/note_repository.dart'; +import 'package:todo/data/note_template.dart'; +import 'package:uuid/uuid.dart'; + +/// Statuses that count as open. +const Set kOpenStatuses = {Status.todo, Status.inProgress}; + +/// The repo note's title. +String repoNoteTitle(String repo) => '${repo.split('/').last} todos'; + +/// Joins two checks with `&&`; either may be absent. +String? composeDoneCheck(String? older, String? newer) { + final a = (older ?? '').trim(); + final b = (newer ?? '').trim(); + if (a.isEmpty) return b.isEmpty ? null : b; + return b.isEmpty ? a : '( $a ) && ( $b )'; +} + +/// A `## title` section. +String repoSection(String title, String body) { + final text = body.trim(); + return text.isEmpty ? '## $title' : '## $title\n$text'; +} + +/// The higher priority. +Priority maxPriority(Priority a, Priority b) => a.value >= b.value ? a : b; + +/// Title line and rest of a note's text. +({String title, String body}) splitNote(String text) => + (title: noteTitle(text), body: text.split('\n').skip(1).join('\n').trim()); + +/// Oldest first; ties broken by id so the choice is deterministic. +int byAge(Note a, Note b) { + final c = a.createdAt.compareTo(b.createdAt); + return c != 0 ? c : a.id.compareTo(b.id); +} + +/// The note to save and whether it was appended. +typedef RepoAdd = ({Note note, bool appended}); + +/// Appends the task to [repo]'s oldest open note, or builds that note. +Future addRepoTask( + NoteRepository repository, { + required String repo, + required String title, + String body = '', + String? doneCheck, + Priority priority = Priority.medium, + String? by, +}) async { + final section = repoSection(title, body); + final open = await repository.listNotes( + filter: NoteFilter(statuses: kOpenStatuses, repo: repo), + ); + if (open.isNotEmpty) { + final existing = (open..sort(byAge)).first; + return ( + note: existing.copyWith( + text: '${existing.text}\n\n$section', + doneCheck: composeDoneCheck(existing.doneCheck, doneCheck), + priority: maxPriority(existing.priority, priority), + ), + appended: true, + ); + } + final now = DateTime.now(); + return ( + note: Note( + id: const Uuid().v4(), + text: '${repoNoteTitle(repo)}\n\n$section', + priority: priority, + status: Status.todo, + createdAt: now, + updatedAt: now, + repo: repo, + doneCheck: composeDoneCheck(null, doneCheck), + by: by, + ), + appended: false, + ); +} diff --git a/lib/data/repo_note_merge.dart b/lib/data/repo_note_merge.dart new file mode 100644 index 0000000..1d8c670 --- /dev/null +++ b/lib/data/repo_note_merge.dart @@ -0,0 +1,50 @@ +/// One-time migration plan: one open note per repo, oldest id kept. +library; + +import 'package:todo/data/note.dart'; +import 'package:todo/data/repo_note.dart'; + +/// The rewritten oldest note and the originals to delete. +typedef RepoMerge = ({Note kept, List removed}); + +/// A merge for every repo whose open notes are not yet one ` todos` +/// note; running it on its own result yields an empty plan. +List planRepoMerges(Iterable notes) { + final byRepo = >{}; + for (final n in notes) { + final repo = n.repo; + if (repo != null && kOpenStatuses.contains(n.status)) { + byRepo.putIfAbsent(repo, () => []).add(n); + } + } + final plan = []; + for (final MapEntry(key: repo, value: group) in byRepo.entries) { + group.sort(byAge); + final title = repoNoteTitle(repo); + if (group.length == 1 && splitNote(group.single.text).title == title) { + continue; + } + plan.add((kept: _merged(title, group), removed: group.sublist(1))); + } + return plan; +} + +Note _merged(String title, List group) { + var doneCheck = group.first.doneCheck; + var priority = group.first.priority; + final sections = []; + for (final (i, n) in group.indexed) { + final (title: t, :body) = splitNote(n.text); + // An already-merged note keeps its sections as they are. + sections.add(t == title ? body : repoSection(t, body)); + if (i > 0) { + doneCheck = composeDoneCheck(doneCheck, n.doneCheck); + priority = maxPriority(priority, n.priority); + } + } + return group.first.copyWith( + text: '$title\n\n${sections.where((s) => s.isNotEmpty).join('\n\n')}', + doneCheck: doneCheck, + priority: priority, + ); +} diff --git a/lib/mcp/task_service.dart b/lib/mcp/task_service.dart index e8a572d..ca32401 100644 --- a/lib/mcp/task_service.dart +++ b/lib/mcp/task_service.dart @@ -3,7 +3,7 @@ import 'package:todo/cli/cli_session.dart'; import 'package:todo/cli/note_lookup.dart'; import 'package:todo/data/note.dart'; import 'package:todo/data/note_repository.dart'; -import 'package:uuid/uuid.dart'; +import 'package:todo/data/repo_note.dart'; /// The six task tools over one persistent sync node; calls run one at a time. class TaskService { @@ -40,28 +40,22 @@ class TaskService { } } - /// `add_task`: a new repo task by claude; returns its full id. + /// `add_task`: joins the repo's open note (by: claude), or creates it. Future add(Map args) => _serial(() async { final a = _Args(args); final repo = a.repo(required: true)!; final title = a.required('title'); - final body = a.optional('body') ?? ''; - final now = DateTime.now(); await _pull(); - return await _save( - Note( - id: const Uuid().v4(), - text: body.isEmpty ? title : '$title\n\n$body', - priority: a.priority() ?? Priority.medium, - status: Status.todo, - createdAt: now, - updatedAt: now, - repo: repo, - doneCheck: a.optional('done_check'), - by: 'claude', - ), - 'added', + final added = await addRepoTask( + _session.repository, + repo: repo, + title: title, + body: a.optional('body') ?? '', + doneCheck: a.optional('done_check'), + priority: a.priority() ?? Priority.medium, + by: 'claude', ); + return await _save(added.note, added.appended ? 'appended to' : 'added'); }); /// `list_tasks`: repo tasks, open ones unless `status` says otherwise. diff --git a/lib/mcp/todo_mcp_server.dart b/lib/mcp/todo_mcp_server.dart index ed7e726..a433c46 100644 --- a/lib/mcp/todo_mcp_server.dart +++ b/lib/mcp/todo_mcp_server.dart @@ -14,9 +14,12 @@ Tasks for kuhy's repos live in the todo app (not in TODO*.md files, which are banned). Use this server instead of writing a TODO file. - At session start in a repo, call list_tasks(repo: "kuhyx/") to see its open tasks. +- A repo has ONE open note, " todos", holding all its tasks as + "## title" sections. add_task appends to it (or creates it) and returns its id. - When you find work you will not finish now, call add_task with the repo, a one-line title, the body (what / where / done / verify) and a done_check: a shell command run from the repo root that exits 0 once the work is done. + Done checks of appended tasks are joined with &&. - When a task's work lands, run its done_check; only if it exits 0 call close_task with evidence " done_check exit 0". Never close without running it. @@ -90,8 +93,10 @@ McpServer _server(TaskService tasks) { tool( 'add_task', - 'Add an open task for a repo (by: claude). Returns "added " and ' - 'its list line. Use when you find work you will not finish now.', + "Add a task to the repo's single open note (by: claude): appended as a " + '"## title" section, or the note is created. Returns "added " ' + 'or "appended to ", then its list line. Use when you find ' + 'work you will not finish now.', { 'repo': repo, 'title': JsonSchema.string(description: 'One-line title.'), diff --git a/lib/ui/note_detail_screen.dart b/lib/ui/note_detail_screen.dart index ad18a54..a106803 100644 --- a/lib/ui/note_detail_screen.dart +++ b/lib/ui/note_detail_screen.dart @@ -1,8 +1,8 @@ import 'dart:async'; -import 'dart:typed_data'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:todo/data/app_settings.dart'; import 'package:todo/data/note.dart'; import 'package:todo/data/note_repository.dart'; @@ -52,6 +52,14 @@ class _NoteDetailScreenState extends State { /// Inserts attached-image links into the editor. final ImageAttachController _attach = ImageAttachController(); + bool _copied = false; + + Future _copy() async { + await Clipboard.setData(ClipboardData(text: _note.text)); + await HapticFeedback.lightImpact(); + if (mounted) setState(() => _copied = true); + } + Future _attachImages(List images, {int rejected = 0}) => attachImages( context, @@ -98,6 +106,13 @@ class _NoteDetailScreenState extends State { onFiles: _attachFiles, picking: widget.attachmentPicking, ), + IconButton( + icon: Icon( + _copied ? Icons.check : Icons.copy_all, + semanticLabel: 'Copy', + ), + onPressed: _copy, + ), IconButton( tooltip: 'Delete note', icon: const Icon(Icons.delete_outline), diff --git a/lib/ui/note_tile.dart b/lib/ui/note_tile.dart index 7260ae4..884b3be 100644 --- a/lib/ui/note_tile.dart +++ b/lib/ui/note_tile.dart @@ -16,7 +16,6 @@ class NoteTile extends StatelessWidget { required this.note, required this.onTap, required this.onActions, - this.showRepo = true, super.key, }); @@ -31,9 +30,6 @@ class NoteTile extends StatelessWidget { /// Called when the trailing actions button is tapped. final VoidCallback onActions; - /// Whether the repo name joins the metadata line (off on its own screen). - final bool showRepo; - @override Widget build(BuildContext context) { final firstLine = noteTitle(note.text); @@ -42,7 +38,7 @@ class NoteTile extends StatelessWidget { note.status.label, note.priority.label, 'edited ${_relative(note.updatedAt)}', - if (showRepo) ?note.repoName, + ?note.repoName, ].join(' · '); return ListTile( title: Text( diff --git a/lib/ui/notes_grid.dart b/lib/ui/notes_grid.dart index 556b8a6..fdd155f 100644 --- a/lib/ui/notes_grid.dart +++ b/lib/ui/notes_grid.dart @@ -5,8 +5,6 @@ import 'package:flutter/material.dart'; import 'package:todo/data/note.dart'; import 'package:todo/ui/note_grid_tile.dart'; -import 'package:todo/ui/repo_group.dart'; -import 'package:todo/ui/repo_tiles.dart'; /// Below this width the notes list is a grid; at or above it, the list. const double kNotesGridMaxWidth = 600; @@ -25,15 +23,14 @@ double _tileHeightFor(double height) { class NotesGrid extends StatelessWidget { /// Creates the grid; [onOpen] and [onActions] get the tapped note. const NotesGrid({ - required this.entries, + required this.notes, required this.onOpen, required this.onActions, - required this.onOpenRepo, super.key, }); - /// Rows in display order: single notes and repo groups. - final List entries; + /// Notes in display order. + final List notes; /// Tap on a tile. final ValueChanged onOpen; @@ -41,22 +38,14 @@ class NotesGrid extends StatelessWidget { /// Long-press on a tile. final ValueChanged onActions; - /// Tap on a repo group tile. - final ValueChanged onOpenRepo; - - Widget _tile(ListEntry entry) => switch (entry) { - NoteEntry(:final note) => NoteGridTile( + Widget _tile(Note note) { + return NoteGridTile( key: ValueKey(note.id), note: note, onTap: () => onOpen(note), onActions: () => onActions(note), - ), - final RepoEntry repo => RepoGridTile( - key: ValueKey(repo.repo), - entry: repo, - onTap: () => onOpenRepo(repo), - ), - }; + ); + } SliverGridDelegate _delegate(double viewportHeight) { return SliverGridDelegateWithFixedCrossAxisCount( @@ -77,8 +66,8 @@ class NotesGrid extends StatelessWidget { builder: (context, box) => GridView.builder( padding: const EdgeInsets.symmetric(horizontal: _sidePadding), gridDelegate: _delegate(box.maxHeight), - itemCount: entries.length, - itemBuilder: (context, i) => _tile(entries[i]), + itemCount: notes.length, + itemBuilder: (context, i) => _tile(notes[i]), ), ), ), diff --git a/lib/ui/notes_list_body.dart b/lib/ui/notes_list_body.dart index abb3c19..e3bfd5f 100644 --- a/lib/ui/notes_list_body.dart +++ b/lib/ui/notes_list_body.dart @@ -1,4 +1,4 @@ -/// The notes list's body: phone grid or desktop list of grouped rows. +/// The notes list's body: phone grid or desktop list of notes. /// /// Split out of `notes_list_screen.dart` for file size. library; @@ -8,22 +8,19 @@ import 'package:flutter/material.dart'; import 'package:todo/data/note.dart'; import 'package:todo/ui/note_tile.dart'; import 'package:todo/ui/notes_grid.dart'; -import 'package:todo/ui/repo_group.dart'; -import 'package:todo/ui/repo_tiles.dart'; -/// Lays [entries] out as a grid below [kNotesGridMaxWidth], else a list. +/// Lays [notes] out as a grid below [kNotesGridMaxWidth], else a list. class NotesListBody extends StatelessWidget { - /// Creates the body; callbacks get the tapped note or repo group. + /// Creates the body; callbacks get the tapped note. const NotesListBody({ - required this.entries, + required this.notes, required this.onOpen, required this.onActions, - required this.onOpenRepo, super.key, }); - /// Rows in display order. - final List entries; + /// Notes in display order. + final List notes; /// Tap on a note. final ValueChanged onOpen; @@ -31,35 +28,20 @@ class NotesListBody extends StatelessWidget { /// Long-press (or the actions button) on a note. final ValueChanged onActions; - /// Tap on a repo group. - final ValueChanged onOpenRepo; - @override Widget build(BuildContext context) { return LayoutBuilder( builder: (context, box) => box.maxWidth < kNotesGridMaxWidth - ? NotesGrid( - entries: entries, - onOpen: onOpen, - onActions: onActions, - onOpenRepo: onOpenRepo, - ) + ? NotesGrid(notes: notes, onOpen: onOpen, onActions: onActions) : ListView.separated( - itemCount: entries.length, + itemCount: notes.length, separatorBuilder: (_, _) => const Divider(height: 1), - itemBuilder: (context, i) => switch (entries[i]) { - NoteEntry(:final note) => NoteTile( - key: ValueKey(note.id), - note: note, - onTap: () => onOpen(note), - onActions: () => onActions(note), - ), - final RepoEntry repo => RepoListTile( - key: ValueKey(repo.repo), - entry: repo, - onTap: () => onOpenRepo(repo), - ), - }, + itemBuilder: (context, i) => NoteTile( + key: ValueKey(notes[i].id), + note: notes[i], + onTap: () => onOpen(notes[i]), + onActions: () => onActions(notes[i]), + ), ), ); } diff --git a/lib/ui/notes_list_screen.dart b/lib/ui/notes_list_screen.dart index 41f28d1..e22318c 100644 --- a/lib/ui/notes_list_screen.dart +++ b/lib/ui/notes_list_screen.dart @@ -5,13 +5,10 @@ import 'package:flutter/material.dart'; import 'package:todo/data/app_settings.dart'; import 'package:todo/data/note.dart'; import 'package:todo/data/note_repository.dart'; -import 'package:todo/ui/app_route.dart'; import 'package:todo/ui/notes_filter_sheet.dart'; import 'package:todo/ui/notes_list_body.dart'; import 'package:todo/ui/notes_list_defaults.dart'; import 'package:todo/ui/notes_list_navigation.dart'; -import 'package:todo/ui/repo_group.dart'; -import 'package:todo/ui/repo_screen.dart'; export 'package:todo/ui/notes_list_defaults.dart'; @@ -106,22 +103,6 @@ class _NotesListScreenState extends State { } } - Future _openRepo(String repo) async { - FocusManager.instance.primaryFocus?.unfocus(); - await Navigator.of(context).push( - appRoute( - context, - (_) => RepoScreen( - repo: repo, - filter: _filter, - sort: _sort, - repository: widget.repository, - appSettings: widget.appSettings, - ), - ), - ); - } - @override Widget build(BuildContext context) { final badgeCount = userFacetCount(_filter); @@ -216,12 +197,10 @@ class _NotesListScreenState extends State { openNote(context, n, widget.repository, widget.appSettings); void actions(Note n) => openNoteActions(context, n, widget.repository); - void openRepo(RepoEntry r) => _openRepo(r.repo); return NotesListBody( - entries: groupByRepo(notes), + notes: notes, onOpen: open, onActions: actions, - onOpenRepo: openRepo, ); }, ), diff --git a/lib/ui/repo_group.dart b/lib/ui/repo_group.dart deleted file mode 100644 index 3929d85..0000000 --- a/lib/ui/repo_group.dart +++ /dev/null @@ -1,70 +0,0 @@ -/// Display-only grouping of the notes list: every note sharing a `repo` -/// collapses into one [RepoEntry]; notes without a repo stay [NoteEntry]s. -/// -/// Storage is untouched -- each note keeps its own done_check and closes -/// individually. Grouping runs on the already filtered/sorted list, so a -/// group only counts notes that passed the active filters. -library; - -import 'package:todo/data/note.dart'; -import 'package:todo/data/note_template.dart'; - -/// One row of the notes list. -sealed class ListEntry { - const ListEntry(); -} - -/// A plain note shown as its own row. -final class NoteEntry extends ListEntry { - /// Wraps [note]. - const NoteEntry(this.note); - - /// The note this row stands for. - final Note note; -} - -/// All notes of one repo, shown as a single row. -final class RepoEntry extends ListEntry { - /// Groups [notes], which all carry [repo]. - const RepoEntry(this.repo, this.notes); - - /// The full `owner/name` repo. - final String repo; - - /// Group members in list order. - final List notes; - - /// Repo name without its owner. - String get name => repo.split('/').last; -} - -/// Collapses [notes] (already filtered and sorted) into rows. A repo's row -/// takes the position of its first note. -List groupByRepo(List notes) { - final groups = >{}; - final entries = []; - for (final n in notes) { - final repo = n.repo; - if (repo == null) { - entries.add(NoteEntry(n)); - } else if (groups[repo] case final members?) { - members.add(n); - } else { - final members = groups[repo] = [n]; - entries.add(RepoEntry(repo, members)); - } - } - return entries; -} - -/// One plain-text block for [notes]: title, body and `id: ` per task, -/// tasks separated by a blank line. -String repoClipboardText(List notes) { - return notes.map(_block).join('\n\n'); -} - -String _block(Note n) { - final body = n.text.split('\n').skip(1).join('\n').trim(); - final id = n.id.length <= 8 ? n.id : n.id.substring(0, 8); - return [noteTitle(n.text), if (body.isNotEmpty) body, 'id: $id'].join('\n'); -} diff --git a/lib/ui/repo_screen.dart b/lib/ui/repo_screen.dart deleted file mode 100644 index fbbb870..0000000 --- a/lib/ui/repo_screen.dart +++ /dev/null @@ -1,99 +0,0 @@ -/// A repo's tasks on one screen, with a copy-all action. -library; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -import 'package:todo/data/app_settings.dart'; -import 'package:todo/data/note.dart'; -import 'package:todo/data/note_repository.dart'; -import 'package:todo/ui/note_tile.dart'; -import 'package:todo/ui/notes_list_navigation.dart'; -import 'package:todo/ui/repo_group.dart'; - -/// Lists the tasks of [repo] that pass the list's active [filter]. -/// -/// Display only: each task is still its own note, opened with the usual -/// detail screen. The copy action puts every listed task (title, body and -/// `id: `) on the clipboard as one plain-text block. -class RepoScreen extends StatefulWidget { - /// Creates the screen for [repo], narrowed by the list's [filter]/[sort]. - const RepoScreen({ - required this.repo, - required this.filter, - required this.sort, - required this.repository, - required this.appSettings, - super.key, - }); - - /// The full `owner/name` repo. - final String repo; - - /// The notes list's active filter; the repo is added on top. - final NoteFilter filter; - - /// The notes list's sort order. - final NoteSort sort; - - /// The store the tasks live in. - final NoteRepository repository; - - /// Passed on to the note detail screen. - final ValueNotifier appSettings; - - @override - State createState() => _RepoScreenState(); -} - -class _RepoScreenState extends State { - late final Stream> _stream = widget.repository.watchNotes( - sort: widget.sort, - filter: widget.filter.copyWith(repo: widget.repo), - ); - List _notes = const []; - bool _copied = false; - - Future _copy() async { - await Clipboard.setData(ClipboardData(text: repoClipboardText(_notes))); - await HapticFeedback.lightImpact(); - if (mounted) setState(() => _copied = true); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text(widget.repo.split('/').last), - actions: [ - IconButton( - tooltip: 'Copy all', - icon: Icon(_copied ? Icons.check : Icons.copy_all), - onPressed: _copy, - ), - ], - ), - body: StreamBuilder>( - stream: _stream, - builder: (context, snapshot) { - _notes = snapshot.data ?? const []; - return ListView.separated( - itemCount: _notes.length, - separatorBuilder: (_, _) => const Divider(height: 1), - itemBuilder: (context, i) { - final n = _notes[i]; - return NoteTile( - key: ValueKey(n.id), - note: n, - showRepo: false, - onTap: () => - openNote(context, n, widget.repository, widget.appSettings), - onActions: () => openNoteActions(context, n, widget.repository), - ); - }, - ); - }, - ), - ); - } -} diff --git a/lib/ui/repo_tiles.dart b/lib/ui/repo_tiles.dart deleted file mode 100644 index d1e98f2..0000000 --- a/lib/ui/repo_tiles.dart +++ /dev/null @@ -1,73 +0,0 @@ -/// The single row a repo's notes collapse into, as a grid tile (phone) and a -/// list tile (desktop). -library; - -import 'package:design_system/design_system.dart'; -import 'package:flutter/material.dart'; - -import 'package:todo/ui/repo_group.dart'; - -String _label(RepoEntry entry) => '${entry.name} · ${entry.notes.length}'; - -/// Grid cell for a repo group. -class RepoGridTile extends StatelessWidget { - /// Creates a tile for [entry]; [onTap] opens the repo screen. - const RepoGridTile({required this.entry, required this.onTap, super.key}); - - /// The group this tile stands for. - final RepoEntry entry; - - /// Open the repo screen. - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return Material( - color: scheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(AppRadius.md), - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.all(AppSpacing.sm), - child: Row( - children: [ - Icon(Icons.folder_outlined, color: scheme.onSurfaceVariant), - const SizedBox(width: AppSpacing.sm), - Expanded( - child: Text( - _label(entry), - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodyMedium, - ), - ), - ], - ), - ), - ), - ); - } -} - -/// List row for a repo group. -class RepoListTile extends StatelessWidget { - /// Creates a row for [entry]; [onTap] opens the repo screen. - const RepoListTile({required this.entry, required this.onTap, super.key}); - - /// The group this row stands for. - final RepoEntry entry; - - /// Open the repo screen. - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return ListTile( - leading: const Icon(Icons.folder_outlined), - title: Text(_label(entry)), - onTap: onTap, - ); - } -} diff --git a/pubspec.yaml b/pubspec.yaml index cb6055f..2cfd9ef 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.56+1 +version: 1.0.57+1 environment: sdk: ^3.12.2 diff --git a/test/cli/cli_harness.dart b/test/cli/cli_harness.dart index 20f7ef5..1a47e2b 100644 --- a/test/cli/cli_harness.dart +++ b/test/cli/cli_harness.dart @@ -42,15 +42,18 @@ class CliHarness { err: err, readStdin: () async => '', open: - opener ?? - (d, {required seedCredentials}) => CliSession.open( - d, - seedCredentials: seedCredentials, - httpClient: db.client, - ), + opener ?? (d, {required seedCredentials}) => open(d, seedCredentials), ); } + /// A real session over the fake RTDB. + Future open(String dataDir, String seedCredentials) => + CliSession.open( + dataDir, + seedCredentials: seedCredentials, + httpClient: db.client, + ); + /// Adds a note from node [node] and returns its id. Future add(String text, [List extra = const []]) async { expect(await run(['--data-dir', dir('a'), '--text', text, ...extra]), 0); diff --git a/test/cli/cli_repo_note_test.dart b/test/cli/cli_repo_note_test.dart new file mode 100644 index 0000000..808aaf9 --- /dev/null +++ b/test/cli/cli_repo_note_test.dart @@ -0,0 +1,151 @@ +// `--repo` adds keep ONE open note per repo; the merge tool collapses the +// notes that existed before the invariant. +import 'package:flutter_test/flutter_test.dart'; +import 'package:todo/cli/cli_session.dart'; +import 'package:todo/cli/merge_repo_notes.dart'; + +import 'cli_harness.dart'; +import 'scripted_session.dart'; + +void main() { + late CliHarness h; + setUp(() => h = CliHarness()); + tearDown(() => h.dispose()); + + Future addTask(String text, {String check = 'true'}) => + h.add(text, ['--repo', 'kuhyx/wake-alarm', '--done-check', check]); + + test( + 'the second --repo add appends to the first and reuses its id', + () async { + final first = await addTask('Fix A\n\nwhat: a', check: 'ca'); + expect( + await h.run([ + '--data-dir', + h.dir('a'), + '--text', + 'Fix B\n\nwhat: b', + '--repo', + 'kuhyx/wake-alarm', + '--done-check', + 'cb', + ]), + 0, + ); + expect(h.out.toString().trim(), first); + expect(h.err.toString(), contains('appended')); + + expect(await h.run(['--get', first, '--data-dir', h.dir('b')]), 0); + expect( + h.out.toString(), + allOf( + contains('wake-alarm todos'), + contains('## Fix A\nwhat: a'), + contains('## Fix B\nwhat: b'), + contains('done_check: ( ca ) && ( cb )'), + ), + ); + expect(await h.run(['--list', '--repo', 'kuhyx/wake-alarm']), 0); + expect(h.out.toString().trim().split('\n'), hasLength(1)); + }, + ); + + test('notes without --repo are never merged', () async { + await h.add('idea one'); + await h.add('idea two'); + expect(await h.run(['--list']), 0); + expect(h.out.toString().trim().split('\n'), hasLength(2)); + }); + + group('merge tool', () { + Future merge(List extra, String data) => runMergeRepoNotes( + ['--data-dir', h.dir(data), ...extra], + env: h.env, + out: h.out, + err: h.err, + open: (d, {required seedCredentials}) => h.open(d, seedCredentials), + ); + + void seed() => h.seedPeer([ + fixtureNote('11111111-a', 'First', repo: 'kuhyx/wake-alarm'), + fixtureNote('22222222-b', 'Second\n\nbody', repo: 'kuhyx/wake-alarm'), + fixtureNote('33333333-c', 'Third', repo: 'kuhyx/wake-alarm'), + fixtureNote('44444444-d', 'Lone', repo: 'kuhyx/todo'), + fixtureNote('55555555-e', 'An idea'), + ]); + + test('dry run changes nothing and prints nothing but the map', () async { + seed(); + expect(await merge([], 'a'), 0); + expect(h.out.toString().trim().split('\n'), [ + '22222222-b\t11111111-a\tkuhyx/wake-alarm', + '33333333-c\t11111111-a\tkuhyx/wake-alarm', + ]); + expect(await h.run(['--list', '--data-dir', h.dir('b')]), 0); + expect(h.out.toString().trim().split('\n'), hasLength(5)); + }); + + test('apply keeps the oldest id, deletes the rest, syncs', () async { + seed(); + expect(await merge(['--apply'], 'a'), 0); + expect(h.out.toString(), contains('22222222-b\t11111111-a')); + expect(await h.run(['--list', '--data-dir', h.dir('b')]), 0); + final lines = h.out.toString().trim().split('\n'); + expect(lines, hasLength(3)); + expect(lines.where((l) => l.contains('kuhyx/wake-alarm')), hasLength(1)); + expect(await h.run(['--get', '11111111', '--data-dir', h.dir('b')]), 0); + expect( + h.out.toString(), + allOf( + contains('wake-alarm todos'), + contains('## First'), + contains('## Second\nbody'), + contains('## Third'), + ), + ); + expect(await h.run(['--get', '44444444', '--data-dir', h.dir('b')]), 0); + expect(h.out.toString(), contains('todo todos')); + }); + + test('a second apply is a no-op', () async { + seed(); + expect(await merge(['--apply'], 'a'), 0); + h.out.clear(); + expect(await merge(['--apply'], 'a'), 0); + expect(h.out.toString().trim(), isEmpty); + }); + + test('--dry-run is accepted and a missing session is an error', () async { + final code = await runMergeRepoNotes( + ['--dry-run'], + env: {'HOME': h.tmp.path, 'XDG_DATA_HOME': h.dir('xdg2')}, + out: h.out, + err: h.err, + ); + expect(code, 1); + expect(h.err.toString(), contains('no Firebase session')); + }); + + test('a push that publishes nothing fails the run', () async { + final code = await runMergeRepoNotes( + ['--apply'], + env: h.env, + out: h.out, + err: h.err, + open: (d, {required seedCredentials}) async { + final s = await ScriptedSession.create(h.db.client, [true, false]); + await s.repository.upsert( + fixtureNote('x', 'Lone', repo: 'kuhyx/todo'), + ); + return s; + }, + ); + expect(code, 1); + expect(h.err.toString(), contains('nothing was pushed')); + }); + + test('unknown flags are a usage error', () async { + expect(await merge(['--bogus'], 'a'), 2); + }); + }); +} diff --git a/test/cli/cli_tasks_test.dart b/test/cli/cli_tasks_test.dart index cfc9565..7b7d3cb 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\tFix gate\n', + '${todo.substring(0, 8)}\ttodo\tmedium\tkuhyx/todo\ttodo todos\n', ); expect(await h.run(['--list', '--status', 'done']), 0); expect(h.out.toString(), isEmpty); @@ -53,10 +53,10 @@ void main() { await addTask('Fix the GATE', 'kuhyx/todo'); await addTask('Other', 'kuhyx/utils'); expect(await h.run(['--search', 'gate']), 0); - expect(h.out.toString(), contains('\tkuhyx/todo\tFix the GATE')); - expect(h.out.toString(), isNot(contains('Other'))); + expect(h.out.toString(), contains('\tkuhyx/todo\ttodo todos')); + expect(h.out.toString(), isNot(contains('utils todos'))); expect(await h.run(['--search', 'UTILS', '--repo', 'kuhyx/utils']), 0); - expect(h.out.toString(), contains('Other')); + expect(h.out.toString(), contains('utils todos')); }); test('--close without evidence exits 2 before any network', () async { diff --git a/test/mcp/mcp_contract_test.dart b/test/mcp/mcp_contract_test.dart index 44556ed..3b76c3f 100644 --- a/test/mcp/mcp_contract_test.dart +++ b/test/mcp/mcp_contract_test.dart @@ -39,10 +39,11 @@ void main() { final listed = await h.call(c, 'list_tasks', {'repo': _repo}); expect(listed, contains(prefix)); - expect(listed, contains('Fix the "quoted" thing')); + expect(listed, contains('todo todos')); expect(listed, isNot(contains('"'))); var got = await h.call(c, 'get_task', {'id': prefix}); + expect(got, contains('## Fix the "quoted" thing\nwhat: details')); expect(got, contains('done_check: grep -q "ok" f')); expect(got, contains('by: claude')); diff --git a/test/mcp/mcp_repo_note_test.dart b/test/mcp/mcp_repo_note_test.dart new file mode 100644 index 0000000..7303eb8 --- /dev/null +++ b/test/mcp/mcp_repo_note_test.dart @@ -0,0 +1,68 @@ +// add_task keeps ONE open note per repo: the second call appends. +import 'package:flutter_test/flutter_test.dart'; +import 'package:mcp_dart/mcp_dart.dart'; + +import 'mcp_harness.dart'; + +const _repo = 'kuhyx/home-guard'; + +void main() { + late McpHarness h; + late McpClient c; + setUp(() async { + h = McpHarness(); + await h.start(); + c = await h.connect(); + }); + tearDown(() => h.dispose()); + + test('the second add_task appends and returns the same id', () async { + final first = await h.call(c, 'add_task', { + 'repo': _repo, + 'title': 'Fix A', + 'body': 'what: a', + 'done_check': 'ca', + 'priority': 'low', + }); + final second = await h.call(c, 'add_task', { + 'repo': _repo, + 'title': 'Fix B', + 'body': 'what: b', + 'done_check': 'cb', + 'priority': 'high', + }); + expect(first, startsWith('added ')); + expect(second, startsWith('appended to ')); + expect(idIn(second), idIn(first)); + + final got = await h.call(c, 'get_task', {'id': idIn(first)}); + expect( + got, + allOf( + contains('home-guard todos'), + contains('## Fix A\nwhat: a'), + contains('## Fix B\nwhat: b'), + contains('done_check: ( ca ) && ( cb )'), + contains('priority: high'), + ), + ); + final listed = await h.call(c, 'list_tasks', {'repo': _repo}); + expect(listed.trim().split('\n'), hasLength(1)); + }); + + test('after the note is closed, the next add starts a new one', () async { + final first = idIn( + await h.call(c, 'add_task', {'repo': _repo, 'title': 'Fix A'}), + ); + await h.call(c, 'close_task', {'id': first, 'evidence': 'smoke test'}); + final next = await h.call(c, 'add_task', {'repo': _repo, 'title': 'B'}); + expect(next, startsWith('added ')); + expect(idIn(next), isNot(first)); + }); + + test('the tool text tells agents about the single note', () async { + final tools = (await c.listTools()).tools; + final add = tools.singleWhere((t) => t.name == 'add_task'); + expect(add.description, contains('appended')); + }); +} diff --git a/test/mcp/mcp_update_test.dart b/test/mcp/mcp_update_test.dart index fe987a7..8b9cbe5 100644 --- a/test/mcp/mcp_update_test.dart +++ b/test/mcp/mcp_update_test.dart @@ -25,7 +25,7 @@ void main() { test('title, body, priority and repo change independently', () async { await h.call(c, 'update_task', {'id': id, 'title': 'New title'}); var got = await h.call(c, 'get_task', {'id': id}); - expect(got, endsWith('---\nNew title\n\nold body')); + expect(got, endsWith('---\nNew title\n\n## Old title\nold body')); await h.call(c, 'update_task', { 'id': id, diff --git a/test/note_detail_screen_test.dart b/test/note_detail_screen_test.dart index de5dd75..9d9db74 100644 --- a/test/note_detail_screen_test.dart +++ b/test/note_detail_screen_test.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:todo/data/app_settings.dart'; import 'package:todo/data/note.dart'; @@ -169,4 +170,33 @@ void main() { expect(await repo.listNotes(), hasLength(1)); expect(find.byType(NoteDetailScreen), findsOneWidget); }); + + testWidgets('the copy icon puts the whole note text on the clipboard', ( + tester, + ) async { + final copied = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'Clipboard.setData') { + copied.add((call.arguments as Map)['text'] as String); + } + return null; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ), + ); + const body = 'wake-alarm todos\n\n## A\nx\n\n## B\ny'; + await pumpDetail(tester, seedNote(body)); + + await tester.tap(find.byIcon(Icons.copy_all)); + await tester.pump(); + + expect(copied, [body]); + expect(find.byIcon(Icons.check), findsOneWidget); + }); } diff --git a/test/notes_list_one_list_test.dart b/test/notes_list_one_list_test.dart index 07cbe57..8250233 100644 --- a/test/notes_list_one_list_test.dart +++ b/test/notes_list_one_list_test.dart @@ -1,4 +1,4 @@ -// One list: ideas and repo groups together, repo searchable, and a filter +// One list: ideas and repo notes together, one row each, repo searchable, and a filter // sheet that fits a Pixel 6a screen without scrolling. import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -30,47 +30,35 @@ void _pixel6a(WidgetTester tester) { } void main() { - testWidgets('an idea and a repo group share the default list', ( - tester, - ) async { + testWidgets('every open note is its own row; no group rows', (tester) async { _pixel6a(tester); await pumpList(tester, seed: _seed, applyFilter: true); expect(find.text('An idea about tea'), findsOneWidget); - // Two open tasks of one repo collapse to ONE row; the done one is - // filtered out before grouping. - expect(find.text('kartka · 2'), findsOneWidget); - expect(find.text('Fix the scanner'), findsNothing); + expect(find.text('Fix the scanner'), findsOneWidget); + expect(find.text('Add barcode cache'), findsOneWidget); + // The done one is filtered out; nothing says "kartka · 2". + expect(find.text('Closed chore'), findsNothing); + expect(find.textContaining(' · 2'), findsNothing); + // The repo name shows as a small label on each repo note's card. + expect(find.text('kartka'), findsNWidgets(2)); }); - testWidgets('searching a repo name finds its group only', (tester) async { + testWidgets('searching a repo name finds its notes only', (tester) async { _pixel6a(tester); await pumpList(tester, seed: _seed, applyFilter: true); await tester.enterText(find.byType(TextField), 'kartka'); await tester.pump(const Duration(milliseconds: 300)); await tester.pump(); - expect(find.text('kartka · 2'), findsOneWidget); + expect(find.text('Fix the scanner'), findsOneWidget); + expect(find.text('Add barcode cache'), findsOneWidget); expect(find.text('An idea about tea'), findsNothing); }); - testWidgets('search narrows a group and hides it when nothing passes', ( - tester, - ) async { - _pixel6a(tester); + testWidgets('wide windows list one row per note', (tester) async { await pumpList(tester, seed: _seed, applyFilter: true); - await tester.enterText(find.byType(TextField), 'scanner'); - await tester.pump(const Duration(milliseconds: 300)); - await tester.pump(); - expect(find.text('kartka · 1'), findsOneWidget); - await tester.enterText(find.byType(TextField), 'zzz'); - await tester.pump(const Duration(milliseconds: 300)); - await tester.pump(); - expect(find.textContaining('kartka'), findsNothing); - }); - - testWidgets('wide windows group too, in list rows', (tester) async { - await pumpList(tester, seed: _seed, applyFilter: true); - expect(find.text('kartka · 2'), findsOneWidget); - expect(find.text('Fix the scanner'), findsNothing); + expect(find.text('Fix the scanner'), findsOneWidget); + expect(find.text('Add barcode cache'), findsOneWidget); + expect(find.byType(ListTile), findsNWidgets(3)); }); testWidgets('a filter sheet fits a Pixel 6a screen with no scrolling', ( diff --git a/test/repo_note_test.dart b/test/repo_note_test.dart new file mode 100644 index 0000000..1d00ae9 --- /dev/null +++ b/test/repo_note_test.dart @@ -0,0 +1,183 @@ +// One open note per repo: the shared add path, the done_check composition +// and the one-time merge plan. +import 'package:flutter_test/flutter_test.dart'; +import 'package:todo/data/note.dart'; +import 'package:todo/data/note_repository.dart'; +import 'package:todo/data/repo_note.dart'; +import 'package:todo/data/repo_note_merge.dart'; + +const _repo = 'kuhyx/wake-alarm'; + +Note _n( + String id, + String text, { + int day = 1, + String repo = _repo, + Status status = Status.todo, + Priority priority = Priority.medium, + String? doneCheck, +}) => Note( + id: id, + text: text, + priority: priority, + status: status, + createdAt: DateTime.utc(2026, 10, day), + updatedAt: DateTime.utc(2026, 10, day), + repo: repo, + doneCheck: doneCheck, + by: 'claude', +); + +void main() { + group('composeDoneCheck', () { + test('both present are wrapped and joined with &&', () { + expect(composeDoneCheck('a', 'b'), '( a ) && ( b )'); + }); + test('only one present is kept as is', () { + expect(composeDoneCheck('a', null), 'a'); + expect(composeDoneCheck(null, 'b'), 'b'); + expect(composeDoneCheck('', 'b'), 'b'); + expect(composeDoneCheck(null, null), isNull); + }); + }); + + group('addRepoTask', () { + late NoteRepository repo; + setUp(() async => repo = await NoteRepository.openInMemory()); + tearDown(() => repo.close()); + + test('creates " todos" with the task as the first section', () async { + final r = await addRepoTask( + repo, + repo: _repo, + title: 'Fix A', + body: 'what: a', + doneCheck: 'true', + priority: Priority.high, + by: 'claude', + ); + expect(r.appended, isFalse); + expect(r.note.text, 'wake-alarm todos\n\n## Fix A\nwhat: a'); + expect(r.note.doneCheck, 'true'); + expect(r.note.priority, Priority.high); + expect(r.note.by, 'claude'); + expect(r.note.status, Status.todo); + }); + + test('appends to the open note: body, check, max priority', () async { + final first = await addRepoTask( + repo, + repo: _repo, + title: 'Fix A', + body: 'what: a', + doneCheck: 'test a', + priority: Priority.high, + ); + await repo.upsert(first.note); + final second = await addRepoTask( + repo, + repo: _repo, + title: 'Fix B', + body: 'what: b', + doneCheck: 'test b', + priority: Priority.low, + ); + expect(second.appended, isTrue); + expect(second.note.id, first.note.id); + expect( + second.note.text, + 'wake-alarm todos\n\n## Fix A\nwhat: a\n\n## Fix B\nwhat: b', + ); + expect(second.note.doneCheck, '( test a ) && ( test b )'); + expect(second.note.priority, Priority.high); + }); + + test('a missing check on either side keeps the other', () async { + final first = await addRepoTask(repo, repo: _repo, title: 'A'); + await repo.upsert(first.note); + final second = await addRepoTask( + repo, + repo: _repo, + title: 'B\nCheck: manual', + doneCheck: 'test b', + ); + expect(second.note.doneCheck, 'test b'); + expect(second.note.text, contains('Check: manual')); + }); + + test('closed notes and other repos do not absorb the task', () async { + await repo.upsert(_n('d', 'old\n\n## x', status: Status.done)); + await repo.upsert(_n('o', 'other\n\n## x', repo: 'kuhyx/other')); + final r = await addRepoTask(repo, repo: _repo, title: 'Fix A'); + expect(r.appended, isFalse); + expect(r.note.id, isNot(anyOf('d', 'o'))); + }); + + test('an in-progress note counts as open; the oldest wins', () async { + await repo.upsert(_n('new', 'T\n\n## n', day: 5)); + await repo.upsert( + _n('old', 'T\n\n## o', day: 2, status: Status.inProgress), + ); + final r = await addRepoTask(repo, repo: _repo, title: 'Z'); + expect(r.note.id, 'old'); + }); + }); + + group('planRepoMerges', () { + test('merges three notes into the oldest, sections oldest first', () { + final plan = planRepoMerges([ + _n('b', 'Second\n\nbody b', day: 2, doneCheck: 'cb'), + _n('a', 'First', day: 1, doneCheck: 'ca', priority: Priority.low), + _n('c', 'Third\n\nbody c', day: 3, priority: Priority.high), + _n('x', 'Elsewhere', repo: 'kuhyx/other', day: 1), + ]); + final m = plan.singleWhere((p) => p.kept.repo == _repo); + expect(m.kept.id, 'a'); + expect(m.removed.map((n) => n.id), ['b', 'c']); + expect( + m.kept.text, + 'wake-alarm todos\n\n## First\n\n## Second\nbody b\n\n## Third\nbody c', + ); + expect(m.kept.doneCheck, '( ca ) && ( cb )'); + expect(m.kept.priority, Priority.high); + }); + + test('single-note repos are retitled with the body as one section', () { + final plan = planRepoMerges([_n('x', 'Only\n\nbody')]); + expect(plan.single.removed, isEmpty); + expect(plan.single.kept.text, 'wake-alarm todos\n\n## Only\nbody'); + }); + + test('already merged repos are left alone (idempotent)', () { + final merged = planRepoMerges([ + _n('a', 'One', day: 1), + _n('b', 'Two', day: 2), + ]).single.kept; + expect(planRepoMerges([merged]), isEmpty); + }); + + test('a merged note plus a new one folds in without re-wrapping', () { + final merged = _n('a', 'wake-alarm todos\n\n## One\nx', day: 1); + final plan = planRepoMerges([merged, _n('b', 'Two\n\ny', day: 2)]); + expect( + plan.single.kept.text, + 'wake-alarm todos\n\n## One\nx\n\n## Two\ny', + ); + }); + + test('closed notes and plain ideas are ignored', () { + final idea = Note( + id: 'i', + text: 'idea', + priority: Priority.medium, + status: Status.todo, + createdAt: DateTime.utc(2026, 10), + updatedAt: DateTime.utc(2026, 10), + ); + expect( + planRepoMerges([idea, _n('d', 'Done', status: Status.done)]), + isEmpty, + ); + }); + }); +} diff --git a/test/repo_screen_test.dart b/test/repo_screen_test.dart deleted file mode 100644 index e465ec3..0000000 --- a/test/repo_screen_test.dart +++ /dev/null @@ -1,129 +0,0 @@ -// The repo screen: all of a repo's tasks, each opening its note, plus the -// copy-all action. -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:todo/data/note.dart'; -import 'package:todo/ui/note_grid_tile.dart'; -import 'package:todo/ui/note_tile.dart'; - -import 'notes_list_harness.dart'; - -Note _task(String id, String text, {String repo = 'kuhyx/kartka'}) => - note(id, text).copyWith(repo: repo, by: 'claude'); - -final _seed = [ - _task('aaaaaaaa-1111', '# Fix scanner\n\nIt crashes on EAN-8.'), - _task('bbbbbbbb-2222', 'Add cache'), - _task('cccccccc-3333', 'Other repo task', repo: 'kuhyx/utils'), -]; - -Future _openRepo(WidgetTester tester) async { - await tester.tap(find.text('kartka · 2')); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 400)); -} - -void main() { - testWidgets('tapping the repo row lists all its tasks', (tester) async { - await pumpList(tester, seed: _seed, applyFilter: true); - await _openRepo(tester); - expect(find.text('Fix scanner'), findsOneWidget); - expect(find.text('Add cache'), findsOneWidget); - expect(find.text('Other repo task'), findsNothing); - }); - - testWidgets('phone grid: the repo tile opens the screen; long-press acts', ( - tester, - ) async { - tester.view - ..physicalSize = const Size(1080, 2400) - ..devicePixelRatio = 2.625; - addTearDown(tester.view.reset); - await pumpList(tester, seed: _seed, applyFilter: true); - await _openRepo(tester); - await tester.longPress(find.text('Add cache')); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 300)); - expect(find.text('Delete note'), findsOneWidget); - }); - - testWidgets('a task on the repo screen opens its note detail', ( - tester, - ) async { - await pumpList(tester, seed: _seed, applyFilter: true); - await _openRepo(tester); - await tester.tap(find.text('Add cache')); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 400)); - expect(find.text('View'), findsOneWidget); - }); - - testWidgets('copy puts every task title, body and id8 on the clipboard', ( - tester, - ) async { - String? copied; - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - SystemChannels.platform, - (call) async { - if (call.method == 'Clipboard.setData') { - copied = (call.arguments as Map)['text'] as String?; - } - return null; - }, - ); - addTearDown( - () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - SystemChannels.platform, - null, - ), - ); - await pumpList(tester, seed: _seed, applyFilter: true); - await _openRepo(tester); - await tester.tap(find.byTooltip('Copy all')); - await tester.pump(); - expect(copied, isNotNull); - expect(copied, contains('Fix scanner')); - expect(copied, contains('It crashes on EAN-8.')); - expect(copied, contains('id: aaaaaaaa')); - expect(copied, contains('Add cache')); - expect(copied, contains('id: bbbbbbbb')); - expect(copied, isNot(contains('Other repo task'))); - expect(copied, contains('\n\n')); - }); - - testWidgets('cards show the bare repo name; an idea shows none', ( - tester, - ) async { - Future pumpCards() => tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: Column( - children: [ - NoteTile(note: _seed[0], onTap: () {}, onActions: () {}), - SizedBox( - height: 120, - child: NoteGridTile( - note: _seed[1], - onTap: () {}, - onActions: () {}, - ), - ), - SizedBox( - height: 120, - child: NoteGridTile( - note: note('i', 'Just an idea'), - onTap: () {}, - onActions: () {}, - ), - ), - ], - ), - ), - ), - ); - await pumpCards(); - expect(find.textContaining('kartka'), findsNWidgets(2)); - expect(find.textContaining('kuhyx/'), findsNothing); - }); -}