Compare commits

...

12 Commits

Author SHA1 Message Date
a30e7b34a9 CLI: --set-text replaces a note's text
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Keeps every other field; text from --text or stdin, empty refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 17:56:23 +01:00
17d6d2a533 Bump file_picker, idb_shim, sqflite_common_ffi
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 17:56:22 +01:00
4e8ccd6bfd Lean gradle.properties for warm daemon
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZUTC9Zzdzsx7VW7dm
2026-11-10 17:51:44 +01:00
6bd2923f0a Instant page routes; coalesce watch re-emits
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWXdsB7CQPNXVN829G1
2026-11-10 15:57:03 +01:00
4c77dd5d2e Bulk update: diff generated fields only
planBulkUpdate compared fresh field values with the stored text, so a
peer's "...00Z" created_at (or a web build's millisecond date) read as a
change and got a fresh clock, erasing the created_by node signal; a
record without a priority field got one written. Compare the note's own
generated fields before and after instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWXdsB7CQPNXVN829G1
2026-11-10 15:41:35 +01:00
757c54beda Multi-select notes with an icon action bar
An app-bar toggle enters selection mode: a tap toggles a note (tint +
check on grid tiles, ListTile.selected + check in the list), Ctrl/Cmd-
click toggles and Shift-click range-selects on desktop, long-press
outside the mode still opens the per-note sheet. While anything is
selected an icon-only bottom bar sets status or priority for the whole
selection in one bulkUpdate, assigns plain notes to a repo picked from
the repos on notes or typed as owner/name, or clears the selection. A
refused assign (note already in a repo) flashes a block icon. Labels
are semantic only; menus pass an empty tooltip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWXdsB7CQPNXVN829G1
2026-11-10 15:39:13 +01:00
5c1bf75bf9 Bulk note writes and assign-to-repo helper
NoteRepository.bulkUpdate sets status/priority on many notes in one
store write (mergeLogs + replaceAll; only changed fields plus updated_at
get a clock). saveReplacing saves one note and tombstones others in one
write. assignToRepo moves plain notes into the repo's open note as
"## title" sections via addRepoTask, demoting body headings below "##"
so a template note stays one section; notes already in a repo are
refused. The seeded watch stream moved to log_watch.dart for the
250-line cap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWXdsB7CQPNXVN829G1
2026-11-10 15:38:54 +01:00
57019eec30 Track who filed a note (created_by)
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
New optional note field created_by (kuhy|claude), stored like by/repo and
omitted when unset so older builds stay wire-compatible. App UI stamps kuhy;
MCP add_task and the CLI stamp claude. list/get/search output includes it.
Human notes sort above Claude's; Claude notes are hidden behind a per-device
eye toggle. CLI --backfill-created-by [--dry-run] classifies old notes by the
node that wrote created_at, writing only the created_by field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 14:18:19 +01:00
cc9789f530 Start exactly the merged item
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
The board orders cards by column, so a cancelled older item with the
same title could be listed last and was started instead of the item
the merge added (T1866). Start now targets <id8>-<sections - 1> only.

Todo: 64d39ab5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 18:28:17 +01:00
135f3f07ea Start a merged task outside the MCP lock
The board refreshes its notes through this MCP server, and add_task
held the server's serial lock while waiting for the board to show
the merged section, so the board could never see it (T1865). The
Start now runs after the lock is released.

Todo: 64d39ab5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 18:24:24 +01:00
ba25e8f176 Wait for the board before a merged Start
The board's note cache lags ~10 s after a merge, so Start found
"no startable item" (T1863); it now waits up to 30 s for the merged
section's card. A Start POST slower than its timeout that did take
effect was reported "board unreachable" (T1864); it now re-reads the
board and reports ok when the card left Start.

Todo: 64d39ab5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 18:18:30 +01:00
beb1d1a49f crdt_sync 0.12.1: serialized FileLogPersistence writes
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 15:29:03 +01:00
60 changed files with 2795 additions and 259 deletions

View File

@ -1,4 +1,7 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
org.gradle.jvmargs=-Xmx2G -XX:MaxMetaspaceSize=1G -XX:ReservedCodeCacheSize=256m -XX:+UseSerialGC -XX:TieredStopAtLevel=1
org.gradle.caching=true
org.gradle.parallel=true
kotlin.compiler.execution.strategy=in-process
android.useAndroidX=true
# This newDsl flag was added by the Flutter template
android.newDsl=false

View File

