mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-10 23:27:01 +01:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a30e7b34a9 | |||
| 17d6d2a533 | |||
| 4e8ccd6bfd | |||
| 6bd2923f0a | |||
| 4c77dd5d2e | |||
| 757c54beda | |||
| 5c1bf75bf9 | |||
| 57019eec30 | |||
| cc9789f530 | |||
| 135f3f07ea | |||
| ba25e8f176 |
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
47
lib/cli/cli_mode.dart
Normal 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;
|
||||
}
|
||||
@ -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!),
|
||||
};
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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;
|
||||
|
||||
44
lib/data/created_by_backfill.dart
Normal file
44
lib/data/created_by_backfill.dart
Normal 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
38
lib/data/log_watch.dart
Normal 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;
|
||||
}
|
||||
@ -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
63
lib/data/note_bulk.dart
Normal 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)),
|
||||
};
|
||||
@ -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);
|
||||
|
||||
@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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
75
lib/data/repo_assign.dart
Normal 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;
|
||||
}
|
||||
@ -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,
|
||||
);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -102,6 +102,7 @@ class CaptureDraft {
|
||||
status: status,
|
||||
createdAt: _createdAt!,
|
||||
updatedAt: now,
|
||||
createdBy: 'kuhy',
|
||||
),
|
||||
);
|
||||
if (live()) lastSavedAt.value = now;
|
||||
|
||||
@ -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,
|
||||
),
|
||||
|
||||
86
lib/ui/note_selection.dart
Normal file
86
lib/ui/note_selection.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
37
lib/ui/notes_assign_flow.dart
Normal file
37
lib/ui/notes_assign_flow.dart
Normal 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;
|
||||
}
|
||||
@ -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),
|
||||
);
|
||||
|
||||
@ -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]),
|
||||
),
|
||||
|
||||
@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
43
lib/ui/notes_list_screen_select.dart
Normal file
43
lib/ui/notes_list_screen_select.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
128
lib/ui/notes_selection_bar.dart
Normal file
128
lib/ui/notes_selection_bar.dart
Normal 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',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
97
lib/ui/repo_picker_sheet.dart
Normal file
97
lib/ui/repo_picker_sheet.dart
Normal 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'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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` |
|
||||
|
||||
44
pubspec.lock
44
pubspec.lock
@ -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:
|
||||
@ -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.2"
|
||||
resolved-ref: "95cd3731ede686114353d32ae6c286ad1694b488"
|
||||
ref: "sync_settings_ui-v0.6.3"
|
||||
resolved-ref: eef7af4c86837ee82891493587ff3456297e5dea
|
||||
url: "https://github.com/kuhyx/utils"
|
||||
source: git
|
||||
version: "0.6.2"
|
||||
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:
|
||||
|
||||
10
pubspec.yaml
10
pubspec.yaml
@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 1.0.63+1
|
||||
version: 1.0.72+1
|
||||
|
||||
environment:
|
||||
sdk: ^3.12.2
|
||||
@ -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.2
|
||||
ref: sync_settings_ui-v0.6.3
|
||||
path: sync_settings_ui
|
||||
url_launcher: 6.3.3
|
||||
uuid: 4.6.0
|
||||
|
||||
21
test/capture_screen_created_by_test.dart
Normal file
21
test/capture_screen_created_by_test.dart
Normal 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');
|
||||
});
|
||||
}
|
||||
85
test/cli/cli_created_by_test.dart
Normal file
85
test/cli/cli_created_by_test.dart
Normal 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'));
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
83
test/cli/cli_set_text_test.dart
Normal file
83
test/cli/cli_set_text_test.dart
Normal 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>()),
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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'));
|
||||
});
|
||||
}
|
||||
|
||||
@ -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
83
test/log_watch_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
@ -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');
|
||||
});
|
||||
|
||||
108
test/mcp/mcp_board_refresh_test.dart
Normal file
108
test/mcp/mcp_board_refresh_test.dart
Normal 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'));
|
||||
});
|
||||
}
|
||||
88
test/mcp/mcp_created_by_test.dart
Normal file
88
test/mcp/mcp_created_by_test.dart
Normal 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');
|
||||
});
|
||||
}
|
||||
@ -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');
|
||||
|
||||
@ -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 {
|
||||
|
||||
181
test/note_created_by_test.dart
Normal file
181
test/note_created_by_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
139
test/note_repository_bulk_test.dart
Normal file
139
test/note_repository_bulk_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
102
test/note_selection_test.dart
Normal file
102
test/note_selection_test.dart
Normal 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'});
|
||||
});
|
||||
}
|
||||
126
test/notes_list_assign_test.dart
Normal file
126
test/notes_list_assign_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
100
test/notes_list_claude_toggle_test.dart
Normal file
100
test/notes_list_claude_toggle_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
177
test/notes_list_select_test.dart
Normal file
177
test/notes_list_select_test.dart
Normal 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
109
test/repo_assign_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user