mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-10 18:07:03 +01:00
One open note per repo, merge tool
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
67212cd7ec
commit
cbe3d9be59
@ -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
|
||||
|
||||
16
bin/merge_repo_notes.dart
Normal file
16
bin/merge_repo_notes.dart
Normal file
@ -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<void> main(List<String> argv) async {
|
||||
exitCode = await runMergeRepoNotes(
|
||||
argv,
|
||||
env: Platform.environment,
|
||||
out: stdout,
|
||||
err: stderr,
|
||||
);
|
||||
}
|
||||
@ -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<int> 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<int> _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<int> list(CliArgs args) async {
|
||||
_report(await session.sync(push: false));
|
||||
|
||||
78
lib/cli/merge_repo_notes.dart
Normal file
78
lib/cli/merge_repo_notes.dart
Normal file
@ -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_id<TAB>kept_id<TAB>repo` 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<int> runMergeRepoNotes(
|
||||
List<String> argv, {
|
||||
required Map<String, String> 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();
|
||||
}
|
||||
}
|
||||
86
lib/data/repo_note.dart
Normal file
86
lib/data/repo_note.dart
Normal file
@ -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<Status> 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<RepoAdd> 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,
|
||||
);
|
||||
}
|
||||
50
lib/data/repo_note_merge.dart
Normal file
50
lib/data/repo_note_merge.dart
Normal file
@ -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<Note> removed});
|
||||
|
||||
/// A merge for every repo whose open notes are not yet one `<name> todos`
|
||||
/// note; running it on its own result yields an empty plan.
|
||||
List<RepoMerge> planRepoMerges(Iterable<Note> notes) {
|
||||
final byRepo = <String, List<Note>>{};
|
||||
for (final n in notes) {
|
||||
final repo = n.repo;
|
||||
if (repo != null && kOpenStatuses.contains(n.status)) {
|
||||
byRepo.putIfAbsent(repo, () => []).add(n);
|
||||
}
|
||||
}
|
||||
final plan = <RepoMerge>[];
|
||||
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<Note> group) {
|
||||
var doneCheck = group.first.doneCheck;
|
||||
var priority = group.first.priority;
|
||||
final sections = <String>[];
|
||||
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,
|
||||
);
|
||||
}
|
||||
@ -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<String> add(Map<String, dynamic> 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,
|
||||
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',
|
||||
),
|
||||
'added',
|
||||
);
|
||||
return await _save(added.note, added.appended ? 'appended to' : 'added');
|
||||
});
|
||||
|
||||
/// `list_tasks`: repo tasks, open ones unless `status` says otherwise.
|
||||
|
||||
@ -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/<name>") to see
|
||||
its open tasks.
|
||||
- A repo has ONE open note, "<repo> 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 "<commit sha> 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 <full id>" 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 <full id>" '
|
||||
'or "appended to <full id>", then its list line. Use when you find '
|
||||
'work you will not finish now.',
|
||||
{
|
||||
'repo': repo,
|
||||
'title': JsonSchema.string(description: 'One-line title.'),
|
||||
|
||||
@ -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<NoteDetailScreen> {
|
||||
/// Inserts attached-image links into the editor.
|
||||
final ImageAttachController _attach = ImageAttachController();
|
||||
|
||||
bool _copied = false;
|
||||
|
||||
Future<void> _copy() async {
|
||||
await Clipboard.setData(ClipboardData(text: _note.text));
|
||||
await HapticFeedback.lightImpact();
|
||||
if (mounted) setState(() => _copied = true);
|
||||
}
|
||||
|
||||
Future<void> _attachImages(List<Uint8List> images, {int rejected = 0}) =>
|
||||
attachImages(
|
||||
context,
|
||||
@ -98,6 +106,13 @@ class _NoteDetailScreenState extends State<NoteDetailScreen> {
|
||||
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),
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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<ListEntry> entries;
|
||||
/// Notes in display order.
|
||||
final List<Note> notes;
|
||||
|
||||
/// Tap on a tile.
|
||||
final ValueChanged<Note> onOpen;
|
||||
@ -41,22 +38,14 @@ class NotesGrid extends StatelessWidget {
|
||||
/// Long-press on a tile.
|
||||
final ValueChanged<Note> onActions;
|
||||
|
||||
/// Tap on a repo group tile.
|
||||
final ValueChanged<RepoEntry> 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]),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -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<ListEntry> entries;
|
||||
/// Notes in display order.
|
||||
final List<Note> notes;
|
||||
|
||||
/// Tap on a note.
|
||||
final ValueChanged<Note> onOpen;
|
||||
@ -31,35 +28,20 @@ class NotesListBody extends StatelessWidget {
|
||||
/// Long-press (or the actions button) on a note.
|
||||
final ValueChanged<Note> onActions;
|
||||
|
||||
/// Tap on a repo group.
|
||||
final ValueChanged<RepoEntry> 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),
|
||||
itemBuilder: (context, i) => NoteTile(
|
||||
key: ValueKey(notes[i].id),
|
||||
note: notes[i],
|
||||
onTap: () => onOpen(notes[i]),
|
||||
onActions: () => onActions(notes[i]),
|
||||
),
|
||||
final RepoEntry repo => RepoListTile(
|
||||
key: ValueKey(repo.repo),
|
||||
entry: repo,
|
||||
onTap: () => onOpenRepo(repo),
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<NotesListScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openRepo(String repo) async {
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
await Navigator.of(context).push<void>(
|
||||
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<NotesListScreen> {
|
||||
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,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@ -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<Note> 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<ListEntry> groupByRepo(List<Note> notes) {
|
||||
final groups = <String, List<Note>>{};
|
||||
final entries = <ListEntry>[];
|
||||
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: <id8>` per task,
|
||||
/// tasks separated by a blank line.
|
||||
String repoClipboardText(List<Note> 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');
|
||||
}
|
||||
@ -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: <id8>`) 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> appSettings;
|
||||
|
||||
@override
|
||||
State<RepoScreen> createState() => _RepoScreenState();
|
||||
}
|
||||
|
||||
class _RepoScreenState extends State<RepoScreen> {
|
||||
late final Stream<List<Note>> _stream = widget.repository.watchNotes(
|
||||
sort: widget.sort,
|
||||
filter: widget.filter.copyWith(repo: widget.repo),
|
||||
);
|
||||
List<Note> _notes = const [];
|
||||
bool _copied = false;
|
||||
|
||||
Future<void> _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<List<Note>>(
|
||||
stream: _stream,
|
||||
builder: (context, snapshot) {
|
||||
_notes = snapshot.data ?? const <Note>[];
|
||||
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),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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<CliSession> open(String dataDir, String seedCredentials) =>
|
||||
CliSession.open(
|
||||
dataDir,
|
||||
seedCredentials: seedCredentials,
|
||||
httpClient: db.client,
|
||||
);
|
||||
|
||||
/// Adds a note from node [node] and returns its id.
|
||||
Future<String> add(String text, [List<String> extra = const []]) async {
|
||||
expect(await run(['--data-dir', dir('a'), '--text', text, ...extra]), 0);
|
||||
|
||||
151
test/cli/cli_repo_note_test.dart
Normal file
151
test/cli/cli_repo_note_test.dart
Normal file
@ -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<String> 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<int> merge(List<String> 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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'));
|
||||
|
||||
|
||||
68
test/mcp/mcp_repo_note_test.dart
Normal file
68
test/mcp/mcp_repo_note_test.dart
Normal file
@ -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'));
|
||||
});
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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 = <String>[];
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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', (
|
||||
|
||||
183
test/repo_note_test.dart
Normal file
183
test/repo_note_test.dart
Normal file
@ -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 "<name> 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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<void> _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<void> 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);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user