@ -4,49 +4,12 @@
/// exact-pinned dependency under the freshness gate.
library;
import 'package:todo/cli/cli_mode.dart';
import 'package:todo/data/note.dart';
export 'package:todo/cli/cli_mode.dart';
export 'package:todo/cli/cli_usage.dart';
/// What the CLI was asked to do.
enum CliMode {
/// Create a note and push it.
add,
/// Print every live note, one per line.
list,
/// Print one note in full.
get,
/// Tombstone one note and push the deletion.
delete,
/// Change one note's status and push it.
setStatus,
/// Set a task done with evidence and push it.
close,
/// Print the notes matching a query, as [list] does.
search,
/// Print usage.
help,
}
/// Thrown for a malformed command line; maps to exit code 2.
class UsageError implements Exception {
/// Creates a [UsageError] carrying [message].
UsageError(this.message);
/// What was wrong with the arguments.
final String message;
@override
String toString() => message;
}
/// The parsed command line.
class CliArgs {
/// Creates parsed arguments; see [parse].
@ -58,6 +21,7 @@ class CliArgs {
this.status,
this.dataDir,
this.newNote = false,
this.dryRun = false,
this.opts = (
repo: null,
query: null,
@ -89,12 +53,19 @@ class CliArgs {
opts[arg] = value();
case '--list':
modes.add(CliMode.list);
case '--backfill-created-by':
modes.add(CliMode.backfill);
case '--dry-run':
opts[arg] = 'true';
case '--search':
modes.add(CliMode.search);
opts[arg] = value();
case '--get' || '--delete' || '--close':
modes.add(CliMode.values.byName(arg.substring(2)));
id = value();
case '--set-text':
modes.add(CliMode.setText);
id = value();
case '--set-status':
modes.add(CliMode.setStatus);
id = value();
@ -106,8 +77,9 @@ class CliArgs {
if (modes.contains(CliMode.help)) return const CliArgs(mode: CliMode.help);
if (modes.length > 1) {
throw UsageError(
'--list, --search, --get, --delete, --set-status and --close are '
'mutually exclusive',
'--list, --search, --get, --delete, --set-status, --set-text, '
'--close and '
'--backfill-created-by are mutually exclusive',
);
}
final mode = modes.isEmpty ? CliMode.add : modes.single;
@ -128,6 +100,7 @@ class CliArgs {
opts.containsKey('--new-note') &&
(opts['--repo'] != null ||
(throw UsageError('--new-note needs --repo'))),
dryRun: opts.containsKey('--dry-run'),
opts: (
repo: _repo(opts['--repo']),
query: _nonBlank(opts['--search'], '--search'),
@ -141,7 +114,8 @@ class CliArgs {
/// The selected mode.
final CliMode mode;
/// Note text from `--text`; null means "read stdin" in [CliMode.add].
/// Note text from `--text`; null means "read stdin" in [CliMode.add] and
/// [CliMode.setText].
final String? text;
/// Full id or 8+ character prefix of the note an id mode acts on.
@ -159,6 +133,9 @@ class CliArgs {
/// `--new-note`: the `--repo` task gets its own note.
final bool newNote;
/// Whether `--dry-run` was given: report what would change, change nothing.
final bool dryRun;
/// `--repo`, `--search`, `--done-check`, `--by` and `--evidence` values.
final ({
String? repo,
@ -174,7 +151,7 @@ class CliArgs {
static const _adding = 'when adding a note';
static const Map<String, (String, Set<CliMode>)> _scopes = {
'--text': (_adding, {CliMode.add}),
'--text': ('$_adding or to --set-text', {CliMode.add, CliMode.setText}),
'--done-check': (_adding, {CliMode.add}),
'--by': (_adding, {CliMode.add}),
'--repo': (
@ -183,6 +160,7 @@ class CliArgs {
),
'--evidence': ('to --close', {CliMode.close}),
'--new-note': (_adding, {CliMode.add}),
'--dry-run': ('to --backfill-created-by', {CliMode.backfill}),
};
static void _checkScopes(Iterable<String> flags, CliMode mode) {

View File

@ -38,6 +38,7 @@ class CliCommands {
repo: repo,
doneCheck: args.opts.doneCheck,
by: args.opts.by,
createdBy: 'claude',
);
await session.repository.upsert(note);
if (!await _push('note')) return 1;
@ -57,6 +58,7 @@ class CliCommands {
doneCheck: args.opts.doneCheck,
priority: args.priority,
by: args.opts.by,
createdBy: 'claude',
);
await _save(added.note);
if (!await _push('note')) return 1;
@ -79,6 +81,7 @@ class CliCommands {
doneCheck: args.opts.doneCheck,
priority: args.priority,
by: args.opts.by,
createdBy: 'claude',
);
await _save(added.note);
if (!await _push('note')) return 1;
@ -133,6 +136,15 @@ class CliCommands {
return 0;
}
/// Replaces a note's whole text (title is its first line) and pushes it.
Future<int> setText(String ref, String text) async {
final note = await _pullAndFind(ref);
await _save(note.copyWith(text: text));
if (!await _push('text change')) return 1;
_out.writeln('${note.id}\ttext');
return 0;
}
/// Marks a note done with [evidence], keeping it, and pushes it.
Future<int> close(String ref, String evidence) async {
final note = await _pullAndFind(ref);
@ -142,6 +154,35 @@ class CliCommands {
return 0;
}
/// Sets `created_by` where unset (see [NoteRepository.backfillCreatedBy]).
/// Prints one `<id>\t<value>\t<title>` line per note, then the totals. With
/// [dryRun] nothing is written or pushed, and peers are only read.
Future<int> backfillCreatedBy(
Set<String> claudeNodes, {
required bool dryRun,
}) async {
_report(await session.sync(push: !dryRun));
final plan = await session.repository.backfillCreatedBy(
claudeNodes,
dryRun: dryRun,
);
final titles = {
for (final n in await session.repository.listNotes()) n.id: n,
};
for (final MapEntry(:key, :value) in plan.entries) {
final note = titles[key];
final title = note == null ? '' : note.text.split('\n').first;
_out.writeln(
'${key.substring(0, key.length.clamp(0, 8))}\t$value\t$title',
);
}
final claude = plan.values.where((v) => v == 'claude').length;
final kuhy = plan.length - claude;
_out.writeln('${dryRun ? 'dry run' : 'set'}: claude $claude, kuhy $kuhy');
if (dryRun || plan.isEmpty) return 0;
return await _push('backfill') ? 0 : 1;
}
Future<Note> _pullAndFind(String ref) async {
_report(await session.sync(push: true));
return findNote(await session.repository.listNotes(), ref);

47
lib/cli/cli_mode.dart Normal file
View File

@ -0,0 +1,47 @@
/// What the CLI can be asked to do, and how a bad command line is reported.
library;
/// What the CLI was asked to do.
enum CliMode {
/// Create a note and push it.
add,
/// Print every live note, one per line.
list,
/// Print one note in full.
get,
/// Tombstone one note and push the deletion.
delete,
/// Change one note's status and push it.
setStatus,
/// Replace one note's whole text and push it.
setText,
/// Set a task done with evidence and push it.
close,
/// Print the notes matching a query, as [list] does.
search,
/// Set `created_by` on notes that lack it.
backfill,
/// Print usage.
help,
}
/// Thrown for a malformed command line; maps to exit code 2.
class UsageError implements Exception {
/// Creates a [UsageError] carrying [message].
UsageError(this.message);
/// What was wrong with the arguments.
final String message;
@override
String toString() => message;
}

View File

@ -54,7 +54,7 @@ Future<int> runCli(
return 0;
}
String? text;
if (args.mode == CliMode.add) {
if (args.mode == CliMode.add || args.mode == CliMode.setText) {
text = args.text ?? await readStdin();
if (text.trim().isEmpty) {
err.writeln('error: empty note (pass --text or pipe text on stdin)');
@ -79,7 +79,12 @@ Future<int> runCli(
CliMode.list || CliMode.search => await cli.list(args),
CliMode.get => await cli.get(args.id!),
CliMode.setStatus => await cli.setStatus(args.id!, args.status!),
CliMode.setText => await cli.setText(args.id!, text!),
CliMode.close => await cli.close(args.id!, args.opts.evidence!),
CliMode.backfill => await cli.backfillCreatedBy(
claudeNodeIds(env),
dryRun: args.dryRun,
),
// Only delete is left: help returned above.
_ => await cli.delete(args.id!),
};

View File

@ -29,6 +29,19 @@ String defaultDataDir(Map<String, String> env) {
return p.join(base, 'todo-cli');
}
/// Node ids of the installs Claude files notes through: the MCP server and
/// the CLI under the same data root as [defaultDataDir]. Missing ones are
/// skipped.
Set<String> claudeNodeIds(Map<String, String> env) {
final cli = defaultDataDir(env);
return {
for (final dir in [cli, p.join(p.dirname(cli), 'todo-mcp')])
if (File(p.join(dir, CliSession.nodeIdFileName)) case final f
when f.existsSync())
f.readAsStringSync().trim(),
}..remove('');
}
/// The open state of one CLI invocation. Call [close] when done.
class CliSession {
/// Wraps already-open parts; [open] is the normal way in. Public so a test

View File

@ -22,9 +22,14 @@ A note with --repo is a repo task; one without is a plain idea.
--get <id> pull, then print one note in full (exit 1 if absent)
--delete <id> pull, tombstone the note, push
--set-status <id> <s> pull, change the note's status (as --status), push
--set-text <id> pull, replace the note's whole text with --text (or
stdin; first line = title), keep its other fields, push
--close <id> --evidence <s>
pull, set status done + evidence (commit sha + done
check exit code), push; refused without --evidence
--backfill-created-by [--dry-run]
pull, set created_by (claude|kuhy) on every note that
has none, push; --dry-run prints the plan only
--data-dir <dir> local log + node id (default
$XDG_DATA_HOME/todo-cli or ~/.local/share/todo-cli)
-h, --help this text

View File

@ -32,7 +32,7 @@ Note findNote(Iterable<Note> notes, String ref) {
return matches.single;
}
/// `<8-char id>\t<status>\t<priority>\t<repo or ->\t<title>`.
/// `<8-char id>\t<status>\t<priority>\t<repo or ->\t<title>\t<created_by>`.
String listLine(Note note) => [
if (note.id.length <= _minPrefix)
note.id
@ -42,6 +42,7 @@ String listLine(Note note) => [
note.priority.name,
note.repo ?? '-',
_title(note),
note.createdBy ?? 'kuhy',
].join('\t');
String _title(Note note) => note.text.split('\n').first;
@ -57,6 +58,7 @@ String noteDetail(Note note) => [
if (note.doneCheck case final v?) 'done_check: $v',
if (note.by case final v?) 'by: $v',
if (note.evidence case final v?) 'evidence: $v',
'created_by: ${note.createdBy ?? 'kuhy'}',
'---',
note.text,
].join('\n');

View File

@ -19,10 +19,14 @@ import 'package:shared_preferences/shared_preferences.dart';
/// sibling to `changesets/<nodeId>.json`.
const String kAdvancedModeRemotePath = 'settings/advancedMode';
/// App-wide, non-sync preferences (currently just `advancedMode`).
/// App-wide, non-sync preferences (`advancedMode`, `showClaudeNotes`).
class AppSettings {
/// Creates an [AppSettings] from its stored fields.
const AppSettings({required this.advancedMode, this.advancedModeUpdatedAt});
const AppSettings({
required this.advancedMode,
this.advancedModeUpdatedAt,
this.showClaudeNotes = false,
});
/// Whether the priority/status/template/mode chrome and the routine
/// save/sync status text are shown. Off by default: casual capture needs
@ -35,6 +39,11 @@ class AppSettings {
/// a value that has never round-tripped through Firebase.
final DateTime? advancedModeUpdatedAt;
/// Whether the notes list shows notes Claude filed. Per device: it lives
/// only in this device's preferences and is never mirrored to Firebase.
final bool showClaudeNotes;
static const _kShowClaude = 'app.showClaudeNotes';
static const _kAdvancedMode = 'app.advancedMode';
static const _kAdvancedModeUpdatedAt = 'app.advancedMode.updatedAt';
@ -44,6 +53,7 @@ class AppSettings {
final updatedAtMillis = prefs.getInt(_kAdvancedModeUpdatedAt);
return AppSettings(
advancedMode: prefs.getBool(_kAdvancedMode) ?? false,
showClaudeNotes: prefs.getBool(_kShowClaude) ?? false,
advancedModeUpdatedAt: updatedAtMillis == null
? null
: DateTime.fromMillisecondsSinceEpoch(updatedAtMillis),
@ -71,6 +81,7 @@ class AppSettings {
final updated = AppSettings(
advancedMode: value,
advancedModeUpdatedAt: updatedAt,
showClaudeNotes: showClaudeNotes,
);
if (client != null) {
try {
@ -89,6 +100,17 @@ class AppSettings {
return updated;
}
/// Persists the per-device Claude-notes choice; nothing leaves the device.
Future<AppSettings> withShowClaudeNotes({required bool value}) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_kShowClaude, value);
return AppSettings(
advancedMode: advancedMode,
advancedModeUpdatedAt: advancedModeUpdatedAt,
showClaudeNotes: value,
);
}
/// Pulls the remote value from [client] and, if it is newer than the
/// local one, applies it locally and returns the reconciled settings.
/// Returns this instance unchanged when [client] is null, there is
@ -100,9 +122,7 @@ class AppSettings {
try {
final remote = await client.getStringMap(kAdvancedModeRemotePath);
if (!remote.containsKey('value')) return this;
final remoteUpdatedMillis = int.tryParse(
remote['updatedAtMillis'] ?? '',
);
final remoteUpdatedMillis = int.tryParse(remote['updatedAtMillis'] ?? '');
if (remoteUpdatedMillis == null) return this;
final remoteUpdatedAt = DateTime.fromMillisecondsSinceEpoch(
remoteUpdatedMillis,
@ -110,6 +130,7 @@ class AppSettings {
final candidate = AppSettings(
advancedMode: remote['value'] == 'true',
advancedModeUpdatedAt: remoteUpdatedAt,
showClaudeNotes: showClaudeNotes,
);
final adopted = adopt(candidate);
if (identical(adopted, this)) return this;

View File

@ -0,0 +1,44 @@
/// Who filed a note, for notes written before `created_by` existed.
///
/// The `created_at` field's clock names the node that last wrote it. The MCP
/// and the CLI are the only nodes Claude writes through, so a note created
/// there carries one of their node ids; anything else came from kuhy's own
/// devices.
library;
import 'package:crdt_sync/crdt_sync.dart';
import 'package:todo/data/note_record.dart';
const _createdAt = 'created_at';
/// `claude` when [record]'s `created_at` was written by one of [claudeNodes],
/// else `kuhy`.
String classifyCreatedBy(Record record, Set<String> claudeNodes) {
final node = record.fields[_createdAt]?.$2.nodeId;
return node != null && claudeNodes.contains(node) ? 'claude' : 'kuhy';
}
/// Live records of [log] with no `created_by` yet, mapped to their value.
Map<String, String> planCreatedByBackfill(Log log, Set<String> claudeNodes) => {
for (final record in log.values)
if (!record.deleted &&
((record.fields[kCreatedByField]?.$1 as String?) ?? '').isEmpty)
record.id: classifyCreatedBy(record, claudeNodes),
};
/// [incoming], carrying [existing]'s `created_by` when [incoming] has none.
///
/// A store replaces a record wholesale, so an edit that does not know the
/// field (an import of an old backup) would otherwise drop it locally.
Record keepCreatedBy(Record incoming, Record? existing) {
final kept = existing?.fields[kCreatedByField];
if (kept == null || incoming.fields.containsKey(kCreatedByField)) {
return incoming;
}
return Record(
id: incoming.id,
fields: {...incoming.fields, kCreatedByField: kept},
deleted: incoming.deleted,
deletedHlc: incoming.deletedHlc,
);
}

38
lib/data/log_watch.dart Normal file
View File

@ -0,0 +1,38 @@
/// A seeded, coalesced stream over the note log's change events.
library;
import 'dart:async';
/// A stream that emits `compute()` when first listened to (the seed), then
/// again after [changes] fires. Seeding in `onListen` -- rather than lazily in
/// an `async*` body -- avoids a race where a write between `listen` and the
/// first generator tick would swallow the initial snapshot.
///
/// Re-emits are coalesced: the first change of a burst arms one
/// zero-duration timer and every further change before it fires is absorbed,
/// so N back-to-back changes cost ONE `compute()` carrying the latest state
/// instead of N full re-queries (each of which repaints the whole list).
/// The timer fires on the next event-loop turn, so tests stay deterministic
/// under `pumpEventQueue` and the widget tester's `pump`.
Stream<T> watchLog<T>(Stream<void> changes, T Function() compute) {
StreamSubscription<void>? sub;
Timer? pending;
late final StreamController<T> controller;
controller = StreamController<T>(
onListen: () {
controller.add(compute());
sub = changes.listen((_) {
pending ??= Timer(Duration.zero, () {
pending = null;
controller.add(compute());
});
});
},
onCancel: () {
pending?.cancel();
pending = null;
return sub?.cancel();
},
);
return controller.stream;
}

View File

@ -76,7 +76,7 @@ enum Status {
}
}
/// The values the optional `by` field may hold: who owns a repo task.
/// The values the optional `by` and `created_by` fields may hold.
const Set<String> kNoteAuthors = {'claude', 'kuhy'};
/// An immutable idea/note record.
@ -99,6 +99,7 @@ class Note {
this.doneCheck,
this.by,
this.evidence,
this.createdBy,
});
/// Builds a [Note] from a raw CRDT query row.
@ -146,6 +147,12 @@ class Note {
/// Proof the task was closed (commit sha + the done check's exit code).
final String? evidence;
/// Who filed the note: one of [kNoteAuthors]; null (older notes) means kuhy.
final String? createdBy;
/// Whether Claude filed this note (hidden by default, sorted last).
bool get isClaudeMade => createdBy == 'claude';
/// Whether this note is a repo task rather than a plain idea.
bool get isRepoTask => repo != null;
@ -162,6 +169,7 @@ class Note {
String? doneCheck,
String? by,
String? evidence,
String? createdBy,
}) {
return Note(
id: id,
@ -174,6 +182,7 @@ class Note {
doneCheck: doneCheck ?? this.doneCheck,
by: by ?? this.by,
evidence: evidence ?? this.evidence,
createdBy: createdBy ?? this.createdBy,
);
}
}

63
lib/data/note_bulk.dart Normal file
View File

@ -0,0 +1,63 @@
/// Partial logs for the multi-note writes: merged in one store write.
///
/// Each write re-serialises the whole log (over a megabyte on a real
/// device), so a bulk action must be one partial log merged once, never one
/// upsert per note. Merging is per field, so a field left out keeps its value
/// and its clock (`created_by` included).
library;
import 'package:crdt_sync/crdt_sync.dart';
import 'package:todo/data/note.dart';
import 'package:todo/data/note_record.dart';
/// Partial records setting [priority] and/or [status] on the live notes of
/// [log] among [ids], all at [hlc].
///
/// Only fields whose value changes are written, plus `updated_at` = [now]
/// (so "last updated" sorting and newer-wins import see the edit). A note
/// with nothing to change, an unknown id and a deleted note are left out.
Log planBulkUpdate(
Log log,
Set<String> ids, {
required Hlc hlc,
required DateTime now,
Priority? priority,
Status? status,
}) {
final plan = <String, Record>{};
for (final id in ids) {
final current = log[id];
if (current == null || current.deleted) continue;
final note = noteFromRecord(current);
final next = note.copyWith(priority: priority, status: status);
if (next.priority == note.priority && next.status == note.status) continue;
// Generated against generated, never against the stored text: a peer's
// `...00Z` (or a web build's millisecond date) would otherwise read as
// a change and get a fresh clock, erasing the `created_at` node signal.
final before = noteFields(note, hlc);
plan[id] = Record(
id: id,
fields: {
for (final MapEntry(:key, :value) in noteFields(
next.copyWith(updatedAt: now),
hlc,
).entries)
if (before[key]?.$1 != value.$1) key: value,
},
);
}
return plan;
}
/// [note] at [hlc] plus a tombstone for each live note of [log] in
/// [removed] (other than [note] itself).
///
/// A merge, not a replace: a field [note] leaves unset keeps its stored
/// value, so this suits notes that only gain content (the repo note an
/// assign appends to), not edits that clear a field.
Log planReplace(Log log, Note note, Iterable<String> removed, Hlc hlc) => {
for (final id in removed)
if (log[id] case final r? when !r.deleted && id != note.id)
id: Record(id: id, fields: const {}, deleted: true, deletedHlc: hlc),
note.id: Record(id: note.id, fields: noteFields(note, hlc)),
};

View File

@ -37,6 +37,7 @@ class NoteFilter {
this.statuses = const {},
this.repo,
this.repoTasksOnly = false,
this.hideClaude = false,
this.createdFrom,
this.createdTo,
this.updatedFrom,
@ -58,6 +59,10 @@ class NoteFilter {
/// Only notes that have a repo (repo tasks). Ideas are excluded.
final bool repoTasksOnly;
/// Leave out notes Claude filed. A view preference rather than a user
/// facet, so it never counts toward [isEmpty] or [activeCount].
final bool hideClaude;
/// Inclusive lower bound (by calendar day) on the creation date.
final DateTime? createdFrom;
@ -104,6 +109,7 @@ class NoteFilter {
Set<Status>? statuses,
String? repo,
bool? repoTasksOnly,
bool? hideClaude,
DateTime? createdFrom,
DateTime? createdTo,
DateTime? updatedFrom,
@ -117,6 +123,7 @@ class NoteFilter {
statuses: statuses ?? this.statuses,
repo: repo ?? this.repo,
repoTasksOnly: repoTasksOnly ?? this.repoTasksOnly,
hideClaude: hideClaude ?? this.hideClaude,
createdFrom: clearCreated ? null : (createdFrom ?? this.createdFrom),
createdTo: clearCreated ? null : (createdTo ?? this.createdTo),
updatedFrom: clearUpdated ? null : (updatedFrom ?? this.updatedFrom),
@ -138,6 +145,7 @@ bool matchesFilter(Note note, NoteFilter filter) {
}
if (filter.repo != null && note.repo != filter.repo) return false;
if (filter.repoTasksOnly && note.repo == null) return false;
if (filter.hideClaude && note.isClaudeMade) return false;
if (filter.priorities.isNotEmpty &&
!filter.priorities.contains(note.priority)) {
return false;
@ -165,8 +173,17 @@ bool _withinDays(DateTime value, DateTime? from, DateTime? to) {
return true;
}
/// The comparator implementing [sort].
/// The comparator implementing [sort]: human-made notes first, Claude-made
/// ones after, and [sort] orders each group.
Comparator<Note> comparatorFor(NoteSort sort) {
final within = _within(sort);
return (a, b) {
if (a.isClaudeMade != b.isClaudeMade) return a.isClaudeMade ? 1 : -1;
return within(a, b);
};
}
Comparator<Note> _within(NoteSort sort) {
switch (sort) {
case NoteSort.createdDesc:
return (a, b) => b.createdAt.compareTo(a.createdAt);

View File

@ -14,6 +14,9 @@ const _doneCheck = 'done_check';
const _by = 'by';
const _evidence = 'evidence';
/// Record field naming who filed the note (`kuhy` or `claude`).
const kCreatedByField = 'created_by';
/// Record fields for [note], all at [hlc]. Unset task fields are omitted, so
/// an idea's record is exactly the v3 field set (and cannot clear a peer's).
Map<String, Field> noteFields(Note note, Hlc hlc) => {
@ -26,6 +29,7 @@ Map<String, Field> noteFields(Note note, Hlc hlc) => {
if (note.doneCheck case final v?) _doneCheck: (v, hlc),
if (note.by case final v?) _by: (v, hlc),
if (note.evidence case final v?) _evidence: (v, hlc),
if (note.createdBy case final v?) kCreatedByField: (v, hlc),
};
/// Reads [record] as a [Note], ignoring unknown fields.
@ -48,6 +52,7 @@ Note noteFromRecord(Record record) {
doneCheck: task(_doneCheck),
by: task(_by),
evidence: task(_evidence),
createdBy: task(kCreatedByField),
);
}

View File

@ -1,7 +1,8 @@
import 'dart:async';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:todo/data/created_by_backfill.dart';
import 'package:todo/data/log_watch.dart';
import 'package:todo/data/note.dart';
import 'package:todo/data/note_bulk.dart';
import 'package:todo/data/note_import.dart';
import 'package:todo/data/note_query.dart';
import 'package:todo/data/note_record.dart';
@ -114,9 +115,57 @@ class NoteRepository {
/// character-by-character autosave: it is cheap and idempotent. All fields
/// share one fresh monotonic clock (they are written together).
Future<void> upsert(Note note) async {
await _store.upsert(
Record(id: note.id, fields: noteFields(note, _store.nextHlc())),
);
await _put(Record(id: note.id, fields: noteFields(note, _store.nextHlc())));
}
Future<void> _put(Record record) =>
_store.upsert(keepCreatedBy(record, _store.get(record.id)));
/// Sets `created_by` on every live note that has none, from the node that
/// wrote its `created_at` (see [classifyCreatedBy]); returns id -> value.
///
/// Writes only that field, at a fresh clock, so no other field's clock
/// moves (a rewrite of `created_at` would erase the signal itself). With
/// [dryRun] nothing is written.
Future<Map<String, String>> backfillCreatedBy(
Set<String> claudeNodes, {
bool dryRun = false,
}) async {
final plan = planCreatedByBackfill(_store.snapshot(), claudeNodes);
if (dryRun || plan.isEmpty) return plan;
final hlc = _store.nextHlc();
await _patch({
for (final e in plan.entries)
e.key: Record(id: e.key, fields: {kCreatedByField: (e.value, hlc)}),
});
return plan;
}
/// Sets [priority] and/or [status] on the live notes [ids] in ONE store
/// write (see [planBulkUpdate]); a no-op when nothing changes.
Future<void> bulkUpdate(
Set<String> ids, {
Priority? priority,
Status? status,
}) => _patch(
planBulkUpdate(
_store.snapshot(),
ids,
hlc: _store.nextHlc(),
now: DateTime.now(),
priority: priority,
status: status,
),
);
/// Saves [note] and deletes [removed] in ONE store write ([planReplace]).
Future<void> saveReplacing(Note note, Iterable<String> removed) =>
_patch(planReplace(_store.snapshot(), note, removed, _store.nextHlc()));
/// Merges a partial log in one write: untouched fields keep their clocks.
Future<void> _patch(Log partial) async {
if (partial.isEmpty) return;
await _store.replaceAll(mergeLogs(_store.snapshot(), partial));
}
/// Soft-deletes a note. The log keeps a sticky tombstone so the deletion
@ -142,10 +191,10 @@ class NoteRepository {
// the import wins locally; the clock decides whether it wins on *other*
// devices, and an import must not win there simply by being recent.
if (local == null) {
await _store.upsert(recordAtNoteTime(note, _nodeId));
await _put(recordAtNoteTime(note, _nodeId));
added++;
} else if (note.updatedAt.isAfter(local.updatedAt)) {
await _store.upsert(recordAtNoteTime(note, _nodeId));
await _put(recordAtNoteTime(note, _nodeId));
updated++;
} else {
skipped++;
@ -165,27 +214,10 @@ class NoteRepository {
Stream<List<Note>> watchNotes({
NoteSort sort = NoteSort.priorityDesc,
NoteFilter filter = const NoteFilter(),
}) => _watch(() => _query(sort, filter));
}) => watchLog(_store.changes, () => _query(sort, filter));
/// Emits the live count of non-deleted notes immediately and on each change.
Stream<int> watchCount() => _watch(_liveCount);
/// A stream that emits `compute()` synchronously when first listened to (the
/// seed), then again on every log change. Seeding in `onListen` — rather than
/// lazily in an `async*` body — avoids a race where a write between `listen`
/// and the first generator tick would swallow the initial snapshot.
Stream<T> _watch<T>(T Function() compute) {
StreamSubscription<void>? sub;
late final StreamController<T> controller;
controller = StreamController<T>(
onListen: () {
controller.add(compute());
sub = _store.changes.listen((_) => controller.add(compute()));
},
onCancel: () => sub?.cancel(),
);
return controller.stream;
}
Stream<int> watchCount() => watchLog(_store.changes, _liveCount);
/// This device's stable node id. Names its file in the sync repo so two
/// devices never write the same path.

75
lib/data/repo_assign.dart Normal file
View File

@ -0,0 +1,75 @@
/// Assigning plain notes to a repo from the UI: each becomes a `## title`
/// section of the repo's one open note ([addRepoTask]), and the original goes.
///
/// Only the data layer: nothing here talks to the board or starts a task.
library;
import 'package:todo/data/note.dart';
import 'package:todo/data/note_repository.dart';
import 'package:todo/data/repo_note.dart';
final _repoShape = RegExp(r'^[\w.-]+/[\w.-]+$');
final _heading = RegExp(r'^(#+)(\s.*)$');
/// Whether [value] looks like `owner/name`.
bool isRepoName(String value) => _repoShape.hasMatch(value.trim());
/// [body] with every heading outside code fences pushed below `##`.
///
/// A repo note splits into tasks at `##` headings, so a template note's own
/// `## what` / `## must` would otherwise turn into tasks of their own.
String demoteHeadings(String body) {
var fenced = false;
final out = <String>[];
for (final line in body.split('\n')) {
if (line.trimLeft().startsWith('```')) fenced = !fenced;
final m = fenced ? null : _heading.firstMatch(line);
if (m == null) {
out.add(line);
continue;
}
final level = m.group(1)!.length;
out.add('${'#' * (level < 2 ? 3 : level + 1)}${m.group(2)}');
}
return out.join('\n');
}
/// Moves the plain [notes] into [repo]'s open note in one write, creating
/// that note when the repo has none; returns the notes refused because
/// they already belong to a repo (left untouched).
Future<List<Note>> assignToRepo(
NoteRepository repository,
List<Note> notes,
String repo,
) async {
final refused = [
for (final n in notes)
if (n.repo != null) n,
];
final plain = [
for (final n in notes)
if (n.repo == null) n,
];
if (plain.isEmpty) return refused;
final first = splitNote(plain.first.text);
var target = (await addRepoTask(
repository,
repo: repo,
title: first.title,
body: demoteHeadings(first.body),
priority: plain.first.priority,
createdBy: 'kuhy',
)).note;
for (final n in plain.skip(1)) {
final (:title, :body) = splitNote(n.text);
target = target.copyWith(
text: '${target.text}\n\n${repoSection(title, demoteHeadings(body))}',
priority: maxPriority(target.priority, n.priority),
);
}
await repository.saveReplacing(
target.copyWith(updatedAt: DateTime.now()),
plain.map((n) => n.id),
);
return refused;
}

View File

@ -71,6 +71,7 @@ Future<RepoAdd> addRepoTask(
String? doneCheck,
Priority priority = Priority.medium,
String? by,
String? createdBy,
}) async {
final section = repoSection(title, body);
final open = (await repository.listNotes(
@ -99,6 +100,7 @@ Future<RepoAdd> addRepoTask(
repo: repo,
doneCheck: composeDoneCheck(null, doneCheck),
by: by,
createdBy: createdBy,
),
appended: false,
);

View File

@ -26,16 +26,24 @@ typedef SplitAdd = ({Note note, SplitKind kind, String? why});
final _heading = RegExp(r'^##\s+(\[x\]\s+)?(.*)$');
/// The open `## title` headings of [text], outside code fences.
Iterable<String> openSections(String text) sync* {
/// Every `## title` heading of [text] outside code fences, open or done.
Iterable<RegExpMatch> _sections(String text) sync* {
var fenced = false;
for (final line in text.split('\n')) {
if (line.trimLeft().startsWith('```')) fenced = !fenced;
final m = fenced ? null : _heading.firstMatch(line);
if (m != null && m.group(1) == null) yield m.group(2)!.trim();
if (m != null) yield m;
}
}
/// The open `## title` headings of [text], outside code fences.
Iterable<String> openSections(String text) => _sections(
text,
).where((m) => m.group(1) == null).map((m) => m.group(2)!.trim());
/// How many board items [text] splits into: one per section, done or not.
int sectionCount(String text) => _sections(text).length;
/// The separate open note of [repo] with an open section titled [title].
Note? duplicateTarget(Iterable<Note> open, String repo, String title) {
final hits =
@ -69,6 +77,7 @@ Future<SplitAdd> addSeparateTask(
String? doneCheck,
Priority priority = Priority.medium,
String? by,
String? createdBy,
Future<bool> Function(Note note)? busy,
}) async {
final section = repoSection(title, body);
@ -105,6 +114,7 @@ Future<SplitAdd> addSeparateTask(
repo: repo,
doneCheck: composeDoneCheck(null, doneCheck),
by: by,
createdBy: createdBy,
),
kind: why == null ? SplitKind.added : SplitKind.fellBack,
why: why,

View File

@ -5,6 +5,7 @@
/// a board that is down must not lose the task that was just saved.
library;
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
@ -19,17 +20,34 @@ final _csrf = RegExp(r'<meta\s+name="todoimpl-csrf"\s+content="([^"]+)"');
/// Talks to the board over [client] at [base].
class BoardClient {
/// [client] is injectable so tests need no network.
BoardClient({http.Client? client, Uri? base})
: _http = client ?? http.Client(),
base = base ?? kBoardBase;
/// [client] is injectable so tests need no network. [wait] bounds how
/// long Start waits for the board to see a merged section (its note cache
/// lags ~10 s, T1863) and to confirm a Start whose reply outran
/// [startTimeout] (T1864); [poll] is the gap between board reads.
BoardClient({
http.Client? client,
Uri? base,
this.wait = const Duration(seconds: 30),
this.poll = const Duration(seconds: 1),
this.startTimeout = const Duration(seconds: 30),
}) : _http = client ?? http.Client(),
base = base ?? kBoardBase;
final http.Client _http;
/// The board's origin; also the `Origin` header of a POST.
final Uri base;
static const _timeout = Duration(seconds: 5);
/// Longest wait for the board to catch up with the note.
final Duration wait;
/// Gap between two board reads while waiting.
final Duration poll;
/// Longest wait for the reply to a Start POST.
final Duration startTimeout;
static const _timeout = Duration(seconds: 10);
Future<List<Map<String, dynamic>>> _cards(String noteId) async {
final res = await _http.get(base.resolve('/api/board')).timeout(_timeout);
@ -54,30 +72,79 @@ class BoardClient {
}
}
/// Clicks Start on the note's last startable item titled [title].
Future<StartResult> start(String noteId, String title) async {
/// Clicks Start on the note's last startable item titled [title]. With
/// [sections], that is the note's last section (`<id8>-<sections - 1>`),
/// which a merge just added: waits until the board shows it.
Future<StartResult> start(
String noteId,
String title, {
int? sections,
}) async {
try {
final card = (await _cards(noteId)).reversed
.where((c) => c['title'] == title && _startable(c))
.firstOrNull;
final last = sections == null ? null : _item(noteId, sections - 1);
bool seen(List<Map<String, dynamic>> cs) =>
last == null || cs.any((c) => c['id'] == last);
final cards = await _until(noteId, seen);
// Exactly the merged item: never an older same-titled one, which the
// board may list last (it orders by column, T1866).
final card = seen(cards)
? cards.reversed
.where((c) => last == null || c['id'] == last)
.where((c) => c['title'] == title && _startable(c))
.firstOrNull
: null;
if (card == null) return _no('no startable item');
final page = await _http.get(base).timeout(_timeout);
final token = _csrf.firstMatch(page.body)?.group(1);
if (token == null) return _no('no CSRF token on the board page');
final id = card['id']! as String;
final res = await _http
.post(
base.resolve('/api/task/$id/start'),
headers: {'Origin': base.origin, 'X-Todoimpl-Token': token},
)
.timeout(_timeout);
if (res.statusCode == 200) return (ok: true, reason: null, item: id);
return _no('board refused start: ${res.statusCode} ${_message(res)}');
return await _post(noteId, id, token);
} on Object catch (e) {
return _no('board unreachable: $e');
}
}
Future<StartResult> _post(String noteId, String id, String token) async {
final http.Response res;
try {
res = await _http
.post(
base.resolve('/api/task/$id/start'),
headers: {'Origin': base.origin, 'X-Todoimpl-Token': token},
)
.timeout(startTimeout);
} on TimeoutException catch (e) {
// The board may still be starting it: a card that left Start took it.
final cards = await _until(
noteId,
(cs) => cs.any((c) => c['id'] == id && !_startable(c)),
);
if (cards.any((c) => c['id'] == id && !_startable(c))) {
return (ok: true, reason: null, item: id);
}
return _no('board unreachable: $e');
}
if (res.statusCode == 200) return (ok: true, reason: null, item: id);
return _no('board refused start: ${res.statusCode} ${_message(res)}');
}
/// The note's cards once [done] holds, or the last read after [wait].
Future<List<Map<String, dynamic>>> _until(
String noteId,
bool Function(List<Map<String, dynamic>>) done,
) async {
final deadline = DateTime.now().add(wait);
var cards = await _cards(noteId);
while (!done(cards) && DateTime.now().isBefore(deadline)) {
await Future<void>.delayed(poll);
cards = await _cards(noteId);
}
return cards;
}
static String _item(String noteId, int index) =>
'${noteId.substring(0, 8)}-$index';
static bool _startable(Map<String, dynamic> card) => (card['actions'] as List)
.cast<Map<String, dynamic>>()
.any((a) => a['id'] == 'start' && a['enabled'] == true);

View File

@ -17,7 +17,7 @@ class TaskService {
final BoardClient? _board;
Future<void> _tail = Future.value();
Future<String> _serial(Future<String> Function() body) {
Future<T> _serial<T>(Future<T> Function() body) {
final run = _tail.then((_) => body());
_tail = run.then((_) {}, onError: (_) {});
return run;
@ -44,8 +44,14 @@ class TaskService {
}
}
/// `add_task`: joins the repo's open note (by: claude), or creates it.
Future<String> add(Map<String, dynamic> args) => _serial(() async {
/// `add_task`: joins the repo's open note (by: claude), or creates it. A
/// board Start after a merge runs once the other calls may go again.
Future<String> add(Map<String, dynamic> args) async {
final (:reply, :start) = await _serial(() => _add(args));
return start == null ? reply : '$reply\n${await start()}';
}
Future<SplitReply> _add(Map<String, dynamic> args) async {
final a = _Args(args);
final repo = a.repo(required: true)!;
final title = a.required('title');
@ -70,9 +76,11 @@ class TaskService {
doneCheck: a.optional('done_check'),
priority: a.priority() ?? Priority.medium,
by: 'claude',
createdBy: 'claude',
);
return await _save(added.note, added.appended ? 'appended to' : 'added');
});
final verb = added.appended ? 'appended to' : 'added';
return (reply: await _save(added.note, verb), start: null);
}
/// `list_tasks`: repo tasks, open ones unless `status` says otherwise.
Future<String> list(Map<String, dynamic> args) => _serial(() async {

View File

@ -10,9 +10,14 @@ import 'package:todo/mcp/board_client.dart';
/// Saves [note] and returns `<verb> <id>` plus its list line.
typedef SaveNote = Future<String> Function(Note note, String verb);
/// Adds a separate task, merging into a duplicate's note when it is idle,
/// and clicks Start on the board after a merge that may run.
Future<String> addSplit(
/// A saved split add: its reply so far, and the board Start still to run.
/// The caller runs [start] after releasing its lock, since the board learns
/// about the merged section by calling this same server (T1865).
typedef SplitReply = ({String reply, Future<String> Function()? start});
/// Adds a separate task, merging into a duplicate's note when it is idle;
/// a merge that may run comes back with the Start to click on the board.
Future<SplitReply> addSplit(
NoteRepository repository,
BoardClient? board,
SaveNote save, {
@ -30,19 +35,33 @@ Future<String> addSplit(
doneCheck: doneCheck,
priority: priority,
by: 'claude',
createdBy: 'claude',
busy: board == null ? null : (n) => board.running(n.id),
);
if (added.kind != SplitKind.merged) {
final reply = await save(added.note, 'added');
return added.why == null ? reply : '$reply\n${added.why}';
return (
reply: added.why == null ? reply : '$reply\n${added.why}',
start: null,
);
}
final reply = await save(added.note, 'merged into');
final block = startBlock(doneCheck, body);
if (block != null) return '$reply\nstart: skipped ($block)';
final start = board == null
? (ok: false, reason: 'no board configured', item: null)
: await board.start(added.note.id, title.trim());
if (start.ok) return '$reply\nstart: ok ${start.item}';
return '$reply\nstart: {ok: false, reason: "${start.reason}"}; '
'the merge is saved, click Start on the board by hand';
if (block != null) {
return (reply: '$reply\nstart: skipped ($block)', start: null);
}
Future<String> start() async {
final s = board == null
? (ok: false, reason: 'no board configured', item: null)
: await board.start(
added.note.id,
title.trim(),
sections: sectionCount(added.note.text),
);
if (s.ok) return 'start: ok ${s.item}';
return 'start: {ok: false, reason: "${s.reason}"}; '
'the merge is saved, click Start on the board by hand';
}
return (reply: reply, start: start);
}

View File

@ -116,7 +116,8 @@ McpServer _server(TaskService tasks) {
tool(
'list_tasks',
'List repo tasks, one line each: 8-char id, status, priority, repo, '
'title. Default status "open" (todo + in_progress).',
'title, created_by (kuhy|claude). Default status "open" '
'(todo + in_progress).',
{
'repo': repo,
'status': JsonSchema.string(

View File

@ -92,6 +92,7 @@ class NotesMarkdown {
doneCheck: attrs['done_check'],
by: attrs['by'],
evidence: attrs['evidence'],
createdBy: attrs['created_by'],
),
);
}
@ -113,6 +114,7 @@ class NotesMarkdown {
('done_check', note.doneCheck),
('by', note.by),
('evidence', note.evidence),
('created_by', note.createdBy),
])
if (value != null) ' $name="${_escape(value)}"',
].join();

View File

@ -1,28 +1,26 @@
/// The one page route every screen is pushed with, on the shared motion tokens.
/// The one page route every screen is pushed with.
library;
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:todo/ui/motion.dart';
/// A route for [builder]: fade plus a short rise over [AppDuration.slow],
/// instant under the OS "remove animations" setting.
Route<T> appRoute<T>(BuildContext context, WidgetBuilder builder) =>
_AppPageRoute<T>(
builder: builder,
duration: motionDuration(context, AppDuration.slow),
);
/// A route for [builder] that switches screens instantly: no fade, no slide.
///
/// Without a GPU the desktop web build repaints the whole 4K canvas in
/// software on every frame, so a 320 ms transition cost ~600 ms of main
/// thread and a 260-300 ms frame stall on every tab switch and note open.
/// The same instant switch applies on the phone. The context is unused but
/// kept so every call site stays as it was.
Route<T> appRoute<T>(BuildContext _, WidgetBuilder builder) =>
_AppPageRoute<T>(builder: builder);
class _AppPageRoute<T> extends MaterialPageRoute<T> {
_AppPageRoute({required super.builder, required this.duration});
final Duration duration;
_AppPageRoute({required super.builder});
@override
Duration get transitionDuration => duration;
Duration get transitionDuration => Duration.zero;
@override
Duration get reverseTransitionDuration => duration;
Duration get reverseTransitionDuration => Duration.zero;
@override
Widget buildTransitions(
@ -30,21 +28,5 @@ class _AppPageRoute<T> extends MaterialPageRoute<T> {
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
final enter = CurvedAnimation(
parent: animation,
curve: AppCurve.decelerate,
reverseCurve: AppCurve.accelerate,
);
return FadeTransition(
opacity: enter,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.04),
end: Offset.zero,
).animate(enter),
child: child,
),
);
}
) => child;
}

View File

@ -102,6 +102,7 @@ class CaptureDraft {
status: status,
createdAt: _createdAt!,
updatedAt: now,
createdBy: 'kuhy',
),
);
if (live()) lastSavedAt.value = now;

View File

@ -7,6 +7,21 @@ import 'package:flutter/material.dart';
import 'package:todo/data/note.dart';
import 'package:todo/data/note_template.dart';
/// Danger for high, the accent for medium, muted for low.
Color priorityColor(Priority p, ColorScheme scheme) => switch (p) {
Priority.high => scheme.error,
Priority.medium => scheme.primary,
Priority.low => scheme.outline,
};
/// The glyph standing for [s] wherever a status is shown without words.
IconData statusIcon(Status s) => switch (s) {
Status.todo => Icons.radio_button_unchecked,
Status.inProgress => Icons.timelapse,
Status.done => Icons.check_circle_outline,
Status.abandoned => Icons.cancel_outlined,
};
/// A note as a grid cell. It fills whatever cell it is given.
class NoteGridTile extends StatelessWidget {
/// Creates a tile for [note].
@ -14,9 +29,13 @@ class NoteGridTile extends StatelessWidget {
required this.note,
required this.onTap,
required this.onActions,
this.selected = false,
super.key,
});
/// Whether the note is part of the multi-selection: a tint and a check.
final bool selected;
/// The note this tile displays.
final Note note;
@ -27,18 +46,7 @@ class NoteGridTile extends StatelessWidget {
final VoidCallback onActions;
/// Danger for high, the accent for medium, muted for low.
Color accentFor(ColorScheme scheme) => switch (note.priority) {
Priority.high => scheme.error,
Priority.medium => scheme.primary,
Priority.low => scheme.outline,
};
static IconData _statusIcon(Status s) => switch (s) {
Status.todo => Icons.radio_button_unchecked,
Status.inProgress => Icons.timelapse,
Status.done => Icons.check_circle_outline,
Status.abandoned => Icons.cancel_outlined,
};
Color accentFor(ColorScheme scheme) => priorityColor(note.priority, scheme);
@override
Widget build(BuildContext context) {
@ -49,7 +57,7 @@ class NoteGridTile extends StatelessWidget {
final closed =
note.status == Status.done || note.status == Status.abandoned;
return Material(
color: scheme.surfaceContainerHigh,
color: selected ? scheme.secondaryContainer : scheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(AppRadius.md),
clipBehavior: Clip.antiAlias,
child: InkWell(
@ -72,14 +80,30 @@ class NoteGridTile extends StatelessWidget {
children: [
// Expanded: a big text scale shortens the title, never overflows.
Expanded(
child: Text(
title.isEmpty ? '(empty)' : title,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
color: closed ? scheme.onSurfaceVariant : null,
decoration: closed ? TextDecoration.lineThrough : null,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
title.isEmpty ? '(empty)' : title,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
color: closed ? scheme.onSurfaceVariant : null,
decoration: closed
? TextDecoration.lineThrough
: null,
),
),
),
if (selected)
Icon(
Icons.check_circle,
size: 20,
color: scheme.primary,
semanticLabel: 'Selected',
),
],
),
),
if (note.repoName case final repo?)
@ -92,7 +116,7 @@ class NoteGridTile extends StatelessWidget {
Row(
children: [
Icon(
_statusIcon(note.status),
statusIcon(note.status),
size: 14,
color: scheme.onSurfaceVariant,
),

View File

@ -0,0 +1,86 @@
/// Which notes of the list are selected, and whether a tap selects.
library;
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:todo/data/note.dart';
/// The notes list's multi-select state.
///
/// Holds ids, not notes, so a selection survives the list re-emitting
/// (a bulk status change rebuilds every tile). Selection order is kept, so
/// an assign appends the notes in the order they were picked.
class NoteSelection extends ChangeNotifier {
final Set<String> _ids = {};
bool _active = false;
String? _anchor;
/// Whether a plain tap selects rather than opens.
bool get active => _active;
/// The selected ids, in the order they were picked.
Set<String> get ids => Set.unmodifiable(_ids);
/// Whether [id] is selected.
bool contains(String id) => _ids.contains(id);
/// Enters selection mode, or leaves it and drops the selection.
void toggleMode() {
if (_active) return clear();
_active = true;
notifyListeners();
}
/// Flips [id] in or out of the selection; enters the mode.
void toggle(String id) {
_active = true;
if (!_ids.remove(id)) _ids.add(id);
_anchor = id;
notifyListeners();
}
/// Selects every note of [order] from the last toggled one to [id]
/// (Shift-click); without an anchor in [order] it is a [toggle].
void extendTo(String id, List<Note> order) {
final from = order.indexWhere((n) => n.id == _anchor);
final to = order.indexWhere((n) => n.id == id);
if (from < 0 || to < 0) return toggle(id);
final (lo, hi) = from < to ? (from, to) : (to, from);
_ids.addAll(order.sublist(lo, hi + 1).map((n) => n.id));
_anchor = id;
notifyListeners();
}
/// Keeps only the selected ids in [keep] (e.g. the notes an assign
/// refused); an empty result is a [clear].
void retain(Set<String> keep) {
_ids.retainWhere(keep.contains);
if (_ids.isEmpty) return clear();
notifyListeners();
}
/// Drops the selection and leaves the mode.
void clear() {
_ids.clear();
_active = false;
_anchor = null;
notifyListeners();
}
/// Handles a tap on [note] of the list [order]; false means "open it".
///
/// Shift extends, Ctrl/Cmd toggles even outside the mode (desktop), and in
/// the mode a plain tap toggles.
bool handleTap(Note note, List<Note> order) {
final keys = HardwareKeyboard.instance;
if (keys.isShiftPressed) {
extendTo(note.id, order);
} else if (_active || keys.isControlPressed || keys.isMetaPressed) {
toggle(note.id);
} else {
return false;
}
return true;
}
}

View File

@ -16,9 +16,13 @@ class NoteTile extends StatelessWidget {
required this.note,
required this.onTap,
required this.onActions,
this.selected = false,
super.key,
});
/// Whether the row is part of the multi-selection: highlighted + a check.
final bool selected;
/// The note this row displays.
final Note note;
@ -41,6 +45,10 @@ class NoteTile extends StatelessWidget {
?note.repoName,
].join(' · ');
return ListTile(
selected: selected,
leading: selected
? const Icon(Icons.check_circle, semanticLabel: 'Selected')
: null,
title: Text(
firstLine.isEmpty ? '(empty)' : firstLine,
maxLines: 2,

View File

@ -0,0 +1,37 @@
/// The selection bar's assign flow: pick a repo, move the notes into it.
///
/// Data-layer only ([assignToRepo]); the UI never reaches the board, so an
/// assign never starts a task.
library;
import 'package:flutter/material.dart';
import 'package:todo/data/note_repository.dart';
import 'package:todo/data/repo_assign.dart';
import 'package:todo/ui/note_selection.dart';
import 'package:todo/ui/repo_picker_sheet.dart';
/// Asks for a repo and assigns the selected plain notes to it, in
/// selection order. True when some notes were refused (already in a repo);
/// those stay selected, the moved ones leave the selection.
Future<bool> assignSelection(
BuildContext context,
NoteRepository repository,
NoteSelection selection,
) async {
final all = await repository.listNotes();
final repos = {for (final n in all) ?n.repo}.toList()..sort();
if (!context.mounted) return false;
final repo = await showModalBottomSheet<String>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (_) => RepoPickerSheet(repos: repos),
);
if (repo == null) return false;
final byId = {for (final n in all) n.id: n};
final chosen = [for (final id in selection.ids) ?byId[id]];
final refused = await assignToRepo(repository, chosen, repo);
selection.retain({for (final n in refused) n.id});
return refused.isNotEmpty;
}

View File

@ -26,9 +26,13 @@ class NotesGrid extends StatelessWidget {
required this.notes,
required this.onOpen,
required this.onActions,
required this.isSelected,
super.key,
});
/// Whether the note with this id is in the multi-selection.
final bool Function(String id) isSelected;
/// Notes in display order.
final List<Note> notes;
@ -42,6 +46,7 @@ class NotesGrid extends StatelessWidget {
return NoteGridTile(
key: ValueKey(note.id),
note: note,
selected: isSelected(note.id),
onTap: () => onOpen(note),
onActions: () => onActions(note),
);

View File

@ -16,9 +16,13 @@ class NotesListBody extends StatelessWidget {
required this.notes,
required this.onOpen,
required this.onActions,
required this.isSelected,
super.key,
});
/// Whether the note with this id is in the multi-selection.
final bool Function(String id) isSelected;
/// Notes in display order.
final List<Note> notes;
@ -32,13 +36,19 @@ class NotesListBody extends StatelessWidget {
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, box) => box.maxWidth < kNotesGridMaxWidth
? NotesGrid(notes: notes, onOpen: onOpen, onActions: onActions)
? NotesGrid(
notes: notes,
onOpen: onOpen,
onActions: onActions,
isSelected: isSelected,
)
: ListView.separated(
itemCount: notes.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, i) => NoteTile(
key: ValueKey(notes[i].id),
note: notes[i],
selected: isSelected(notes[i].id),
onTap: () => onOpen(notes[i]),
onActions: () => onActions(notes[i]),
),

View File

@ -5,13 +5,18 @@ 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/note_selection.dart';
import 'package:todo/ui/notes_assign_flow.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/notes_selection_bar.dart';
export 'package:todo/ui/notes_list_defaults.dart';
part 'notes_list_screen_select.dart';
/// Searchable, filterable, sortable list of stored/synced notes.
///
/// The heavy lifting (WHERE/ORDER BY) lives in [NoteRepository]; this screen
@ -53,25 +58,46 @@ class _NotesListScreenState extends State<NotesListScreen> {
/// Memoised stream; only replaced when [_sort]/[_filter] actually change.
late Stream<List<Note>> _stream;
/// Multi-select; any change rebuilds the screen (tiles + bottom bar).
late final _selection = NoteSelection()..addListener(() => setState(() {}));
@override
void initState() {
super.initState();
_stream = widget.repository.watchNotes(sort: _sort, filter: _filter);
_stream = _watch();
}
@override
void dispose() {
_debounce?.cancel();
_searchController.dispose();
_selection.dispose();
super.dispose();
}
/// Rebuilds the watch stream for the current sort + filter. Call only
/// from handlers that change those, never from [build].
void _applyState() {
setState(() {
_stream = widget.repository.watchNotes(sort: _sort, filter: _filter);
});
setState(() => _stream = _watch());
}
/// The query for the current sort + filter, minus Claude-made notes unless
/// this device's eye toggle is on.
Stream<List<Note>> _watch() => widget.repository.watchNotes(
sort: _sort,
filter: _filter.copyWith(
hideClaude: !widget.appSettings.value.showClaudeNotes,
),
);
Future<void> _toggleClaudeNotes() async {
final settings = widget.appSettings.value;
final next = await settings.withShowClaudeNotes(
value: !settings.showClaudeNotes,
);
if (!mounted) return;
widget.appSettings.value = next;
_applyState();
}
void _onSearchChanged(String value) {
@ -110,6 +136,17 @@ class _NotesListScreenState extends State<NotesListScreen> {
appBar: AppBar(
title: const Text('Notes'),
actions: [
_selectButton(),
// Eye, no label: open = Claude-made notes shown, slashed = hidden.
IconButton(
icon: Icon(
widget.appSettings.value.showClaudeNotes
? Icons.visibility
: Icons.visibility_off,
semanticLabel: 'Claude notes',
),
onPressed: _toggleClaudeNotes,
),
PopupMenuButton<NoteSort>(
tooltip: 'Sort',
icon: const Icon(Icons.sort),
@ -193,20 +230,18 @@ class _NotesListScreenState extends State<NotesListScreen> {
),
);
}
void open(Note n) =>
openNote(context, n, widget.repository, widget.appSettings);
void actions(Note n) =>
openNoteActions(context, n, widget.repository);
return NotesListBody(
notes: notes,
onOpen: open,
onActions: actions,
onOpen: (n) => _tap(n, notes),
onActions: _longPress,
isSelected: _selection.contains,
);
},
),
),
],
),
bottomNavigationBar: _selectionBar(),
);
}
}

View File

@ -0,0 +1,43 @@
/// The notes list's multi-select wiring: the app-bar toggle, taps, and the
/// bottom action bar.
///
/// A `part` of `notes_list_screen.dart` because it reads the screen's
/// private selection and repository; split out for file size.
part of 'notes_list_screen.dart';
extension _Selecting on _NotesListScreenState {
/// App-bar toggle into and out of selection mode (tinted while on).
Widget _selectButton() => IconButton(
key: const ValueKey('select-mode'),
isSelected: _selection.active,
onPressed: _selection.toggleMode,
icon: const Icon(Icons.checklist, semanticLabel: 'Select'),
);
/// A tap on [note] of [notes]: selects in the mode or with Shift/Ctrl,
/// else opens it.
void _tap(Note note, List<Note> notes) {
if (_selection.handleTap(note, notes)) return;
unawaited(openNote(context, note, widget.repository, widget.appSettings));
}
/// A long-press: toggles in the mode, else the per-note sheet.
void _longPress(Note note) {
if (_selection.active) return _selection.toggle(note.id);
unawaited(openNoteActions(context, note, widget.repository));
}
/// The bar, only while something is selected. Status and priority are
/// ONE store write for the whole selection ([NoteRepository.bulkUpdate]).
Widget? _selectionBar() {
if (_selection.ids.isEmpty) return null;
final repo = widget.repository;
return NotesSelectionBar(
onStatus: (s) => unawaited(repo.bulkUpdate(_selection.ids, status: s)),
onPriority: (p) =>
unawaited(repo.bulkUpdate(_selection.ids, priority: p)),
onAssign: () => assignSelection(context, repo, _selection),
onClear: _selection.clear,
);
}
}

View File

@ -0,0 +1,128 @@
/// The icon-only action bar shown while notes are selected.
library;
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:todo/data/note.dart';
import 'package:todo/ui/note_grid_tile.dart';
/// How long the assign icon shows the refusal block.
const kRefusalFlash = Duration(milliseconds: 1500);
/// Set status, set priority, assign to repo, clear: icons only.
///
/// Menus pass `tooltip: ''` because a tooltip is visible text; the icons
/// carry semantic labels instead.
class NotesSelectionBar extends StatefulWidget {
/// Creates the bar; every callback acts on the whole selection.
const NotesSelectionBar({
required this.onStatus,
required this.onPriority,
required this.onAssign,
required this.onClear,
super.key,
});
/// Sets every selected note's status.
final ValueChanged<Status> onStatus;
/// Sets every selected note's priority.
final ValueChanged<Priority> onPriority;
/// Runs the assign flow; true when some notes were refused.
final Future<bool> Function() onAssign;
/// Drops the selection.
final VoidCallback onClear;
@override
State<NotesSelectionBar> createState() => _NotesSelectionBarState();
}
class _NotesSelectionBarState extends State<NotesSelectionBar> {
Timer? _flash;
@override
void dispose() {
_flash?.cancel();
super.dispose();
}
Future<void> _assign() async {
if (!await widget.onAssign() || !mounted) return;
_flash?.cancel();
setState(() => _flash = Timer(kRefusalFlash, () => setState(() {})));
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final refused = _flash?.isActive ?? false;
return Material(
key: const ValueKey('selection-bar'),
color: scheme.surfaceContainer,
child: SafeArea(
top: false,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
PopupMenuButton<Status>(
key: const ValueKey('bulk-status'),
tooltip: '',
icon: const Icon(Icons.task_alt, semanticLabel: 'Set status'),
onSelected: widget.onStatus,
itemBuilder: (_) => [
for (final s in Status.values)
PopupMenuItem(
key: ValueKey('status-${s.name}'),
value: s,
child: Icon(statusIcon(s), semanticLabel: s.label),
),
],
),
PopupMenuButton<Priority>(
key: const ValueKey('bulk-priority'),
tooltip: '',
icon: const Icon(
Icons.flag_outlined,
semanticLabel: 'Set priority',
),
onSelected: widget.onPriority,
itemBuilder: (_) => [
for (final p in Priority.values.reversed)
PopupMenuItem(
key: ValueKey('priority-${p.name}'),
value: p,
child: Icon(
Icons.flag,
color: priorityColor(p, scheme),
semanticLabel: p.label,
),
),
],
),
IconButton(
key: const ValueKey('bulk-assign'),
onPressed: _assign,
icon: Icon(
refused ? Icons.block : Icons.drive_file_move_outline,
color: refused ? scheme.error : null,
semanticLabel: 'Assign to repo',
),
),
IconButton(
key: const ValueKey('bulk-clear'),
onPressed: widget.onClear,
icon: const Icon(
Icons.deselect,
semanticLabel: 'Clear selection',
),
),
],
),
),
);
}
}

View File

@ -0,0 +1,97 @@
/// Bottom sheet picking the repo selected notes are assigned to.
library;
import 'package:flutter/material.dart';
import 'package:todo/data/repo_assign.dart';
/// The repos already on notes, plus a field for a new `kuhyx/<name>`.
///
/// Pops with the chosen `owner/name`; dismissing it pops null. The field
/// starts at `kuhyx/` (content, not a label) and refuses anything that is
/// not `owner/name` with an error icon rather than words.
class RepoPickerSheet extends StatefulWidget {
/// Creates the picker listing [repos].
const RepoPickerSheet({required this.repos, super.key});
/// Distinct repos already present on notes, in display order.
final List<String> repos;
@override
State<RepoPickerSheet> createState() => _RepoPickerSheetState();
}
class _RepoPickerSheetState extends State<RepoPickerSheet> {
final _field = TextEditingController(text: 'kuhyx/');
bool _bad = false;
@override
void dispose() {
_field.dispose();
super.dispose();
}
void _submit() {
final value = _field.text.trim();
if (!isRepoName(value)) return setState(() => _bad = true);
Navigator.of(context).pop(value);
}
@override
Widget build(BuildContext context) {
return Padding(
key: const ValueKey('repo-picker'),
padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom),
child: SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: ListView(
shrinkWrap: true,
children: [
for (final repo in widget.repos)
ListTile(
key: ValueKey('repo-$repo'),
leading: const Icon(Icons.folder_outlined),
title: Text(repo),
onTap: () => Navigator.of(context).pop(repo),
),
],
),
),
Padding(
padding: const EdgeInsets.all(12),
child: TextField(
key: const ValueKey('repo-new'),
controller: _field,
textInputAction: TextInputAction.done,
onSubmitted: (_) => _submit(),
onChanged: (_) {
if (_bad) setState(() => _bad = false);
},
decoration: InputDecoration(
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.create_new_folder_outlined),
error: _bad
? const Icon(
Icons.error_outline,
size: 16,
semanticLabel: 'Not owner/name',
)
: null,
suffixIcon: IconButton(
key: const ValueKey('repo-new-submit'),
onPressed: _submit,
icon: const Icon(Icons.check, semanticLabel: 'Use repo'),
),
),
),
),
],
),
),
);
}
}

View File

@ -10,6 +10,12 @@ The server is one more sync node: it reuses `lib/cli` (`CliSession`,
`todo-sync/notes/<node id>.json` plus `todo-sync/revs/<node id>`.
Code: `lib/mcp/`, entrypoint `mcp/todo_mcp.dart`, tests `test/mcp/`.
Output shapes: `list_tasks` and `search_tasks` print one tab-separated line per
task (`id8 status priority repo title created_by`); `get_task` prints
`key: value` lines (including `created_by`) then `---` and the text.
`created_by` is `claude` for notes filed through `add_task` or the CLI and
`kuhy` for everything else (a missing value reads as `kuhy`).
| What | Where |
|---|---|
| Firebase seed (read once) | `~/.config/todo-mcp/firebase_auth.json` |

View File

@ -5,10 +5,10 @@ packages:
dependency: transitive
description:
name: android_file_picker
sha256: "868183c959e754e915954d8074c20cfa4cfdb4684b35ecc08378437be1a542d8"
sha256: "04c1306eda42c23ecbf449c909f4fa05c5e66fc48cf5f994d8d08b9d45e5396b"
url: "https://pub.dev"
source: hosted
version: "2.1.3"
version: "2.1.5"
archive:
dependency: transitive
description:
@ -109,11 +109,11 @@ packages:
dependency: "direct main"
description:
path: crdt_sync_dart
ref: "crdt_sync_dart-v0.12.0"
resolved-ref: "59b33949e4a130bb76c77e7434ddf4bbf8cde3a3"
ref: "crdt_sync_dart-v0.12.1"
resolved-ref: "6641d85f0550ca60433fc8e0d13a3dafdbb664d6"
url: "https://github.com/kuhyx/utils"
source: git
version: "0.12.0"
version: "0.12.1"
cross_file:
dependency: transitive
description:
@ -142,11 +142,11 @@ packages:
dependency: transitive
description:
path: data_transfer_flutter
ref: "data_transfer_flutter-v0.2.1"
resolved-ref: d4fca83c9f2ed33b3315cd838b9ca532ae8e559c
ref: "data_transfer_flutter-v0.2.2"
resolved-ref: eef7af4c86837ee82891493587ff3456297e5dea
url: "https://github.com/kuhyx/utils"
source: git
version: "0.2.1"
version: "0.2.2"
dbus:
dependency: transitive
description:
@ -208,10 +208,10 @@ packages:
dependency: "direct main"
description:
name: file_picker
sha256: c64036857780d7125f011d543f89eedcb5df61da473668bace4eee9ab875284d
sha256: "079dc46b753abbddb7f77cd87e6ed9ecc62902580707514568321bdf8e050a86"
url: "https://pub.dev"
source: hosted
version: "13.3.2"
version: "13.3.3"
file_picker_darwin:
dependency: transitive
description:
@ -224,10 +224,10 @@ packages:
dependency: transitive
description:
name: file_picker_linux
sha256: c6562fc7d426db2fb85ea66fe2627953d2709932df5584ceaacf0aa5e39cf4da
sha256: "9215ee8c672508cdd9a325202669f849e1baac883658626b3abed5d7777e6a4b"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
version: "2.0.4"
file_picker_platform_interface:
dependency: transitive
description:
@ -240,10 +240,10 @@ packages:
dependency: transitive
description:
name: file_picker_web
sha256: a2288008280aabe371aff029466b3405e60838e0a1d714767488c59b926c8b8c
sha256: "50749a6da2984fc29609e102ad4d80fd1d07e6895150f8e4922086167d9d45d8"
url: "https://pub.dev"
source: hosted
version: "4.2.0"
version: "4.2.1"
file_selector:
dependency: "direct main"
description:
@ -449,10 +449,10 @@ packages:
dependency: "direct main"
description:
name: idb_shim
sha256: "60965b556624f7018ec26205283e32670bc78c6fe1388d4b90bdfe6b2311b87b"
sha256: "507d8f3154fa4e41165941c5aa8509c6bf155bb30f76ba25a00d1e7ba1dd64c9"
url: "https://pub.dev"
source: hosted
version: "2.11.2"
version: "2.11.3"
image:
dependency: "direct main"
description:
@ -833,10 +833,10 @@ packages:
dependency: transitive
description:
name: sembast
sha256: "35775a26edbdf0d891bab304929a712e103c9d9a6e2fb5ebbd8437bb3139615a"
sha256: "4f71f8c95105918fa207065429c913f38d0fb05fec9f3a745bbf4c113f5d482b"
url: "https://pub.dev"
source: hosted
version: "3.10.0"
version: "3.10.1"
share_plus:
dependency: "direct main"
description:
@ -934,10 +934,10 @@ packages:
dependency: "direct main"
description:
name: sqflite_common_ffi
sha256: "119d730460264a1f11f414d9eaa43fb686789357e81ce35f9e8ccb66ddd040e4"
sha256: "26911d808e4d33b3965da8821440ef4b021d3bc7282f5eb76e6621827995dd86"
url: "https://pub.dev"
source: hosted
version: "2.4.5"
version: "2.4.6"
sqflite_common_ffi_web:
dependency: transitive
description:
@ -1006,11 +1006,11 @@ packages:
dependency: "direct main"
description:
path: sync_settings_ui
ref: "sync_settings_ui-v0.6.1"
resolved-ref: ebedbf4b27fa332f7b8e6d80117d1c409bf3a7ad
ref: "sync_settings_ui-v0.6.3"
resolved-ref: eef7af4c86837ee82891493587ff3456297e5dea
url: "https://github.com/kuhyx/utils"
source: git
version: "0.6.1"
version: "0.6.3"
synchronized:
dependency: transitive
description:
@ -1159,10 +1159,10 @@ packages:
dependency: transitive
description:
name: windows_file_picker
sha256: "18452aa293de3b6961c6d05b0e71b4be7bb90c67635abdd3aa5c4e75dd875072"
sha256: cd1be37ada0abcfaa55bddb7410e8515844e35cd77669c35d9a28a15722299d8
url: "https://pub.dev"
source: hosted
version: "2.0.1"
version: "2.0.2"
xdg_directories:
dependency: transitive
description:

View File

@ -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.62+1
version: 1.0.72+1
environment:
sdk: ^3.12.2
@ -33,7 +33,7 @@ dependencies:
crdt_sync:
git:
url: https://github.com/kuhyx/utils
ref: crdt_sync_dart-v0.12.0
ref: crdt_sync_dart-v0.12.1
path: crdt_sync_dart
# The following adds the Cupertino Icons font to your application.
@ -47,7 +47,7 @@ dependencies:
url: https://github.com/kuhyx/utils
ref: design_system-v0.3.1
path: design_system
file_picker: 13.3.2
file_picker: 13.3.3
file_selector: 1.1.0
flutter:
sdk: flutter
@ -69,7 +69,7 @@ dependencies:
ref: google_account_token-v0.1.0
path: google_account_token
http: 1.6.0
idb_shim: 2.11.2
idb_shim: 2.11.3
# Re-encodes attached images (resize, EXIF strip) in pure Dart, so the
# phone and the desktop wrapper share one code path.
image: 4.12.0
@ -85,7 +85,7 @@ dependencies:
path_provider: 2.1.6
share_plus: 13.3.1
shared_preferences: 2.5.6
sqflite_common_ffi: 2.4.5
sqflite_common_ffi: 2.4.6
# Retained only to read the legacy on-device DB during the one-time
# migration in NoteRepository.open; remove once all devices are migrated.
sqlite_crdt: 3.0.4
@ -97,7 +97,7 @@ dependencies:
sync_settings_ui:
git:
url: https://github.com/kuhyx/utils
ref: sync_settings_ui-v0.6.1
ref: sync_settings_ui-v0.6.3
path: sync_settings_ui
url_launcher: 6.3.3
uuid: 4.6.0

View File

@ -0,0 +1,21 @@
// A note typed into the app is stamped created_by kuhy.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'capture_screen_harness.dart';
void main() {
testWidgets('a captured note is stamped kuhy and keeps it on edit', (
tester,
) async {
final repo = await pumpCapture(tester);
await tester.enterText(find.byType(TextField).first, 'First line');
await tester.pump();
await tester.enterText(find.byType(TextField).first, 'First line, more');
await tester.pump();
final saved = (await repo.listNotes()).single;
expect(saved.text, 'First line, more');
expect(saved.createdBy, 'kuhy');
});
}

View File

@ -0,0 +1,85 @@
// created_by through the CLI: stamping, output, and the backfill command.
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'cli_harness.dart';
void main() {
late CliHarness h;
setUp(() => h = CliHarness());
tearDown(() => h.dispose());
test('a CLI add is stamped claude, repo task or idea', () async {
final idea = await h.add('Plain idea');
final task = await h.add('Fix gate', ['--repo', 'kuhyx/todo']);
for (final id in [idea, task]) {
expect(await h.run(['--get', id, '--data-dir', h.dir('b')]), 0);
expect(h.out.toString(), contains('created_by: claude'));
}
expect(await h.run(['--list']), 0);
expect(
h.out.toString().trim().split('\n'),
everyElement(endsWith('\tclaude')),
);
});
test('a --new-note add is stamped claude', () async {
final id = await h.add('Own note', ['--repo', 'kuhyx/todo', '--new-note']);
expect(await h.run(['--get', id, '--data-dir', h.dir('b')]), 0);
expect(h.out.toString(), contains('created_by: claude'));
});
test('a peer note without created_by reads as kuhy', () async {
h.seedPeer([fixtureNote('peer-note-1', 'From the phone')]);
expect(await h.run(['--get', 'peer-note-1']), 0);
expect(h.out.toString(), contains('created_by: kuhy'));
expect(await h.run(['--list']), 0);
expect(h.out.toString(), endsWith('From the phone\tkuhy\n'));
});
group('--backfill-created-by', () {
void seed() {
final mcp = Directory('${h.env['XDG_DATA_HOME']}/todo-mcp')
..createSync(recursive: true);
File('${mcp.path}/node_id').writeAsStringSync('mcp-node\n');
h.seedPeer([
fixtureNote('mcp-note-0001', 'Make a text-free', repo: 'kuhyx/a'),
], node: 'mcp-node');
h.seedPeer([fixtureNote('idea-note-0001', 'My idea')], node: 'phone');
}
test('--dry-run lists the plan and writes nothing', () async {
seed();
expect(await h.run(['--backfill-created-by', '--dry-run']), 0);
final lines = h.out.toString().trim().split('\n');
expect(lines, contains('mcp-note\tclaude\tMake a text-free'));
expect(lines, contains('idea-not\tkuhy\tMy idea'));
expect(lines.last, 'dry run: claude 1, kuhy 1');
expect(h.db.methods, isNot(contains('PUT')), reason: 'nothing pushed');
});
test('a real run sets it once, pushes it, and is then a no-op', () async {
seed();
expect(await h.run(['--backfill-created-by']), 0);
expect(h.out.toString().trim().split('\n').last, 'set: claude 1, kuhy 1');
expect(
await h.run(['--get', 'mcp-note-0001', '--data-dir', h.dir('b')]),
0,
);
expect(h.out.toString(), contains('created_by: claude'));
expect(
await h.run(['--get', 'idea-note-0001', '--data-dir', h.dir('b')]),
0,
);
expect(h.out.toString(), contains('created_by: kuhy'));
expect(await h.run(['--backfill-created-by']), 0);
expect(h.out.toString().trim(), 'set: claude 0, kuhy 0');
});
test('--dry-run is refused without the backfill flag', () async {
expect(await h.run(['--dry-run']), 2);
expect(h.err.toString(), contains('--dry-run only applies'));
});
});
}

View File

@ -32,7 +32,11 @@ class CliHarness {
String dir(String name) => '${tmp.path}/$name';
Future<int> run(List<String> argv, {SessionOpener? opener}) async {
Future<int> run(
List<String> argv, {
SessionOpener? opener,
String stdin = '',
}) async {
out.clear();
err.clear();
return runCli(
@ -40,7 +44,7 @@ class CliHarness {
env: env,
out: out,
err: err,
readStdin: () async => '',
readStdin: () async => stdin,
open:
opener ?? (d, {required seedCredentials}) => open(d, seedCredentials),
);
@ -60,10 +64,10 @@ class CliHarness {
return out.toString().trim();
}
/// Publishes [notes] as another device's log.
void seedPeer(List<Note> notes) {
db.files['todo-sync/notes/peer~2Ejson'] = logToJson({
for (final n in notes) n.id: NoteRepository.recordAtNoteTime(n, 'peer'),
/// Publishes [notes] as device [node]'s log.
void seedPeer(List<Note> notes, {String node = 'peer'}) {
db.files['todo-sync/notes/$node~2Ejson'] = logToJson({
for (final n in notes) n.id: NoteRepository.recordAtNoteTime(n, node),
});
}
}

View File

@ -121,7 +121,7 @@ void main() {
expect(await run(['--list', '--data-dir', dir('b')]), 0);
expect(
out.toString().trim(),
'${id.substring(0, 8)}\tin_progress\thigh\t-\tTitle',
'${id.substring(0, 8)}\tin_progress\thigh\t-\tTitle\tclaude',
);
expect(await run(['--get', id, '--data-dir', dir('b')]), 0);

View File

@ -0,0 +1,83 @@
// --set-text: replace a note's whole text, keeping every other field.
import 'package:flutter_test/flutter_test.dart';
import 'package:todo/cli/cli_args.dart';
import 'package:todo/cli/cli_mode.dart';
import 'package:todo/cli/cli_session.dart';
import 'cli_harness.dart';
import 'scripted_session.dart';
void main() {
late CliHarness h;
setUp(() => h = CliHarness());
tearDown(() => h.dispose());
test('--set-text replaces the text and keeps repo and status', () async {
final id = await h.add('Old title\nold body', ['--repo', 'kuhyx/todo']);
expect(await h.run(['--set-status', id, 'in_progress']), 0);
expect(
await h.run([
'--set-text',
id.substring(0, 8),
'--text',
'New title\nnew body',
'--data-dir',
h.dir('a'),
]),
0,
);
expect(h.out.toString().trim(), '$id\ttext');
expect(await h.run(['--get', id, '--data-dir', h.dir('b')]), 0);
expect(
h.out.toString(),
allOf(
contains('New title\nnew body'),
isNot(contains('old body')),
contains('repo: kuhyx/todo'),
contains('in_progress'),
),
);
});
test('--set-text reads stdin when --text is absent', () async {
final id = await h.add('Old');
expect(await h.run(['--set-text', id], stdin: 'From stdin'), 0);
expect(await h.run(['--get', id, '--data-dir', h.dir('b')]), 0);
expect(h.out.toString(), contains('From stdin'));
});
test('--set-text refuses empty text and changes nothing', () async {
final id = await h.add('Keep me');
expect(await h.run(['--set-text', id, '--text', ' ']), 2);
expect(h.err.toString(), contains('empty note'));
expect(await h.run(['--get', id]), 0);
expect(h.out.toString(), contains('Keep me'));
});
test('--set-text on a missing id exits 1', () async {
expect(await h.run(['--set-text', 'ffffffff', '--text', 'x']), 1);
expect(h.err.toString(), contains('no live note with id ffffffff'));
});
test('--set-text fails when the push reports nothing published', () async {
Future<CliSession> opener(String d, {required String seedCredentials}) =>
ScriptedSession.create(h.db.client, [true, false], noteId: 'stuck');
expect(
await h.run(['--set-text', 'stuck', '--text', 'x'], opener: opener),
1,
);
expect(h.err.toString(), contains('text change saved locally'));
});
test('parse: --set-text takes an id and allows --text', () {
final args = CliArgs.parse(['--set-text', 'abc', '--text', 'T']);
expect(args.mode, CliMode.setText);
expect(args.id, 'abc');
expect(args.text, 'T');
expect(() => CliArgs.parse(['--set-text']), throwsA(isA<UsageError>()));
expect(
() => CliArgs.parse(['--set-text', 'a', '--repo', 'kuhyx/todo']),
throwsA(isA<UsageError>()),
);
});
}

View File

@ -41,7 +41,7 @@ void main() {
expect(await h.run(['--list', '--repo', 'kuhyx/todo']), 0);
expect(
h.out.toString(),
'${todo.substring(0, 8)}\ttodo\tmedium\tkuhyx/todo\ttodo todos\n',
'${todo.substring(0, 8)}\ttodo\tmedium\tkuhyx/todo\ttodo todos\tclaude\n',
);
expect(await h.run(['--list', '--status', 'done']), 0);
expect(h.out.toString(), isEmpty);

View File

@ -65,12 +65,15 @@ void main() {
);
});
test('listLine: 8-char id, status, priority, repo, title', () {
test('listLine: 8-char id, status, priority, repo, title, author', () {
expect(
listLine(notes[2].copyWith(status: Status.inProgress)),
'12345678\tin_progress\tmedium\t-\tFirst',
'12345678\tin_progress\tmedium\t-\tFirst\tkuhy',
);
expect(
listLine(notes[4]),
'99999999\ttodo\tmedium\tkuhyx/todo\tOnly\tkuhy',
);
expect(listLine(notes[4]), '99999999\ttodo\tmedium\tkuhyx/todo\tOnly');
expect(listLine(fixtureNote('abc', 'Short')), startsWith('abc\t'));
});
}

View File

@ -29,6 +29,11 @@ class FakeNoteRepository implements NoteRepository {
NoteSort? lastSort;
NoteFilter? lastFilter;
/// Calls to [upsert] and to the one-write [bulkUpdate], so a bulk action
/// can be shown to be one call rather than one write per note.
int upsertCalls = 0;
int bulkCalls = 0;
/// Emits the current snapshot to a new subscriber first (so late-binding
/// [StreamBuilder]s get the seed), then forwards subsequent changes.
Stream<List<Note>> _snapshots() async* {
@ -46,12 +51,37 @@ class FakeNoteRepository implements NoteRepository {
@override
Future<void> upsert(Note note) async {
upsertCalls++;
_notes
..removeWhere((n) => n.id == note.id)
..add(note);
_emit();
}
@override
Future<void> bulkUpdate(
Set<String> ids, {
Priority? priority,
Status? status,
}) async {
bulkCalls++;
for (final (i, n) in _notes.indexed.toList()) {
if (ids.contains(n.id)) {
_notes[i] = n.copyWith(priority: priority, status: status);
}
}
_emit();
}
@override
Future<void> saveReplacing(Note note, Iterable<String> removed) async {
final gone = {...removed, note.id};
_notes
..removeWhere((n) => gone.contains(n.id))
..add(note);
_emit();
}
@override
Future<void> delete(String id) async {
_notes.removeWhere((n) => n.id == id);
@ -83,7 +113,9 @@ class FakeNoteRepository implements NoteRepository {
Future<List<Note>> listNotes({
NoteSort sort = NoteSort.modifiedDesc,
NoteFilter filter = const NoteFilter(),
}) async => List.unmodifiable(_notes);
}) async => List.unmodifiable(
applyFilter ? _notes.where((n) => matchesFilter(n, filter)) : _notes,
);
@override
Stream<List<Note>> watchNotes({
@ -104,6 +136,12 @@ class FakeNoteRepository implements NoteRepository {
@override
Stream<int> watchCount() => _snapshots().map((n) => n.length);
@override
Future<Map<String, String>> backfillCreatedBy(
Set<String> claudeNodes, {
bool dryRun = false,
}) async => {};
@override
String get nodeId => 'fake-node';

83
test/log_watch_test.dart Normal file
View File

@ -0,0 +1,83 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:todo/data/log_watch.dart';
void main() {
late StreamController<void> changes;
late int computes;
setUp(() {
changes = StreamController<void>.broadcast(sync: true);
computes = 0;
});
tearDown(() => changes.close());
Stream<int> watch() => watchLog(changes.stream, () => ++computes);
test('the seed is emitted at once on listen', () async {
final seen = <int>[];
final sub = watch().listen(seen.add);
expect(computes, 1, reason: 'seeded inside listen, not on a timer');
await pumpEventQueue();
expect(seen, [1]);
await sub.cancel();
});
test(
'a burst of 5 changes yields one recompute carrying the latest',
() async {
final seen = <int>[];
final sub = watch().listen(seen.add);
for (var i = 0; i < 5; i++) {
changes.add(null);
}
expect(computes, 1, reason: 'nothing recomputed inside the burst');
await pumpEventQueue();
await sub.cancel();
expect(computes, 2);
expect(seen, [1, 2]);
},
);
test('changes after a flush start a new coalescing window', () async {
final seen = <int>[];
final sub = watch().listen(seen.add);
changes.add(null);
await pumpEventQueue();
changes.add(null);
changes.add(null);
await pumpEventQueue();
await sub.cancel();
expect(seen, [1, 2, 3]);
});
test(
'cancel drops a pending recompute and the change subscription',
() async {
final sub = watch().listen((_) {});
changes.add(null);
await sub.cancel();
await pumpEventQueue();
expect(computes, 1);
expect(changes.hasListener, isFalse);
},
);
test('each listener gets its own stream state', () async {
final a = <int>[];
final b = <int>[];
final subA = watch().listen(a.add);
final subB = watch().listen(b.add);
changes.add(null);
await pumpEventQueue();
await subA.cancel();
await subB.cancel();
expect(a.length, 2);
expect(b.length, 2);
});
}

View File

@ -32,13 +32,26 @@ class _Board {
String startBody = '{"ok":false,"error":{"message":"nope"}}';
String page = '<meta name="todoimpl-csrf" content="tok-1">';
BoardClient client() => BoardClient(
/// Board reads so far; [onRead] may change [cards] between reads.
int reads = 0;
void Function(int read)? onRead;
Duration postDelay = Duration.zero;
BoardClient client({
Duration wait = Duration.zero,
Duration startTimeout = const Duration(seconds: 5),
}) => BoardClient(
wait: wait,
poll: const Duration(milliseconds: 5),
startTimeout: startTimeout,
client: MockClient((req) async {
if (req.method == 'POST') {
posts.add(req);
await Future<void>.delayed(postDelay);
return http.Response(startBody, startStatus);
}
if (req.url.path == '/api/board') {
onRead?.call(++reads);
return http.Response.bytes(
utf8.encode(jsonEncode({'tasks': cards})),
boardStatus,
@ -124,6 +137,78 @@ void main() {
);
});
test('T1863: waits for the board to see a freshly merged section', () async {
final b = _Board([_card('cccccccc-0', 'Młody')]);
b.onRead = (n) {
if (n == 3) b.cards.add(_card('cccccccc-1', 'Młody'));
};
final r = await b
.client(wait: const Duration(seconds: 2))
.start(_id, 'Młody', sections: 2);
expect(r, (ok: true, reason: null, item: 'cccccccc-1'));
});
test('T1863: a board that never shows the section gives up', () async {
final b = _Board([_card('cccccccc-0', 'Młody')]);
final r = await b
.client(wait: const Duration(milliseconds: 50))
.start(_id, 'Młody', sections: 2);
expect(r.ok, isFalse);
expect(r.reason, 'no startable item');
expect(b.posts, isEmpty);
});
test('T1866: starts the merged item, not a re-startable older one', () async {
// The board lists by column: a cancelled older item may come last.
final b = _Board([
_card('cccccccc-0', 'Młody'),
_card('cccccccc-2', 'Młody'),
_card('cccccccc-1', 'Młody', column: 'done'),
]);
final r = await b.client().start(_id, 'Młody', sections: 3);
expect(r, (ok: true, reason: null, item: 'cccccccc-2'));
final other = _Board([
_card('cccccccc-1', 'Młody', column: 'done'),
_card('cccccccc-0', 'Młody', start: false),
]);
final none = await other.client().start(_id, 'Młody', sections: 1);
expect(none.reason, 'no startable item');
expect(other.posts, isEmpty);
});
test('T1864: a slow Start that took effect is ok, not unreachable', () async {
final b = _Board([_card('cccccccc-0', 'Młody')])
..postDelay = const Duration(milliseconds: 200);
b.onRead = (n) {
// After the POST was sent the card has left backlog (no Start action).
if (b.posts.isNotEmpty) {
b.cards[0] = _card(
'cccccccc-0',
'Młody',
column: 'running',
start: false,
);
}
};
final r = await b
.client(startTimeout: const Duration(milliseconds: 50))
.start(_id, 'Młody');
expect(r, (ok: true, reason: null, item: 'cccccccc-0'));
});
test(
'T1864: a slow Start that did not take effect is still a reason',
() async {
final b = _Board([_card('cccccccc-0', 'Młody')])
..postDelay = const Duration(milliseconds: 200);
final r = await b
.client(startTimeout: const Duration(milliseconds: 50))
.start(_id, 'Młody');
expect(r.ok, isFalse);
expect(r.reason, startsWith('board unreachable'));
},
);
test('the default client targets the loopback board', () {
expect(BoardClient().base.toString(), 'http://127.0.0.1:8786');
});

View File

@ -0,0 +1,108 @@
// T1865: the real board learns about notes by calling this MCP server, so a
// merged add_task must not hold the server while it waits for the board.
import 'dart:async';
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:mcp_dart/mcp_dart.dart';
import 'package:todo/mcp/board_client.dart';
import 'mcp_harness.dart';
void main() {
late McpHarness h;
late McpClient c;
late McpClient boardSide;
String? note;
var cards = <Map<String, dynamic>>[];
final posts = <String>[];
// A board whose every read refreshes its cards from this MCP server, as
// the real one does; a refresh that cannot get through keeps stale cards.
Future<List<Map<String, dynamic>>> refresh() async {
final id = note;
if (id == null) return cards;
try {
final text = await h
.call(boardSide, 'get_task', {'id': id})
.timeout(const Duration(milliseconds: 300));
final n = '\n$text'.split('\n## ').length - 1;
cards = [
for (var i = 0; i < n; i++)
{
'id': '${id.substring(0, 8)}-$i',
'title': 'Młody',
'column': 'backlog',
'actions': [
{'id': 'start', 'enabled': true},
],
},
];
} on TimeoutException {
// Stale, like the real cache when the server does not answer.
}
return cards;
}
final board = BoardClient(
wait: const Duration(seconds: 2),
poll: const Duration(milliseconds: 20),
client: MockClient((req) async {
if (req.method == 'POST') {
posts.add(req.url.path);
return http.Response('{"ok":true}', 200);
}
if (req.url.path == '/api/board') {
return http.Response.bytes(
utf8.encode(jsonEncode({'tasks': await refresh()})),
200,
);
}
return http.Response('<meta name="todoimpl-csrf" content="t">', 200);
}),
);
setUp(() async {
note = null;
cards = [];
posts.clear();
h = McpHarness(board: board);
await h.start();
c = await h.connect();
boardSide = await h.connect();
});
tearDown(() => h.dispose());
test('T1865: Start after a merge sees the board refreshed', () async {
final args = {
'repo': 'kuhyx/herosi',
'title': 'Młody',
'body': 'what',
'new_note': true,
'done_check': 'true',
};
note = idIn(await h.call(c, 'add_task', args));
final r = await h.call(c, 'add_task', args);
expect(r, startsWith('merged into $note'));
expect(r, contains('start: ok ${note!.substring(0, 8)}-1'));
expect(posts, ['/api/task/${note!.substring(0, 8)}-1/start']);
});
test('a merge with no board configured says click Start', () async {
final bare = McpHarness();
await bare.start();
addTearDown(bare.dispose);
final client = await bare.connect();
final args = {'repo': 'kuhyx/herosi', 'title': 'A', 'new_note': true};
final x = idIn(await bare.call(client, 'add_task', args));
final r = await bare.call(client, 'add_task', {
...args,
'done_check': 'true',
});
expect(r, startsWith('merged into $x'));
expect(r, contains('reason: "no board configured"'));
expect(r, contains('click Start on the board by hand'));
});
}

View File

@ -0,0 +1,88 @@
// add_task stamps created_by claude; every read tool reports it.
import 'package:flutter_test/flutter_test.dart';
import 'package:mcp_dart/mcp_dart.dart';
import 'package:todo/data/note.dart';
import 'mcp_harness.dart';
void main() {
late McpHarness h;
late McpClient c;
setUp(() async {
h = McpHarness();
await h.start();
c = await h.connect();
});
tearDown(() => h.dispose());
test('add_task is stamped claude and list/get/search show it', () async {
final added = await h.call(c, 'add_task', {
'repo': 'kuhyx/todo',
'title': 'Fix gate',
});
final id = idIn(added);
final stored = (await h.session.repository.listNotes()).single;
expect(stored.createdBy, 'claude');
expect(
await h.call(c, 'get_task', {'id': id}),
contains('created_by: claude'),
);
for (final listed in [
await h.call(c, 'list_tasks', {}),
await h.call(c, 'search_tasks', {'query': 'gate'}),
]) {
expect(listed.trim(), endsWith('\tclaude'));
}
});
test('a separate new_note task is stamped claude too', () async {
await h.call(c, 'add_task', {
'repo': 'kuhyx/todo',
'title': 'Own note',
'new_note': true,
});
expect((await h.session.repository.listNotes()).single.createdBy, 'claude');
});
test('a record without created_by reads as kuhy', () async {
final at = DateTime.utc(2026, 11);
await h.session.repository.upsert(
Note(
id: 'human-task-0001',
text: 'Human task',
priority: Priority.medium,
status: Status.todo,
createdAt: at,
updatedAt: at,
repo: 'kuhyx/todo',
),
);
expect(
await h.call(c, 'get_task', {'id': 'human-task-0001'}),
contains('created_by: kuhy'),
);
expect(
(await h.call(c, 'list_tasks', {})).trim(),
endsWith('Human task\tkuhy'),
);
});
test('joining a human-made repo note keeps its author', () async {
final at = DateTime.utc(2026, 11);
await h.session.repository.upsert(
Note(
id: 'human-task-0001',
text: 'todo todos',
priority: Priority.medium,
status: Status.todo,
createdAt: at,
updatedAt: at,
repo: 'kuhyx/todo',
createdBy: 'kuhy',
),
);
await h.call(c, 'add_task', {'repo': 'kuhyx/todo', 'title': 'More'});
expect((await h.session.repository.listNotes()).single.createdBy, 'kuhy');
});
}

View File

@ -6,6 +6,7 @@ import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:mcp_dart/mcp_dart.dart';
import 'package:todo/mcp/board_client.dart';
import 'package:todo/mcp/task_service.dart';
import 'mcp_harness.dart';
@ -15,9 +16,14 @@ void main() {
late McpHarness h;
late McpClient c;
var cards = <Map<String, dynamic>>[];
// Cards the board shows only from its next read on (its note cache lags).
var late_ = <Map<String, dynamic>>[];
var lag = 0; // board reads before [late_] shows
var up = true;
final posts = <String>[];
final board = BoardClient(
wait: const Duration(seconds: 2),
poll: const Duration(milliseconds: 5),
client: MockClient((req) async {
if (!up) throw http.ClientException('connection refused');
if (req.method == 'POST') {
@ -25,17 +31,26 @@ void main() {
return http.Response('{"ok":true}', 200);
}
if (req.url.path == '/api/board') {
if (lag-- == 0) {
cards = [...cards, ...late_];
late_ = [];
}
final shown = [...cards];
return http.Response.bytes(
utf8.encode(jsonEncode({'tasks': cards})),
utf8.encode(jsonEncode({'tasks': shown})),
200,
);
}
return http.Response('<meta name="todoimpl-csrf" content="t">', 200);
}),
);
List<Map<String, dynamic>> cardsOf(String id, String column) => [
List<Map<String, dynamic>> cardsOf(
String id,
String column, [
int index = 0,
]) => [
{
'id': '${id.substring(0, 8)}-0',
'id': '${id.substring(0, 8)}-$index',
'title': 'Młody',
'column': column,
'actions': [
@ -46,6 +61,8 @@ void main() {
setUp(() async {
cards = [];
late_ = [];
lag = 0;
up = true;
posts.clear();
h = McpHarness(board: board);
@ -78,16 +95,22 @@ void main() {
expect(posts, isEmpty);
});
test('C3/C6: a duplicate merges and Start is POSTed', () async {
final x = idIn(await add({'done_check': 'true'}));
cards = cardsOf(x, 'backlog');
final r = await add({'done_check': 'true'});
expect(r, startsWith('merged into $x'));
expect(r, contains('start: ok ${x.substring(0, 8)}-0'));
expect(posts, ['/api/task/${x.substring(0, 8)}-0/start']);
final listed = await h.call(c, 'list_tasks', {'repo': _repo});
expect(listed.trim().split('\n'), hasLength(1));
});
test(
'C3/C6/T1863: a duplicate merges, Start waits for the new item',
() async {
final x = idIn(await add({'done_check': 'true'}));
cards = cardsOf(x, 'backlog');
// The merged section shows up one read later than the merge itself.
late_ = cardsOf(x, 'backlog', 1);
lag = 2; // past the guard's running read and the first Start read
final r = await add({'done_check': 'true'});
expect(r, startsWith('merged into $x'));
expect(r, contains('start: ok ${x.substring(0, 8)}-1'));
expect(posts, ['/api/task/${x.substring(0, 8)}-1/start']);
final listed = await h.call(c, 'list_tasks', {'repo': _repo});
expect(listed.trim().split('\n'), hasLength(1));
},
);
test('C6: done_check none or low priority merges without Start', () async {
final x = idIn(await add({}));
@ -120,6 +143,22 @@ void main() {
expect(r, contains('click Start on the board by hand'));
});
test('a non-boolean new_note is refused by the service too', () {
// The MCP schema rejects it first; the service guards its own callers.
expect(
TaskService(
h.session,
).add({'repo': _repo, 'title': 'Młody', 'new_note': 'yes'}),
throwsA(
isA<FormatException>().having(
(e) => e.message,
'message',
'new_note: must be a boolean',
),
),
);
});
test('the add_task schema offers new_note', () async {
final tools = (await c.listTools()).tools;
final add = tools.singleWhere((t) => t.name == 'add_task');

View File

@ -13,7 +13,7 @@ double _barValue(WidgetTester tester) => tester
.widget<LinearProgressIndicator>(find.byType(LinearProgressIndicator))
.value!;
Future<void> _pushPage(WidgetTester tester) async {
Future<Route<void>> _pushPage(WidgetTester tester) async {
late BuildContext ctx;
await tester.pumpWidget(
MaterialApp(
@ -25,12 +25,10 @@ Future<void> _pushPage(WidgetTester tester) async {
),
),
);
unawaited(
Navigator.of(
ctx,
).push<void>(appRoute(ctx, (_) => const Scaffold(body: Text('page')))),
);
final route = appRoute<void>(ctx, (_) => const Scaffold(body: Text('page')));
unawaited(Navigator.of(ctx).push<void>(route));
await tester.pump();
return route;
}
void main() {
@ -46,22 +44,20 @@ void main() {
expect(offenders, isEmpty);
});
testWidgets('a pushed page eases in over AppDuration.slow', (tester) async {
await _pushPage(tester);
await tester.pump(AppDuration.slow ~/ 4);
testWidgets('a pushed page is instant: no fade or slide', (tester) async {
final route = await _pushPage(tester) as PageRoute<void>;
expect(route.transitionDuration, Duration.zero);
expect(route.reverseTransitionDuration, Duration.zero);
// Fully visible and covering the old page after a single pump.
expect(find.text('home'), findsNothing);
expect(find.text('page'), findsOneWidget);
final fade = tester.widget<FadeTransition>(
find
.ancestor(
of: find.text('page'),
matching: find.byType(FadeTransition),
)
.first,
final page = find.ancestor(
of: find.text('page'),
matching: find.byType(FadeTransition),
);
expect(fade.opacity.value, inExclusiveRange(0, 1));
await tester.pump(AppDuration.slow);
expect(find.text('home'), findsNothing); // fully covered, route settled
for (final f in tester.widgetList<FadeTransition>(page)) {
expect(f.opacity.value, 1);
}
});
testWidgets('remove-animations: the page is there at once', (tester) async {

View File

@ -0,0 +1,181 @@
// created_by: record mapping, merge safety against older builds, list order,
// the hide filter and backfill classification.
import 'package:crdt_sync/crdt_sync.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:todo/data/created_by_backfill.dart';
import 'package:todo/data/note.dart';
import 'package:todo/data/note_repository.dart';
Note _note(
String id, {
String? createdBy,
Priority priority = Priority.medium,
DateTime? updated,
}) {
final at = updated ?? DateTime.utc(2026, 11);
return Note(
id: id,
text: 'note $id',
priority: priority,
status: Status.todo,
createdAt: at,
updatedAt: at,
createdBy: createdBy,
);
}
/// What an older build writes: no created_by, replacing the record locally.
Map<String, Field> _oldFields(Note n, Hlc hlc) => {
'text': (n.text, hlc),
'priority': (n.priority.value, hlc),
'status': (n.status.value, hlc),
'created_at': (n.createdAt.toIso8601String(), hlc),
'updated_at': (n.updatedAt.toIso8601String(), hlc),
};
Log _overTheWire(Log log) => logFromJson(logToJson(log));
void main() {
group('record mapping', () {
test('created_by is written only when set and read back', () {
final hlc = Hlc(wallTimeMs: 1, counter: 0, nodeId: 'n');
expect(
noteFields(_note('a'), hlc).containsKey('created_by'),
isFalse,
reason: 'an unset field keeps the record wire-identical',
);
final fields = noteFields(_note('a', createdBy: 'claude'), hlc);
expect(fields['created_by']!.$1, 'claude');
final back = noteFromRecord(Record(id: 'a', fields: fields));
expect(back.createdBy, 'claude');
expect(back.isClaudeMade, isTrue);
expect(_note('b', createdBy: 'kuhy').isClaudeMade, isFalse);
expect(_note('c').isClaudeMade, isFalse);
});
test('copyWith keeps createdBy unless replaced', () {
final n = _note('a', createdBy: 'claude');
expect(n.copyWith(text: 'x').createdBy, 'claude');
expect(n.copyWith(createdBy: 'kuhy').createdBy, 'kuhy');
});
});
group('merge safety', () {
test(
'an older build upsert (no created_by) does not wipe a peer value',
() async {
final current = await NoteRepository.openInMemory(nodeId: 'new');
await current.upsert(_note('a', createdBy: 'claude'));
final old = LogStore(persistence: MemoryPersistence(), nodeId: 'old');
await old.load();
await old.replaceAll(
mergeLogs(old.snapshot(), _overTheWire(current.exportLog())),
);
final edited = _note('a', updated: DateTime.utc(2026, 11, 2));
await old.upsert(
Record(id: 'a', fields: _oldFields(edited, old.nextHlc())),
);
expect(old.snapshot()['a']!.fields.containsKey('created_by'), isFalse);
await current.importLog(_overTheWire(old.snapshot()));
expect((await current.listNotes()).single.createdBy, 'claude');
// Pulling the other way restores it on the older device too.
await old.replaceAll(
mergeLogs(old.snapshot(), _overTheWire(current.exportLog())),
);
expect(old.snapshot()['a']!.fields['created_by']!.$1, 'claude');
},
);
test('upserting a note without createdBy keeps the stored value', () async {
final repo = await NoteRepository.openInMemory();
await repo.upsert(_note('a', createdBy: 'claude'));
await repo.upsert(_note('a', updated: DateTime.utc(2026, 11, 3)));
expect((await repo.listNotes()).single.createdBy, 'claude');
await repo.importNotes([_note('a', updated: DateTime.utc(2027))]);
expect((await repo.listNotes()).single.createdBy, 'claude');
});
});
group('order and hiding', () {
final claudeHigh = _note('c', createdBy: 'claude', priority: Priority.high);
final humanLow = _note('h', createdBy: 'kuhy', priority: Priority.low);
final legacy = _note('l', priority: Priority.medium);
test(
'human notes come first under every sort, then the old order',
() async {
final repo = await NoteRepository.openInMemory();
for (final n in [claudeHigh, humanLow, legacy]) {
await repo.upsert(n);
}
for (final sort in NoteSort.values) {
final ids = [for (final n in await repo.listNotes(sort: sort)) n.id];
expect(ids.last, 'c', reason: '$sort');
}
final byPriority = await repo.listNotes();
expect([for (final n in byPriority) n.id], ['l', 'h', 'c']);
},
);
test('hideClaude drops Claude-made notes and is not a user facet', () {
const filter = NoteFilter(hideClaude: true);
expect(matchesFilter(claudeHigh, filter), isFalse);
expect(matchesFilter(humanLow, filter), isTrue);
expect(matchesFilter(legacy, filter), isTrue);
expect(matchesFilter(claudeHigh, const NoteFilter()), isTrue);
expect(filter.isEmpty, isTrue);
expect(filter.activeCount, 0);
expect(filter.copyWith(query: 'x').hideClaude, isTrue);
expect(filter.copyWith(hideClaude: false).hideClaude, isFalse);
});
});
group('backfill', () {
Record rec(String id, String node, {String? createdBy, bool dead = false}) {
final hlc = Hlc(wallTimeMs: 5, counter: 0, nodeId: node);
return Record(
id: id,
fields: {...noteFields(_note(id, createdBy: createdBy), hlc)},
deleted: dead,
deletedHlc: dead ? hlc : null,
);
}
test('classifies by the node that wrote created_at', () {
const claude = {'mcp', 'cli'};
expect(classifyCreatedBy(rec('a', 'mcp'), claude), 'claude');
expect(classifyCreatedBy(rec('a', 'cli'), claude), 'claude');
expect(classifyCreatedBy(rec('a', 'phone'), claude), 'kuhy');
const bare = Record(id: 'x', fields: {});
expect(classifyCreatedBy(bare, claude), 'kuhy');
});
test('writes only unset, live records, and only that field', () async {
final repo = await NoteRepository.openInMemory(nodeId: 'cli');
await repo.importLog({
'a': rec('a', 'mcp'),
'b': rec('b', 'phone'),
'c': rec('c', 'phone', createdBy: 'claude'),
'd': rec('d', 'mcp', dead: true),
});
const claude = {'mcp', 'cli'};
final dry = await repo.backfillCreatedBy(claude, dryRun: true);
expect(dry, {'a': 'claude', 'b': 'kuhy'});
expect(repo.exportLog()['a']!.fields.containsKey('created_by'), isFalse);
final done = await repo.backfillCreatedBy(claude);
expect(done, dry);
final log = repo.exportLog();
expect(log['a']!.fields['created_by']!.$1, 'claude');
expect(log['b']!.fields['created_by']!.$1, 'kuhy');
expect(log['c']!.fields['created_by']!.$1, 'claude');
expect(log['d']!.fields.containsKey('created_by'), isFalse);
// created_at keeps its original clock: the signal is not destroyed.
expect(log['b']!.fields['created_at']!.$2.nodeId, 'phone');
expect(await repo.backfillCreatedBy(claude), isEmpty);
});
});
}

View File

@ -0,0 +1,139 @@
/// Bulk status/priority and save-replacing: one store write each.
library;
import 'package:crdt_sync/crdt_sync.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:todo/data/note.dart';
import 'package:todo/data/note_repository.dart';
import 'note_repository_helpers.dart';
/// Counts log writes, so "one write" is measured, not assumed.
class _CountingPersistence implements LogPersistence {
final _inner = MemoryPersistence();
int writes = 0;
@override
Future<String?> read() => _inner.read();
@override
Future<void> write(String text) {
writes++;
return _inner.write(text);
}
}
Future<(NoteRepository, _CountingPersistence)> _open() async {
final p = _CountingPersistence();
final repo = await NoteRepository.openWith(persistence: p, nodeId: 'dev');
addTearDown(repo.close);
return (repo, p);
}
Hlc _clock(NoteRepository repo, String id, String field) =>
repo.exportLog()[id]!.fields[field]!.$2;
void main() {
test('sets status and priority on every id in ONE write', () async {
final (repo, p) = await _open();
for (final id in ['a', 'b', 'c']) {
await repo.upsert(
noteFixture(id, 'note $id').copyWith(createdBy: 'kuhy'),
);
}
final textClock = _clock(repo, 'a', 'text');
final createdClock = _clock(repo, 'a', 'created_at');
p.writes = 0;
await repo.bulkUpdate(
{'a', 'b', 'c'},
status: Status.inProgress,
priority: Priority.high,
);
expect(p.writes, 1);
final notes = {for (final n in await repo.listNotes()) n.id: n};
for (final n in notes.values) {
expect(n.status, Status.inProgress);
expect(n.priority, Priority.high);
expect(n.createdBy, 'kuhy');
}
expect(_clock(repo, 'a', 'text'), textClock);
expect(_clock(repo, 'a', 'created_at'), createdClock);
expect(_clock(repo, 'a', 'status') > textClock, isTrue);
expect(_clock(repo, 'a', 'updated_at') > textClock, isTrue);
});
test('touches only fields whose value changes', () async {
final (repo, _) = await _open();
await repo.upsert(noteFixture('a', 'A', priority: Priority.high));
final priorityClock = _clock(repo, 'a', 'priority');
await repo.bulkUpdate({'a'}, priority: Priority.high, status: Status.done);
expect(_clock(repo, 'a', 'priority'), priorityClock);
expect((await repo.listNotes()).single.status, Status.done);
});
test(
'a stored value Dart would format differently keeps its clock',
() async {
final (repo, _) = await _open();
final peer = Hlc(wallTimeMs: 1000, counter: 0, nodeId: 'mcp');
// A peer's (or a web build's) timestamp text, and no priority field.
await repo.importLog({
'a': Record(
id: 'a',
fields: {
'text': ('A', peer),
'status': (0, peer),
'created_at': ('2026-01-01T00:00:00Z', peer),
'updated_at': ('2026-01-01T00:00:00Z', peer),
},
),
});
await repo.bulkUpdate({'a'}, status: Status.done);
final fields = repo.exportLog()['a']!.fields;
expect(fields['created_at'], ('2026-01-01T00:00:00Z', peer));
expect(fields.containsKey('priority'), isFalse);
expect(fields['status']!.$2.nodeId, 'dev');
},
);
test('no write when nothing changes or ids are unknown/deleted', () async {
final (repo, p) = await _open();
await repo.upsert(noteFixture('a', 'A'));
await repo.upsert(noteFixture('gone', 'G'));
await repo.delete('gone');
p.writes = 0;
await repo.bulkUpdate({'a'}, priority: Priority.medium);
await repo.bulkUpdate({'missing', 'gone'}, status: Status.done);
await repo.bulkUpdate({'a'});
expect(p.writes, 0);
expect(repo.exportLog().containsKey('missing'), isFalse);
expect(repo.exportLog()['gone']!.fields.containsKey('status'), isTrue);
});
test('saveReplacing saves one note and tombstones the rest', () async {
final (repo, p) = await _open();
await repo.upsert(noteFixture('a', 'A').copyWith(createdBy: 'kuhy'));
await repo.upsert(noteFixture('b', 'B'));
await repo.upsert(noteFixture('keep', 'K'));
p.writes = 0;
final saved = noteFixture('a', 'A2', status: Status.inProgress);
await repo.saveReplacing(saved, ['b', 'missing']);
expect(p.writes, 1);
final notes = {for (final n in await repo.listNotes()) n.id: n};
expect(notes.keys, unorderedEquals(['a', 'keep']));
expect(notes['a']!.text, 'A2');
expect(notes['a']!.createdBy, 'kuhy');
expect(repo.exportLog()['b']!.deleted, isTrue);
expect(repo.exportLog().containsKey('missing'), isFalse);
});
}

View File

@ -0,0 +1,102 @@
/// The notes list's selection state, apart from any widget.
library;
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:todo/ui/note_selection.dart';
import 'notes_list_harness.dart';
void main() {
final order = [
note('a', 'A'),
note('b', 'B'),
note('c', 'C'),
note('d', 'D'),
];
test('mode toggles; leaving it clears the selection', () {
final s = NoteSelection();
addTearDown(s.dispose);
expect(s.active, isFalse);
s.toggleMode();
expect(s.active, isTrue);
s.toggle('a');
expect(s.ids, {'a'});
s.toggleMode();
expect(s.active, isFalse);
expect(s.ids, isEmpty);
});
test('toggle adds then removes, and enters the mode', () {
final s = NoteSelection();
addTearDown(s.dispose);
var notified = 0;
s.addListener(() => notified++);
s.toggle('b');
expect(s.active, isTrue);
expect(s.contains('b'), isTrue);
s.toggle('b');
expect(s.contains('b'), isFalse);
expect(s.active, isTrue);
expect(notified, 2);
});
test('extendTo selects the run from the anchor, either direction', () {
final s = NoteSelection();
addTearDown(s.dispose);
s
..toggle('d')
..extendTo('b', order);
expect(s.ids, {'b', 'c', 'd'});
s
..clear()
..toggle('a')
..extendTo('c', order);
expect(s.ids, {'a', 'b', 'c'});
});
test('extendTo without a usable anchor just toggles', () {
final s = NoteSelection();
addTearDown(s.dispose);
s.extendTo('c', order);
expect(s.ids, {'c'});
s
..clear()
..toggle('zz')
..extendTo('a', order);
expect(s.ids, {'zz', 'a'});
});
test('clear empties and leaves the mode', () {
final s = NoteSelection();
addTearDown(s.dispose);
s
..toggle('a')
..clear();
expect(s.ids, isEmpty);
expect(s.active, isFalse);
});
testWidgets('handleTap: plain tap outside the mode is not consumed', (
t,
) async {
final s = NoteSelection();
addTearDown(s.dispose);
expect(s.handleTap(order[0], order), isFalse);
expect(s.ids, isEmpty);
await t.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
expect(s.handleTap(order[0], order), isTrue);
await t.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
expect(s.ids, {'a'});
await t.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft);
expect(s.handleTap(order[2], order), isTrue);
await t.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft);
expect(s.ids, {'a', 'b', 'c'});
expect(s.handleTap(order[1], order), isTrue);
expect(s.ids, {'a', 'c'});
});
}

View File

@ -0,0 +1,126 @@
/// Assign-to-repo from the selection bar: picker, move, refusal.
library;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:todo/data/note.dart';
import 'fake_note_repository.dart';
import 'notes_list_harness.dart';
void _phone(WidgetTester t) {
t.view
..physicalSize = const Size(1080, 2400)
..devicePixelRatio = 2.625;
addTearDown(t.view.reset);
}
Note _repoNote(String id, String text, String repo) {
final n = note(id, text);
return n.copyWith(repo: repo);
}
Future<FakeNoteRepository> _select(
WidgetTester t,
List<Note> seed,
List<String> titles,
) async {
final repo = await pumpList(t, seed: seed, applyFilter: true);
await t.tap(find.byKey(const ValueKey('select-mode')));
await t.pump();
for (final title in titles) {
await t.tap(find.text(title));
await t.pump();
}
await t.tap(find.byKey(const ValueKey('bulk-assign')));
await t.pump();
await t.pump(const Duration(milliseconds: 400));
return repo;
}
Future<void> _settle(WidgetTester t) async {
await t.pump();
await t.pump(const Duration(milliseconds: 400));
}
void main() {
testWidgets('picker lists known repos; picking one moves the notes', (
t,
) async {
_phone(t);
final repo = await _select(
t,
[
_repoNote('r', 'app todos\n\n## Old', 'kuhyx/app'),
_repoNote('o', 'web todos', 'kuhyx/web'),
note('p1', 'Fix login'),
note('p2', 'Tidy up'),
],
['Fix login', 'Tidy up'],
);
expect(find.byKey(const ValueKey('repo-kuhyx/app')), findsOneWidget);
expect(find.byKey(const ValueKey('repo-kuhyx/web')), findsOneWidget);
await t.tap(find.byKey(const ValueKey('repo-kuhyx/app')));
await _settle(t);
final notes = {for (final n in await repo.listNotes()) n.id: n};
expect(notes.keys, unorderedEquals(['r', 'o']));
expect(
notes['r']!.text,
'app todos\n\n## Old\n\n## Fix login\n\n## Tidy up',
);
expect(repo.upsertCalls, 0);
expect(find.byKey(const ValueKey('selection-bar')), findsNothing);
});
testWidgets('a new kuhyx/<name> typed in the field creates its note', (
t,
) async {
_phone(t);
final repo = await _select(t, [note('p', 'Ship it')], ['Ship it']);
final field = find.byKey(const ValueKey('repo-new'));
expect(t.widget<TextField>(field).controller!.text, 'kuhyx/');
await t.enterText(field, 'kuhyx/');
await t.tap(find.byKey(const ValueKey('repo-new-submit')));
await _settle(t);
expect(find.byKey(const ValueKey('repo-picker')), findsOneWidget);
await t.enterText(field, 'kuhyx/fresh');
await t.testTextInput.receiveAction(TextInputAction.done);
await _settle(t);
final made = (await repo.listNotes()).single;
expect(made.repo, 'kuhyx/fresh');
expect(made.text, 'fresh todos\n\n## Ship it');
});
testWidgets('dismissing the picker changes nothing', (t) async {
_phone(t);
final repo = await _select(t, [note('p', 'Ship it')], ['Ship it']);
await t.tapAt(const Offset(10, 40));
await _settle(t);
expect((await repo.listNotes()).single.id, 'p');
expect(find.byKey(const ValueKey('selection-bar')), findsOneWidget);
});
testWidgets('a note already in a repo is refused with a block icon', (
t,
) async {
_phone(t);
final repo = await _select(
t,
[_repoNote('r', 'app todos\n\n## Old', 'kuhyx/app')],
['app todos'],
);
await t.tap(find.byKey(const ValueKey('repo-kuhyx/app')));
await _settle(t);
expect((await repo.listNotes()).single.text, 'app todos\n\n## Old');
expect(find.byIcon(Icons.block), findsOneWidget);
expect(find.byKey(const ValueKey('selection-bar')), findsOneWidget);
await t.pump(const Duration(seconds: 2));
expect(find.byIcon(Icons.block), findsNothing);
});
}

View File

@ -0,0 +1,100 @@
// Claude-made notes: hidden by default, an eye toggle shows them, the choice
// is remembered per device, and human notes always sort first.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:todo/data/app_settings.dart';
import 'package:todo/data/note.dart';
import 'package:todo/ui/notes_list_screen.dart';
import 'fake_note_repository.dart';
import 'notes_list_harness.dart';
final _seed = [
note(
'c',
'Claude chore',
priority: Priority.high,
).copyWith(createdBy: 'claude'),
note('h', 'Human idea', priority: Priority.low).copyWith(createdBy: 'kuhy'),
note('l', 'Legacy idea'),
];
Future<ValueNotifier<AppSettings>> _pump(
WidgetTester tester, {
AppSettings settings = const AppSettings(advancedMode: true),
}) async {
final repo = FakeNoteRepository(_seed, true);
addTearDown(repo.close);
final notifier = ValueNotifier(settings);
addTearDown(notifier.dispose);
await tester.pumpWidget(
MaterialApp(
home: NotesListScreen(repository: repo, appSettings: notifier),
),
);
await tester.pump();
return notifier;
}
Future<void> _tap(WidgetTester tester, IconData icon) async {
await tester.tap(find.byIcon(icon));
await tester.pump();
await tester.pump();
}
void main() {
setUp(() => SharedPreferences.setMockInitialValues({}));
testWidgets('Claude-made notes are hidden until the eye is tapped', (
tester,
) async {
await _pump(tester);
expect(find.text('Claude chore'), findsNothing);
expect(find.text('Human idea'), findsOneWidget);
expect(find.text('Legacy idea'), findsOneWidget);
expect(find.byIcon(Icons.visibility_off), findsOneWidget);
await _tap(tester, Icons.visibility_off);
expect(find.text('Claude chore'), findsOneWidget);
expect(find.byIcon(Icons.visibility), findsOneWidget);
await _tap(tester, Icons.visibility);
expect(find.text('Claude chore'), findsNothing);
});
testWidgets('shown, Claude notes sort below every human note', (
tester,
) async {
await _pump(
tester,
settings: const AppSettings(advancedMode: true, showClaudeNotes: true),
);
double y(String t) => tester.getTopLeft(find.text(t)).dy;
// The default 800x600 view lists one note per row, in display order; the
// high-priority Claude note still comes last.
expect(y('Claude chore'), greaterThan(y('Human idea')));
expect(y('Claude chore'), greaterThan(y('Legacy idea')));
});
testWidgets('the choice is persisted per device and reloaded', (
tester,
) async {
final notifier = await _pump(tester);
await _tap(tester, Icons.visibility_off);
expect(notifier.value.showClaudeNotes, isTrue);
final prefs = await SharedPreferences.getInstance();
expect(prefs.getBool('app.showClaudeNotes'), isTrue);
expect((await AppSettings.load()).showClaudeNotes, isTrue);
});
test('advancedMode and the toggle do not overwrite each other', () async {
final shown = await const AppSettings(
advancedMode: false,
).withShowClaudeNotes(value: true);
expect(shown.advancedMode, isFalse);
final both = await shown.withAdvancedMode(value: true);
expect(both.showClaudeNotes, isTrue);
expect(both.advancedMode, isTrue);
});
}

View File

@ -0,0 +1,177 @@
/// Multi-select on the notes list: mode, toggling, bulk status/priority.
library;
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_actions_sheet.dart';
import 'package:todo/ui/note_detail_screen.dart';
import 'fake_note_repository.dart';
import 'notes_list_harness.dart';
const _dpr = 2.625;
/// Pixel 6a: 1080x2400 px at 2.625 with the real system-bar insets.
void _pixel6a(WidgetTester t) {
const bars = FakeViewPadding(top: 132, bottom: 63);
t.view
..physicalSize = const Size(1080, 2400)
..devicePixelRatio = _dpr
..padding = bars
..viewPadding = bars;
addTearDown(t.view.reset);
}
void _desktop(WidgetTester t) {
t.view
..physicalSize = const Size(1600, 1000)
..devicePixelRatio = 1;
addTearDown(t.view.reset);
}
final _seed = [note('a', 'Alpha'), note('b', 'Beta'), note('c', 'Gamma')];
Finder _checkIn(String id) => find.descendant(
of: find.byKey(ValueKey(id)),
matching: find.byIcon(Icons.check_circle),
);
Future<void> _pick(WidgetTester t, String button, String item) async {
await t.tap(find.byKey(ValueKey(button)));
await t.pump();
await t.pump(const Duration(milliseconds: 400));
await t.tap(find.byKey(ValueKey(item)));
await t.pump();
await t.pump(const Duration(milliseconds: 400));
}
Future<FakeNoteRepository> _selectAll(WidgetTester t) async {
final repo = await pumpList(t, seed: _seed);
await t.tap(find.byKey(const ValueKey('select-mode')));
await t.pump();
for (final title in ['Alpha', 'Beta', 'Gamma']) {
await t.tap(find.text(title));
await t.pump();
}
return repo;
}
void main() {
testWidgets('no bar and no checks until something is selected', (t) async {
_pixel6a(t);
await pumpList(t, seed: _seed);
expect(find.byKey(const ValueKey('selection-bar')), findsNothing);
await t.tap(find.byKey(const ValueKey('select-mode')));
await t.pump();
expect(find.byKey(const ValueKey('selection-bar')), findsNothing);
expect(find.byIcon(Icons.check_circle), findsNothing);
});
testWidgets('in selection mode a tap toggles instead of opening', (t) async {
_pixel6a(t);
await pumpList(t, seed: _seed);
await t.tap(find.byKey(const ValueKey('select-mode')));
await t.pump();
await t.tap(find.text('Alpha'));
await t.pump();
expect(find.byType(NoteDetailScreen), findsNothing);
expect(_checkIn('a'), findsOneWidget);
expect(_checkIn('b'), findsNothing);
expect(find.byKey(const ValueKey('selection-bar')), findsOneWidget);
await t.longPress(find.text('Beta'));
await t.pump();
expect(_checkIn('b'), findsOneWidget);
expect(find.byType(NoteActionsSheet), findsNothing);
await t.tap(find.text('Alpha'));
await t.pump();
expect(_checkIn('a'), findsNothing);
});
testWidgets('long-press outside selection mode still opens the sheet', (
t,
) async {
_pixel6a(t);
await pumpList(t, seed: _seed);
await t.longPress(find.text('Alpha'));
await t.pump();
await t.pump(const Duration(milliseconds: 400));
expect(find.byType(NoteActionsSheet), findsOneWidget);
});
testWidgets('bulk status then priority: one call each, no upserts', (
t,
) async {
_pixel6a(t);
final repo = await _selectAll(t);
await _pick(t, 'bulk-status', 'status-inProgress');
await _pick(t, 'bulk-priority', 'priority-high');
expect(repo.bulkCalls, 2);
expect(repo.upsertCalls, 0);
final notes = await repo.listNotes();
expect(notes.map((n) => n.status).toSet(), {Status.inProgress});
expect(notes.map((n) => n.priority).toSet(), {Priority.high});
// Selection survives an action, so status + priority is one selection.
expect(find.byIcon(Icons.check_circle), findsNWidgets(3));
});
testWidgets('clear empties the selection and leaves the mode', (t) async {
_pixel6a(t);
await _selectAll(t);
await t.tap(find.byKey(const ValueKey('bulk-clear')));
await t.pump();
expect(find.byKey(const ValueKey('selection-bar')), findsNothing);
await t.tap(find.text('Alpha'));
await t.pump();
await t.pump(const Duration(milliseconds: 500));
expect(find.byType(NoteDetailScreen), findsOneWidget);
});
testWidgets('desktop list: ctrl-click toggles, shift-click extends', (
t,
) async {
_desktop(t);
await pumpList(t, seed: _seed);
await t.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
await t.tap(find.text('Alpha'));
await t.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
await t.pump();
expect(find.byType(NoteDetailScreen), findsNothing);
await t.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft);
await t.tap(find.text('Gamma'));
await t.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft);
await t.pump();
for (final id in ['a', 'b', 'c']) {
final tile = t.widget<ListTile>(
find.descendant(
of: find.byKey(ValueKey(id)),
matching: find.byType(ListTile),
),
);
expect(tile.selected, isTrue, reason: id);
}
expect(_checkIn('b'), findsOneWidget);
});
testWidgets('R7: bar icons are hit-testable above the bottom inset', (
t,
) async {
_pixel6a(t);
await _selectAll(t);
const bottomEdge = 2400 / _dpr - 63 / _dpr;
for (final key in [
'bulk-status',
'bulk-priority',
'bulk-assign',
'bulk-clear',
]) {
final f = find.byKey(ValueKey(key));
expect(f.hitTestable(), findsOneWidget, reason: key);
expect(t.getRect(f).bottom, lessThanOrEqualTo(bottomEdge), reason: key);
}
});
}

109
test/repo_assign_test.dart Normal file
View File

@ -0,0 +1,109 @@
/// Assigning plain notes to a repo: sections in its one open note.
library;
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_assign.dart';
import 'package:todo/data/repo_note_split.dart';
import 'note_repository_helpers.dart';
Future<NoteRepository> _repo(List<Note> seed) async {
final repo = await NoteRepository.openInMemory();
addTearDown(repo.close);
for (final n in seed) {
await repo.upsert(n);
}
return repo;
}
Note _task(String id, String text, String repo, {Status? status}) =>
noteFixture(
id,
text,
status: status ?? Status.todo,
createdAt: DateTime(2026),
).copyWith(repo: repo, doneCheck: 'true');
void main() {
test('appends each plain note as one section and removes it', () async {
final repo = await _repo([
_task('r', 'app todos\n\n## Old\nbody', 'kuhyx/app'),
_task('closed', 'app todos\n\n## x', 'kuhyx/app', status: Status.done),
noteFixture('p1', 'Fix login\n\n## what\n_why_\nit 500s'),
noteFixture('p2', 'Tidy', priority: Priority.high),
]);
final plain = (await repo.listNotes()).where((n) => n.repo == null).toList()
..sort((a, b) => a.id.compareTo(b.id));
// Sections land in the order given (the UI passes selection order).
final refused = await assignToRepo(repo, plain, 'kuhyx/app');
expect(refused, isEmpty);
final notes = {for (final n in await repo.listNotes()) n.id: n};
expect(notes.keys, unorderedEquals(['r', 'closed']));
final text = notes['r']!.text;
expect(openSections(text), ['Old', 'Fix login', 'Tidy']);
expect(text, contains('### what\n_why_\nit 500s'));
expect(notes['r']!.priority, Priority.high);
expect(notes['r']!.doneCheck, 'true');
});
test('creates the repo note when the repo has none open', () async {
final repo = await _repo([noteFixture('p', 'Ship it\nnow')]);
await assignToRepo(repo, await repo.listNotes(), 'kuhyx/new-app');
final made = (await repo.listNotes()).single;
expect(made.id, isNot('p'));
expect(made.repo, 'kuhyx/new-app');
expect(made.status, Status.todo);
expect(made.createdBy, 'kuhy');
expect(made.text, 'new-app todos\n\n## Ship it\nnow');
});
test('refuses notes that already have a repo, moves the rest', () async {
final repo = await _repo([
_task('t', 'other todos\n\n## A', 'kuhyx/other'),
noteFixture('p', 'Plain'),
]);
final refused = await assignToRepo(
repo,
await repo.listNotes(),
'kuhyx/app',
);
expect(refused.map((n) => n.id), ['t']);
final notes = {for (final n in await repo.listNotes()) n.id: n};
expect(notes['t']!.text, 'other todos\n\n## A');
expect(notes.containsKey('p'), isFalse);
});
test('only repo notes: a no-op that returns them all', () async {
final repo = await _repo([_task('t', 'other todos', 'kuhyx/other')]);
final before = repo.exportLog();
final refused = await assignToRepo(
repo,
await repo.listNotes(),
'kuhyx/app',
);
expect(refused, hasLength(1));
expect(repo.exportLog(), before);
});
test('demoteHeadings keeps a body inside its section', () {
expect(
demoteHeadings('# a\n## b\n### c\n```\n## code\n```\ntext #x'),
'### a\n### b\n#### c\n```\n## code\n```\ntext #x',
);
});
test('isRepoName accepts owner/name only', () {
expect(isRepoName('kuhyx/todo-app'), isTrue);
expect(isRepoName('kuhyx/'), isFalse);
expect(isRepoName('todo'), isFalse);
expect(isRepoName('a/b/c'), isFalse);
});
}