mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-11 18:14:02 +01:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c9eb4d6cff | |||
| 1010d96426 | |||
| 319f18d33a | |||
| c3ac6ec05d | |||
| 0e56cddd47 | |||
| 203e61d6ac | |||
| edf6969949 | |||
| 5ea7ba7b73 | |||
| 091e68a2d9 | |||
| a30e7b34a9 | |||
| 17d6d2a533 | |||
| 4e8ccd6bfd | |||
| 6bd2923f0a | |||
| 4c77dd5d2e | |||
| 757c54beda | |||
| 5c1bf75bf9 | |||
| 57019eec30 | |||
| cc9789f530 | |||
| 135f3f07ea | |||
| ba25e8f176 |
@ -50,6 +50,7 @@ and sync peer-to-peer through a GitHub repo used as dumb storage.
|
|||||||
- lint: `flutter analyze`
|
- lint: `flutter analyze`
|
||||||
- coverage: `flutter test --coverage --reporter=compact`
|
- coverage: `flutter test --coverage --reporter=compact`
|
||||||
- coverage-gaps: `coverage-gaps coverage/lcov.info`
|
- coverage-gaps: `coverage-gaps coverage/lcov.info`
|
||||||
|
- deploy: `scripts/deploy.sh` (MCP service, desktop pacman package, phone release; exits non-zero unless every health check passes)
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
|
|||||||
@ -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
|
android.useAndroidX=true
|
||||||
# This newDsl flag was added by the Flutter template
|
# This newDsl flag was added by the Flutter template
|
||||||
android.newDsl=false
|
android.newDsl=false
|
||||||
|
|||||||
@ -4,49 +4,12 @@
|
|||||||
/// exact-pinned dependency under the freshness gate.
|
/// exact-pinned dependency under the freshness gate.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
import 'package:todo/cli/cli_mode.dart';
|
||||||
import 'package:todo/data/note.dart';
|
import 'package:todo/data/note.dart';
|
||||||
|
|
||||||
|
export 'package:todo/cli/cli_mode.dart';
|
||||||
export 'package:todo/cli/cli_usage.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.
|
/// The parsed command line.
|
||||||
class CliArgs {
|
class CliArgs {
|
||||||
/// Creates parsed arguments; see [parse].
|
/// Creates parsed arguments; see [parse].
|
||||||
@ -58,6 +21,7 @@ class CliArgs {
|
|||||||
this.status,
|
this.status,
|
||||||
this.dataDir,
|
this.dataDir,
|
||||||
this.newNote = false,
|
this.newNote = false,
|
||||||
|
this.dryRun = false,
|
||||||
this.opts = (
|
this.opts = (
|
||||||
repo: null,
|
repo: null,
|
||||||
query: null,
|
query: null,
|
||||||
@ -89,12 +53,19 @@ class CliArgs {
|
|||||||
opts[arg] = value();
|
opts[arg] = value();
|
||||||
case '--list':
|
case '--list':
|
||||||
modes.add(CliMode.list);
|
modes.add(CliMode.list);
|
||||||
|
case '--backfill-created-by':
|
||||||
|
modes.add(CliMode.backfill);
|
||||||
|
case '--dry-run':
|
||||||
|
opts[arg] = 'true';
|
||||||
case '--search':
|
case '--search':
|
||||||
modes.add(CliMode.search);
|
modes.add(CliMode.search);
|
||||||
opts[arg] = value();
|
opts[arg] = value();
|
||||||
case '--get' || '--delete' || '--close':
|
case '--get' || '--delete' || '--close':
|
||||||
modes.add(CliMode.values.byName(arg.substring(2)));
|
modes.add(CliMode.values.byName(arg.substring(2)));
|
||||||
id = value();
|
id = value();
|
||||||
|
case '--set-text':
|
||||||
|
modes.add(CliMode.setText);
|
||||||
|
id = value();
|
||||||
case '--set-status':
|
case '--set-status':
|
||||||
modes.add(CliMode.setStatus);
|
modes.add(CliMode.setStatus);
|
||||||
id = value();
|
id = value();
|
||||||
@ -106,8 +77,9 @@ class CliArgs {
|
|||||||
if (modes.contains(CliMode.help)) return const CliArgs(mode: CliMode.help);
|
if (modes.contains(CliMode.help)) return const CliArgs(mode: CliMode.help);
|
||||||
if (modes.length > 1) {
|
if (modes.length > 1) {
|
||||||
throw UsageError(
|
throw UsageError(
|
||||||
'--list, --search, --get, --delete, --set-status and --close are '
|
'--list, --search, --get, --delete, --set-status, --set-text, '
|
||||||
'mutually exclusive',
|
'--close and '
|
||||||
|
'--backfill-created-by are mutually exclusive',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final mode = modes.isEmpty ? CliMode.add : modes.single;
|
final mode = modes.isEmpty ? CliMode.add : modes.single;
|
||||||
@ -128,6 +100,7 @@ class CliArgs {
|
|||||||
opts.containsKey('--new-note') &&
|
opts.containsKey('--new-note') &&
|
||||||
(opts['--repo'] != null ||
|
(opts['--repo'] != null ||
|
||||||
(throw UsageError('--new-note needs --repo'))),
|
(throw UsageError('--new-note needs --repo'))),
|
||||||
|
dryRun: opts.containsKey('--dry-run'),
|
||||||
opts: (
|
opts: (
|
||||||
repo: _repo(opts['--repo']),
|
repo: _repo(opts['--repo']),
|
||||||
query: _nonBlank(opts['--search'], '--search'),
|
query: _nonBlank(opts['--search'], '--search'),
|
||||||
@ -141,7 +114,8 @@ class CliArgs {
|
|||||||
/// The selected mode.
|
/// The selected mode.
|
||||||
final CliMode 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;
|
final String? text;
|
||||||
|
|
||||||
/// Full id or 8+ character prefix of the note an id mode acts on.
|
/// 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.
|
/// `--new-note`: the `--repo` task gets its own note.
|
||||||
final bool newNote;
|
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.
|
/// `--repo`, `--search`, `--done-check`, `--by` and `--evidence` values.
|
||||||
final ({
|
final ({
|
||||||
String? repo,
|
String? repo,
|
||||||
@ -174,7 +151,7 @@ class CliArgs {
|
|||||||
|
|
||||||
static const _adding = 'when adding a note';
|
static const _adding = 'when adding a note';
|
||||||
static const Map<String, (String, Set<CliMode>)> _scopes = {
|
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}),
|
'--done-check': (_adding, {CliMode.add}),
|
||||||
'--by': (_adding, {CliMode.add}),
|
'--by': (_adding, {CliMode.add}),
|
||||||
'--repo': (
|
'--repo': (
|
||||||
@ -183,6 +160,7 @@ class CliArgs {
|
|||||||
),
|
),
|
||||||
'--evidence': ('to --close', {CliMode.close}),
|
'--evidence': ('to --close', {CliMode.close}),
|
||||||
'--new-note': (_adding, {CliMode.add}),
|
'--new-note': (_adding, {CliMode.add}),
|
||||||
|
'--dry-run': ('to --backfill-created-by', {CliMode.backfill}),
|
||||||
};
|
};
|
||||||
|
|
||||||
static void _checkScopes(Iterable<String> flags, CliMode mode) {
|
static void _checkScopes(Iterable<String> flags, CliMode mode) {
|
||||||
|
|||||||
@ -38,6 +38,7 @@ class CliCommands {
|
|||||||
repo: repo,
|
repo: repo,
|
||||||
doneCheck: args.opts.doneCheck,
|
doneCheck: args.opts.doneCheck,
|
||||||
by: args.opts.by,
|
by: args.opts.by,
|
||||||
|
createdBy: 'claude',
|
||||||
);
|
);
|
||||||
await session.repository.upsert(note);
|
await session.repository.upsert(note);
|
||||||
if (!await _push('note')) return 1;
|
if (!await _push('note')) return 1;
|
||||||
@ -57,6 +58,7 @@ class CliCommands {
|
|||||||
doneCheck: args.opts.doneCheck,
|
doneCheck: args.opts.doneCheck,
|
||||||
priority: args.priority,
|
priority: args.priority,
|
||||||
by: args.opts.by,
|
by: args.opts.by,
|
||||||
|
createdBy: 'claude',
|
||||||
);
|
);
|
||||||
await _save(added.note);
|
await _save(added.note);
|
||||||
if (!await _push('note')) return 1;
|
if (!await _push('note')) return 1;
|
||||||
@ -79,6 +81,7 @@ class CliCommands {
|
|||||||
doneCheck: args.opts.doneCheck,
|
doneCheck: args.opts.doneCheck,
|
||||||
priority: args.priority,
|
priority: args.priority,
|
||||||
by: args.opts.by,
|
by: args.opts.by,
|
||||||
|
createdBy: 'claude',
|
||||||
);
|
);
|
||||||
await _save(added.note);
|
await _save(added.note);
|
||||||
if (!await _push('note')) return 1;
|
if (!await _push('note')) return 1;
|
||||||
@ -133,6 +136,15 @@ class CliCommands {
|
|||||||
return 0;
|
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.
|
/// Marks a note done with [evidence], keeping it, and pushes it.
|
||||||
Future<int> close(String ref, String evidence) async {
|
Future<int> close(String ref, String evidence) async {
|
||||||
final note = await _pullAndFind(ref);
|
final note = await _pullAndFind(ref);
|
||||||
@ -142,6 +154,35 @@ class CliCommands {
|
|||||||
return 0;
|
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 {
|
Future<Note> _pullAndFind(String ref) async {
|
||||||
_report(await session.sync(push: true));
|
_report(await session.sync(push: true));
|
||||||
return findNote(await session.repository.listNotes(), ref);
|
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;
|
return 0;
|
||||||
}
|
}
|
||||||
String? text;
|
String? text;
|
||||||
if (args.mode == CliMode.add) {
|
if (args.mode == CliMode.add || args.mode == CliMode.setText) {
|
||||||
text = args.text ?? await readStdin();
|
text = args.text ?? await readStdin();
|
||||||
if (text.trim().isEmpty) {
|
if (text.trim().isEmpty) {
|
||||||
err.writeln('error: empty note (pass --text or pipe text on stdin)');
|
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.list || CliMode.search => await cli.list(args),
|
||||||
CliMode.get => await cli.get(args.id!),
|
CliMode.get => await cli.get(args.id!),
|
||||||
CliMode.setStatus => await cli.setStatus(args.id!, args.status!),
|
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.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.
|
// Only delete is left: help returned above.
|
||||||
_ => await cli.delete(args.id!),
|
_ => await cli.delete(args.id!),
|
||||||
};
|
};
|
||||||
|
|||||||
@ -29,6 +29,19 @@ String defaultDataDir(Map<String, String> env) {
|
|||||||
return p.join(base, 'todo-cli');
|
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.
|
/// The open state of one CLI invocation. Call [close] when done.
|
||||||
class CliSession {
|
class CliSession {
|
||||||
/// Wraps already-open parts; [open] is the normal way in. Public so a test
|
/// 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)
|
--get <id> pull, then print one note in full (exit 1 if absent)
|
||||||
--delete <id> pull, tombstone the note, push
|
--delete <id> pull, tombstone the note, push
|
||||||
--set-status <id> <s> pull, change the note's status (as --status), 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>
|
--close <id> --evidence <s>
|
||||||
pull, set status done + evidence (commit sha + done
|
pull, set status done + evidence (commit sha + done
|
||||||
check exit code), push; refused without --evidence
|
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
|
--data-dir <dir> local log + node id (default
|
||||||
$XDG_DATA_HOME/todo-cli or ~/.local/share/todo-cli)
|
$XDG_DATA_HOME/todo-cli or ~/.local/share/todo-cli)
|
||||||
-h, --help this text
|
-h, --help this text
|
||||||
|
|||||||
@ -32,7 +32,7 @@ Note findNote(Iterable<Note> notes, String ref) {
|
|||||||
return matches.single;
|
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) => [
|
String listLine(Note note) => [
|
||||||
if (note.id.length <= _minPrefix)
|
if (note.id.length <= _minPrefix)
|
||||||
note.id
|
note.id
|
||||||
@ -42,6 +42,7 @@ String listLine(Note note) => [
|
|||||||
note.priority.name,
|
note.priority.name,
|
||||||
note.repo ?? '-',
|
note.repo ?? '-',
|
||||||
_title(note),
|
_title(note),
|
||||||
|
note.createdBy ?? 'kuhy',
|
||||||
].join('\t');
|
].join('\t');
|
||||||
|
|
||||||
String _title(Note note) => note.text.split('\n').first;
|
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.doneCheck case final v?) 'done_check: $v',
|
||||||
if (note.by case final v?) 'by: $v',
|
if (note.by case final v?) 'by: $v',
|
||||||
if (note.evidence case final v?) 'evidence: $v',
|
if (note.evidence case final v?) 'evidence: $v',
|
||||||
|
'created_by: ${note.createdBy ?? 'kuhy'}',
|
||||||
'---',
|
'---',
|
||||||
note.text,
|
note.text,
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|||||||
@ -19,10 +19,14 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||||||
/// sibling to `changesets/<nodeId>.json`.
|
/// sibling to `changesets/<nodeId>.json`.
|
||||||
const String kAdvancedModeRemotePath = 'settings/advancedMode';
|
const String kAdvancedModeRemotePath = 'settings/advancedMode';
|
||||||
|
|
||||||
/// App-wide, non-sync preferences (currently just `advancedMode`).
|
/// App-wide, non-sync preferences (`advancedMode`, `showClaudeNotes`).
|
||||||
class AppSettings {
|
class AppSettings {
|
||||||
/// Creates an [AppSettings] from its stored fields.
|
/// 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
|
/// Whether the priority/status/template/mode chrome and the routine
|
||||||
/// save/sync status text are shown. Off by default: casual capture needs
|
/// 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.
|
/// a value that has never round-tripped through Firebase.
|
||||||
final DateTime? advancedModeUpdatedAt;
|
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 _kAdvancedMode = 'app.advancedMode';
|
||||||
static const _kAdvancedModeUpdatedAt = 'app.advancedMode.updatedAt';
|
static const _kAdvancedModeUpdatedAt = 'app.advancedMode.updatedAt';
|
||||||
|
|
||||||
@ -44,6 +53,7 @@ class AppSettings {
|
|||||||
final updatedAtMillis = prefs.getInt(_kAdvancedModeUpdatedAt);
|
final updatedAtMillis = prefs.getInt(_kAdvancedModeUpdatedAt);
|
||||||
return AppSettings(
|
return AppSettings(
|
||||||
advancedMode: prefs.getBool(_kAdvancedMode) ?? false,
|
advancedMode: prefs.getBool(_kAdvancedMode) ?? false,
|
||||||
|
showClaudeNotes: prefs.getBool(_kShowClaude) ?? false,
|
||||||
advancedModeUpdatedAt: updatedAtMillis == null
|
advancedModeUpdatedAt: updatedAtMillis == null
|
||||||
? null
|
? null
|
||||||
: DateTime.fromMillisecondsSinceEpoch(updatedAtMillis),
|
: DateTime.fromMillisecondsSinceEpoch(updatedAtMillis),
|
||||||
@ -71,6 +81,7 @@ class AppSettings {
|
|||||||
final updated = AppSettings(
|
final updated = AppSettings(
|
||||||
advancedMode: value,
|
advancedMode: value,
|
||||||
advancedModeUpdatedAt: updatedAt,
|
advancedModeUpdatedAt: updatedAt,
|
||||||
|
showClaudeNotes: showClaudeNotes,
|
||||||
);
|
);
|
||||||
if (client != null) {
|
if (client != null) {
|
||||||
try {
|
try {
|
||||||
@ -89,6 +100,17 @@ class AppSettings {
|
|||||||
return updated;
|
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
|
/// Pulls the remote value from [client] and, if it is newer than the
|
||||||
/// local one, applies it locally and returns the reconciled settings.
|
/// local one, applies it locally and returns the reconciled settings.
|
||||||
/// Returns this instance unchanged when [client] is null, there is
|
/// Returns this instance unchanged when [client] is null, there is
|
||||||
@ -100,9 +122,7 @@ class AppSettings {
|
|||||||
try {
|
try {
|
||||||
final remote = await client.getStringMap(kAdvancedModeRemotePath);
|
final remote = await client.getStringMap(kAdvancedModeRemotePath);
|
||||||
if (!remote.containsKey('value')) return this;
|
if (!remote.containsKey('value')) return this;
|
||||||
final remoteUpdatedMillis = int.tryParse(
|
final remoteUpdatedMillis = int.tryParse(remote['updatedAtMillis'] ?? '');
|
||||||
remote['updatedAtMillis'] ?? '',
|
|
||||||
);
|
|
||||||
if (remoteUpdatedMillis == null) return this;
|
if (remoteUpdatedMillis == null) return this;
|
||||||
final remoteUpdatedAt = DateTime.fromMillisecondsSinceEpoch(
|
final remoteUpdatedAt = DateTime.fromMillisecondsSinceEpoch(
|
||||||
remoteUpdatedMillis,
|
remoteUpdatedMillis,
|
||||||
@ -110,6 +130,7 @@ class AppSettings {
|
|||||||
final candidate = AppSettings(
|
final candidate = AppSettings(
|
||||||
advancedMode: remote['value'] == 'true',
|
advancedMode: remote['value'] == 'true',
|
||||||
advancedModeUpdatedAt: remoteUpdatedAt,
|
advancedModeUpdatedAt: remoteUpdatedAt,
|
||||||
|
showClaudeNotes: showClaudeNotes,
|
||||||
);
|
);
|
||||||
final adopted = adopt(candidate);
|
final adopted = adopt(candidate);
|
||||||
if (identical(adopted, this)) return this;
|
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'};
|
const Set<String> kNoteAuthors = {'claude', 'kuhy'};
|
||||||
|
|
||||||
/// An immutable idea/note record.
|
/// An immutable idea/note record.
|
||||||
@ -99,6 +99,7 @@ class Note {
|
|||||||
this.doneCheck,
|
this.doneCheck,
|
||||||
this.by,
|
this.by,
|
||||||
this.evidence,
|
this.evidence,
|
||||||
|
this.createdBy,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Builds a [Note] from a raw CRDT query row.
|
/// 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).
|
/// Proof the task was closed (commit sha + the done check's exit code).
|
||||||
final String? evidence;
|
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.
|
/// Whether this note is a repo task rather than a plain idea.
|
||||||
bool get isRepoTask => repo != null;
|
bool get isRepoTask => repo != null;
|
||||||
|
|
||||||
@ -162,6 +169,7 @@ class Note {
|
|||||||
String? doneCheck,
|
String? doneCheck,
|
||||||
String? by,
|
String? by,
|
||||||
String? evidence,
|
String? evidence,
|
||||||
|
String? createdBy,
|
||||||
}) {
|
}) {
|
||||||
return Note(
|
return Note(
|
||||||
id: id,
|
id: id,
|
||||||
@ -174,6 +182,7 @@ class Note {
|
|||||||
doneCheck: doneCheck ?? this.doneCheck,
|
doneCheck: doneCheck ?? this.doneCheck,
|
||||||
by: by ?? this.by,
|
by: by ?? this.by,
|
||||||
evidence: evidence ?? this.evidence,
|
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.statuses = const {},
|
||||||
this.repo,
|
this.repo,
|
||||||
this.repoTasksOnly = false,
|
this.repoTasksOnly = false,
|
||||||
|
this.hideClaude = false,
|
||||||
this.createdFrom,
|
this.createdFrom,
|
||||||
this.createdTo,
|
this.createdTo,
|
||||||
this.updatedFrom,
|
this.updatedFrom,
|
||||||
@ -58,6 +59,10 @@ class NoteFilter {
|
|||||||
/// Only notes that have a repo (repo tasks). Ideas are excluded.
|
/// Only notes that have a repo (repo tasks). Ideas are excluded.
|
||||||
final bool repoTasksOnly;
|
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.
|
/// Inclusive lower bound (by calendar day) on the creation date.
|
||||||
final DateTime? createdFrom;
|
final DateTime? createdFrom;
|
||||||
|
|
||||||
@ -104,6 +109,7 @@ class NoteFilter {
|
|||||||
Set<Status>? statuses,
|
Set<Status>? statuses,
|
||||||
String? repo,
|
String? repo,
|
||||||
bool? repoTasksOnly,
|
bool? repoTasksOnly,
|
||||||
|
bool? hideClaude,
|
||||||
DateTime? createdFrom,
|
DateTime? createdFrom,
|
||||||
DateTime? createdTo,
|
DateTime? createdTo,
|
||||||
DateTime? updatedFrom,
|
DateTime? updatedFrom,
|
||||||
@ -117,6 +123,7 @@ class NoteFilter {
|
|||||||
statuses: statuses ?? this.statuses,
|
statuses: statuses ?? this.statuses,
|
||||||
repo: repo ?? this.repo,
|
repo: repo ?? this.repo,
|
||||||
repoTasksOnly: repoTasksOnly ?? this.repoTasksOnly,
|
repoTasksOnly: repoTasksOnly ?? this.repoTasksOnly,
|
||||||
|
hideClaude: hideClaude ?? this.hideClaude,
|
||||||
createdFrom: clearCreated ? null : (createdFrom ?? this.createdFrom),
|
createdFrom: clearCreated ? null : (createdFrom ?? this.createdFrom),
|
||||||
createdTo: clearCreated ? null : (createdTo ?? this.createdTo),
|
createdTo: clearCreated ? null : (createdTo ?? this.createdTo),
|
||||||
updatedFrom: clearUpdated ? null : (updatedFrom ?? this.updatedFrom),
|
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.repo != null && note.repo != filter.repo) return false;
|
||||||
if (filter.repoTasksOnly && note.repo == null) return false;
|
if (filter.repoTasksOnly && note.repo == null) return false;
|
||||||
|
if (filter.hideClaude && note.isClaudeMade) return false;
|
||||||
if (filter.priorities.isNotEmpty &&
|
if (filter.priorities.isNotEmpty &&
|
||||||
!filter.priorities.contains(note.priority)) {
|
!filter.priorities.contains(note.priority)) {
|
||||||
return false;
|
return false;
|
||||||
@ -165,8 +173,17 @@ bool _withinDays(DateTime value, DateTime? from, DateTime? to) {
|
|||||||
return true;
|
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) {
|
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) {
|
switch (sort) {
|
||||||
case NoteSort.createdDesc:
|
case NoteSort.createdDesc:
|
||||||
return (a, b) => b.createdAt.compareTo(a.createdAt);
|
return (a, b) => b.createdAt.compareTo(a.createdAt);
|
||||||
|
|||||||
@ -14,6 +14,9 @@ const _doneCheck = 'done_check';
|
|||||||
const _by = 'by';
|
const _by = 'by';
|
||||||
const _evidence = 'evidence';
|
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
|
/// 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).
|
/// an idea's record is exactly the v3 field set (and cannot clear a peer's).
|
||||||
Map<String, Field> noteFields(Note note, Hlc hlc) => {
|
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.doneCheck case final v?) _doneCheck: (v, hlc),
|
||||||
if (note.by case final v?) _by: (v, hlc),
|
if (note.by case final v?) _by: (v, hlc),
|
||||||
if (note.evidence case final v?) _evidence: (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.
|
/// Reads [record] as a [Note], ignoring unknown fields.
|
||||||
@ -48,6 +52,7 @@ Note noteFromRecord(Record record) {
|
|||||||
doneCheck: task(_doneCheck),
|
doneCheck: task(_doneCheck),
|
||||||
by: task(_by),
|
by: task(_by),
|
||||||
evidence: task(_evidence),
|
evidence: task(_evidence),
|
||||||
|
createdBy: task(kCreatedByField),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:crdt_sync/crdt_sync.dart';
|
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.dart';
|
||||||
|
import 'package:todo/data/note_bulk.dart';
|
||||||
import 'package:todo/data/note_import.dart';
|
import 'package:todo/data/note_import.dart';
|
||||||
import 'package:todo/data/note_query.dart';
|
import 'package:todo/data/note_query.dart';
|
||||||
import 'package:todo/data/note_record.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
|
/// character-by-character autosave: it is cheap and idempotent. All fields
|
||||||
/// share one fresh monotonic clock (they are written together).
|
/// share one fresh monotonic clock (they are written together).
|
||||||
Future<void> upsert(Note note) async {
|
Future<void> upsert(Note note) async {
|
||||||
await _store.upsert(
|
await _put(Record(id: note.id, fields: noteFields(note, _store.nextHlc())));
|
||||||
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
|
/// 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*
|
// the import wins locally; the clock decides whether it wins on *other*
|
||||||
// devices, and an import must not win there simply by being recent.
|
// devices, and an import must not win there simply by being recent.
|
||||||
if (local == null) {
|
if (local == null) {
|
||||||
await _store.upsert(recordAtNoteTime(note, _nodeId));
|
await _put(recordAtNoteTime(note, _nodeId));
|
||||||
added++;
|
added++;
|
||||||
} else if (note.updatedAt.isAfter(local.updatedAt)) {
|
} else if (note.updatedAt.isAfter(local.updatedAt)) {
|
||||||
await _store.upsert(recordAtNoteTime(note, _nodeId));
|
await _put(recordAtNoteTime(note, _nodeId));
|
||||||
updated++;
|
updated++;
|
||||||
} else {
|
} else {
|
||||||
skipped++;
|
skipped++;
|
||||||
@ -165,27 +214,10 @@ class NoteRepository {
|
|||||||
Stream<List<Note>> watchNotes({
|
Stream<List<Note>> watchNotes({
|
||||||
NoteSort sort = NoteSort.priorityDesc,
|
NoteSort sort = NoteSort.priorityDesc,
|
||||||
NoteFilter filter = const NoteFilter(),
|
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.
|
/// Emits the live count of non-deleted notes immediately and on each change.
|
||||||
Stream<int> watchCount() => _watch(_liveCount);
|
Stream<int> watchCount() => watchLog(_store.changes, _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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// This device's stable node id. Names its file in the sync repo so two
|
/// This device's stable node id. Names its file in the sync repo so two
|
||||||
/// devices never write the same path.
|
/// 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,
|
String? doneCheck,
|
||||||
Priority priority = Priority.medium,
|
Priority priority = Priority.medium,
|
||||||
String? by,
|
String? by,
|
||||||
|
String? createdBy,
|
||||||
}) async {
|
}) async {
|
||||||
final section = repoSection(title, body);
|
final section = repoSection(title, body);
|
||||||
final open = (await repository.listNotes(
|
final open = (await repository.listNotes(
|
||||||
@ -99,6 +100,7 @@ Future<RepoAdd> addRepoTask(
|
|||||||
repo: repo,
|
repo: repo,
|
||||||
doneCheck: composeDoneCheck(null, doneCheck),
|
doneCheck: composeDoneCheck(null, doneCheck),
|
||||||
by: by,
|
by: by,
|
||||||
|
createdBy: createdBy,
|
||||||
),
|
),
|
||||||
appended: false,
|
appended: false,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -26,16 +26,24 @@ typedef SplitAdd = ({Note note, SplitKind kind, String? why});
|
|||||||
|
|
||||||
final _heading = RegExp(r'^##\s+(\[x\]\s+)?(.*)$');
|
final _heading = RegExp(r'^##\s+(\[x\]\s+)?(.*)$');
|
||||||
|
|
||||||
/// The open `## title` headings of [text], outside code fences.
|
/// Every `## title` heading of [text] outside code fences, open or done.
|
||||||
Iterable<String> openSections(String text) sync* {
|
Iterable<RegExpMatch> _sections(String text) sync* {
|
||||||
var fenced = false;
|
var fenced = false;
|
||||||
for (final line in text.split('\n')) {
|
for (final line in text.split('\n')) {
|
||||||
if (line.trimLeft().startsWith('```')) fenced = !fenced;
|
if (line.trimLeft().startsWith('```')) fenced = !fenced;
|
||||||
final m = fenced ? null : _heading.firstMatch(line);
|
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].
|
/// The separate open note of [repo] with an open section titled [title].
|
||||||
Note? duplicateTarget(Iterable<Note> open, String repo, String title) {
|
Note? duplicateTarget(Iterable<Note> open, String repo, String title) {
|
||||||
final hits =
|
final hits =
|
||||||
@ -69,6 +77,7 @@ Future<SplitAdd> addSeparateTask(
|
|||||||
String? doneCheck,
|
String? doneCheck,
|
||||||
Priority priority = Priority.medium,
|
Priority priority = Priority.medium,
|
||||||
String? by,
|
String? by,
|
||||||
|
String? createdBy,
|
||||||
Future<bool> Function(Note note)? busy,
|
Future<bool> Function(Note note)? busy,
|
||||||
}) async {
|
}) async {
|
||||||
final section = repoSection(title, body);
|
final section = repoSection(title, body);
|
||||||
@ -105,6 +114,7 @@ Future<SplitAdd> addSeparateTask(
|
|||||||
repo: repo,
|
repo: repo,
|
||||||
doneCheck: composeDoneCheck(null, doneCheck),
|
doneCheck: composeDoneCheck(null, doneCheck),
|
||||||
by: by,
|
by: by,
|
||||||
|
createdBy: createdBy,
|
||||||
),
|
),
|
||||||
kind: why == null ? SplitKind.added : SplitKind.fellBack,
|
kind: why == null ? SplitKind.added : SplitKind.fellBack,
|
||||||
why: why,
|
why: why,
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import 'package:todo/data/note_repository.dart';
|
|||||||
import 'package:todo/data/repository_factory.dart';
|
import 'package:todo/data/repository_factory.dart';
|
||||||
import 'package:todo/frame_stats.dart';
|
import 'package:todo/frame_stats.dart';
|
||||||
import 'package:todo/ui/capture_screen.dart';
|
import 'package:todo/ui/capture_screen.dart';
|
||||||
|
import 'package:todo/ui/text_free_localizations.dart';
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
@ -69,6 +70,7 @@ class TodoApp extends StatelessWidget {
|
|||||||
store: imageStore,
|
store: imageStore,
|
||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
title: 'todo',
|
title: 'todo',
|
||||||
|
localizationsDelegates: kTextFreeLocalizations,
|
||||||
theme: buildLightTheme(),
|
theme: buildLightTheme(),
|
||||||
darkTheme: buildDarkTheme(),
|
darkTheme: buildDarkTheme(),
|
||||||
home: CaptureScreen(
|
home: CaptureScreen(
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
/// a board that is down must not lose the task that was just saved.
|
/// a board that is down must not lose the task that was just saved.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:http/http.dart' as http;
|
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].
|
/// Talks to the board over [client] at [base].
|
||||||
class BoardClient {
|
class BoardClient {
|
||||||
/// [client] is injectable so tests need no network.
|
/// [client] is injectable so tests need no network. [wait] bounds how
|
||||||
BoardClient({http.Client? client, Uri? base})
|
/// long Start waits for the board to see a merged section (its note cache
|
||||||
: _http = client ?? http.Client(),
|
/// lags ~10 s, T1863) and to confirm a Start whose reply outran
|
||||||
base = base ?? kBoardBase;
|
/// [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;
|
final http.Client _http;
|
||||||
|
|
||||||
/// The board's origin; also the `Origin` header of a POST.
|
/// The board's origin; also the `Origin` header of a POST.
|
||||||
final Uri base;
|
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 {
|
Future<List<Map<String, dynamic>>> _cards(String noteId) async {
|
||||||
final res = await _http.get(base.resolve('/api/board')).timeout(_timeout);
|
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].
|
/// Clicks Start on the note's last startable item titled [title]. With
|
||||||
Future<StartResult> start(String noteId, String title) async {
|
/// [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 {
|
try {
|
||||||
final card = (await _cards(noteId)).reversed
|
final last = sections == null ? null : _item(noteId, sections - 1);
|
||||||
.where((c) => c['title'] == title && _startable(c))
|
bool seen(List<Map<String, dynamic>> cs) =>
|
||||||
.firstOrNull;
|
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');
|
if (card == null) return _no('no startable item');
|
||||||
final page = await _http.get(base).timeout(_timeout);
|
final page = await _http.get(base).timeout(_timeout);
|
||||||
final token = _csrf.firstMatch(page.body)?.group(1);
|
final token = _csrf.firstMatch(page.body)?.group(1);
|
||||||
if (token == null) return _no('no CSRF token on the board page');
|
if (token == null) return _no('no CSRF token on the board page');
|
||||||
final id = card['id']! as String;
|
final id = card['id']! as String;
|
||||||
final res = await _http
|
return await _post(noteId, id, token);
|
||||||
.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)}');
|
|
||||||
} on Object catch (e) {
|
} on Object catch (e) {
|
||||||
return _no('board unreachable: $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)
|
static bool _startable(Map<String, dynamic> card) => (card['actions'] as List)
|
||||||
.cast<Map<String, dynamic>>()
|
.cast<Map<String, dynamic>>()
|
||||||
.any((a) => a['id'] == 'start' && a['enabled'] == true);
|
.any((a) => a['id'] == 'start' && a['enabled'] == true);
|
||||||
|
|||||||
@ -17,7 +17,7 @@ class TaskService {
|
|||||||
final BoardClient? _board;
|
final BoardClient? _board;
|
||||||
Future<void> _tail = Future.value();
|
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());
|
final run = _tail.then((_) => body());
|
||||||
_tail = run.then((_) {}, onError: (_) {});
|
_tail = run.then((_) {}, onError: (_) {});
|
||||||
return run;
|
return run;
|
||||||
@ -44,8 +44,14 @@ class TaskService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `add_task`: joins the repo's open note (by: claude), or creates it.
|
/// `add_task`: joins the repo's open note (by: claude), or creates it. A
|
||||||
Future<String> add(Map<String, dynamic> args) => _serial(() async {
|
/// 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 a = _Args(args);
|
||||||
final repo = a.repo(required: true)!;
|
final repo = a.repo(required: true)!;
|
||||||
final title = a.required('title');
|
final title = a.required('title');
|
||||||
@ -70,9 +76,11 @@ class TaskService {
|
|||||||
doneCheck: a.optional('done_check'),
|
doneCheck: a.optional('done_check'),
|
||||||
priority: a.priority() ?? Priority.medium,
|
priority: a.priority() ?? Priority.medium,
|
||||||
by: 'claude',
|
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.
|
/// `list_tasks`: repo tasks, open ones unless `status` says otherwise.
|
||||||
Future<String> list(Map<String, dynamic> args) => _serial(() async {
|
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.
|
/// Saves [note] and returns `<verb> <id>` plus its list line.
|
||||||
typedef SaveNote = Future<String> Function(Note note, String verb);
|
typedef SaveNote = Future<String> Function(Note note, String verb);
|
||||||
|
|
||||||
/// Adds a separate task, merging into a duplicate's note when it is idle,
|
/// A saved split add: its reply so far, and the board Start still to run.
|
||||||
/// and clicks Start on the board after a merge that may run.
|
/// The caller runs [start] after releasing its lock, since the board learns
|
||||||
Future<String> addSplit(
|
/// 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,
|
NoteRepository repository,
|
||||||
BoardClient? board,
|
BoardClient? board,
|
||||||
SaveNote save, {
|
SaveNote save, {
|
||||||
@ -30,19 +35,33 @@ Future<String> addSplit(
|
|||||||
doneCheck: doneCheck,
|
doneCheck: doneCheck,
|
||||||
priority: priority,
|
priority: priority,
|
||||||
by: 'claude',
|
by: 'claude',
|
||||||
|
createdBy: 'claude',
|
||||||
busy: board == null ? null : (n) => board.running(n.id),
|
busy: board == null ? null : (n) => board.running(n.id),
|
||||||
);
|
);
|
||||||
if (added.kind != SplitKind.merged) {
|
if (added.kind != SplitKind.merged) {
|
||||||
final reply = await save(added.note, 'added');
|
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 reply = await save(added.note, 'merged into');
|
||||||
final block = startBlock(doneCheck, body);
|
final block = startBlock(doneCheck, body);
|
||||||
if (block != null) return '$reply\nstart: skipped ($block)';
|
if (block != null) {
|
||||||
final start = board == null
|
return (reply: '$reply\nstart: skipped ($block)', start: null);
|
||||||
? (ok: false, reason: 'no board configured', item: null)
|
}
|
||||||
: await board.start(added.note.id, title.trim());
|
Future<String> start() async {
|
||||||
if (start.ok) return '$reply\nstart: ok ${start.item}';
|
final s = board == null
|
||||||
return '$reply\nstart: {ok: false, reason: "${start.reason}"}; '
|
? (ok: false, reason: 'no board configured', item: null)
|
||||||
'the merge is saved, click Start on the board by hand';
|
: 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(
|
tool(
|
||||||
'list_tasks',
|
'list_tasks',
|
||||||
'List repo tasks, one line each: 8-char id, status, priority, repo, '
|
'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,
|
'repo': repo,
|
||||||
'status': JsonSchema.string(
|
'status': JsonSchema.string(
|
||||||
|
|||||||
@ -92,6 +92,7 @@ class NotesMarkdown {
|
|||||||
doneCheck: attrs['done_check'],
|
doneCheck: attrs['done_check'],
|
||||||
by: attrs['by'],
|
by: attrs['by'],
|
||||||
evidence: attrs['evidence'],
|
evidence: attrs['evidence'],
|
||||||
|
createdBy: attrs['created_by'],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -113,6 +114,7 @@ class NotesMarkdown {
|
|||||||
('done_check', note.doneCheck),
|
('done_check', note.doneCheck),
|
||||||
('by', note.by),
|
('by', note.by),
|
||||||
('evidence', note.evidence),
|
('evidence', note.evidence),
|
||||||
|
('created_by', note.createdBy),
|
||||||
])
|
])
|
||||||
if (value != null) ' $name="${_escape(value)}"',
|
if (value != null) ' $name="${_escape(value)}"',
|
||||||
].join();
|
].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;
|
library;
|
||||||
|
|
||||||
import 'package:design_system/design_system.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:todo/ui/motion.dart';
|
|
||||||
|
|
||||||
/// A route for [builder]: fade plus a short rise over [AppDuration.slow],
|
/// A route for [builder] that switches screens instantly: no fade, no slide.
|
||||||
/// instant under the OS "remove animations" setting.
|
///
|
||||||
Route<T> appRoute<T>(BuildContext context, WidgetBuilder builder) =>
|
/// Without a GPU the desktop web build repaints the whole 4K canvas in
|
||||||
_AppPageRoute<T>(
|
/// software on every frame, so a 320 ms transition cost ~600 ms of main
|
||||||
builder: builder,
|
/// thread and a 260-300 ms frame stall on every tab switch and note open.
|
||||||
duration: motionDuration(context, AppDuration.slow),
|
/// 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> {
|
class _AppPageRoute<T> extends MaterialPageRoute<T> {
|
||||||
_AppPageRoute({required super.builder, required this.duration});
|
_AppPageRoute({required super.builder});
|
||||||
|
|
||||||
final Duration duration;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Duration get transitionDuration => duration;
|
Duration get transitionDuration => Duration.zero;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Duration get reverseTransitionDuration => duration;
|
Duration get reverseTransitionDuration => Duration.zero;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget buildTransitions(
|
Widget buildTransitions(
|
||||||
@ -30,21 +28,5 @@ class _AppPageRoute<T> extends MaterialPageRoute<T> {
|
|||||||
Animation<double> animation,
|
Animation<double> animation,
|
||||||
Animation<double> secondaryAnimation,
|
Animation<double> secondaryAnimation,
|
||||||
Widget child,
|
Widget child,
|
||||||
) {
|
) => 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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -44,18 +44,24 @@ Future<List<XFile>> pickAttachments(
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.photo_camera_outlined),
|
leading: const Icon(
|
||||||
title: const Text('Camera'),
|
Icons.photo_camera_outlined,
|
||||||
|
semanticLabel: 'Camera',
|
||||||
|
),
|
||||||
onTap: () => Navigator.pop(context, AttachmentPickSource.camera),
|
onTap: () => Navigator.pop(context, AttachmentPickSource.camera),
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.photo_library_outlined),
|
leading: const Icon(
|
||||||
title: const Text('Gallery'),
|
Icons.photo_library_outlined,
|
||||||
|
semanticLabel: 'Gallery',
|
||||||
|
),
|
||||||
onTap: () => Navigator.pop(context, AttachmentPickSource.gallery),
|
onTap: () => Navigator.pop(context, AttachmentPickSource.gallery),
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.insert_drive_file_outlined),
|
leading: const Icon(
|
||||||
title: const Text('File'),
|
Icons.insert_drive_file_outlined,
|
||||||
|
semanticLabel: 'File',
|
||||||
|
),
|
||||||
onTap: () => Navigator.pop(context, AttachmentPickSource.file),
|
onTap: () => Navigator.pop(context, AttachmentPickSource.file),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -102,8 +108,7 @@ class AttachButton extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return IconButton(
|
return IconButton(
|
||||||
tooltip: 'Attach',
|
icon: const Icon(Icons.attach_file, semanticLabel: 'Attach'),
|
||||||
icon: const Icon(Icons.attach_file),
|
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
final files = await (picking ?? pickAttachments)(context);
|
final files = await (picking ?? pickAttachments)(context);
|
||||||
if (files.isNotEmpty) onFiles(files);
|
if (files.isNotEmpty) onFiles(files);
|
||||||
|
|||||||
@ -86,13 +86,11 @@ class CaptureAppBar extends StatelessWidget implements PreferredSizeWidget {
|
|||||||
// Not gated on advanced mode: attaching is basic capture.
|
// Not gated on advanced mode: attaching is basic capture.
|
||||||
AttachButton(onFiles: onAttachFiles, picking: attachmentPicking),
|
AttachButton(onFiles: onAttachFiles, picking: attachmentPicking),
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'New note',
|
|
||||||
onPressed: onNewNote,
|
onPressed: onNewNote,
|
||||||
icon: const Icon(Icons.check),
|
icon: const Icon(Icons.check, semanticLabel: 'New note'),
|
||||||
),
|
),
|
||||||
if (advanced)
|
if (advanced)
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Sync',
|
|
||||||
onPressed: syncing ? null : onSync,
|
onPressed: syncing ? null : onSync,
|
||||||
icon: syncing
|
icon: syncing
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
@ -100,12 +98,11 @@ class CaptureAppBar extends StatelessWidget implements PreferredSizeWidget {
|
|||||||
height: 18,
|
height: 18,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
)
|
)
|
||||||
: const Icon(Icons.sync),
|
: const Icon(Icons.sync, semanticLabel: 'Sync'),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Notes',
|
|
||||||
onPressed: onOpenList,
|
onPressed: onOpenList,
|
||||||
icon: const Icon(Icons.list),
|
icon: const Icon(Icons.list, semanticLabel: 'Notes'),
|
||||||
),
|
),
|
||||||
// Live count of stored notes, proving local persistence. Sits directly
|
// Live count of stored notes, proving local persistence. Sits directly
|
||||||
// right of the Notes button it belongs to, and carries no icon of its
|
// right of the Notes button it belongs to, and carries no icon of its
|
||||||
@ -122,9 +119,8 @@ class CaptureAppBar extends StatelessWidget implements PreferredSizeWidget {
|
|||||||
),
|
),
|
||||||
CaptureConfirmMark(captured: captured),
|
CaptureConfirmMark(captured: captured),
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Settings',
|
|
||||||
onPressed: onOpenSettings,
|
onPressed: onOpenSettings,
|
||||||
icon: const Icon(Icons.settings),
|
icon: const Icon(Icons.settings, semanticLabel: 'Settings'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@ -102,6 +102,7 @@ class CaptureDraft {
|
|||||||
status: status,
|
status: status,
|
||||||
createdAt: _createdAt!,
|
createdAt: _createdAt!,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
|
createdBy: 'kuhy',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (live()) lastSavedAt.value = now;
|
if (live()) lastSavedAt.value = now;
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:crdt_sync/crdt_sync.dart';
|
import 'package:crdt_sync/crdt_sync.dart';
|
||||||
import 'package:design_system/design_system.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
@ -22,6 +21,7 @@ import 'package:todo/ui/capture_draft.dart';
|
|||||||
import 'package:todo/ui/capture_status.dart';
|
import 'package:todo/ui/capture_status.dart';
|
||||||
import 'package:todo/ui/capture_status_footer.dart';
|
import 'package:todo/ui/capture_status_footer.dart';
|
||||||
import 'package:todo/ui/capture_sync_runner.dart';
|
import 'package:todo/ui/capture_sync_runner.dart';
|
||||||
|
import 'package:todo/ui/cue_snack.dart';
|
||||||
import 'package:todo/ui/image_attach.dart';
|
import 'package:todo/ui/image_attach.dart';
|
||||||
import 'package:todo/ui/image_input.dart';
|
import 'package:todo/ui/image_input.dart';
|
||||||
import 'package:todo/ui/note_form.dart';
|
import 'package:todo/ui/note_form.dart';
|
||||||
@ -194,7 +194,14 @@ class _CaptureScreenState extends State<CaptureScreen>
|
|||||||
Future<void> _sync() async {
|
Future<void> _sync() async {
|
||||||
final settings = _settings ?? await SyncSettings.load();
|
final settings = _settings ?? await SyncSettings.load();
|
||||||
if (!settings.isConfigured && await openFirebase() == null) {
|
if (!settings.isConfigured && await openFirebase() == null) {
|
||||||
_showSnack('Connect Firebase (or add a GitHub token) in settings');
|
// The cue plus the settings screen opening is the whole message.
|
||||||
|
if (mounted) {
|
||||||
|
showCue(
|
||||||
|
context,
|
||||||
|
Icons.cloud_off,
|
||||||
|
'Connect Firebase (or add a GitHub token) in settings',
|
||||||
|
);
|
||||||
|
}
|
||||||
await _openSettings();
|
await _openSettings();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -215,12 +222,7 @@ class _CaptureScreenState extends State<CaptureScreen>
|
|||||||
if (outcome.ok) 'detail': outcome.detail,
|
if (outcome.ok) 'detail': outcome.detail,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
_showSnack(
|
_showSyncCue(outcome);
|
||||||
outcome.ok
|
|
||||||
? 'Synced: ${outcome.detail}'
|
|
||||||
: 'Sync failed: ${outcome.detail}',
|
|
||||||
isError: !outcome.ok,
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _syncing = false);
|
if (mounted) setState(() => _syncing = false);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -104,13 +104,20 @@ extension _CaptureSyncBehaviour on _CaptureScreenState {
|
|||||||
/// replace-don't-queue behaviour match every other app; [isError] picks
|
/// replace-don't-queue behaviour match every other app; [isError] picks
|
||||||
/// the danger hue and the longer duration, because an error the user
|
/// the danger hue and the longer duration, because an error the user
|
||||||
/// missed is an error they will hit again.
|
/// missed is an error they will hit again.
|
||||||
void _showSnack(String message, {bool isError = false}) {
|
/// A manual sync's result as a glyph: done, done-but-degraded (Firebase
|
||||||
|
/// off or a peer unreadable, in the danger hue), or failed. The summary
|
||||||
|
/// sentence is spoken only; the status line keeps the failure time.
|
||||||
|
void _showSyncCue(CaptureSyncOutcome outcome) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (isError) {
|
final spoken = outcome.ok
|
||||||
showError(context, message);
|
? 'Synced: ${outcome.detail}'
|
||||||
} else {
|
: 'Sync failed: ${outcome.detail}';
|
||||||
showToast(context, message);
|
final (icon, error) = !outcome.ok
|
||||||
}
|
? (Icons.sync_problem, true)
|
||||||
|
: outcome.degraded
|
||||||
|
? (Icons.warning_amber, true)
|
||||||
|
: (Icons.cloud_done, false);
|
||||||
|
showCue(context, icon, spoken, error: error);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reloads the persisted last-sync outcome so the status line survives a
|
/// Reloads the persisted last-sync outcome so the status line survives a
|
||||||
|
|||||||
@ -44,16 +44,16 @@ class CaptureStatusFooter extends StatelessWidget {
|
|||||||
if (status == null || status.ok) {
|
if (status == null || status.ok) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
// Glyph + time in the danger hue; the error itself is spoken.
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(top: 8),
|
padding: const EdgeInsets.only(top: 8),
|
||||||
child: Text(
|
child: _cueLine(
|
||||||
|
Icons.sync_problem,
|
||||||
|
formatSyncTime(status.time),
|
||||||
'Sync failed at ${formatSyncTime(status.time)} · '
|
'Sync failed at ${formatSyncTime(status.time)} · '
|
||||||
'${status.detail}',
|
'${status.detail}',
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
theme.textTheme.bodySmall,
|
||||||
color: theme.colorScheme.error,
|
theme.colorScheme.error,
|
||||||
),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -63,15 +63,45 @@ class CaptureStatusFooter extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.only(top: 4),
|
padding: const EdgeInsets.only(top: 4),
|
||||||
child: ValueListenableBuilder<DateTime?>(
|
child: ValueListenableBuilder<DateTime?>(
|
||||||
valueListenable: lastSavedAt,
|
valueListenable: lastSavedAt,
|
||||||
builder: (context, savedAt, _) => Text(
|
// Before the first save: the save glyph alone, muted.
|
||||||
savedAt == null
|
builder: (context, savedAt, _) => savedAt == null
|
||||||
? 'Autosaves as you type'
|
? _cueLine(
|
||||||
: 'Saved locally at ${formatSyncTime(savedAt)}',
|
Icons.save_outlined,
|
||||||
style: theme.textTheme.bodySmall,
|
'',
|
||||||
),
|
'Autosaves as you type',
|
||||||
|
theme.textTheme.bodySmall,
|
||||||
|
theme.colorScheme.outline,
|
||||||
|
)
|
||||||
|
: _cueLine(
|
||||||
|
Icons.save,
|
||||||
|
formatSyncTime(savedAt),
|
||||||
|
'Saved locally at ${formatSyncTime(savedAt)}',
|
||||||
|
theme.textTheme.bodySmall,
|
||||||
|
null,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One status line: a small glyph and a time, spoken as [spoken].
|
||||||
|
Widget _cueLine(
|
||||||
|
IconData icon,
|
||||||
|
String time,
|
||||||
|
String spoken,
|
||||||
|
TextStyle? style,
|
||||||
|
Color? color,
|
||||||
|
) => Semantics(
|
||||||
|
label: spoken,
|
||||||
|
excludeSemantics: true,
|
||||||
|
container: true,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 16, color: color),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(time, style: style?.copyWith(color: color)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,11 +26,16 @@ class CaptureSyncOutcome {
|
|||||||
required this.ok,
|
required this.ok,
|
||||||
required this.detail,
|
required this.detail,
|
||||||
this.reconciled,
|
this.reconciled,
|
||||||
|
this.degraded = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Whether the pass completed without throwing.
|
/// Whether the pass completed without throwing.
|
||||||
final bool ok;
|
final bool ok;
|
||||||
|
|
||||||
|
/// A pass that completed but not fully: Firebase was not connected, or a
|
||||||
|
/// peer's file could not be read. Shown as a warning, not a plain success.
|
||||||
|
final bool degraded;
|
||||||
|
|
||||||
/// One-line summary: merged/skipped counts on success, the error otherwise.
|
/// One-line summary: merged/skipped counts on success, the error otherwise.
|
||||||
final String detail;
|
final String detail;
|
||||||
|
|
||||||
@ -90,10 +95,12 @@ class CaptureSyncRunner {
|
|||||||
);
|
);
|
||||||
final detail = describeSyncResult(result.syncResult);
|
final detail = describeSyncResult(result.syncResult);
|
||||||
await status.record(ok: true, detail: detail, live: live());
|
await status.record(ok: true, detail: detail, live: live());
|
||||||
|
final synced = result.syncResult;
|
||||||
return CaptureSyncOutcome(
|
return CaptureSyncOutcome(
|
||||||
ok: true,
|
ok: true,
|
||||||
detail: detail,
|
detail: detail,
|
||||||
reconciled: result.appSettings,
|
reconciled: result.appSettings,
|
||||||
|
degraded: !synced.firebaseConnected || synced.skippedFiles.isNotEmpty,
|
||||||
);
|
);
|
||||||
} on Exception catch (e) {
|
} on Exception catch (e) {
|
||||||
await status.record(ok: false, detail: '$e', live: live());
|
await status.record(ok: false, detail: '$e', live: live());
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
import 'package:todo/attachments/image_password_store.dart';
|
import 'package:todo/attachments/image_password_store.dart';
|
||||||
@ -18,6 +19,7 @@ import 'package:todo/attachments/image_store_api.dart';
|
|||||||
import 'package:todo/attachments/image_store_scope.dart';
|
import 'package:todo/attachments/image_store_scope.dart';
|
||||||
import 'package:todo/images/dufs_image_client.dart';
|
import 'package:todo/images/dufs_image_client.dart';
|
||||||
import 'package:todo/ui/app_route.dart';
|
import 'package:todo/ui/app_route.dart';
|
||||||
|
import 'package:todo/ui/status_cue.dart';
|
||||||
|
|
||||||
/// The command that creates the login, shown wherever it is missing.
|
/// The command that creates the login, shown wherever it is missing.
|
||||||
const kAddLoginCommand =
|
const kAddLoginCommand =
|
||||||
@ -34,8 +36,7 @@ class CloudImagesTile extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => ListTile(
|
Widget build(BuildContext context) => ListTile(
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
title: const Text('Cloud images'),
|
leading: const Icon(Icons.cloud, semanticLabel: 'Cloud images'),
|
||||||
subtitle: const Text('Where attached images and files upload (dufs)'),
|
|
||||||
trailing: const Icon(Icons.chevron_right),
|
trailing: const Icon(Icons.chevron_right),
|
||||||
onTap: () => Navigator.of(context).push<void>(
|
onTap: () => Navigator.of(context).push<void>(
|
||||||
appRoute(context, (_) => CloudImagesScreen(httpClient: httpClient)),
|
appRoute(context, (_) => CloudImagesScreen(httpClient: httpClient)),
|
||||||
@ -69,7 +70,7 @@ class CloudImagesScreen extends StatefulWidget {
|
|||||||
class _CloudImagesScreenState extends State<CloudImagesScreen> {
|
class _CloudImagesScreenState extends State<CloudImagesScreen> {
|
||||||
final _password = TextEditingController();
|
final _password = TextEditingController();
|
||||||
ImageStoreStatus? _status;
|
ImageStoreStatus? _status;
|
||||||
String? _checkResult;
|
StatusCue? _checkResult;
|
||||||
bool _checking = false;
|
bool _checking = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -98,7 +99,11 @@ class _CloudImagesScreenState extends State<CloudImagesScreen> {
|
|||||||
final password = _password.text.trim();
|
final password = _password.text.trim();
|
||||||
setState(() => _checking = true);
|
setState(() => _checking = true);
|
||||||
final saved = await widget.passwords.write(password);
|
final saved = await widget.passwords.write(password);
|
||||||
var result = 'Could not save: the keystore is unavailable';
|
var result = const StatusCue(
|
||||||
|
Icons.no_encryption,
|
||||||
|
'Could not save: the keystore is unavailable',
|
||||||
|
error: true,
|
||||||
|
);
|
||||||
if (saved) {
|
if (saved) {
|
||||||
final client = DufsImageClient(
|
final client = DufsImageClient(
|
||||||
DufsLogin(user: kDufsImagesUser, password: password),
|
DufsLogin(user: kDufsImagesUser, password: password),
|
||||||
@ -106,9 +111,17 @@ class _CloudImagesScreenState extends State<CloudImagesScreen> {
|
|||||||
);
|
);
|
||||||
final check = await client.check();
|
final check = await client.check();
|
||||||
result = switch (check) {
|
result = switch (check) {
|
||||||
DufsCheck.ok => 'Saved — dufs accepted the login',
|
DufsCheck.ok => const StatusCue.ok('Saved — dufs accepted the login'),
|
||||||
DufsCheck.rejected => 'Saved, but dufs rejected the password',
|
DufsCheck.rejected => const StatusCue(
|
||||||
DufsCheck.unreachable => 'Saved — dufs is unreachable right now',
|
Icons.lock,
|
||||||
|
'Saved, but dufs rejected the password',
|
||||||
|
error: true,
|
||||||
|
),
|
||||||
|
DufsCheck.unreachable => const StatusCue(
|
||||||
|
Icons.cloud_off,
|
||||||
|
'Saved — dufs is unreachable right now',
|
||||||
|
error: true,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
// Upload what queued up while there was no password, now rather than
|
// Upload what queued up while there was no password, now rather than
|
||||||
// on the next sync. No note texts: an empty ref set only uploads, it
|
// on the next sync. No note texts: an empty ref set only uploads, it
|
||||||
@ -129,7 +142,9 @@ class _CloudImagesScreenState extends State<CloudImagesScreen> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final status = _status;
|
final status = _status;
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Cloud images')),
|
appBar: AppBar(
|
||||||
|
title: const Icon(Icons.cloud_outlined, semanticLabel: 'Cloud images'),
|
||||||
|
),
|
||||||
body: ListView(
|
body: ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
@ -138,36 +153,78 @@ class _CloudImagesScreenState extends State<CloudImagesScreen> {
|
|||||||
controller: _password,
|
controller: _password,
|
||||||
obscureText: true,
|
obscureText: true,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Password for the "todo" dufs login',
|
prefixIcon: Icon(
|
||||||
|
Icons.lock_outline,
|
||||||
|
semanticLabel: 'Password for the "todo" dufs login',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: _checking ? null : _saveAndTest,
|
onPressed: _checking ? null : _saveAndTest,
|
||||||
child: const Text('Save & test'),
|
child: const Icon(Icons.save, semanticLabel: 'Save & test'),
|
||||||
),
|
),
|
||||||
if (_checkResult case final result?) ...[
|
if (_checkResult case final result?) ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(result),
|
StatusCueView(result),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
],
|
],
|
||||||
if (status != null) ...[
|
if (status != null) ...[
|
||||||
Text(
|
// Shape + colour: a filled check, or a red error ring followed by
|
||||||
status.configured
|
// a copy button that puts the fixing command on the clipboard.
|
||||||
? 'Login: configured'
|
Row(
|
||||||
: 'Login: missing — create it on the PC with',
|
children: [
|
||||||
|
Icon(
|
||||||
|
status.configured ? Icons.check_circle : Icons.error_outline,
|
||||||
|
color: status.configured
|
||||||
|
? Theme.of(context).colorScheme.primary
|
||||||
|
: Theme.of(context).colorScheme.error,
|
||||||
|
semanticLabel: status.configured
|
||||||
|
? 'Login: configured'
|
||||||
|
: 'Login: missing — create it on the PC with',
|
||||||
|
),
|
||||||
|
if (!status.configured)
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => unawaited(
|
||||||
|
Clipboard.setData(
|
||||||
|
const ClipboardData(text: kAddLoginCommand),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.content_copy,
|
||||||
|
semanticLabel: 'Copy: $kAddLoginCommand',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
if (!status.configured) const SelectableText(kAddLoginCommand),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text('Waiting to upload: ${status.pending}'),
|
// Icon + the count itself: the number is the information.
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.cloud_upload_outlined),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'${status.pending}',
|
||||||
|
semanticsLabel: 'Waiting to upload: ${status.pending}',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
// Shrunk + no location, as glyphs; the sentence is spoken only.
|
||||||
'Images are shrunk to 2560px JPEGs without location data before '
|
const Row(
|
||||||
'they leave this device. Other files upload unchanged, at any '
|
children: [
|
||||||
'size.',
|
Icon(
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
Icons.photo_size_select_large,
|
||||||
|
semanticLabel:
|
||||||
|
'Images are shrunk to 2560px JPEGs without location data '
|
||||||
|
'before they leave this device. Other files upload '
|
||||||
|
'unchanged, at any size.',
|
||||||
|
),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Icon(Icons.location_off),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
58
lib/ui/confirm_delete.dart
Normal file
58
lib/ui/confirm_delete.dart
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
/// Icon-only confirmation before deleting a note.
|
||||||
|
///
|
||||||
|
/// Replaces design_system's `confirmDestructive` here because that dialog
|
||||||
|
/// paints its title, message and button labels as text. The safety contract
|
||||||
|
/// is kept: Cancel is autofocused (a reflexive Return keeps the note), Escape
|
||||||
|
/// and the barrier answer "no", and the confirm glyph wears the error colour.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
/// Asks whether to delete a note; true only when the delete glyph is pressed.
|
||||||
|
Future<bool> confirmDeleteNote(BuildContext context) async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => const _ConfirmDeleteDialog(),
|
||||||
|
);
|
||||||
|
return confirmed ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ConfirmDeleteDialog extends StatelessWidget {
|
||||||
|
const _ConfirmDeleteDialog();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final error = Theme.of(context).colorScheme.error;
|
||||||
|
return CallbackShortcuts(
|
||||||
|
bindings: {
|
||||||
|
const SingleActivator(LogicalKeyboardKey.escape): () =>
|
||||||
|
Navigator.of(context).pop(false),
|
||||||
|
},
|
||||||
|
child: AlertDialog(
|
||||||
|
// Spoken when the dialog opens; the old written body.
|
||||||
|
semanticLabel: 'This cannot be undone.',
|
||||||
|
title: Icon(
|
||||||
|
Icons.delete_forever,
|
||||||
|
size: 40,
|
||||||
|
color: error,
|
||||||
|
semanticLabel: 'Delete note?',
|
||||||
|
),
|
||||||
|
actionsAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
key: const ValueKey('confirm-cancel'),
|
||||||
|
autofocus: true,
|
||||||
|
onPressed: () => Navigator.of(context).pop(false),
|
||||||
|
icon: const Icon(Icons.close, semanticLabel: 'Cancel'),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
key: const ValueKey('confirm-delete'),
|
||||||
|
onPressed: () => Navigator.of(context).pop(true),
|
||||||
|
icon: Icon(Icons.delete, color: error, semanticLabel: 'Delete'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
58
lib/ui/cue_snack.dart
Normal file
58
lib/ui/cue_snack.dart
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
/// Transient feedback as a glyph, not a sentence (kuhy's textgate rule).
|
||||||
|
///
|
||||||
|
/// The design system's `showToast`/`showError` paint a message; this paints
|
||||||
|
/// an icon (plus an optional number) and keeps the old sentence as the
|
||||||
|
/// spoken label only. Colour is the second channel: errors use the danger
|
||||||
|
/// hue and stay up longer, as `showError` does.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
const Duration _toastDuration = Duration(seconds: 3);
|
||||||
|
const Duration _errorDuration = Duration(seconds: 5);
|
||||||
|
|
||||||
|
/// Shows [icon] (and [count], if given) in a snack bar; [spoken] is what a
|
||||||
|
/// screen reader says. Returns quietly when no `ScaffoldMessenger` is above
|
||||||
|
/// [context] (an async callback outliving its screen).
|
||||||
|
void showCue(
|
||||||
|
BuildContext context,
|
||||||
|
IconData icon,
|
||||||
|
String spoken, {
|
||||||
|
bool error = false,
|
||||||
|
int? count,
|
||||||
|
}) {
|
||||||
|
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||||
|
if (messenger == null) return;
|
||||||
|
final colors = context.statusColors;
|
||||||
|
final fg = error ? colors.onStatus : null;
|
||||||
|
// Replace rather than queue: a queued cue describes an action the user
|
||||||
|
// has already moved on from.
|
||||||
|
messenger
|
||||||
|
..clearSnackBars()
|
||||||
|
..showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Semantics(
|
||||||
|
label: spoken,
|
||||||
|
excludeSemantics: true,
|
||||||
|
// Its own node: otherwise the label merges into the snack bar's.
|
||||||
|
container: true,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: fg),
|
||||||
|
if (count != null) ...[
|
||||||
|
const SizedBox(width: AppSpacing.sm),
|
||||||
|
Text('$count', style: TextStyle(color: fg)),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
backgroundColor: error ? colors.danger : null,
|
||||||
|
duration: error ? _errorDuration : _toastDuration,
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
23
lib/ui/empty_notes_cue.dart
Normal file
23
lib/ui/empty_notes_cue.dart
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
/// The notes list's empty state as a picture, not a sentence.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// A large muted glyph: an empty inbox when there are no notes at all, a
|
||||||
|
/// crossed-out filter when the current filter hides every note (the filter
|
||||||
|
/// button's badge is the second channel saying a filter is on).
|
||||||
|
class EmptyNotesCue extends StatelessWidget {
|
||||||
|
/// Creates the cue; [filtered] picks the filter variant.
|
||||||
|
const EmptyNotesCue({required this.filtered, super.key});
|
||||||
|
|
||||||
|
/// Whether a filter is active (notes may exist but none match).
|
||||||
|
final bool filtered;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Icon(
|
||||||
|
filtered ? Icons.filter_alt_off_outlined : Icons.inbox_outlined,
|
||||||
|
size: 64,
|
||||||
|
color: Theme.of(context).colorScheme.outline,
|
||||||
|
semanticLabel: filtered ? 'No notes match these filters' : 'No notes yet',
|
||||||
|
);
|
||||||
|
}
|
||||||
105
lib/ui/enum_glyphs.dart
Normal file
105
lib/ui/enum_glyphs.dart
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
/// Glyphs for the Priority and Status enums, and the chip row that picks
|
||||||
|
/// them, so every picker shows the same shape + colour for the same value.
|
||||||
|
///
|
||||||
|
/// The enum's `label` survives only as the spoken (semantics) label.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:todo/data/note.dart';
|
||||||
|
import 'package:todo/ui/note_grid_tile.dart';
|
||||||
|
|
||||||
|
/// Shape channel for a priority: up for high, level for medium, down for low.
|
||||||
|
/// Colour ([priorityColor]) is the second channel.
|
||||||
|
IconData priorityIcon(Priority p) => switch (p) {
|
||||||
|
Priority.high => Icons.keyboard_double_arrow_up,
|
||||||
|
Priority.medium => Icons.drag_handle,
|
||||||
|
Priority.low => Icons.keyboard_double_arrow_down,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Heading glyph standing for "status" (matches the bulk-status button).
|
||||||
|
const IconData kStatusHeadingIcon = Icons.task_alt;
|
||||||
|
|
||||||
|
/// Heading glyph standing for "priority" (matches the bulk-priority button).
|
||||||
|
const IconData kPriorityHeadingIcon = Icons.flag_outlined;
|
||||||
|
|
||||||
|
/// [s] as its glyph, spoken as its label.
|
||||||
|
Widget statusGlyph(Status s, {double? size}) =>
|
||||||
|
Icon(statusIcon(s), size: size, semanticLabel: s.label);
|
||||||
|
|
||||||
|
/// [p] as its tinted glyph, spoken as its label.
|
||||||
|
Widget priorityGlyph(BuildContext context, Priority p, {double? size}) => Icon(
|
||||||
|
priorityIcon(p),
|
||||||
|
size: size,
|
||||||
|
color: priorityColor(p, Theme.of(context).colorScheme),
|
||||||
|
semanticLabel: p.label,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// A heading glyph followed by one chip per value, each chip a glyph.
|
||||||
|
///
|
||||||
|
/// [multi] switches between FilterChips (toggle many) and ChoiceChips (pick
|
||||||
|
/// one); either way the selected chips are filled and checked.
|
||||||
|
class GlyphChips<T> extends StatelessWidget {
|
||||||
|
/// Creates a chip row for [values].
|
||||||
|
const GlyphChips({
|
||||||
|
required this.heading,
|
||||||
|
required this.semanticLabel,
|
||||||
|
required this.values,
|
||||||
|
required this.selected,
|
||||||
|
required this.glyphOf,
|
||||||
|
required this.onTap,
|
||||||
|
this.multi = false,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Glyph shown in front of the chips in place of a written heading.
|
||||||
|
final IconData heading;
|
||||||
|
|
||||||
|
/// Spoken name of the row (the old written heading).
|
||||||
|
final String semanticLabel;
|
||||||
|
|
||||||
|
/// Every value offered.
|
||||||
|
final List<T> values;
|
||||||
|
|
||||||
|
/// Values currently selected.
|
||||||
|
final Set<T> selected;
|
||||||
|
|
||||||
|
/// Builds the glyph for one value.
|
||||||
|
final Widget Function(T) glyphOf;
|
||||||
|
|
||||||
|
/// Called with the tapped value.
|
||||||
|
final ValueChanged<T> onTap;
|
||||||
|
|
||||||
|
/// Whether this is a multi-select (filter) row.
|
||||||
|
final bool multi;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Icon(heading, semanticLabel: semanticLabel),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
for (final v in values)
|
||||||
|
if (multi)
|
||||||
|
FilterChip(
|
||||||
|
label: glyphOf(v),
|
||||||
|
selected: selected.contains(v),
|
||||||
|
onSelected: (_) => onTap(v),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
ChoiceChip(
|
||||||
|
label: glyphOf(v),
|
||||||
|
selected: selected.contains(v),
|
||||||
|
onSelected: (_) => onTap(v),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9,6 +9,7 @@ import 'package:todo/data/app_settings.dart';
|
|||||||
import 'package:todo/data/note_repository.dart';
|
import 'package:todo/data/note_repository.dart';
|
||||||
import 'package:todo/sync/run_sync.dart';
|
import 'package:todo/sync/run_sync.dart';
|
||||||
import 'package:todo/sync/sync_settings.dart';
|
import 'package:todo/sync/sync_settings.dart';
|
||||||
|
import 'package:todo/ui/status_cue.dart';
|
||||||
|
|
||||||
part 'github_mirror_screen_widget.dart';
|
part 'github_mirror_screen_widget.dart';
|
||||||
|
|
||||||
@ -27,7 +28,7 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
bool _testing = false;
|
bool _testing = false;
|
||||||
String? _status;
|
StatusCue? _status;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
@ -49,8 +50,13 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
|
|||||||
Future<void> _connectGitHub() async {
|
Future<void> _connectGitHub() async {
|
||||||
final clientId = _clientId.text.trim();
|
final clientId = _clientId.text.trim();
|
||||||
if (clientId.isEmpty) {
|
if (clientId.isEmpty) {
|
||||||
setState(() => _status = 'Enter the OAuth App client id first.');
|
return _show(
|
||||||
return;
|
const StatusCue(
|
||||||
|
Icons.key_off,
|
||||||
|
'Enter the OAuth App client id first.',
|
||||||
|
error: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
final auth = GitHubDeviceAuth(
|
final auth = GitHubDeviceAuth(
|
||||||
clientId: clientId,
|
clientId: clientId,
|
||||||
@ -67,20 +73,19 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
|
|||||||
if (token != null && token.isNotEmpty) {
|
if (token != null && token.isNotEmpty) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_token.text = token;
|
_token.text = token;
|
||||||
_status = 'Connected — syncing…';
|
_status = const StatusCue(Icons.sync, 'Connected — syncing…');
|
||||||
});
|
});
|
||||||
await _current.save();
|
await _current.save();
|
||||||
await _syncAfterConnect();
|
await _syncAfterConnect();
|
||||||
}
|
}
|
||||||
} on Exception catch (e) {
|
} on Exception catch (e) {
|
||||||
if (mounted) setState(() => _status = 'Could not start device flow: $e');
|
_show(StatusCue.failed('Could not start device flow: $e'));
|
||||||
} finally {
|
} finally {
|
||||||
auth.close();
|
auth.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runs a sync right after connecting so the user's notes download
|
/// Syncs right after connecting so notes download at once, with a cue.
|
||||||
/// immediately and they get clear confirmation it worked.
|
|
||||||
Future<void> _syncAfterConnect() async {
|
Future<void> _syncAfterConnect() async {
|
||||||
final s = _current;
|
final s = _current;
|
||||||
try {
|
try {
|
||||||
@ -96,19 +101,29 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
|
|||||||
widget.appSettings.value = widget.appSettings.value.adopt(
|
widget.appSettings.value = widget.appSettings.value.adopt(
|
||||||
run.appSettings,
|
run.appSettings,
|
||||||
);
|
);
|
||||||
if (mounted) {
|
_show(
|
||||||
setState(
|
StatusCue.ok(
|
||||||
() => _status =
|
'Connected and synced (merged '
|
||||||
'Connected and synced (merged '
|
'${run.syncResult.mergedDevices} device(s)). Your notes are '
|
||||||
'${run.syncResult.mergedDevices} device(s)). Your notes are '
|
'up to date.',
|
||||||
'up to date.',
|
),
|
||||||
);
|
);
|
||||||
}
|
|
||||||
} on Exception catch (e) {
|
} on Exception catch (e) {
|
||||||
if (mounted) setState(() => _status = 'Connected, but sync failed: $e');
|
_show(
|
||||||
|
StatusCue(
|
||||||
|
Icons.sync_problem,
|
||||||
|
'Connected, but sync failed: $e',
|
||||||
|
error: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Shows [cue] as the screen's latest result, if still mounted.
|
||||||
|
void _show(StatusCue cue) {
|
||||||
|
if (mounted) setState(() => _status = cue);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _test() async {
|
Future<void> _test() async {
|
||||||
setState(() {
|
setState(() {
|
||||||
_testing = true;
|
_testing = true;
|
||||||
@ -123,13 +138,17 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
|
|||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
final ok = await client.canAccessRepo();
|
final ok = await client.canAccessRepo();
|
||||||
setState(
|
_show(
|
||||||
() => _status = ok
|
ok
|
||||||
? 'Connected — repo is reachable.'
|
? const StatusCue.ok('Connected — repo is reachable.')
|
||||||
: 'Could not access ${s.owner}/${s.repo}. Check token scope.',
|
: StatusCue(
|
||||||
|
Icons.lock,
|
||||||
|
'Could not access ${s.owner}/${s.repo}. Check token scope.',
|
||||||
|
error: true,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
} on Exception catch (e) {
|
} on Exception catch (e) {
|
||||||
setState(() => _status = 'Error: $e');
|
_show(StatusCue.failed('Error: $e'));
|
||||||
} finally {
|
} finally {
|
||||||
client.close();
|
client.close();
|
||||||
if (mounted) setState(() => _testing = false);
|
if (mounted) setState(() => _testing = false);
|
||||||
@ -145,29 +164,27 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Advanced sync (GitHub)')),
|
appBar: AppBar(
|
||||||
|
title: const Icon(
|
||||||
|
Icons.cloud_sync,
|
||||||
|
semanticLabel: 'Advanced sync (GitHub)',
|
||||||
|
),
|
||||||
|
),
|
||||||
body: ListView(
|
body: ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
Text(
|
|
||||||
'Authorize in your browser — no token to paste. Syncs to '
|
|
||||||
'kuhyx/syncs by default.',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Align(
|
Align(
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
child: FilledButton.icon(
|
child: IconButton.filled(
|
||||||
onPressed: _connectGitHub,
|
onPressed: _connectGitHub,
|
||||||
icon: const Icon(Icons.login),
|
icon: const Icon(Icons.login, semanticLabel: 'Connect GitHub'),
|
||||||
label: const Text('Connect GitHub'),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextField(
|
TextField(
|
||||||
controller: _owner,
|
controller: _owner,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'GitHub owner',
|
prefixIcon: Icon(Icons.person, semanticLabel: 'GitHub owner'),
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -175,7 +192,7 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
|
|||||||
TextField(
|
TextField(
|
||||||
controller: _repo,
|
controller: _repo,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Repository name',
|
prefixIcon: Icon(Icons.folder, semanticLabel: 'Repository name'),
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -183,8 +200,7 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
|
|||||||
TextField(
|
TextField(
|
||||||
controller: _clientId,
|
controller: _clientId,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'OAuth App client id',
|
prefixIcon: Icon(Icons.key, semanticLabel: 'OAuth App client id'),
|
||||||
helperText: 'Leave as the baked-in default unless self-hosting',
|
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -193,15 +209,17 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
|
|||||||
controller: _token,
|
controller: _token,
|
||||||
obscureText: true,
|
obscureText: true,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Access token (fallback)',
|
prefixIcon: Icon(
|
||||||
helperText: 'Contents: read/write on the sync repo only',
|
Icons.vpn_key,
|
||||||
|
semanticLabel: 'Access token (fallback)',
|
||||||
|
),
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
OutlinedButton.icon(
|
IconButton.outlined(
|
||||||
onPressed: _testing ? null : _test,
|
onPressed: _testing ? null : _test,
|
||||||
icon: _testing
|
icon: _testing
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
@ -209,20 +227,21 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
|
|||||||
height: 16,
|
height: 16,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
)
|
)
|
||||||
: const Icon(Icons.wifi_tethering),
|
: const Icon(
|
||||||
label: const Text('Test connection'),
|
Icons.wifi_tethering,
|
||||||
|
semanticLabel: 'Test connection',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
FilledButton.icon(
|
IconButton.filled(
|
||||||
onPressed: _save,
|
onPressed: _save,
|
||||||
icon: const Icon(Icons.save),
|
icon: const Icon(Icons.save, semanticLabel: 'Save'),
|
||||||
label: const Text('Save'),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (_status != null) ...[
|
if (_status != null) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(_status!, style: Theme.of(context).textTheme.bodyMedium),
|
StatusCueView(_status!),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@ -9,12 +9,12 @@ library;
|
|||||||
|
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:design_system/design_system.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
|
||||||
|
|
||||||
import 'package:todo/attachments/image_store_api.dart';
|
import 'package:todo/attachments/image_store_api.dart';
|
||||||
import 'package:todo/attachments/image_store_scope.dart';
|
import 'package:todo/attachments/image_store_scope.dart';
|
||||||
import 'package:todo/images/attachment_ref.dart';
|
import 'package:todo/images/attachment_ref.dart';
|
||||||
|
import 'package:todo/ui/cue_snack.dart';
|
||||||
|
|
||||||
/// Lets a screen insert lines into whichever editor is currently mounted.
|
/// Lets a screen insert lines into whichever editor is currently mounted.
|
||||||
class ImageAttachController {
|
class ImageAttachController {
|
||||||
@ -73,11 +73,15 @@ Future<void> attachImages(
|
|||||||
target.insert([for (final ref in refs) ref.imageMarkdown]);
|
target.insert([for (final ref in refs) ref.imageMarkdown]);
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
if (failure != null) {
|
if (failure != null) {
|
||||||
showError(context, failure);
|
showCue(context, Icons.cloud_off, failure, error: true);
|
||||||
} else if (notImages > 0) {
|
} else if (notImages > 0) {
|
||||||
showError(
|
// A broken-image glyph plus how many were refused.
|
||||||
|
showCue(
|
||||||
context,
|
context,
|
||||||
|
Icons.broken_image_outlined,
|
||||||
notImages == 1 ? 'Not an image' : '$notImages files were not images',
|
notImages == 1 ? 'Not an image' : '$notImages files were not images',
|
||||||
|
error: true,
|
||||||
|
count: notImages,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -104,5 +108,7 @@ Future<void> attachFiles(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
target.insert([for (final attachment in attached) attachment.markdown]);
|
target.insert([for (final attachment in attached) attachment.markdown]);
|
||||||
if (failure != null && context.mounted) showError(context, failure);
|
if (failure != null && context.mounted) {
|
||||||
|
showCue(context, Icons.cloud_off, failure, error: true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:todo/attachments/image_store_api.dart';
|
import 'package:todo/attachments/image_store_api.dart';
|
||||||
import 'package:todo/attachments/image_store_scope.dart';
|
import 'package:todo/attachments/image_store_scope.dart';
|
||||||
import 'package:todo/images/attachment_ref.dart';
|
import 'package:todo/images/attachment_ref.dart';
|
||||||
|
import 'package:todo/ui/cue_snack.dart';
|
||||||
import 'package:todo/ui/images/file_opening.dart';
|
import 'package:todo/ui/images/file_opening.dart';
|
||||||
import 'package:todo/ui/images/image_embed.dart';
|
import 'package:todo/ui/images/image_embed.dart';
|
||||||
|
|
||||||
@ -51,7 +52,9 @@ class _FileEmbedState extends State<FileEmbed> {
|
|||||||
|
|
||||||
Future<void> _open(ImageSource source) async {
|
Future<void> _open(ImageSource source) async {
|
||||||
final failure = await widget.opening(source, widget.attachment.name);
|
final failure = await widget.opening(source, widget.attachment.name);
|
||||||
if (failure != null && mounted) showError(context, failure);
|
if (failure != null && mounted) {
|
||||||
|
showCue(context, Icons.block, failure, error: true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -73,9 +76,10 @@ class _FileEmbedState extends State<FileEmbed> {
|
|||||||
style: widget.compact ? theme.textTheme.bodySmall : null,
|
style: widget.compact ? theme.textTheme.bodySmall : null,
|
||||||
);
|
);
|
||||||
final pending = store.isPending(attachment.ref)
|
final pending = store.isPending(attachment.ref)
|
||||||
? const Tooltip(
|
? const Icon(
|
||||||
message: kImageWaitingToUpload,
|
Icons.cloud_upload_outlined,
|
||||||
child: Icon(Icons.cloud_upload_outlined, size: 18),
|
size: 18,
|
||||||
|
semanticLabel: kImageWaitingToUpload,
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
final content = widget.compact
|
final content = widget.compact
|
||||||
@ -104,15 +108,21 @@ class _FileEmbedState extends State<FileEmbed> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
name,
|
name,
|
||||||
Text(detail, style: theme.textTheme.bodySmall),
|
// Not downloaded: a download glyph; else the size.
|
||||||
|
if (size == null)
|
||||||
|
const Icon(Icons.cloud_download_outlined, size: 16)
|
||||||
|
else
|
||||||
|
Text(detail, style: theme.textTheme.bodySmall),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
?pending,
|
?pending,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
// Spoken only: nothing paints this string.
|
||||||
|
final semanticLabel = 'Attached file ${attachment.name}, $detail';
|
||||||
return Semantics(
|
return Semantics(
|
||||||
label: 'Attached file ${attachment.name}, $detail',
|
label: semanticLabel,
|
||||||
button: size != null,
|
button: size != null,
|
||||||
child: Material(
|
child: Material(
|
||||||
color: theme.colorScheme.surfaceContainerHighest,
|
color: theme.colorScheme.surfaceContainerHighest,
|
||||||
|
|||||||
@ -33,8 +33,10 @@ class ImageEmbed extends StatelessWidget {
|
|||||||
final store = ImageStoreScope.of(context);
|
final store = ImageStoreScope.of(context);
|
||||||
final source = store.sourceFor(ref);
|
final source = store.sourceFor(ref);
|
||||||
final picture = imageFor(source, fit: fit);
|
final picture = imageFor(source, fit: fit);
|
||||||
|
// Spoken only: nothing paints this string.
|
||||||
|
const semanticLabel = 'Attached image';
|
||||||
return Semantics(
|
return Semantics(
|
||||||
label: 'Attached image',
|
label: semanticLabel,
|
||||||
button: source is! MissingImageSource,
|
button: source is! MissingImageSource,
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: source is MissingImageSource
|
onTap: source is MissingImageSource
|
||||||
@ -52,9 +54,10 @@ class ImageEmbed extends StatelessWidget {
|
|||||||
const Positioned(
|
const Positioned(
|
||||||
right: 4,
|
right: 4,
|
||||||
top: 4,
|
top: 4,
|
||||||
child: Tooltip(
|
child: Icon(
|
||||||
message: kImageWaitingToUpload,
|
Icons.cloud_upload_outlined,
|
||||||
child: Icon(Icons.cloud_upload_outlined, size: 18),
|
size: 18,
|
||||||
|
semanticLabel: kImageWaitingToUpload,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -85,7 +88,7 @@ Widget imageFor(ImageSource source, {required BoxFit fit}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Grey box with [kImageNotDownloaded].
|
/// Grey box with a download glyph, spoken as [kImageNotDownloaded].
|
||||||
class ImagePlaceholder extends StatelessWidget {
|
class ImagePlaceholder extends StatelessWidget {
|
||||||
/// Creates the placeholder.
|
/// Creates the placeholder.
|
||||||
const ImagePlaceholder({super.key});
|
const ImagePlaceholder({super.key});
|
||||||
@ -98,10 +101,10 @@ class ImagePlaceholder extends StatelessWidget {
|
|||||||
child: Center(
|
child: Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
child: Text(
|
child: Icon(
|
||||||
kImageNotDownloaded,
|
Icons.cloud_download_outlined,
|
||||||
textAlign: TextAlign.center,
|
color: theme.colorScheme.outline,
|
||||||
style: theme.textTheme.bodySmall,
|
semanticLabel: kImageNotDownloaded,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -6,10 +6,11 @@ library;
|
|||||||
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:design_system/design_system.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:todo/data/note.dart';
|
import 'package:todo/data/note.dart';
|
||||||
import 'package:todo/data/note_template.dart';
|
import 'package:todo/data/note_template.dart';
|
||||||
|
import 'package:todo/ui/confirm_delete.dart';
|
||||||
|
import 'package:todo/ui/enum_glyphs.dart';
|
||||||
import 'package:todo/ui/note_sheet_details.dart';
|
import 'package:todo/ui/note_sheet_details.dart';
|
||||||
import 'package:todo/ui/note_task_details.dart';
|
import 'package:todo/ui/note_task_details.dart';
|
||||||
|
|
||||||
@ -61,53 +62,54 @@ class _NoteActionsSheetState extends State<NoteActionsSheet> {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
// User content; untitled shows nothing, not a placeholder.
|
||||||
firstLine.isEmpty ? '(empty)' : firstLine,
|
if (firstLine.isNotEmpty)
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
Text(
|
||||||
maxLines: 2,
|
firstLine,
|
||||||
overflow: TextOverflow.ellipsis,
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
),
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
NoteSheetDetails(note: widget.note),
|
NoteSheetDetails(note: widget.note),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_EnumChips<Status>(
|
GlyphChips<Status>(
|
||||||
label: 'Status',
|
heading: kStatusHeadingIcon,
|
||||||
|
semanticLabel: 'Status',
|
||||||
values: Status.values,
|
values: Status.values,
|
||||||
selected: {_status},
|
selected: {_status},
|
||||||
labelOf: (s) => s.label,
|
glyphOf: statusGlyph,
|
||||||
onSelected: (s) {
|
onTap: (s) {
|
||||||
setState(() => _status = s);
|
setState(() => _status = s);
|
||||||
unawaited(_persist());
|
unawaited(_persist());
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_EnumChips<Priority>(
|
GlyphChips<Priority>(
|
||||||
label: 'Priority',
|
heading: kPriorityHeadingIcon,
|
||||||
|
semanticLabel: 'Priority',
|
||||||
values: Priority.values,
|
values: Priority.values,
|
||||||
selected: {_priority},
|
selected: {_priority},
|
||||||
labelOf: (p) => p.label,
|
glyphOf: (p) => priorityGlyph(context, p),
|
||||||
onSelected: (p) {
|
onTap: (p) {
|
||||||
setState(() => _priority = p);
|
setState(() => _priority = p);
|
||||||
unawaited(_persist());
|
unawaited(_persist());
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
NoteTaskDetails(note: widget.note),
|
NoteTaskDetails(note: widget.note),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
OutlinedButton.icon(
|
Align(
|
||||||
icon: const Icon(Icons.delete_outline),
|
child: IconButton.outlined(
|
||||||
label: const Text('Delete note'),
|
color: Theme.of(context).colorScheme.error,
|
||||||
style: OutlinedButton.styleFrom(
|
icon: const Icon(
|
||||||
foregroundColor: Theme.of(context).colorScheme.error,
|
Icons.delete_outline,
|
||||||
|
semanticLabel: 'Delete note',
|
||||||
|
),
|
||||||
|
onPressed: () async {
|
||||||
|
if (!await confirmDeleteNote(context)) return;
|
||||||
|
await widget.onDelete();
|
||||||
|
if (context.mounted) Navigator.of(context).pop();
|
||||||
|
},
|
||||||
),
|
),
|
||||||
onPressed: () async {
|
|
||||||
final confirmed = await confirmDestructive(
|
|
||||||
context,
|
|
||||||
title: 'Delete note?',
|
|
||||||
message: 'This cannot be undone.',
|
|
||||||
);
|
|
||||||
if (!confirmed) return;
|
|
||||||
await widget.onDelete();
|
|
||||||
if (context.mounted) Navigator.of(context).pop();
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -116,42 +118,3 @@ class _NoteActionsSheetState extends State<NoteActionsSheet> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Single-select chip row for an enum (used for per-note priority/status).
|
|
||||||
class _EnumChips<T> extends StatelessWidget {
|
|
||||||
const _EnumChips({
|
|
||||||
required this.label,
|
|
||||||
required this.values,
|
|
||||||
required this.selected,
|
|
||||||
required this.labelOf,
|
|
||||||
required this.onSelected,
|
|
||||||
});
|
|
||||||
|
|
||||||
final String label;
|
|
||||||
final List<T> values;
|
|
||||||
final Set<T> selected;
|
|
||||||
final String Function(T) labelOf;
|
|
||||||
final ValueChanged<T> onSelected;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(label, style: Theme.of(context).textTheme.labelLarge),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Wrap(
|
|
||||||
spacing: 8,
|
|
||||||
children: [
|
|
||||||
for (final v in values)
|
|
||||||
ChoiceChip(
|
|
||||||
label: Text(labelOf(v)),
|
|
||||||
selected: selected.contains(v),
|
|
||||||
onSelected: (_) => onSelected(v),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -9,19 +9,29 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Glyph for "created" (a note added), shared by the filter and sort menus.
|
||||||
|
const IconData kCreatedIcon = Icons.post_add;
|
||||||
|
|
||||||
|
/// Glyph for "last updated", shared by the filter and sort menus.
|
||||||
|
const IconData kUpdatedIcon = Icons.update;
|
||||||
|
|
||||||
/// A labelled date range shown as one preset menu plus a custom picker.
|
/// A labelled date range shown as one preset menu plus a custom picker.
|
||||||
class DateRangeField extends StatelessWidget {
|
class DateRangeField extends StatelessWidget {
|
||||||
/// Creates a labelled date-range control.
|
/// Creates a labelled date-range control.
|
||||||
const DateRangeField({
|
const DateRangeField({
|
||||||
required this.label,
|
required this.icon,
|
||||||
|
required this.semanticLabel,
|
||||||
required this.from,
|
required this.from,
|
||||||
required this.to,
|
required this.to,
|
||||||
required this.onChanged,
|
required this.onChanged,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Heading shown above the control (e.g. 'Created').
|
/// Glyph shown in the control's border in place of a written heading.
|
||||||
final String label;
|
final IconData icon;
|
||||||
|
|
||||||
|
/// Spoken name of the control (e.g. 'Created'); never painted.
|
||||||
|
final String semanticLabel;
|
||||||
|
|
||||||
/// Start of the selected range, or null when unbounded.
|
/// Start of the selected range, or null when unbounded.
|
||||||
final DateTime? from;
|
final DateTime? from;
|
||||||
@ -86,15 +96,16 @@ class DateRangeField extends StatelessWidget {
|
|||||||
// A popup menu rather than DropdownButton: re-picking "Custom…" while a
|
// A popup menu rather than DropdownButton: re-picking "Custom…" while a
|
||||||
// custom range is shown must still reopen the picker.
|
// custom range is shown must still reopen the picker.
|
||||||
return PopupMenuButton<_Preset>(
|
return PopupMenuButton<_Preset>(
|
||||||
tooltip: label,
|
// Empty, not the label: a tooltip is painted on long-press.
|
||||||
|
tooltip: '',
|
||||||
onSelected: (p) => _select(context, p),
|
onSelected: (p) => _select(context, p),
|
||||||
itemBuilder: (_) => [
|
itemBuilder: (_) => [
|
||||||
for (final p in _Preset.values)
|
for (final p in _Preset.values)
|
||||||
PopupMenuItem(value: p, child: Text(p.label)),
|
PopupMenuItem(value: p, child: p.glyph()),
|
||||||
],
|
],
|
||||||
child: InputDecorator(
|
child: InputDecorator(
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: label,
|
prefixIcon: Icon(icon, semanticLabel: semanticLabel),
|
||||||
isDense: true,
|
isDense: true,
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
@ -103,10 +114,9 @@ class DateRangeField extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
suffixIcon: const Icon(Icons.arrow_drop_down),
|
suffixIcon: const Icon(Icons.arrow_drop_down),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: current == _Preset.custom
|
||||||
current == _Preset.custom ? _rangeLabel() : current.label,
|
? Text(_rangeLabel(), style: theme.textTheme.bodyMedium)
|
||||||
style: theme.textTheme.bodyMedium,
|
: current.glyph(),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -131,8 +141,26 @@ enum _Preset {
|
|||||||
|
|
||||||
const _Preset(this.label, this.days);
|
const _Preset(this.label, this.days);
|
||||||
|
|
||||||
|
/// Spoken name only; what is painted is [glyph].
|
||||||
final String label;
|
final String label;
|
||||||
final int? days;
|
final int? days;
|
||||||
|
|
||||||
|
/// "Any" is an infinity sign, "today" the today-calendar, a span its day
|
||||||
|
/// count beside a range-calendar, "custom" the edit-calendar.
|
||||||
|
Widget glyph() => Semantics(
|
||||||
|
label: label,
|
||||||
|
excludeSemantics: true,
|
||||||
|
container: true,
|
||||||
|
child: switch (this) {
|
||||||
|
any => const Icon(Icons.all_inclusive),
|
||||||
|
today => const Icon(Icons.today),
|
||||||
|
custom => const Icon(Icons.edit_calendar),
|
||||||
|
week || month => Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [const Icon(Icons.date_range), Text(' $days')],
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
static const List<_Preset> dayPresets = [today, week, month];
|
static const List<_Preset> dayPresets = [today, week, month];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:design_system/design_system.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:todo/data/app_settings.dart';
|
import 'package:todo/data/app_settings.dart';
|
||||||
@ -8,6 +7,7 @@ import 'package:todo/data/note.dart';
|
|||||||
import 'package:todo/data/note_repository.dart';
|
import 'package:todo/data/note_repository.dart';
|
||||||
import 'package:todo/data/note_template.dart';
|
import 'package:todo/data/note_template.dart';
|
||||||
import 'package:todo/ui/attachment_picking.dart';
|
import 'package:todo/ui/attachment_picking.dart';
|
||||||
|
import 'package:todo/ui/confirm_delete.dart';
|
||||||
import 'package:todo/ui/image_attach.dart';
|
import 'package:todo/ui/image_attach.dart';
|
||||||
import 'package:todo/ui/image_input.dart';
|
import 'package:todo/ui/image_input.dart';
|
||||||
import 'package:todo/ui/note_form.dart';
|
import 'package:todo/ui/note_form.dart';
|
||||||
@ -85,12 +85,7 @@ class _NoteDetailScreenState extends State<NoteDetailScreen> {
|
|||||||
_persist(_note.copyWith(text: text, updatedAt: DateTime.now()));
|
_persist(_note.copyWith(text: text, updatedAt: DateTime.now()));
|
||||||
|
|
||||||
Future<void> _delete() async {
|
Future<void> _delete() async {
|
||||||
final confirmed = await confirmDestructive(
|
if (!await confirmDeleteNote(context)) return;
|
||||||
context,
|
|
||||||
title: 'Delete note?',
|
|
||||||
message: 'This cannot be undone.',
|
|
||||||
);
|
|
||||||
if (!confirmed) return;
|
|
||||||
await widget.repository.delete(_note.id);
|
await widget.repository.delete(_note.id);
|
||||||
if (mounted) Navigator.of(context).pop();
|
if (mounted) Navigator.of(context).pop();
|
||||||
}
|
}
|
||||||
@ -100,7 +95,8 @@ class _NoteDetailScreenState extends State<NoteDetailScreen> {
|
|||||||
final title = noteTitle(_note.text);
|
final title = noteTitle(_note.text);
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(title.isEmpty ? '(empty)' : title),
|
// An untitled note shows no title rather than a written placeholder.
|
||||||
|
title: title.isEmpty ? null : Text(title),
|
||||||
actions: [
|
actions: [
|
||||||
AttachButton(
|
AttachButton(
|
||||||
onFiles: _attachFiles,
|
onFiles: _attachFiles,
|
||||||
@ -114,8 +110,10 @@ class _NoteDetailScreenState extends State<NoteDetailScreen> {
|
|||||||
onPressed: _copy,
|
onPressed: _copy,
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Delete note',
|
icon: const Icon(
|
||||||
icon: const Icon(Icons.delete_outline),
|
Icons.delete_outline,
|
||||||
|
semanticLabel: 'Delete note',
|
||||||
|
),
|
||||||
onPressed: _delete,
|
onPressed: _delete,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import 'package:design_system/design_system.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'package:todo/data/note.dart';
|
import 'package:todo/data/note.dart';
|
||||||
import 'package:todo/data/note_template.dart';
|
import 'package:todo/data/note_template.dart';
|
||||||
|
import 'package:todo/ui/cue_snack.dart';
|
||||||
import 'package:todo/ui/image_attach.dart';
|
import 'package:todo/ui/image_attach.dart';
|
||||||
import 'package:todo/ui/markdown_view.dart';
|
import 'package:todo/ui/markdown_view.dart';
|
||||||
import 'package:todo/ui/note_editor_chrome.dart';
|
import 'package:todo/ui/note_editor_chrome.dart';
|
||||||
@ -127,9 +127,12 @@ class _NoteEditorState extends State<NoteEditor> {
|
|||||||
case NoteEditorMode.guided:
|
case NoteEditorMode.guided:
|
||||||
// raw -> guided: only if the edited text still fits the template.
|
// raw -> guided: only if the edited text still fits the template.
|
||||||
if (!_doc.tryAdoptGuided()) {
|
if (!_doc.tryAdoptGuided()) {
|
||||||
showError(
|
// The mode toggle staying on raw is the second channel.
|
||||||
|
showCue(
|
||||||
context,
|
context,
|
||||||
|
Icons.rule,
|
||||||
"Text doesn't match the template — staying in raw",
|
"Text doesn't match the template — staying in raw",
|
||||||
|
error: true,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import 'package:todo/data/note_template.dart';
|
import 'package:todo/data/note_template.dart';
|
||||||
import 'package:todo/ui/note_editor_mode.dart';
|
import 'package:todo/ui/note_editor_mode.dart';
|
||||||
|
import 'package:todo/ui/template_glyphs.dart';
|
||||||
|
|
||||||
/// Template dropdown above a segmented View / Guided / Raw selector.
|
/// Template dropdown above a segmented View / Guided / Raw selector.
|
||||||
class NoteEditorChrome extends StatelessWidget {
|
class NoteEditorChrome extends StatelessWidget {
|
||||||
@ -43,20 +44,20 @@ class NoteEditorChrome extends StatelessWidget {
|
|||||||
DropdownButtonFormField<String>(
|
DropdownButtonFormField<String>(
|
||||||
initialValue: template.id,
|
initialValue: template.id,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Template',
|
// A prefix, not a floating label: a floated icon sat on the
|
||||||
|
// border on top of the selected template's glyph.
|
||||||
|
prefixIcon: Icon(Icons.description, semanticLabel: 'Template'),
|
||||||
isDense: true,
|
isDense: true,
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
),
|
),
|
||||||
items: [
|
items: [
|
||||||
for (final t in NoteTemplate.all)
|
for (final t in NoteTemplate.all)
|
||||||
DropdownMenuItem(value: t.id, child: Text(t.label)),
|
DropdownMenuItem(value: t.id, child: templateGlyph(t)),
|
||||||
],
|
],
|
||||||
onChanged: (id) {
|
onChanged: (id) {
|
||||||
if (id == null) return;
|
if (id == null) return;
|
||||||
onTemplateSelected(
|
onTemplateSelected(NoteTemplate.all.firstWhere((t) => t.id == id));
|
||||||
NoteTemplate.all.firstWhere((t) => t.id == id),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@ -67,8 +68,7 @@ class NoteEditorChrome extends StatelessWidget {
|
|||||||
segments: [
|
segments: [
|
||||||
const ButtonSegment(
|
const ButtonSegment(
|
||||||
value: NoteEditorMode.preview,
|
value: NoteEditorMode.preview,
|
||||||
icon: Icon(Icons.visibility_outlined),
|
icon: Icon(Icons.visibility_outlined, semanticLabel: 'View'),
|
||||||
label: Text('View'),
|
|
||||||
),
|
),
|
||||||
// Guided is offered for any structured template; tapping it
|
// Guided is offered for any structured template; tapping it
|
||||||
// on an empty draft opens the wizard, and is blocked at
|
// on an empty draft opens the wizard, and is blocked at
|
||||||
@ -76,13 +76,11 @@ class NoteEditorChrome extends StatelessWidget {
|
|||||||
if (!template.isFreeform)
|
if (!template.isFreeform)
|
||||||
const ButtonSegment(
|
const ButtonSegment(
|
||||||
value: NoteEditorMode.guided,
|
value: NoteEditorMode.guided,
|
||||||
icon: Icon(Icons.checklist),
|
icon: Icon(Icons.checklist, semanticLabel: 'Guided'),
|
||||||
label: Text('Guided'),
|
|
||||||
),
|
),
|
||||||
const ButtonSegment(
|
const ButtonSegment(
|
||||||
value: NoteEditorMode.raw,
|
value: NoteEditorMode.raw,
|
||||||
icon: Icon(Icons.notes),
|
icon: Icon(Icons.notes, semanticLabel: 'Raw'),
|
||||||
label: Text('Raw'),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
selected: {mode},
|
selected: {mode},
|
||||||
|
|||||||
@ -44,10 +44,8 @@ class NoteEditorRawField extends StatelessWidget {
|
|||||||
textAlignVertical: TextAlignVertical.top,
|
textAlignVertical: TextAlignVertical.top,
|
||||||
keyboardType: TextInputType.multiline,
|
keyboardType: TextInputType.multiline,
|
||||||
style: Theme.of(context).textTheme.bodyLarge,
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
decoration: const InputDecoration(
|
// No hint: the cursor in an empty full-height box says "type".
|
||||||
border: InputBorder.none,
|
decoration: const InputDecoration(border: InputBorder.none),
|
||||||
hintText: 'Write your idea…',
|
|
||||||
),
|
|
||||||
onChanged: (_) => onChanged(),
|
onChanged: (_) => onChanged(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import 'package:todo/data/note_template.dart';
|
import 'package:todo/data/note_template.dart';
|
||||||
import 'package:todo/ui/motion.dart';
|
import 'package:todo/ui/motion.dart';
|
||||||
|
import 'package:todo/ui/template_glyphs.dart';
|
||||||
|
|
||||||
/// One step of the guided editor: label, guidance, input, and navigation.
|
/// One step of the guided editor: label, guidance, input, and navigation.
|
||||||
class NoteEditorStepPage extends StatelessWidget {
|
class NoteEditorStepPage extends StatelessWidget {
|
||||||
@ -66,8 +67,7 @@ class NoteEditorStepPage extends StatelessWidget {
|
|||||||
Align(
|
Align(
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
icon: const Icon(Icons.arrow_back),
|
icon: const Icon(Icons.arrow_back, semanticLabel: 'Exit guided'),
|
||||||
tooltip: 'Exit guided',
|
|
||||||
onPressed: onExit,
|
onPressed: onExit,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -96,16 +96,14 @@ class NoteEditorStepPage extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
// A glyph per section; its label (the note's `## heading`) and
|
||||||
section.isTitle ? 'title' : section.label,
|
// the helper sentence are spoken only.
|
||||||
style: theme.textTheme.titleMedium,
|
Icon(
|
||||||
),
|
sectionIcon(section),
|
||||||
const SizedBox(height: 4),
|
color: theme.colorScheme.primary,
|
||||||
Text(
|
semanticLabel: section.isTitle
|
||||||
section.helper,
|
? section.helper
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
: '${section.label}: ${section.helper}',
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
],
|
],
|
||||||
@ -123,9 +121,10 @@ class NoteEditorStepPage extends StatelessWidget {
|
|||||||
maxLines: null,
|
maxLines: null,
|
||||||
keyboardType: TextInputType.multiline,
|
keyboardType: TextInputType.multiline,
|
||||||
textAlignVertical: TextAlignVertical.top,
|
textAlignVertical: TextAlignVertical.top,
|
||||||
decoration: InputDecoration(
|
// No hintText: a hint is painted text. The section glyph
|
||||||
hintText: section.hint,
|
// above already names the field.
|
||||||
border: const OutlineInputBorder(),
|
decoration: const InputDecoration(
|
||||||
|
border: OutlineInputBorder(),
|
||||||
isDense: true,
|
isDense: true,
|
||||||
),
|
),
|
||||||
onChanged: (_) => onChanged(),
|
onChanged: (_) => onChanged(),
|
||||||
@ -140,16 +139,22 @@ class NoteEditorStepPage extends StatelessWidget {
|
|||||||
if (index > 0)
|
if (index > 0)
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => onGoToStep(index - 1),
|
onPressed: () => onGoToStep(index - 1),
|
||||||
child: const Text('Back'),
|
child: const Icon(
|
||||||
|
Icons.navigate_before,
|
||||||
|
semanticLabel: 'Back',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
if (index < total - 1)
|
if (index < total - 1)
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () => onGoToStep(index + 1),
|
onPressed: () => onGoToStep(index + 1),
|
||||||
child: const Text('Next'),
|
child: const Icon(Icons.navigate_next, semanticLabel: 'Next'),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
FilledButton(onPressed: onExit, child: const Text('Done')),
|
FilledButton(
|
||||||
|
onPressed: onExit,
|
||||||
|
child: const Icon(Icons.check, semanticLabel: 'Done'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -13,6 +13,8 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import 'package:todo/data/note.dart';
|
import 'package:todo/data/note.dart';
|
||||||
import 'package:todo/data/note_template.dart';
|
import 'package:todo/data/note_template.dart';
|
||||||
|
import 'package:todo/ui/enum_glyphs.dart';
|
||||||
|
import 'package:todo/ui/template_glyphs.dart';
|
||||||
|
|
||||||
/// Asks for a priority, then a template, then hands both back via [onStart].
|
/// Asks for a priority, then a template, then hands both back via [onStart].
|
||||||
class NoteEditorWizard extends StatefulWidget {
|
class NoteEditorWizard extends StatefulWidget {
|
||||||
@ -48,7 +50,7 @@ class _NoteEditorWizardState extends State<NoteEditorWizard> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final semanticLabel = 'Step ${_step + 1} of 2';
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@ -56,11 +58,24 @@ class _NoteEditorWizardState extends State<NoteEditorWizard> {
|
|||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.close),
|
icon: const Icon(Icons.close, semanticLabel: 'Cancel'),
|
||||||
tooltip: 'Cancel',
|
|
||||||
onPressed: widget.onCancel,
|
onPressed: widget.onCancel,
|
||||||
),
|
),
|
||||||
Text('Step ${_step + 1} of 2', style: theme.textTheme.labelLarge),
|
// Two step dots, the current one filled: position by shape.
|
||||||
|
Semantics(
|
||||||
|
label: semanticLabel,
|
||||||
|
child: ExcludeSemantics(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
for (var i = 0; i < 2; i++)
|
||||||
|
Icon(
|
||||||
|
i == _step ? Icons.circle : Icons.circle_outlined,
|
||||||
|
size: 12,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@ -74,14 +89,14 @@ class _NoteEditorWizardState extends State<NoteEditorWizard> {
|
|||||||
DropdownButtonFormField<Priority>(
|
DropdownButtonFormField<Priority>(
|
||||||
initialValue: _priority,
|
initialValue: _priority,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Priority',
|
prefixIcon: Icon(kPriorityHeadingIcon, semanticLabel: 'Priority'),
|
||||||
isDense: true,
|
isDense: true,
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
),
|
),
|
||||||
items: [
|
items: [
|
||||||
for (final p in Priority.values)
|
for (final p in Priority.values)
|
||||||
DropdownMenuItem(value: p, child: Text(p.label)),
|
DropdownMenuItem(value: p, child: priorityGlyph(context, p)),
|
||||||
],
|
],
|
||||||
onChanged: (p) {
|
onChanged: (p) {
|
||||||
if (p != null) setState(() => _priority = p);
|
if (p != null) setState(() => _priority = p);
|
||||||
@ -92,7 +107,7 @@ class _NoteEditorWizardState extends State<NoteEditorWizard> {
|
|||||||
alignment: Alignment.centerRight,
|
alignment: Alignment.centerRight,
|
||||||
child: FilledButton(
|
child: FilledButton(
|
||||||
onPressed: () => setState(() => _step = 1),
|
onPressed: () => setState(() => _step = 1),
|
||||||
child: const Text('Next'),
|
child: const Icon(Icons.navigate_next, semanticLabel: 'Next'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
@ -104,14 +119,14 @@ class _NoteEditorWizardState extends State<NoteEditorWizard> {
|
|||||||
DropdownButtonFormField<String>(
|
DropdownButtonFormField<String>(
|
||||||
initialValue: _template.id,
|
initialValue: _template.id,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Template',
|
prefixIcon: Icon(Icons.description, semanticLabel: 'Template'),
|
||||||
isDense: true,
|
isDense: true,
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
),
|
),
|
||||||
items: [
|
items: [
|
||||||
for (final t in templates)
|
for (final t in templates)
|
||||||
DropdownMenuItem(value: t.id, child: Text(t.label)),
|
DropdownMenuItem(value: t.id, child: templateGlyph(t)),
|
||||||
],
|
],
|
||||||
onChanged: (id) {
|
onChanged: (id) {
|
||||||
if (id == null) return;
|
if (id == null) return;
|
||||||
@ -124,12 +139,12 @@ class _NoteEditorWizardState extends State<NoteEditorWizard> {
|
|||||||
children: [
|
children: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => setState(() => _step = 0),
|
onPressed: () => setState(() => _step = 0),
|
||||||
child: const Text('Back'),
|
child: const Icon(Icons.arrow_back, semanticLabel: 'Back'),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () => widget.onStart(_priority, _template),
|
onPressed: () => widget.onStart(_priority, _template),
|
||||||
child: const Text('Start'),
|
child: const Icon(Icons.play_arrow, semanticLabel: 'Start'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@ -6,6 +6,25 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import 'package:todo/data/note.dart';
|
import 'package:todo/data/note.dart';
|
||||||
import 'package:todo/data/note_template.dart';
|
import 'package:todo/data/note_template.dart';
|
||||||
|
import 'package:todo/ui/enum_glyphs.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,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Painted in place of an empty note's title: a mark, not a word.
|
||||||
|
const String kEmptyTitle = '…';
|
||||||
|
|
||||||
|
/// 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.
|
/// A note as a grid cell. It fills whatever cell it is given.
|
||||||
class NoteGridTile extends StatelessWidget {
|
class NoteGridTile extends StatelessWidget {
|
||||||
@ -14,9 +33,13 @@ class NoteGridTile extends StatelessWidget {
|
|||||||
required this.note,
|
required this.note,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
required this.onActions,
|
required this.onActions,
|
||||||
|
this.selected = false,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Whether the note is part of the multi-selection: a tint and a check.
|
||||||
|
final bool selected;
|
||||||
|
|
||||||
/// The note this tile displays.
|
/// The note this tile displays.
|
||||||
final Note note;
|
final Note note;
|
||||||
|
|
||||||
@ -27,18 +50,7 @@ class NoteGridTile extends StatelessWidget {
|
|||||||
final VoidCallback onActions;
|
final VoidCallback onActions;
|
||||||
|
|
||||||
/// Danger for high, the accent for medium, muted for low.
|
/// Danger for high, the accent for medium, muted for low.
|
||||||
Color accentFor(ColorScheme scheme) => switch (note.priority) {
|
Color accentFor(ColorScheme scheme) => priorityColor(note.priority, scheme);
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@ -49,7 +61,7 @@ class NoteGridTile extends StatelessWidget {
|
|||||||
final closed =
|
final closed =
|
||||||
note.status == Status.done || note.status == Status.abandoned;
|
note.status == Status.done || note.status == Status.abandoned;
|
||||||
return Material(
|
return Material(
|
||||||
color: scheme.surfaceContainerHigh,
|
color: selected ? scheme.secondaryContainer : scheme.surfaceContainerHigh,
|
||||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
@ -72,14 +84,30 @@ class NoteGridTile extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
// Expanded: a big text scale shortens the title, never overflows.
|
// Expanded: a big text scale shortens the title, never overflows.
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Row(
|
||||||
title.isEmpty ? '(empty)' : title,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
maxLines: 3,
|
children: [
|
||||||
overflow: TextOverflow.ellipsis,
|
Expanded(
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
child: Text(
|
||||||
color: closed ? scheme.onSurfaceVariant : null,
|
title.isEmpty ? kEmptyTitle : title,
|
||||||
decoration: closed ? TextDecoration.lineThrough : null,
|
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?)
|
if (note.repoName case final repo?)
|
||||||
@ -91,25 +119,20 @@ class NoteGridTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
|
// Shape + the tile's accent border carry status/priority;
|
||||||
|
// the enum words survive only as spoken labels.
|
||||||
Icon(
|
Icon(
|
||||||
_statusIcon(note.status),
|
statusIcon(note.status),
|
||||||
size: 14,
|
size: 14,
|
||||||
color: scheme.onSurfaceVariant,
|
color: scheme.onSurfaceVariant,
|
||||||
),
|
semanticLabel: note.status.label,
|
||||||
const SizedBox(width: AppSpacing.xs),
|
|
||||||
Flexible(
|
|
||||||
child: Text(
|
|
||||||
note.status.label,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: cue,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
Text(
|
Icon(
|
||||||
note.priority.label,
|
priorityIcon(note.priority),
|
||||||
maxLines: 1,
|
size: 14,
|
||||||
style: cue?.copyWith(color: accentFor(scheme)),
|
color: accentFor(scheme),
|
||||||
|
semanticLabel: note.priority.label,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@ -8,24 +8,30 @@ library;
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'package:todo/data/note.dart';
|
import 'package:todo/data/note.dart';
|
||||||
|
import 'package:todo/ui/enum_glyphs.dart';
|
||||||
|
|
||||||
/// A compact labelled dropdown for picking an enum value (priority/status).
|
/// A compact dropdown for picking an enum value (priority/status), headed by
|
||||||
|
/// a glyph and showing each value as its glyph.
|
||||||
///
|
///
|
||||||
/// Generic over the enum type [T] so the same control drives both pickers
|
/// Generic over the enum type [T] so the same control drives both pickers
|
||||||
/// without duplication; [labelOf] maps a value to its display string.
|
/// without duplication; [glyphOf] maps a value to its glyph.
|
||||||
class MetaDropdown<T> extends StatelessWidget {
|
class MetaDropdown<T> extends StatelessWidget {
|
||||||
/// Creates a dropdown showing [value] out of [values].
|
/// Creates a dropdown showing [value] out of [values].
|
||||||
const MetaDropdown({
|
const MetaDropdown({
|
||||||
required this.label,
|
required this.heading,
|
||||||
|
required this.semanticLabel,
|
||||||
required this.value,
|
required this.value,
|
||||||
required this.values,
|
required this.values,
|
||||||
required this.labelOf,
|
required this.glyphOf,
|
||||||
required this.onChanged,
|
required this.onChanged,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Label shown above the control.
|
/// Glyph in the control's border in place of a written heading.
|
||||||
final String label;
|
final IconData heading;
|
||||||
|
|
||||||
|
/// Spoken name of the control; never painted.
|
||||||
|
final String semanticLabel;
|
||||||
|
|
||||||
/// Currently selected value.
|
/// Currently selected value.
|
||||||
final T value;
|
final T value;
|
||||||
@ -33,8 +39,8 @@ class MetaDropdown<T> extends StatelessWidget {
|
|||||||
/// Every selectable value.
|
/// Every selectable value.
|
||||||
final List<T> values;
|
final List<T> values;
|
||||||
|
|
||||||
/// Maps a value to its display string.
|
/// Maps a value to its glyph.
|
||||||
final String Function(T) labelOf;
|
final Widget Function(T) glyphOf;
|
||||||
|
|
||||||
/// Called with the newly picked value.
|
/// Called with the newly picked value.
|
||||||
final ValueChanged<T> onChanged;
|
final ValueChanged<T> onChanged;
|
||||||
@ -43,7 +49,7 @@ class MetaDropdown<T> extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return InputDecorator(
|
return InputDecorator(
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: label,
|
prefixIcon: Icon(heading, semanticLabel: semanticLabel),
|
||||||
isDense: true,
|
isDense: true,
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
@ -55,7 +61,7 @@ class MetaDropdown<T> extends StatelessWidget {
|
|||||||
isExpanded: true,
|
isExpanded: true,
|
||||||
items: [
|
items: [
|
||||||
for (final v in values)
|
for (final v in values)
|
||||||
DropdownMenuItem<T>(value: v, child: Text(labelOf(v))),
|
DropdownMenuItem<T>(value: v, child: glyphOf(v)),
|
||||||
],
|
],
|
||||||
onChanged: (v) {
|
onChanged: (v) {
|
||||||
if (v != null) onChanged(v);
|
if (v != null) onChanged(v);
|
||||||
@ -98,20 +104,22 @@ class NoteMetaRow extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: MetaDropdown<Priority>(
|
child: MetaDropdown<Priority>(
|
||||||
label: 'Priority',
|
heading: kPriorityHeadingIcon,
|
||||||
|
semanticLabel: 'Priority',
|
||||||
value: priority,
|
value: priority,
|
||||||
values: Priority.values,
|
values: Priority.values,
|
||||||
labelOf: (p) => p.label,
|
glyphOf: (p) => priorityGlyph(context, p),
|
||||||
onChanged: onPriorityChanged,
|
onChanged: onPriorityChanged,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: MetaDropdown<Status>(
|
child: MetaDropdown<Status>(
|
||||||
label: 'Status',
|
heading: kStatusHeadingIcon,
|
||||||
|
semanticLabel: 'Status',
|
||||||
value: status,
|
value: status,
|
||||||
values: Status.values,
|
values: Status.values,
|
||||||
labelOf: (s) => s.label,
|
glyphOf: statusGlyph,
|
||||||
onChanged: onStatusChanged,
|
onChanged: onStatusChanged,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -39,12 +39,25 @@ class NoteSheetDetails extends StatelessWidget {
|
|||||||
Text(body, style: theme.bodyMedium),
|
Text(body, style: theme.bodyMedium),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Wrap(
|
||||||
'Edited ${_stamp(note.updatedAt)}'
|
spacing: 12,
|
||||||
' · Created ${_stamp(note.createdAt)}',
|
children: [
|
||||||
style: theme.bodySmall,
|
_dated(Icons.history, 'Edited', note.updatedAt, theme.bodySmall),
|
||||||
|
_dated(Icons.add, 'Created', note.createdAt, theme.bodySmall),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A glyph (edited = history, created = plus) before its timestamp.
|
||||||
|
Widget _dated(IconData icon, String spoken, DateTime t, TextStyle? style) =>
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 16, semanticLabel: spoken),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(_stamp(t), style: style),
|
||||||
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,20 +12,27 @@ class NoteTaskDetails extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context).textTheme;
|
final theme = Theme.of(context).textTheme;
|
||||||
|
// A glyph per field in place of its written name (kept spoken only);
|
||||||
|
// the values are content and stay as typed.
|
||||||
final rows = [
|
final rows = [
|
||||||
('Repo', note.repo),
|
(Icons.folder_outlined, 'Repo', note.repo),
|
||||||
('Done check', note.doneCheck),
|
(Icons.fact_check_outlined, 'Done check', note.doneCheck),
|
||||||
('By', note.by),
|
(Icons.person_outline, 'By', note.by),
|
||||||
('Evidence', note.evidence),
|
(Icons.verified_outlined, 'Evidence', note.evidence),
|
||||||
].where((row) => row.$2 != null);
|
].where((row) => row.$3 != null);
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
for (final (label, value) in rows) ...[
|
for (final (icon, label, value) in rows) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(label, style: theme.labelLarge),
|
Row(
|
||||||
const SizedBox(height: 4),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
Text(value!, style: theme.bodyMedium),
|
children: [
|
||||||
|
Icon(icon, size: 20, semanticLabel: label),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(child: Text(value!, style: theme.bodyMedium)),
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@ -8,6 +8,8 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import 'package:todo/data/note.dart';
|
import 'package:todo/data/note.dart';
|
||||||
import 'package:todo/data/note_template.dart';
|
import 'package:todo/data/note_template.dart';
|
||||||
|
import 'package:todo/ui/enum_glyphs.dart';
|
||||||
|
import 'package:todo/ui/note_grid_tile.dart';
|
||||||
|
|
||||||
/// One row in the notes list: title, metadata line, and an actions button.
|
/// One row in the notes list: title, metadata line, and an actions button.
|
||||||
class NoteTile extends StatelessWidget {
|
class NoteTile extends StatelessWidget {
|
||||||
@ -16,9 +18,13 @@ class NoteTile extends StatelessWidget {
|
|||||||
required this.note,
|
required this.note,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
required this.onActions,
|
required this.onActions,
|
||||||
|
this.selected = false,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Whether the row is part of the multi-selection: highlighted + a check.
|
||||||
|
final bool selected;
|
||||||
|
|
||||||
/// The note this row displays.
|
/// The note this row displays.
|
||||||
final Note note;
|
final Note note;
|
||||||
|
|
||||||
@ -33,23 +39,19 @@ class NoteTile extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final firstLine = noteTitle(note.text);
|
final firstLine = noteTitle(note.text);
|
||||||
// Every note has a status and a priority now, so both are always shown.
|
|
||||||
final meta = <String>[
|
|
||||||
note.status.label,
|
|
||||||
note.priority.label,
|
|
||||||
'edited ${_relative(note.updatedAt)}',
|
|
||||||
?note.repoName,
|
|
||||||
].join(' · ');
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
|
selected: selected,
|
||||||
|
leading: selected
|
||||||
|
? const Icon(Icons.check_circle, semanticLabel: 'Selected')
|
||||||
|
: null,
|
||||||
title: Text(
|
title: Text(
|
||||||
firstLine.isEmpty ? '(empty)' : firstLine,
|
firstLine.isEmpty ? kEmptyTitle : firstLine,
|
||||||
maxLines: 2,
|
maxLines: 2,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
subtitle: Text(meta),
|
subtitle: _meta(context),
|
||||||
trailing: IconButton(
|
trailing: IconButton(
|
||||||
icon: const Icon(Icons.more_vert),
|
icon: const Icon(Icons.more_vert, semanticLabel: 'Quick actions'),
|
||||||
tooltip: 'Quick actions',
|
|
||||||
onPressed: onActions,
|
onPressed: onActions,
|
||||||
),
|
),
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
@ -57,14 +59,28 @@ class NoteTile extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compact relative time like "2m ago" for the list subtitle.
|
/// Status and priority glyphs, the edit age, and the repo (content).
|
||||||
String _relative(DateTime t) {
|
/// Every note has a status and a priority, so both are always shown.
|
||||||
final d = DateTime.now().difference(t);
|
Widget _meta(BuildContext context) => Row(
|
||||||
if (d.inMinutes < 1) return 'just now';
|
children: [
|
||||||
if (d.inHours < 1) return '${d.inMinutes}m ago';
|
statusGlyph(note.status, size: 16),
|
||||||
if (d.inDays < 1) return '${d.inHours}h ago';
|
const SizedBox(width: 6),
|
||||||
return '${d.inDays}d ago';
|
priorityGlyph(context, note.priority, size: 16),
|
||||||
}
|
const SizedBox(width: 6),
|
||||||
|
const Icon(Icons.history, size: 16, semanticLabel: 'Edited'),
|
||||||
|
Text(relativeAge(note.updatedAt)),
|
||||||
|
if (note.repoName case final repo?) ...[
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Flexible(child: Text(repo, overflow: TextOverflow.ellipsis)),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bottom sheet for editing one note's priority/status or deleting it.
|
/// Compact age like "2m" / "3h" / "4d": a number plus a unit symbol.
|
||||||
|
String relativeAge(DateTime t) {
|
||||||
|
final d = DateTime.now().difference(t);
|
||||||
|
if (d.inHours < 1) return '${d.inMinutes}m';
|
||||||
|
if (d.inDays < 1) return '${d.inHours}h';
|
||||||
|
return '${d.inDays}d';
|
||||||
|
}
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
@ -10,6 +10,7 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import 'package:todo/data/note.dart';
|
import 'package:todo/data/note.dart';
|
||||||
import 'package:todo/data/note_repository.dart';
|
import 'package:todo/data/note_repository.dart';
|
||||||
|
import 'package:todo/ui/enum_glyphs.dart';
|
||||||
import 'package:todo/ui/note_date_range_field.dart';
|
import 'package:todo/ui/note_date_range_field.dart';
|
||||||
import 'package:todo/ui/notes_list_defaults.dart';
|
import 'package:todo/ui/notes_list_defaults.dart';
|
||||||
|
|
||||||
@ -66,7 +67,6 @@ class _FilterSheetState extends State<FilterSheet> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.fromLTRB(
|
padding: EdgeInsets.fromLTRB(
|
||||||
@ -82,33 +82,38 @@ class _FilterSheetState extends State<FilterSheet> {
|
|||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Text('Filters', style: theme.textTheme.titleLarge),
|
const Icon(Icons.filter_list, semanticLabel: 'Filters'),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: _clearAll,
|
onPressed: _clearAll,
|
||||||
child: const Text('Clear all'),
|
child: const Icon(Icons.clear, semanticLabel: 'Clear all'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_MultiChips<Status>(
|
GlyphChips<Status>(
|
||||||
label: 'Status',
|
multi: true,
|
||||||
|
heading: kStatusHeadingIcon,
|
||||||
|
semanticLabel: 'Status',
|
||||||
values: Status.values,
|
values: Status.values,
|
||||||
selected: _statuses,
|
selected: _statuses,
|
||||||
labelOf: (s) => s.label,
|
glyphOf: statusGlyph,
|
||||||
onToggle: (s) => _toggle(_statuses, s),
|
onTap: (s) => _toggle(_statuses, s),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_MultiChips<Priority>(
|
GlyphChips<Priority>(
|
||||||
label: 'Priority',
|
multi: true,
|
||||||
|
heading: kPriorityHeadingIcon,
|
||||||
|
semanticLabel: 'Priority',
|
||||||
values: Priority.values,
|
values: Priority.values,
|
||||||
selected: _priorities,
|
selected: _priorities,
|
||||||
labelOf: (p) => p.label,
|
glyphOf: (p) => priorityGlyph(context, p),
|
||||||
onToggle: (p) => _toggle(_priorities, p),
|
onTap: (p) => _toggle(_priorities, p),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
DateRangeField(
|
DateRangeField(
|
||||||
label: 'Created',
|
icon: kCreatedIcon,
|
||||||
|
semanticLabel: 'Created',
|
||||||
from: _createdFrom,
|
from: _createdFrom,
|
||||||
to: _createdTo,
|
to: _createdTo,
|
||||||
onChanged: (from, to) => setState(() {
|
onChanged: (from, to) => setState(() {
|
||||||
@ -118,7 +123,8 @@ class _FilterSheetState extends State<FilterSheet> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
DateRangeField(
|
DateRangeField(
|
||||||
label: 'Last updated',
|
icon: kUpdatedIcon,
|
||||||
|
semanticLabel: 'Last updated',
|
||||||
from: _updatedFrom,
|
from: _updatedFrom,
|
||||||
to: _updatedTo,
|
to: _updatedTo,
|
||||||
onChanged: (from, to) => setState(() {
|
onChanged: (from, to) => setState(() {
|
||||||
@ -129,7 +135,7 @@ class _FilterSheetState extends State<FilterSheet> {
|
|||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () => Navigator.of(context).pop(_build()),
|
onPressed: () => Navigator.of(context).pop(_build()),
|
||||||
child: const Text('Apply'),
|
child: const Icon(Icons.check, semanticLabel: 'Apply'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -139,45 +145,6 @@ class _FilterSheetState extends State<FilterSheet> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Multi-select chip group for an enum (used by the filter sheet).
|
|
||||||
class _MultiChips<T> extends StatelessWidget {
|
|
||||||
const _MultiChips({
|
|
||||||
required this.label,
|
|
||||||
required this.values,
|
|
||||||
required this.selected,
|
|
||||||
required this.labelOf,
|
|
||||||
required this.onToggle,
|
|
||||||
});
|
|
||||||
|
|
||||||
final String label;
|
|
||||||
final List<T> values;
|
|
||||||
final Set<T> selected;
|
|
||||||
final String Function(T) labelOf;
|
|
||||||
final ValueChanged<T> onToggle;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(label, style: Theme.of(context).textTheme.labelLarge),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Wrap(
|
|
||||||
spacing: 8,
|
|
||||||
children: [
|
|
||||||
for (final v in values)
|
|
||||||
FilterChip(
|
|
||||||
label: Text(labelOf(v)),
|
|
||||||
selected: selected.contains(v),
|
|
||||||
onSelected: (_) => onToggle(v),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A date-range control offering quick presets plus a custom range picker.
|
/// A date-range control offering quick presets plus a custom range picker.
|
||||||
///
|
///
|
||||||
/// Reports the chosen [from]/[to] (day granularity, both inclusive) back to
|
/// Reports the chosen [from]/[to] (day granularity, both inclusive) back to
|
||||||
|
|||||||
@ -26,9 +26,13 @@ class NotesGrid extends StatelessWidget {
|
|||||||
required this.notes,
|
required this.notes,
|
||||||
required this.onOpen,
|
required this.onOpen,
|
||||||
required this.onActions,
|
required this.onActions,
|
||||||
|
required this.isSelected,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Whether the note with this id is in the multi-selection.
|
||||||
|
final bool Function(String id) isSelected;
|
||||||
|
|
||||||
/// Notes in display order.
|
/// Notes in display order.
|
||||||
final List<Note> notes;
|
final List<Note> notes;
|
||||||
|
|
||||||
@ -42,6 +46,7 @@ class NotesGrid extends StatelessWidget {
|
|||||||
return NoteGridTile(
|
return NoteGridTile(
|
||||||
key: ValueKey(note.id),
|
key: ValueKey(note.id),
|
||||||
note: note,
|
note: note,
|
||||||
|
selected: isSelected(note.id),
|
||||||
onTap: () => onOpen(note),
|
onTap: () => onOpen(note),
|
||||||
onActions: () => onActions(note),
|
onActions: () => onActions(note),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -16,9 +16,13 @@ class NotesListBody extends StatelessWidget {
|
|||||||
required this.notes,
|
required this.notes,
|
||||||
required this.onOpen,
|
required this.onOpen,
|
||||||
required this.onActions,
|
required this.onActions,
|
||||||
|
required this.isSelected,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Whether the note with this id is in the multi-selection.
|
||||||
|
final bool Function(String id) isSelected;
|
||||||
|
|
||||||
/// Notes in display order.
|
/// Notes in display order.
|
||||||
final List<Note> notes;
|
final List<Note> notes;
|
||||||
|
|
||||||
@ -32,13 +36,19 @@ class NotesListBody extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, box) => box.maxWidth < kNotesGridMaxWidth
|
builder: (context, box) => box.maxWidth < kNotesGridMaxWidth
|
||||||
? NotesGrid(notes: notes, onOpen: onOpen, onActions: onActions)
|
? NotesGrid(
|
||||||
|
notes: notes,
|
||||||
|
onOpen: onOpen,
|
||||||
|
onActions: onActions,
|
||||||
|
isSelected: isSelected,
|
||||||
|
)
|
||||||
: ListView.separated(
|
: ListView.separated(
|
||||||
itemCount: notes.length,
|
itemCount: notes.length,
|
||||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||||
itemBuilder: (context, i) => NoteTile(
|
itemBuilder: (context, i) => NoteTile(
|
||||||
key: ValueKey(notes[i].id),
|
key: ValueKey(notes[i].id),
|
||||||
note: notes[i],
|
note: notes[i],
|
||||||
|
selected: isSelected(notes[i].id),
|
||||||
onTap: () => onOpen(notes[i]),
|
onTap: () => onOpen(notes[i]),
|
||||||
onActions: () => onActions(notes[i]),
|
onActions: () => onActions(notes[i]),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -5,13 +5,19 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:todo/data/app_settings.dart';
|
import 'package:todo/data/app_settings.dart';
|
||||||
import 'package:todo/data/note.dart';
|
import 'package:todo/data/note.dart';
|
||||||
import 'package:todo/data/note_repository.dart';
|
import 'package:todo/data/note_repository.dart';
|
||||||
|
import 'package:todo/ui/empty_notes_cue.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_filter_sheet.dart';
|
||||||
import 'package:todo/ui/notes_list_body.dart';
|
import 'package:todo/ui/notes_list_body.dart';
|
||||||
import 'package:todo/ui/notes_list_defaults.dart';
|
import 'package:todo/ui/notes_list_defaults.dart';
|
||||||
import 'package:todo/ui/notes_list_navigation.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';
|
export 'package:todo/ui/notes_list_defaults.dart';
|
||||||
|
|
||||||
|
part 'notes_list_screen_select.dart';
|
||||||
|
|
||||||
/// Searchable, filterable, sortable list of stored/synced notes.
|
/// Searchable, filterable, sortable list of stored/synced notes.
|
||||||
///
|
///
|
||||||
/// The heavy lifting (WHERE/ORDER BY) lives in [NoteRepository]; this screen
|
/// The heavy lifting (WHERE/ORDER BY) lives in [NoteRepository]; this screen
|
||||||
@ -53,25 +59,46 @@ class _NotesListScreenState extends State<NotesListScreen> {
|
|||||||
/// Memoised stream; only replaced when [_sort]/[_filter] actually change.
|
/// Memoised stream; only replaced when [_sort]/[_filter] actually change.
|
||||||
late Stream<List<Note>> _stream;
|
late Stream<List<Note>> _stream;
|
||||||
|
|
||||||
|
/// Multi-select; any change rebuilds the screen (tiles + bottom bar).
|
||||||
|
late final _selection = NoteSelection()..addListener(() => setState(() {}));
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_stream = widget.repository.watchNotes(sort: _sort, filter: _filter);
|
_stream = _watch();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_debounce?.cancel();
|
_debounce?.cancel();
|
||||||
_searchController.dispose();
|
_searchController.dispose();
|
||||||
|
_selection.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rebuilds the watch stream for the current sort + filter. Call only
|
/// Rebuilds the watch stream for the current sort + filter. Call only
|
||||||
/// from handlers that change those, never from [build].
|
/// from handlers that change those, never from [build].
|
||||||
void _applyState() {
|
void _applyState() {
|
||||||
setState(() {
|
setState(() => _stream = _watch());
|
||||||
_stream = widget.repository.watchNotes(sort: _sort, filter: _filter);
|
}
|
||||||
});
|
|
||||||
|
/// 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) {
|
void _onSearchChanged(String value) {
|
||||||
@ -108,29 +135,41 @@ class _NotesListScreenState extends State<NotesListScreen> {
|
|||||||
final badgeCount = userFacetCount(_filter);
|
final badgeCount = userFacetCount(_filter);
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Notes'),
|
title: const Icon(Icons.note, semanticLabel: 'Notes'),
|
||||||
actions: [
|
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>(
|
PopupMenuButton<NoteSort>(
|
||||||
tooltip: 'Sort',
|
// Empty, not omitted: null falls back to the painted "Show menu".
|
||||||
icon: const Icon(Icons.sort),
|
tooltip: '',
|
||||||
|
icon: const Icon(Icons.sort, semanticLabel: 'Sort'),
|
||||||
initialValue: _sort,
|
initialValue: _sort,
|
||||||
onSelected: _setSort,
|
onSelected: _setSort,
|
||||||
itemBuilder: (_) => const [
|
itemBuilder: (_) => const [
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: NoteSort.modifiedDesc,
|
value: NoteSort.modifiedDesc,
|
||||||
child: Text('Last updated'),
|
child: Icon(Icons.update, semanticLabel: 'Last updated'),
|
||||||
),
|
),
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: NoteSort.createdDesc,
|
value: NoteSort.createdDesc,
|
||||||
child: Text('Newest created'),
|
child: Icon(Icons.post_add, semanticLabel: 'Newest created'),
|
||||||
),
|
),
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: NoteSort.alphabetical,
|
value: NoteSort.alphabetical,
|
||||||
child: Text('Alphabetical'),
|
child: Icon(Icons.sort_by_alpha, semanticLabel: 'Alphabetical'),
|
||||||
),
|
),
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: NoteSort.priorityDesc,
|
value: NoteSort.priorityDesc,
|
||||||
child: Text('Priority'),
|
child: Icon(Icons.flag_outlined, semanticLabel: 'Priority'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -144,8 +183,7 @@ class _NotesListScreenState extends State<NotesListScreen> {
|
|||||||
label: Text('$badgeCount'),
|
label: Text('$badgeCount'),
|
||||||
offset: const Offset(-8, 4),
|
offset: const Offset(-8, 4),
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
tooltip: 'Filter',
|
icon: const Icon(Icons.filter_list, semanticLabel: 'Filter'),
|
||||||
icon: const Icon(Icons.filter_list),
|
|
||||||
onPressed: _openFilters,
|
onPressed: _openFilters,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -161,15 +199,16 @@ class _NotesListScreenState extends State<NotesListScreen> {
|
|||||||
onChanged: _onSearchChanged,
|
onChanged: _onSearchChanged,
|
||||||
textInputAction: TextInputAction.search,
|
textInputAction: TextInputAction.search,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Search notes…',
|
|
||||||
prefixIcon: const Icon(Icons.search),
|
prefixIcon: const Icon(Icons.search),
|
||||||
isDense: true,
|
isDense: true,
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
suffixIcon: _searchController.text.isEmpty
|
suffixIcon: _searchController.text.isEmpty
|
||||||
? null
|
? null
|
||||||
: IconButton(
|
: IconButton(
|
||||||
tooltip: 'Clear search',
|
icon: const Icon(
|
||||||
icon: const Icon(Icons.clear),
|
Icons.clear,
|
||||||
|
semanticLabel: 'Clear search',
|
||||||
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
_searchController.clear();
|
_searchController.clear();
|
||||||
_onSearchChanged('');
|
_onSearchChanged('');
|
||||||
@ -186,27 +225,21 @@ class _NotesListScreenState extends State<NotesListScreen> {
|
|||||||
final notes = snapshot.data ?? const <Note>[];
|
final notes = snapshot.data ?? const <Note>[];
|
||||||
if (notes.isEmpty) {
|
if (notes.isEmpty) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Text(
|
child: EmptyNotesCue(filtered: !_filter.isEmpty),
|
||||||
_filter.isEmpty
|
|
||||||
? 'No notes yet'
|
|
||||||
: 'No notes match these filters',
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
void open(Note n) =>
|
|
||||||
openNote(context, n, widget.repository, widget.appSettings);
|
|
||||||
void actions(Note n) =>
|
|
||||||
openNoteActions(context, n, widget.repository);
|
|
||||||
return NotesListBody(
|
return NotesListBody(
|
||||||
notes: notes,
|
notes: notes,
|
||||||
onOpen: open,
|
onOpen: (n) => _tap(n, notes),
|
||||||
onActions: actions,
|
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'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -105,11 +105,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
/// session is available, best-effort mirrors it so the choice follows the
|
/// session is available, best-effort mirrors it so the choice follows the
|
||||||
/// user to their other devices.
|
/// user to their other devices.
|
||||||
///
|
///
|
||||||
/// The local write always lands — a signed-in-as-wrong-account device
|
/// The local write always lands: a [FirebaseAuthError] from opening the
|
||||||
/// throwing [FirebaseAuthError] out of `openFirebase()` must not take the
|
/// client (wrong account) is caught on its own, before the write.
|
||||||
/// toggle down with it, so opening the client has its own try/catch,
|
|
||||||
/// separate from (and preceding) the try that guards the local write and
|
|
||||||
/// mirror push.
|
|
||||||
Future<void> _setAdvancedMode(bool value) async {
|
Future<void> _setAdvancedMode(bool value) async {
|
||||||
final analytics = widget.analytics;
|
final analytics = widget.analytics;
|
||||||
if (analytics != null) {
|
if (analytics != null) {
|
||||||
@ -206,7 +203,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Settings')),
|
appBar: AppBar(
|
||||||
|
title: const Icon(Icons.settings, semanticLabel: 'Settings'),
|
||||||
|
),
|
||||||
body: ListView(
|
body: ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
@ -214,33 +213,38 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
valueListenable: widget.appSettings,
|
valueListenable: widget.appSettings,
|
||||||
builder: (context, settings, _) => SwitchListTile(
|
builder: (context, settings, _) => SwitchListTile(
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
title: const Text('Enable advanced'),
|
secondary: const Icon(Icons.tune, semanticLabel: _advanced),
|
||||||
subtitle: const Text(
|
|
||||||
'Priority/status, templates, view modes, and sync details '
|
|
||||||
'in the capture screen',
|
|
||||||
),
|
|
||||||
value: settings.advancedMode,
|
value: settings.advancedMode,
|
||||||
onChanged: (value) => unawaited(_setAdvancedMode(value)),
|
onChanged: (value) => unawaited(_setAdvancedMode(value)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
ListTile(
|
_link(
|
||||||
contentPadding: EdgeInsets.zero,
|
const Icon(Icons.sync, semanticLabel: 'Sync settings: Firebase'),
|
||||||
title: const Text('Sync settings'),
|
_openSyncSettings,
|
||||||
subtitle: const Text('Firebase sync and notes backup'),
|
|
||||||
trailing: const Icon(Icons.chevron_right),
|
|
||||||
onTap: () => unawaited(_openSyncSettings()),
|
|
||||||
),
|
),
|
||||||
CloudImagesTile(httpClient: widget.httpClient),
|
CloudImagesTile(httpClient: widget.httpClient),
|
||||||
ListTile(
|
_link(
|
||||||
contentPadding: EdgeInsets.zero,
|
Icon(
|
||||||
title: const Text('Advanced sync (GitHub)'),
|
Icons.cloud_sync,
|
||||||
subtitle: const Text('Cutover mirror — not recommended'),
|
color: Theme.of(context).colorScheme.outline,
|
||||||
trailing: const Icon(Icons.chevron_right),
|
semanticLabel: 'Advanced sync (GitHub): not recommended',
|
||||||
onTap: () => unawaited(_openGitHubMirror()),
|
),
|
||||||
|
_openGitHubMirror,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Spoken only: what the advanced switch turns on.
|
||||||
|
static const _advanced = 'Enable advanced: priority, status, view modes';
|
||||||
|
|
||||||
|
/// A row that opens a sub-screen: its glyph (muted = not recommended).
|
||||||
|
Widget _link(Icon icon, Future<void> Function() open) => ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading: icon,
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () => unawaited(open()),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
49
lib/ui/status_cue.dart
Normal file
49
lib/ui/status_cue.dart
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
/// A screen's latest result as a glyph in place of a status sentence.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// One result: what to paint, whether it is a failure, and the old
|
||||||
|
/// sentence, kept as the spoken label only.
|
||||||
|
class StatusCue {
|
||||||
|
/// Creates a cue.
|
||||||
|
const StatusCue(this.icon, this.spoken, {this.error = false});
|
||||||
|
|
||||||
|
/// A success: a check glyph in the accent colour.
|
||||||
|
const StatusCue.ok(this.spoken) : icon = Icons.check_circle, error = false;
|
||||||
|
|
||||||
|
/// A failure: an error glyph in the danger hue.
|
||||||
|
const StatusCue.failed(this.spoken) : icon = Icons.error, error = true;
|
||||||
|
|
||||||
|
/// The glyph painted.
|
||||||
|
final IconData icon;
|
||||||
|
|
||||||
|
/// What a screen reader says.
|
||||||
|
final String spoken;
|
||||||
|
|
||||||
|
/// Whether this is a failure (danger hue) rather than a success.
|
||||||
|
final bool error;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paints [cue] as a large glyph; colour is the second channel.
|
||||||
|
class StatusCueView extends StatelessWidget {
|
||||||
|
/// Creates the view.
|
||||||
|
const StatusCueView(this.cue, {super.key});
|
||||||
|
|
||||||
|
/// The result shown.
|
||||||
|
final StatusCue cue;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
return Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Icon(
|
||||||
|
cue.icon,
|
||||||
|
size: 32,
|
||||||
|
color: cue.error ? scheme.error : scheme.primary,
|
||||||
|
semanticLabel: cue.spoken,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
30
lib/ui/template_glyphs.dart
Normal file
30
lib/ui/template_glyphs.dart
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
/// Glyphs standing for note templates and their sections in the editor.
|
||||||
|
///
|
||||||
|
/// The section labels stay in `note_template.dart`: they are the note's own
|
||||||
|
/// `## heading` format, read by `parse()`, BACKLOG.md and the CLI. Only what
|
||||||
|
/// the editor paints around them is a glyph; the label and helper are spoken.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:todo/data/note_template.dart';
|
||||||
|
|
||||||
|
/// The glyph for template [t]: a structured form, or a blank page.
|
||||||
|
IconData templateIcon(NoteTemplate t) =>
|
||||||
|
t.isFreeform ? Icons.note_outlined : Icons.assignment_outlined;
|
||||||
|
|
||||||
|
/// [t] as its glyph, spoken as its label.
|
||||||
|
Widget templateGlyph(NoteTemplate t) =>
|
||||||
|
Icon(templateIcon(t), semanticLabel: t.label);
|
||||||
|
|
||||||
|
/// The glyph heading a guided step, keyed by the section's stable `key`.
|
||||||
|
IconData sectionIcon(TemplateSection s) => switch (s.key) {
|
||||||
|
'title' => Icons.title,
|
||||||
|
'what' => Icons.lightbulb_outline,
|
||||||
|
'where' => Icons.place_outlined,
|
||||||
|
'must' => Icons.rule,
|
||||||
|
'done' => Icons.emoji_events_outlined,
|
||||||
|
'verify' => Icons.devices,
|
||||||
|
'refs' => Icons.menu_book_outlined,
|
||||||
|
_ => Icons.notes,
|
||||||
|
};
|
||||||
55
lib/ui/text_free_localizations.dart
Normal file
55
lib/ui/text_free_localizations.dart
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
/// Framework strings the app paints without asking for them.
|
||||||
|
///
|
||||||
|
/// Material's own buttons (the app bar back arrow, a dialog's close, the
|
||||||
|
/// drawer, the overflow menu) carry tooltips from [MaterialLocalizations],
|
||||||
|
/// and a tooltip is painted on long-press and hover. kuhy's textgate rule
|
||||||
|
/// says nothing he sees has words he did not ask for, so these tooltips are
|
||||||
|
/// blanked: an empty tooltip is not built at all. Screen readers still get
|
||||||
|
/// the button's semantics from the icon itself.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// English Material strings with every button tooltip blanked.
|
||||||
|
class TextFreeMaterialLocalizations extends DefaultMaterialLocalizations {
|
||||||
|
/// Creates the localizations.
|
||||||
|
const TextFreeMaterialLocalizations();
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backButtonTooltip => '';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get closeButtonTooltip => '';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get openAppDrawerTooltip => '';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showMenuTooltip => '';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get moreButtonTooltip => '';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get deleteButtonTooltip => '';
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Delegate extends LocalizationsDelegate<MaterialLocalizations> {
|
||||||
|
const _Delegate();
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool isSupported(Locale locale) => locale.languageCode == 'en';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<MaterialLocalizations> load(Locale locale) =>
|
||||||
|
SynchronousFuture(const TextFreeMaterialLocalizations());
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldReload(_Delegate old) => false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pass as `MaterialApp.localizationsDelegates`.
|
||||||
|
const List<LocalizationsDelegate<dynamic>> kTextFreeLocalizations = [
|
||||||
|
_Delegate(),
|
||||||
|
];
|
||||||
@ -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>`.
|
`todo-sync/notes/<node id>.json` plus `todo-sync/revs/<node id>`.
|
||||||
Code: `lib/mcp/`, entrypoint `mcp/todo_mcp.dart`, tests `test/mcp/`.
|
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 |
|
| What | Where |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Firebase seed (read once) | `~/.config/todo-mcp/firebase_auth.json` |
|
| Firebase seed (read once) | `~/.config/todo-mcp/firebase_auth.json` |
|
||||||
|
|||||||
50
pubspec.lock
50
pubspec.lock
@ -5,10 +5,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: android_file_picker
|
name: android_file_picker
|
||||||
sha256: "868183c959e754e915954d8074c20cfa4cfdb4684b35ecc08378437be1a542d8"
|
sha256: "04c1306eda42c23ecbf449c909f4fa05c5e66fc48cf5f994d8d08b9d45e5396b"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.3"
|
version: "2.1.5"
|
||||||
archive:
|
archive:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -142,11 +142,11 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
path: data_transfer_flutter
|
path: data_transfer_flutter
|
||||||
ref: "data_transfer_flutter-v0.2.1"
|
ref: "data_transfer_flutter-v0.2.2"
|
||||||
resolved-ref: d4fca83c9f2ed33b3315cd838b9ca532ae8e559c
|
resolved-ref: eef7af4c86837ee82891493587ff3456297e5dea
|
||||||
url: "https://github.com/kuhyx/utils"
|
url: "https://github.com/kuhyx/utils"
|
||||||
source: git
|
source: git
|
||||||
version: "0.2.1"
|
version: "0.2.2"
|
||||||
dbus:
|
dbus:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -208,10 +208,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: file_picker
|
name: file_picker
|
||||||
sha256: c64036857780d7125f011d543f89eedcb5df61da473668bace4eee9ab875284d
|
sha256: "079dc46b753abbddb7f77cd87e6ed9ecc62902580707514568321bdf8e050a86"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "13.3.2"
|
version: "13.3.3"
|
||||||
file_picker_darwin:
|
file_picker_darwin:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -224,10 +224,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: file_picker_linux
|
name: file_picker_linux
|
||||||
sha256: c6562fc7d426db2fb85ea66fe2627953d2709932df5584ceaacf0aa5e39cf4da
|
sha256: "9215ee8c672508cdd9a325202669f849e1baac883658626b3abed5d7777e6a4b"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.2"
|
version: "2.0.4"
|
||||||
file_picker_platform_interface:
|
file_picker_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -240,10 +240,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: file_picker_web
|
name: file_picker_web
|
||||||
sha256: a2288008280aabe371aff029466b3405e60838e0a1d714767488c59b926c8b8c
|
sha256: "50749a6da2984fc29609e102ad4d80fd1d07e6895150f8e4922086167d9d45d8"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.2.0"
|
version: "4.2.1"
|
||||||
file_selector:
|
file_selector:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -399,11 +399,11 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
path: github_device_auth
|
path: github_device_auth
|
||||||
ref: "github_device_auth-v0.3.2"
|
ref: "github_device_auth-v0.4.0"
|
||||||
resolved-ref: "4ccaba1bb3e495ef72d70c6a7e7d7ec98d755792"
|
resolved-ref: "3084080d744c348a217727e26a6be838e7d5a4ff"
|
||||||
url: "https://github.com/kuhyx/utils"
|
url: "https://github.com/kuhyx/utils"
|
||||||
source: git
|
source: git
|
||||||
version: "0.3.2"
|
version: "0.4.0"
|
||||||
glob:
|
glob:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -449,10 +449,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: idb_shim
|
name: idb_shim
|
||||||
sha256: "60965b556624f7018ec26205283e32670bc78c6fe1388d4b90bdfe6b2311b87b"
|
sha256: "507d8f3154fa4e41165941c5aa8509c6bf155bb30f76ba25a00d1e7ba1dd64c9"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.11.2"
|
version: "2.11.3"
|
||||||
image:
|
image:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -833,10 +833,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: sembast
|
name: sembast
|
||||||
sha256: "35775a26edbdf0d891bab304929a712e103c9d9a6e2fb5ebbd8437bb3139615a"
|
sha256: "4f71f8c95105918fa207065429c913f38d0fb05fec9f3a745bbf4c113f5d482b"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.10.0"
|
version: "3.10.1"
|
||||||
share_plus:
|
share_plus:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -934,10 +934,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: sqflite_common_ffi
|
name: sqflite_common_ffi
|
||||||
sha256: "119d730460264a1f11f414d9eaa43fb686789357e81ce35f9e8ccb66ddd040e4"
|
sha256: "9aad7a63bb8de6fe4c1e5367e8dcd8dfc89d491f44a526edf8e58a5a1fb1feed"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.5"
|
version: "2.4.7"
|
||||||
sqflite_common_ffi_web:
|
sqflite_common_ffi_web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -1006,11 +1006,11 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
path: sync_settings_ui
|
path: sync_settings_ui
|
||||||
ref: "sync_settings_ui-v0.6.2"
|
ref: "sync_settings_ui-v0.7.0"
|
||||||
resolved-ref: "95cd3731ede686114353d32ae6c286ad1694b488"
|
resolved-ref: "3084080d744c348a217727e26a6be838e7d5a4ff"
|
||||||
url: "https://github.com/kuhyx/utils"
|
url: "https://github.com/kuhyx/utils"
|
||||||
source: git
|
source: git
|
||||||
version: "0.6.2"
|
version: "0.7.0"
|
||||||
synchronized:
|
synchronized:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -1159,10 +1159,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: windows_file_picker
|
name: windows_file_picker
|
||||||
sha256: "18452aa293de3b6961c6d05b0e71b4be7bb90c67635abdd3aa5c4e75dd875072"
|
sha256: cd1be37ada0abcfaa55bddb7410e8515844e35cd77669c35d9a28a15722299d8
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.1"
|
version: "2.0.2"
|
||||||
xdg_directories:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
12
pubspec.yaml
12
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
|
# 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
|
# 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.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 1.0.63+1
|
version: 1.0.83+1
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.12.2
|
sdk: ^3.12.2
|
||||||
@ -47,7 +47,7 @@ dependencies:
|
|||||||
url: https://github.com/kuhyx/utils
|
url: https://github.com/kuhyx/utils
|
||||||
ref: design_system-v0.3.1
|
ref: design_system-v0.3.1
|
||||||
path: design_system
|
path: design_system
|
||||||
file_picker: 13.3.2
|
file_picker: 13.3.3
|
||||||
file_selector: 1.1.0
|
file_selector: 1.1.0
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
@ -59,7 +59,7 @@ dependencies:
|
|||||||
github_device_auth:
|
github_device_auth:
|
||||||
git:
|
git:
|
||||||
url: https://github.com/kuhyx/utils
|
url: https://github.com/kuhyx/utils
|
||||||
ref: github_device_auth-v0.3.2
|
ref: github_device_auth-v0.4.0
|
||||||
path: github_device_auth
|
path: github_device_auth
|
||||||
# One-tap Google ID token via the framework AccountManager chooser (FOSS,
|
# One-tap Google ID token via the framework AccountManager chooser (FOSS,
|
||||||
# no Play Services sign-in SDK). Used by the sync backend in lib/sync/.
|
# no Play Services sign-in SDK). Used by the sync backend in lib/sync/.
|
||||||
@ -69,7 +69,7 @@ dependencies:
|
|||||||
ref: google_account_token-v0.1.0
|
ref: google_account_token-v0.1.0
|
||||||
path: google_account_token
|
path: google_account_token
|
||||||
http: 1.6.0
|
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
|
# Re-encodes attached images (resize, EXIF strip) in pure Dart, so the
|
||||||
# phone and the desktop wrapper share one code path.
|
# phone and the desktop wrapper share one code path.
|
||||||
image: 4.12.0
|
image: 4.12.0
|
||||||
@ -85,7 +85,7 @@ dependencies:
|
|||||||
path_provider: 2.1.6
|
path_provider: 2.1.6
|
||||||
share_plus: 13.3.1
|
share_plus: 13.3.1
|
||||||
shared_preferences: 2.5.6
|
shared_preferences: 2.5.6
|
||||||
sqflite_common_ffi: 2.4.5
|
sqflite_common_ffi: 2.4.7
|
||||||
# Retained only to read the legacy on-device DB during the one-time
|
# Retained only to read the legacy on-device DB during the one-time
|
||||||
# migration in NoteRepository.open; remove once all devices are migrated.
|
# migration in NoteRepository.open; remove once all devices are migrated.
|
||||||
sqlite_crdt: 3.0.4
|
sqlite_crdt: 3.0.4
|
||||||
@ -97,7 +97,7 @@ dependencies:
|
|||||||
sync_settings_ui:
|
sync_settings_ui:
|
||||||
git:
|
git:
|
||||||
url: https://github.com/kuhyx/utils
|
url: https://github.com/kuhyx/utils
|
||||||
ref: sync_settings_ui-v0.6.2
|
ref: sync_settings_ui-v0.7.0
|
||||||
path: sync_settings_ui
|
path: sync_settings_ui
|
||||||
url_launcher: 6.3.3
|
url_launcher: 6.3.3
|
||||||
uuid: 4.6.0
|
uuid: 4.6.0
|
||||||
|
|||||||
183
scripts/deploy.sh
Executable file
183
scripts/deploy.sh
Executable file
@ -0,0 +1,183 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Deploy todo everywhere it runs, then prove each one is live.
|
||||||
|
#
|
||||||
|
# scripts/deploy.sh # all targets: mcp desktop phone
|
||||||
|
# scripts/deploy.sh mcp desktop # only the named targets
|
||||||
|
#
|
||||||
|
# Targets (each has its own health check; any failure makes the exit non-zero,
|
||||||
|
# but the remaining targets still deploy so one unplugged phone does not leave
|
||||||
|
# the desktop on old code):
|
||||||
|
# mcp frozen binary + todo-mcp.service (mcp/install.sh), restart, then a
|
||||||
|
# real initialize + list_tasks against :8735 (mcp/verify_mcp.py).
|
||||||
|
# desktop web build + pacman package todo-flutter (install_arch.sh); health
|
||||||
|
# = installed package and bundled web version.json match pubspec.
|
||||||
|
# phone release APK via phone_deploy.sh (adb install -r only; it has no
|
||||||
|
# uninstall / pm clear path); health = versionName on the Pixel
|
||||||
|
# matches pubspec and the app process is running.
|
||||||
|
#
|
||||||
|
# Idempotent: desktop and phone skip the build when the installed version
|
||||||
|
# already equals pubspec's (every commit bumps the patch version, so an equal
|
||||||
|
# version means equal code); the MCP binary is cheap and always rebuilt.
|
||||||
|
#
|
||||||
|
# Nothing live runs from ~/src/todo (the MCP is a frozen binary, the desktop
|
||||||
|
# app lives in /opt/todo), so this deploys the checkout it sits in and never
|
||||||
|
# pulls kuhy's working tree.
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
readonly ROOT
|
||||||
|
readonly SERIAL="${ADB_SERIAL:-23181JEGR08034}"
|
||||||
|
readonly APP_ID="dev.kuhy.todo"
|
||||||
|
readonly MCP_UNIT="todo-mcp.service"
|
||||||
|
readonly MCP_URL="http://127.0.0.1:8735/mcp"
|
||||||
|
readonly MCP_TOKEN="${HOME}/.config/todo-mcp-client/token"
|
||||||
|
readonly PHONE_DEPLOY="${HOME}/.claude/scripts/phone_deploy.sh"
|
||||||
|
readonly CAPPED="${HOME}/.claude/scripts/capped.sh"
|
||||||
|
readonly WEB_VERSION_JSON="/opt/todo/web/version.json"
|
||||||
|
readonly KEY_PROPS="$ROOT/android/key.properties"
|
||||||
|
readonly SHARED_KEY_PROPS="${HOME}/.android/release/key.properties"
|
||||||
|
|
||||||
|
# systemd units and Ship do not source ~/.zshenv; phone_deploy needs the SDK
|
||||||
|
# for its apksigner check.
|
||||||
|
export ANDROID_HOME="${ANDROID_HOME:-${HOME}/sdk/foss-android-sdk}"
|
||||||
|
export ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT:-$ANDROID_HOME}"
|
||||||
|
|
||||||
|
FAILED=()
|
||||||
|
|
||||||
|
say() { echo "[deploy:$1] $2"; }
|
||||||
|
fail() {
|
||||||
|
echo "[deploy:$1] FAIL: $2" >&2
|
||||||
|
FAILED+=("$1")
|
||||||
|
}
|
||||||
|
|
||||||
|
# pubspec `version: 1.0.77+1` -> `1.0.77` (the +build part is CI's).
|
||||||
|
pubspec_version() {
|
||||||
|
sed -n 's/^version:[[:space:]]*\([^+[:space:]]*\).*/\1/p' "$ROOT/pubspec.yaml"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Builds run under the shared resource cap when it exists on this machine.
|
||||||
|
heavy() {
|
||||||
|
if [[ -x "$CAPPED" ]]; then
|
||||||
|
CAP_MEM=4G CAP_CPU_PCT=20 "$CAPPED" "$@"
|
||||||
|
else
|
||||||
|
"$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
mcp_pid() { systemctl --user show -p MainPID --value "$MCP_UNIT" 2>/dev/null || echo 0; }
|
||||||
|
|
||||||
|
deploy_mcp() {
|
||||||
|
local old_pid new_pid out
|
||||||
|
old_pid="$(mcp_pid)"
|
||||||
|
heavy "$ROOT/mcp/install.sh" || { fail mcp "mcp/install.sh failed"; return; }
|
||||||
|
# install.sh only restarts a running unit; make sure it is enabled + up.
|
||||||
|
systemctl --user enable --now "$MCP_UNIT" >/dev/null 2>&1 ||
|
||||||
|
{ fail mcp "cannot start $MCP_UNIT"; return; }
|
||||||
|
systemctl --user is-active --quiet "$MCP_UNIT" ||
|
||||||
|
{ fail mcp "$MCP_UNIT not active"; return; }
|
||||||
|
new_pid="$(mcp_pid)"
|
||||||
|
if [[ "$new_pid" == 0 || "$new_pid" == "$old_pid" ]]; then
|
||||||
|
fail mcp "$MCP_UNIT was not restarted (pid $old_pid -> $new_pid)"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
[[ -s "$MCP_TOKEN" ]] || { fail mcp "no client token at $MCP_TOKEN"; return; }
|
||||||
|
out="$(python3 "$ROOT/mcp/verify_mcp.py" "$MCP_URL" \
|
||||||
|
--token-file "$MCP_TOKEN" --wait 20 2>&1)" ||
|
||||||
|
{ fail mcp "live check: $out"; return; }
|
||||||
|
say mcp "ok: pid $new_pid, ${out//$'\n'/, }"
|
||||||
|
}
|
||||||
|
|
||||||
|
desktop_versions_match() {
|
||||||
|
local want="$1" pkg web
|
||||||
|
pkg="$(pacman -Q todo-flutter 2>/dev/null | awk '{print $2}')"
|
||||||
|
web="$(sed -n 's/.*"version":"\([^"]*\)".*/\1/p' "$WEB_VERSION_JSON" 2>/dev/null)"
|
||||||
|
[[ "${pkg%-*}" == "$want" && "$web" == "$want" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
deploy_desktop() {
|
||||||
|
local want
|
||||||
|
want="$(pubspec_version)"
|
||||||
|
if desktop_versions_match "$want"; then
|
||||||
|
say desktop "ok: todo-flutter $want already installed"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
sudo -n true 2>/dev/null ||
|
||||||
|
{ fail desktop "sudo needs a password; run: ! $ROOT/install_arch.sh"; return; }
|
||||||
|
heavy "$ROOT/install_arch.sh" || { fail desktop "install_arch.sh failed"; return; }
|
||||||
|
if desktop_versions_match "$want"; then
|
||||||
|
say desktop "ok: todo-flutter $want installed"
|
||||||
|
else
|
||||||
|
fail desktop "installed version is not $want ($(pacman -Q todo-flutter 2>&1))"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
phone_version() {
|
||||||
|
adb -s "$SERIAL" shell dumpsys package "$APP_ID" 2>/dev/null |
|
||||||
|
sed -n 's/^[[:space:]]*versionName=\([^[:space:]]*\).*/\1/p' | head -n 1
|
||||||
|
}
|
||||||
|
|
||||||
|
phone_health() {
|
||||||
|
local want="$1" got
|
||||||
|
got="$(phone_version)"
|
||||||
|
[[ "$got" == "$want" ]] || { fail phone "versionName on phone is '$got', want $want"; return; }
|
||||||
|
adb -s "$SERIAL" shell pidof "$APP_ID" >/dev/null 2>&1 ||
|
||||||
|
adb -s "$SERIAL" shell monkey -p "$APP_ID" -c android.intent.category.LAUNCHER 1 >/dev/null 2>&1 || true
|
||||||
|
sleep 2
|
||||||
|
adb -s "$SERIAL" shell pidof "$APP_ID" >/dev/null 2>&1 ||
|
||||||
|
{ fail phone "$APP_ID is installed but not running"; return; }
|
||||||
|
say phone "ok: $APP_ID $want running on $SERIAL"
|
||||||
|
}
|
||||||
|
|
||||||
|
deploy_phone() {
|
||||||
|
local want rc=0
|
||||||
|
want="$(pubspec_version)"
|
||||||
|
if [[ "$(adb -s "$SERIAL" get-state 2>/dev/null)" != device ]]; then
|
||||||
|
fail phone "phone unreachable: $SERIAL not attached (plug it in, enable USB debugging)"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if [[ "$(phone_version)" == "$want" ]]; then
|
||||||
|
say phone "already at $want, skipping build"
|
||||||
|
else
|
||||||
|
# A fresh checkout has no (gitignored) key.properties; without it the
|
||||||
|
# build signs with the debug key and cannot install over a CI build.
|
||||||
|
[[ -f "$KEY_PROPS" || ! -f "$SHARED_KEY_PROPS" ]] ||
|
||||||
|
install -m 600 "$SHARED_KEY_PROPS" "$KEY_PROPS"
|
||||||
|
# Outside a Claude session each phone_lease call would name its own
|
||||||
|
# pid as owner, so phone_deploy's launch step blocked on the lease its
|
||||||
|
# own install step took (exit 35). One owner for the whole run.
|
||||||
|
# Exit 48 = load too high right now; wait it out (up to 15 min).
|
||||||
|
local tries=0
|
||||||
|
while :; do
|
||||||
|
rc=0
|
||||||
|
PHONE_LEASE_OWNER="${PHONE_LEASE_OWNER:-deploy:$$}" ADB_SERIAL="$SERIAL" \
|
||||||
|
heavy "$PHONE_DEPLOY" "$ROOT" --release || rc=$?
|
||||||
|
((rc == 48 && ++tries <= 30)) || break
|
||||||
|
say phone "machine busy, retrying in 30 s ($tries/30)"
|
||||||
|
sleep 30
|
||||||
|
done
|
||||||
|
((rc == 0)) || { fail phone "phone_deploy.sh exited $rc (see its exit-code table)"; return; }
|
||||||
|
fi
|
||||||
|
phone_health "$want"
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
local targets=("$@") t
|
||||||
|
((${#targets[@]})) || targets=(mcp desktop phone)
|
||||||
|
for t in "${targets[@]}"; do
|
||||||
|
case "$t" in
|
||||||
|
mcp | desktop | phone) "deploy_$t" ;;
|
||||||
|
*) echo "usage: $0 [mcp] [desktop] [phone]" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
if ((${#FAILED[@]})); then
|
||||||
|
echo "[deploy] FAILED: ${FAILED[*]}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "[deploy] all healthy: ${targets[*]} at $(pubspec_version)"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@ -20,6 +20,7 @@ import 'fake_note_repository.dart';
|
|||||||
import 'fake_secure_storage.dart';
|
import 'fake_secure_storage.dart';
|
||||||
|
|
||||||
import 'capture_screen_harness.dart';
|
import 'capture_screen_harness.dart';
|
||||||
|
import 'text_free_guard.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets(
|
testWidgets(
|
||||||
@ -140,7 +141,7 @@ void main() {
|
|||||||
// Capture must never be interrupted by a snackbar…
|
// Capture must never be interrupted by a snackbar…
|
||||||
expect(find.byType(SnackBar), findsNothing);
|
expect(find.byType(SnackBar), findsNothing);
|
||||||
// …but the failure is no longer swallowed: the status line shows it.
|
// …but the failure is no longer swallowed: the status line shows it.
|
||||||
expect(find.textContaining('Sync failed at'), findsOneWidget);
|
expect(spoken('Sync failed at'), findsOneWidget);
|
||||||
expect(find.textContaining('offline'), findsOneWidget);
|
expect(spoken('offline'), findsOneWidget);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
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');
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -20,6 +20,7 @@ import 'fake_note_repository.dart';
|
|||||||
import 'fake_secure_storage.dart';
|
import 'fake_secure_storage.dart';
|
||||||
|
|
||||||
import 'capture_screen_harness.dart';
|
import 'capture_screen_harness.dart';
|
||||||
|
import 'text_free_guard.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('defaults to Raw, with Guided available via the entry wizard', (
|
testWidgets('defaults to Raw, with Guided available via the entry wizard', (
|
||||||
@ -29,18 +30,18 @@ void main() {
|
|||||||
|
|
||||||
// Defaults to Raw: a single text field, no note persisted yet.
|
// Defaults to Raw: a single text field, no note persisted yet.
|
||||||
expect(find.byType(TextField), findsOneWidget);
|
expect(find.byType(TextField), findsOneWidget);
|
||||||
expect(find.text('Guided'), findsOneWidget);
|
expect(find.bySemanticsLabel('Guided'), findsOneWidget);
|
||||||
expect(find.text('0'), findsOneWidget);
|
expect(find.text('0'), findsOneWidget);
|
||||||
|
|
||||||
// Tapping Guided on the empty draft opens the priority+template wizard
|
// Tapping Guided on the empty draft opens the priority+template wizard
|
||||||
// rather than jumping straight into the stepper.
|
// rather than jumping straight into the stepper.
|
||||||
await tester.tap(find.text('Guided'));
|
await tester.tap(find.bySemanticsLabel('Guided'));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
expect(find.text('Step 1 of 2'), findsOneWidget);
|
expect(find.bySemanticsLabel('Step 1 of 2'), findsOneWidget);
|
||||||
|
|
||||||
await tester.tap(find.text('Next'));
|
await tester.tap(find.bySemanticsLabel('Next'));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
await tester.tap(find.text('Start'));
|
await tester.tap(find.bySemanticsLabel('Start'));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
// Now on step 1 of the full-screen step page.
|
// Now on step 1 of the full-screen step page.
|
||||||
@ -49,13 +50,13 @@ void main() {
|
|||||||
find.text('1 / ${NoteTemplate.llmDesignSpec.sections.length}'),
|
find.text('1 / ${NoteTemplate.llmDesignSpec.sections.length}'),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
expect(find.textContaining('imperative'), findsOneWidget); // title helper
|
expect(spoken('imperative'), findsOneWidget); // title helper
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('saving the untouched template creates no note', (tester) async {
|
testWidgets('saving the untouched template creates no note', (tester) async {
|
||||||
final repo = await pumpCapture(tester);
|
final repo = await pumpCapture(tester);
|
||||||
|
|
||||||
await tester.tap(find.byTooltip('New note'));
|
await tester.tap(find.bySemanticsLabel('New note'));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
expect(await repo.listNotes(), isEmpty);
|
expect(await repo.listNotes(), isEmpty);
|
||||||
@ -83,7 +84,7 @@ void main() {
|
|||||||
|
|
||||||
await tester.enterText(find.byType(TextField).first, 'A real idea');
|
await tester.enterText(find.byType(TextField).first, 'A real idea');
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
await tester.tap(find.byTooltip('New note'));
|
await tester.tap(find.bySemanticsLabel('New note'));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
expect(await repo.listNotes(), hasLength(1));
|
expect(await repo.listNotes(), hasLength(1));
|
||||||
@ -98,15 +99,16 @@ void main() {
|
|||||||
) async {
|
) async {
|
||||||
await pumpCapture(tester); // empty prefs → no token → not configured
|
await pumpCapture(tester); // empty prefs → no token → not configured
|
||||||
|
|
||||||
await tester.tap(find.byTooltip('Sync'));
|
await tester.tap(find.bySemanticsLabel('Sync'));
|
||||||
await tester.pump(); // settings load + snackbar
|
await tester.pump(); // settings load + snackbar
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 500)); // snack fades in
|
||||||
|
|
||||||
// The behaviour that matters is the routing, not the wording: an
|
// The behaviour that matters is the routing, not the wording: an
|
||||||
// unconfigured device is sent to settings to connect a backend. Asserting
|
// unconfigured device is sent to settings to connect a backend. Asserting
|
||||||
// on the snackbar text tied this to one phrasing, and the snackbar is
|
// on the snackbar text tied this to one phrasing, and the snackbar is
|
||||||
// gone by the time settings has pushed anyway.
|
// gone by the time settings has pushed anyway.
|
||||||
expect(find.textContaining('Connect Firebase'), findsWidgets);
|
expect(spoken('Connect Firebase'), findsWidgets);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('the notes-list button navigates to the list screen', (
|
testWidgets('the notes-list button navigates to the list screen', (
|
||||||
@ -114,11 +116,11 @@ void main() {
|
|||||||
) async {
|
) async {
|
||||||
await pumpCapture(tester);
|
await pumpCapture(tester);
|
||||||
|
|
||||||
await tester.tap(find.byTooltip('Notes'));
|
await tester.tap(find.bySemanticsLabel('Notes'));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
await tester.pump(const Duration(milliseconds: 300)); // route transition
|
await tester.pump(const Duration(milliseconds: 300)); // route transition
|
||||||
|
|
||||||
expect(find.text('Notes'), findsOneWidget); // list screen app bar title
|
expect(find.byIcon(Icons.note), findsOneWidget); // list screen title
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('changing the priority dropdown updates the saved note', (
|
testWidgets('changing the priority dropdown updates the saved note', (
|
||||||
@ -134,7 +136,7 @@ void main() {
|
|||||||
);
|
);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
await tester.pump(const Duration(milliseconds: 400)); // menu open
|
await tester.pump(const Duration(milliseconds: 400)); // menu open
|
||||||
await tester.tap(find.text('High').last);
|
await tester.tap(find.bySemanticsLabel('High').last);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
expect((await repo.listNotes()).single.priority, Priority.high);
|
expect((await repo.listNotes()).single.priority, Priority.high);
|
||||||
@ -153,7 +155,7 @@ void main() {
|
|||||||
);
|
);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
await tester.pump(const Duration(milliseconds: 400)); // menu open
|
await tester.pump(const Duration(milliseconds: 400)); // menu open
|
||||||
await tester.tap(find.text('In progress').last);
|
await tester.tap(find.bySemanticsLabel('In progress').last);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
expect((await repo.listNotes()).single.status, Status.inProgress);
|
expect((await repo.listNotes()).single.status, Status.inProgress);
|
||||||
|
|||||||
@ -28,6 +28,7 @@ import 'package:todo/ui/attachment_picking.dart';
|
|||||||
import 'package:todo/ui/capture_screen.dart';
|
import 'package:todo/ui/capture_screen.dart';
|
||||||
import 'package:todo/ui/github_mirror_screen.dart';
|
import 'package:todo/ui/github_mirror_screen.dart';
|
||||||
import 'package:todo/ui/settings_screen.dart';
|
import 'package:todo/ui/settings_screen.dart';
|
||||||
|
import 'package:todo/ui/text_free_localizations.dart';
|
||||||
|
|
||||||
import 'fake_note_repository.dart';
|
import 'fake_note_repository.dart';
|
||||||
import 'fake_secure_storage.dart';
|
import 'fake_secure_storage.dart';
|
||||||
@ -73,6 +74,8 @@ Future<FakeNoteRepository> pumpCapture(
|
|||||||
debounce: Duration.zero,
|
debounce: Duration.zero,
|
||||||
);
|
);
|
||||||
final app = MaterialApp(
|
final app = MaterialApp(
|
||||||
|
// As in main.dart: Material's own button tooltips are blanked.
|
||||||
|
localizationsDelegates: kTextFreeLocalizations,
|
||||||
home: CaptureScreen(
|
home: CaptureScreen(
|
||||||
repository: repo,
|
repository: repo,
|
||||||
appSettings:
|
appSettings:
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import 'fake_note_repository.dart';
|
|||||||
import 'fake_secure_storage.dart';
|
import 'fake_secure_storage.dart';
|
||||||
|
|
||||||
import 'capture_screen_harness.dart';
|
import 'capture_screen_harness.dart';
|
||||||
|
import 'text_free_guard.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets(
|
testWidgets(
|
||||||
@ -35,8 +36,8 @@ void main() {
|
|||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
// Only a failure is worth surfacing; a routine success is silent.
|
// Only a failure is worth surfacing; a routine success is silent.
|
||||||
expect(find.textContaining('Synced at'), findsNothing);
|
expect(spoken('Synced at'), findsNothing);
|
||||||
expect(find.textContaining('merged 0 device(s)'), findsNothing);
|
expect(spoken('merged 0 device(s)'), findsNothing);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -53,8 +54,8 @@ void main() {
|
|||||||
);
|
);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
expect(find.textContaining('Sync failed at 12:00:00'), findsOneWidget);
|
expect(spoken('Sync failed at 12:00:00'), findsOneWidget);
|
||||||
expect(find.textContaining('rate limited'), findsOneWidget);
|
expect(spoken('rate limited'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('losing focus triggers a debounced auto-sync', (tester) async {
|
testWidgets('losing focus triggers a debounced auto-sync', (tester) async {
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import 'fake_note_repository.dart';
|
|||||||
import 'fake_secure_storage.dart';
|
import 'fake_secure_storage.dart';
|
||||||
|
|
||||||
import 'capture_screen_harness.dart';
|
import 'capture_screen_harness.dart';
|
||||||
|
import 'text_free_guard.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('Sync with a configured token runs the sync service', (
|
testWidgets('Sync with a configured token runs the sync service', (
|
||||||
@ -35,18 +36,17 @@ void main() {
|
|||||||
});
|
});
|
||||||
await pumpCapture(tester, prefs: configuredPrefs, httpClient: mock);
|
await pumpCapture(tester, prefs: configuredPrefs, httpClient: mock);
|
||||||
|
|
||||||
await tester.tap(find.byTooltip('Sync'));
|
await tester.tap(find.bySemanticsLabel('Sync'));
|
||||||
await tester.pump(); // setState(_syncing = true)
|
await tester.pump(); // setState(_syncing = true)
|
||||||
await tester.pump(); // service runs, snackbar scheduled
|
await tester.pump(); // service runs, snackbar scheduled
|
||||||
await tester.pump(); // snackbar builds
|
await tester.pump(); // snackbar builds
|
||||||
|
await tester.pump(const Duration(milliseconds: 500)); // snack fades in
|
||||||
|
|
||||||
// No firebaseFactory was injected, so this runs over GitHub alone --
|
// No firebaseFactory was injected, so this runs over GitHub alone --
|
||||||
// the status line must say so rather than reading identically to a
|
// the status line must say so rather than reading identically to a
|
||||||
// Firebase-connected sync.
|
// Firebase-connected sync.
|
||||||
expect(
|
expect(
|
||||||
find.textContaining(
|
spoken('Synced: GitHub-only (Firebase not connected) — merged 0 device'),
|
||||||
'Synced: GitHub-only (Firebase not connected) — merged 0 device',
|
|
||||||
),
|
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@ -55,14 +55,15 @@ void main() {
|
|||||||
final mock = MockClient((_) async => throw Exception('offline'));
|
final mock = MockClient((_) async => throw Exception('offline'));
|
||||||
await pumpCapture(tester, prefs: configuredPrefs, httpClient: mock);
|
await pumpCapture(tester, prefs: configuredPrefs, httpClient: mock);
|
||||||
|
|
||||||
await tester.tap(find.byTooltip('Sync'));
|
await tester.tap(find.bySemanticsLabel('Sync'));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 500)); // snack fades in
|
||||||
|
|
||||||
// 'Sync failed:' is the snackbar; the status line says 'Sync failed at'.
|
// 'Sync failed:' is the snackbar; the status line says 'Sync failed at'.
|
||||||
expect(find.textContaining('Sync failed:'), findsOneWidget);
|
expect(spoken('Sync failed:'), findsOneWidget);
|
||||||
expect(find.textContaining('Sync failed at'), findsOneWidget);
|
expect(spoken('Sync failed at'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('manual sync reports peer files it could not decode', (
|
testWidgets('manual sync reports peer files it could not decode', (
|
||||||
@ -85,13 +86,14 @@ void main() {
|
|||||||
});
|
});
|
||||||
await pumpCapture(tester, prefs: configuredPrefs, httpClient: mock);
|
await pumpCapture(tester, prefs: configuredPrefs, httpClient: mock);
|
||||||
|
|
||||||
await tester.tap(find.byTooltip('Sync'));
|
await tester.tap(find.bySemanticsLabel('Sync'));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 500)); // snack fades in
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
find.textContaining('skipped 1 unreadable file(s)'),
|
spoken('skipped 1 unreadable file(s)'),
|
||||||
findsWidgets, // snackbar and status line both call it out
|
findsWidgets, // snackbar and status line both call it out
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@ -101,7 +103,7 @@ void main() {
|
|||||||
) async {
|
) async {
|
||||||
await pumpCapture(tester);
|
await pumpCapture(tester);
|
||||||
|
|
||||||
await tester.tap(find.byTooltip('Settings'));
|
await tester.tap(find.bySemanticsLabel('Settings'));
|
||||||
await tester.pumpAndSettle(); // route transition
|
await tester.pumpAndSettle(); // route transition
|
||||||
expect(find.byType(SettingsScreen), findsOneWidget); // settings is up
|
expect(find.byType(SettingsScreen), findsOneWidget); // settings is up
|
||||||
|
|
||||||
@ -112,19 +114,19 @@ void main() {
|
|||||||
// without popping a result at all, so a stale in-memory value would be
|
// without popping a result at all, so a stale in-memory value would be
|
||||||
// wrong there too). This exercises that reload-after-return path, then
|
// wrong there too). This exercises that reload-after-return path, then
|
||||||
// Back returns to capture.
|
// Back returns to capture.
|
||||||
await tester.tap(find.text('Advanced sync (GitHub)'));
|
await tester.tap(find.byIcon(Icons.cloud_sync));
|
||||||
await tester.pumpAndSettle(); // route transition
|
await tester.pumpAndSettle(); // route transition
|
||||||
expect(find.byType(GitHubMirrorScreen), findsOneWidget);
|
expect(find.byType(GitHubMirrorScreen), findsOneWidget);
|
||||||
|
|
||||||
await tester.tap(find.text('Save'));
|
await tester.tap(find.bySemanticsLabel('Save'));
|
||||||
await tester.pumpAndSettle(); // save + pop transition back to Settings
|
await tester.pumpAndSettle(); // save + pop transition back to Settings
|
||||||
expect(find.byType(SettingsScreen), findsOneWidget);
|
expect(find.byType(SettingsScreen), findsOneWidget);
|
||||||
|
|
||||||
await tester.pageBack();
|
await tester.tap(find.byType(BackButton)); // no 'Back' tooltip to find
|
||||||
await tester.pumpAndSettle(); // pop transition back to capture
|
await tester.pumpAndSettle(); // pop transition back to capture
|
||||||
|
|
||||||
expect(find.byType(SettingsScreen), findsNothing); // back on capture
|
expect(find.byType(SettingsScreen), findsNothing); // back on capture
|
||||||
expect(find.byTooltip('Settings'), findsOneWidget);
|
expect(find.bySemanticsLabel('Settings'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
// A MockClient that records request methods and answers the sync flow:
|
// A MockClient that records request methods and answers the sync flow:
|
||||||
@ -148,42 +150,36 @@ void main() {
|
|||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
// The editor's own template/mode chrome is gone.
|
// The editor's own template/mode chrome is gone.
|
||||||
expect(find.text('Guided'), findsNothing);
|
expect(find.bySemanticsLabel('Guided'), findsNothing);
|
||||||
expect(find.text('Template'), findsNothing);
|
expect(find.bySemanticsLabel('Template'), findsNothing);
|
||||||
// The capture screen's priority/status row is gone.
|
// The capture screen's priority/status row is gone.
|
||||||
expect(find.text('Priority'), findsNothing);
|
expect(find.bySemanticsLabel('Priority'), findsNothing);
|
||||||
expect(find.text('Status'), findsNothing);
|
expect(find.bySemanticsLabel('Status'), findsNothing);
|
||||||
// Routine status chatter is gone; only a badge count remains.
|
// Routine status chatter is gone; only a badge count remains.
|
||||||
expect(find.textContaining('Autosaves as you type'), findsNothing);
|
expect(spoken('Autosaves as you type'), findsNothing);
|
||||||
expect(find.text('1'), findsOneWidget);
|
expect(find.text('1'), findsOneWidget);
|
||||||
// The note still persisted normally underneath the simplified UI.
|
// The note still persisted normally underneath the simplified UI.
|
||||||
expect(find.text('A plain idea'), findsOneWidget);
|
expect(find.text('A plain idea'), findsOneWidget);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
testWidgets(
|
testWidgets('draft text survives an advancedMode toggle mid-typing', (
|
||||||
'draft text survives an advancedMode toggle mid-typing',
|
tester,
|
||||||
(tester) async {
|
) async {
|
||||||
final appSettings = ValueNotifier(
|
final appSettings = ValueNotifier(const AppSettings(advancedMode: true));
|
||||||
const AppSettings(advancedMode: true),
|
await pumpCapture(tester, appSettings: appSettings);
|
||||||
);
|
|
||||||
await pumpCapture(tester, appSettings: appSettings);
|
|
||||||
|
|
||||||
await tester.enterText(
|
await tester.enterText(find.byType(TextField).first, 'my important idea');
|
||||||
find.byType(TextField).first,
|
await tester.pump();
|
||||||
'my important idea',
|
|
||||||
);
|
|
||||||
await tester.pump();
|
|
||||||
|
|
||||||
// NoteForm's metadata row is conditionally present, so toggling
|
// NoteForm's metadata row is conditionally present, so toggling
|
||||||
// advancedMode changes the editor's position beneath it — a key one
|
// advancedMode changes the editor's position beneath it — a key one
|
||||||
// level too shallow doesn't survive that reposition and Flutter
|
// level too shallow doesn't survive that reposition and Flutter
|
||||||
// remounts the editor with an empty draft, losing the typing.
|
// remounts the editor with an empty draft, losing the typing.
|
||||||
appSettings.value = const AppSettings(advancedMode: false);
|
appSettings.value = const AppSettings(advancedMode: false);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
final field = tester.widget<TextField>(find.byType(TextField).first);
|
final field = tester.widget<TextField>(find.byType(TextField).first);
|
||||||
expect(field.controller?.text, contains('my important idea'));
|
expect(field.controller?.text, contains('my important idea'));
|
||||||
},
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
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';
|
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();
|
out.clear();
|
||||||
err.clear();
|
err.clear();
|
||||||
return runCli(
|
return runCli(
|
||||||
@ -40,7 +44,7 @@ class CliHarness {
|
|||||||
env: env,
|
env: env,
|
||||||
out: out,
|
out: out,
|
||||||
err: err,
|
err: err,
|
||||||
readStdin: () async => '',
|
readStdin: () async => stdin,
|
||||||
open:
|
open:
|
||||||
opener ?? (d, {required seedCredentials}) => open(d, seedCredentials),
|
opener ?? (d, {required seedCredentials}) => open(d, seedCredentials),
|
||||||
);
|
);
|
||||||
@ -60,10 +64,10 @@ class CliHarness {
|
|||||||
return out.toString().trim();
|
return out.toString().trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Publishes [notes] as another device's log.
|
/// Publishes [notes] as device [node]'s log.
|
||||||
void seedPeer(List<Note> notes) {
|
void seedPeer(List<Note> notes, {String node = 'peer'}) {
|
||||||
db.files['todo-sync/notes/peer~2Ejson'] = logToJson({
|
db.files['todo-sync/notes/$node~2Ejson'] = logToJson({
|
||||||
for (final n in notes) n.id: NoteRepository.recordAtNoteTime(n, 'peer'),
|
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(await run(['--list', '--data-dir', dir('b')]), 0);
|
||||||
expect(
|
expect(
|
||||||
out.toString().trim(),
|
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);
|
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(await h.run(['--list', '--repo', 'kuhyx/todo']), 0);
|
||||||
expect(
|
expect(
|
||||||
h.out.toString(),
|
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(await h.run(['--list', '--status', 'done']), 0);
|
||||||
expect(h.out.toString(), isEmpty);
|
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(
|
expect(
|
||||||
listLine(notes[2].copyWith(status: Status.inProgress)),
|
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'));
|
expect(listLine(fixtureNote('abc', 'Short')), startsWith('abc\t'));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
57
test/confirm_delete_test.dart
Normal file
57
test/confirm_delete_test.dart
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:todo/ui/confirm_delete.dart';
|
||||||
|
|
||||||
|
/// Opens the dialog and returns a getter for its eventual answer.
|
||||||
|
Future<bool? Function()> _open(WidgetTester tester) async {
|
||||||
|
bool? answer;
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Builder(
|
||||||
|
builder: (context) => Scaffold(
|
||||||
|
body: TextButton(
|
||||||
|
onPressed: () async => answer = await confirmDeleteNote(context),
|
||||||
|
child: const SizedBox.square(dimension: 40),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.tap(find.byType(TextButton));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
return () => answer;
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('the delete glyph answers yes', (tester) async {
|
||||||
|
final answer = await _open(tester);
|
||||||
|
expect(find.bySemanticsLabel('Delete note?'), findsOneWidget);
|
||||||
|
await tester.tap(find.byKey(const ValueKey('confirm-delete')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(answer(), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('cancel is autofocused, so Return keeps the note', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final answer = await _open(tester);
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(answer(), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Escape answers no', (tester) async {
|
||||||
|
final answer = await _open(tester);
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(answer(), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('tapping the barrier answers no', (tester) async {
|
||||||
|
final answer = await _open(tester);
|
||||||
|
await tester.tapAt(const Offset(5, 5));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(answer(), isFalse);
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -29,6 +29,11 @@ class FakeNoteRepository implements NoteRepository {
|
|||||||
NoteSort? lastSort;
|
NoteSort? lastSort;
|
||||||
NoteFilter? lastFilter;
|
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
|
/// Emits the current snapshot to a new subscriber first (so late-binding
|
||||||
/// [StreamBuilder]s get the seed), then forwards subsequent changes.
|
/// [StreamBuilder]s get the seed), then forwards subsequent changes.
|
||||||
Stream<List<Note>> _snapshots() async* {
|
Stream<List<Note>> _snapshots() async* {
|
||||||
@ -46,12 +51,37 @@ class FakeNoteRepository implements NoteRepository {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> upsert(Note note) async {
|
Future<void> upsert(Note note) async {
|
||||||
|
upsertCalls++;
|
||||||
_notes
|
_notes
|
||||||
..removeWhere((n) => n.id == note.id)
|
..removeWhere((n) => n.id == note.id)
|
||||||
..add(note);
|
..add(note);
|
||||||
_emit();
|
_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
|
@override
|
||||||
Future<void> delete(String id) async {
|
Future<void> delete(String id) async {
|
||||||
_notes.removeWhere((n) => n.id == id);
|
_notes.removeWhere((n) => n.id == id);
|
||||||
@ -83,7 +113,9 @@ class FakeNoteRepository implements NoteRepository {
|
|||||||
Future<List<Note>> listNotes({
|
Future<List<Note>> listNotes({
|
||||||
NoteSort sort = NoteSort.modifiedDesc,
|
NoteSort sort = NoteSort.modifiedDesc,
|
||||||
NoteFilter filter = const NoteFilter(),
|
NoteFilter filter = const NoteFilter(),
|
||||||
}) async => List.unmodifiable(_notes);
|
}) async => List.unmodifiable(
|
||||||
|
applyFilter ? _notes.where((n) => matchesFilter(n, filter)) : _notes,
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Stream<List<Note>> watchNotes({
|
Stream<List<Note>> watchNotes({
|
||||||
@ -104,6 +136,12 @@ class FakeNoteRepository implements NoteRepository {
|
|||||||
@override
|
@override
|
||||||
Stream<int> watchCount() => _snapshots().map((n) => n.length);
|
Stream<int> watchCount() => _snapshots().map((n) => n.length);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Map<String, String>> backfillCreatedBy(
|
||||||
|
Set<String> claudeNodes, {
|
||||||
|
bool dryRun = false,
|
||||||
|
}) async => {};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get nodeId => 'fake-node';
|
String get nodeId => 'fake-node';
|
||||||
|
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import 'fake_note_repository.dart';
|
|||||||
import 'fake_secure_storage.dart';
|
import 'fake_secure_storage.dart';
|
||||||
|
|
||||||
import 'github_mirror_harness.dart';
|
import 'github_mirror_harness.dart';
|
||||||
|
import 'text_free_guard.dart';
|
||||||
|
|
||||||
/// Stub launcher that records the URL instead of opening it, so `_openPage`
|
/// Stub launcher that records the URL instead of opening it, so `_openPage`
|
||||||
/// can be exercised without a real platform channel.
|
/// can be exercised without a real platform channel.
|
||||||
@ -72,7 +73,7 @@ void main() {
|
|||||||
httpClient: mock,
|
httpClient: mock,
|
||||||
);
|
);
|
||||||
|
|
||||||
await tester.tap(find.text('Connect GitHub'));
|
await tester.tap(find.bySemanticsLabel('Connect GitHub'));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
await tester.pump(const Duration(milliseconds: 50));
|
await tester.pump(const Duration(milliseconds: 50));
|
||||||
@ -80,7 +81,7 @@ void main() {
|
|||||||
await tester.pump();
|
await tester.pump();
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(find.textContaining('sync failed'), findsOneWidget);
|
expect(spoken('sync failed'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('Save persists the settings and closes the screen', (
|
testWidgets('Save persists the settings and closes the screen', (
|
||||||
@ -135,7 +136,7 @@ void main() {
|
|||||||
await tester.pump(const Duration(milliseconds: 400)); // route transition
|
await tester.pump(const Duration(milliseconds: 400)); // route transition
|
||||||
expect(find.byType(GitHubMirrorScreen), findsOneWidget); // screen is up
|
expect(find.byType(GitHubMirrorScreen), findsOneWidget); // screen is up
|
||||||
|
|
||||||
await tester.tap(find.text('Save'));
|
await tester.tap(find.bySemanticsLabel('Save'));
|
||||||
await tester.pump(); // run _save (persist + pop)
|
await tester.pump(); // run _save (persist + pop)
|
||||||
await tester.pump(const Duration(milliseconds: 400)); // pop transition
|
await tester.pump(const Duration(milliseconds: 400)); // pop transition
|
||||||
|
|
||||||
@ -195,14 +196,15 @@ void main() {
|
|||||||
httpClient: mock,
|
httpClient: mock,
|
||||||
);
|
);
|
||||||
|
|
||||||
await tester.tap(find.text('Connect GitHub'));
|
await tester.tap(find.bySemanticsLabel('Connect GitHub'));
|
||||||
await tester.pump(); // requestDeviceCode
|
await tester.pump(); // requestDeviceCode
|
||||||
await tester.pump(); // dialog builds, poll starts (interval 0)
|
await tester.pump(); // dialog builds, poll starts (interval 0)
|
||||||
expect(find.text('WXYZ-1234'), findsOneWidget);
|
expect(find.text('WXYZ-1234'), findsOneWidget);
|
||||||
|
|
||||||
await tester.pump(const Duration(milliseconds: 1)); // _delay(0) fires
|
await tester.pump(const Duration(milliseconds: 1)); // _delay(0) fires
|
||||||
await tester.pump(); // token error throws → _error set
|
await tester.pump(); // token error throws → _error set
|
||||||
expect(find.textContaining('nope'), findsOneWidget); // error rendered
|
await tester.pump(const Duration(milliseconds: 300)); // dialog faded in
|
||||||
|
expect(spoken('nope'), findsOneWidget); // error rendered (spoken glyph)
|
||||||
|
|
||||||
// Tap the "open on GitHub" action: copies the code and launches the URL.
|
// Tap the "open on GitHub" action: copies the code and launches the URL.
|
||||||
// _openPage awaits Clipboard.setData then the launcher's supportsMode +
|
// _openPage awaits Clipboard.setData then the launcher's supportsMode +
|
||||||
@ -212,7 +214,7 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(launcher.launched, 'https://github.com/login/device');
|
expect(launcher.launched, 'https://github.com/login/device');
|
||||||
|
|
||||||
await tester.tap(find.text('Cancel'));
|
await tester.tap(find.bySemanticsLabel('Cancel'));
|
||||||
await tester.pumpAndSettle(); // finish the dialog pop animation
|
await tester.pumpAndSettle(); // finish the dialog pop animation
|
||||||
expect(find.text('WXYZ-1234'), findsNothing); // dialog dismissed
|
expect(find.text('WXYZ-1234'), findsNothing); // dialog dismissed
|
||||||
});
|
});
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import 'fake_note_repository.dart';
|
|||||||
import 'fake_secure_storage.dart';
|
import 'fake_secure_storage.dart';
|
||||||
|
|
||||||
import 'github_mirror_harness.dart';
|
import 'github_mirror_harness.dart';
|
||||||
|
import 'text_free_guard.dart';
|
||||||
|
|
||||||
/// Stub launcher that records the URL instead of opening it, so `_openPage`
|
/// Stub launcher that records the URL instead of opening it, so `_openPage`
|
||||||
/// can be exercised without a real platform channel.
|
/// can be exercised without a real platform channel.
|
||||||
@ -43,45 +44,42 @@ void main() {
|
|||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
await pumpMirror(tester);
|
await pumpMirror(tester);
|
||||||
await tester.tap(find.text('Connect GitHub'));
|
await tester.tap(find.bySemanticsLabel('Connect GitHub'));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
expect(
|
expect(spoken('Enter the OAuth App client id'), findsOneWidget);
|
||||||
find.textContaining('Enter the OAuth App client id'),
|
|
||||||
findsOneWidget,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('Test connection reports a reachable repo', (tester) async {
|
testWidgets('Test connection reports a reachable repo', (tester) async {
|
||||||
final mock = MockClient((_) async => http.Response('{}', 200));
|
final mock = MockClient((_) async => http.Response('{}', 200));
|
||||||
await pumpMirror(tester, httpClient: mock);
|
await pumpMirror(tester, httpClient: mock);
|
||||||
|
|
||||||
await tester.tap(find.text('Test connection'));
|
await tester.tap(find.bySemanticsLabel('Test connection'));
|
||||||
await tester.pump(); // start
|
await tester.pump(); // start
|
||||||
await tester.pump(); // resolve future + rebuild
|
await tester.pump(); // resolve future + rebuild
|
||||||
|
|
||||||
expect(find.textContaining('reachable'), findsOneWidget);
|
expect(spoken('reachable'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('Test connection reports an inaccessible repo', (tester) async {
|
testWidgets('Test connection reports an inaccessible repo', (tester) async {
|
||||||
final mock = MockClient((_) async => http.Response('', 404));
|
final mock = MockClient((_) async => http.Response('', 404));
|
||||||
await pumpMirror(tester, httpClient: mock);
|
await pumpMirror(tester, httpClient: mock);
|
||||||
|
|
||||||
await tester.tap(find.text('Test connection'));
|
await tester.tap(find.bySemanticsLabel('Test connection'));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
expect(find.textContaining('Could not access'), findsOneWidget);
|
expect(spoken('Could not access'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('Test connection surfaces a network error', (tester) async {
|
testWidgets('Test connection surfaces a network error', (tester) async {
|
||||||
final mock = MockClient((_) async => throw Exception('offline'));
|
final mock = MockClient((_) async => throw Exception('offline'));
|
||||||
await pumpMirror(tester, httpClient: mock);
|
await pumpMirror(tester, httpClient: mock);
|
||||||
|
|
||||||
await tester.tap(find.text('Test connection'));
|
await tester.tap(find.bySemanticsLabel('Test connection'));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
expect(find.textContaining('Error:'), findsOneWidget);
|
expect(spoken('Error:'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('device flow failure to start shows a message', (tester) async {
|
testWidgets('device flow failure to start shows a message', (tester) async {
|
||||||
@ -97,11 +95,11 @@ void main() {
|
|||||||
httpClient: mock,
|
httpClient: mock,
|
||||||
);
|
);
|
||||||
|
|
||||||
await tester.tap(find.text('Connect GitHub'));
|
await tester.tap(find.bySemanticsLabel('Connect GitHub'));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
expect(find.textContaining('Could not start device flow'), findsOneWidget);
|
expect(spoken('Could not start device flow'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('device flow happy path saves the token', (tester) async {
|
testWidgets('device flow happy path saves the token', (tester) async {
|
||||||
@ -133,7 +131,7 @@ void main() {
|
|||||||
httpClient: mock,
|
httpClient: mock,
|
||||||
);
|
);
|
||||||
|
|
||||||
await tester.tap(find.text('Connect GitHub'));
|
await tester.tap(find.bySemanticsLabel('Connect GitHub'));
|
||||||
await tester.pump(); // requestDeviceCode
|
await tester.pump(); // requestDeviceCode
|
||||||
await tester.pump(); // dialog builds, shows the user code
|
await tester.pump(); // dialog builds, shows the user code
|
||||||
expect(find.text('WXYZ-1234'), findsOneWidget);
|
expect(find.text('WXYZ-1234'), findsOneWidget);
|
||||||
@ -145,6 +143,6 @@ void main() {
|
|||||||
await tester.pump();
|
await tester.pump();
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(find.textContaining('Connected and synced'), findsOneWidget);
|
expect(spoken('Connected and synced'), findsOneWidget);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
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 startBody = '{"ok":false,"error":{"message":"nope"}}';
|
||||||
String page = '<meta name="todoimpl-csrf" content="tok-1">';
|
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 {
|
client: MockClient((req) async {
|
||||||
if (req.method == 'POST') {
|
if (req.method == 'POST') {
|
||||||
posts.add(req);
|
posts.add(req);
|
||||||
|
await Future<void>.delayed(postDelay);
|
||||||
return http.Response(startBody, startStatus);
|
return http.Response(startBody, startStatus);
|
||||||
}
|
}
|
||||||
if (req.url.path == '/api/board') {
|
if (req.url.path == '/api/board') {
|
||||||
|
onRead?.call(++reads);
|
||||||
return http.Response.bytes(
|
return http.Response.bytes(
|
||||||
utf8.encode(jsonEncode({'tasks': cards})),
|
utf8.encode(jsonEncode({'tasks': cards})),
|
||||||
boardStatus,
|
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', () {
|
test('the default client targets the loopback board', () {
|
||||||
expect(BoardClient().base.toString(), 'http://127.0.0.1:8786');
|
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:http/testing.dart';
|
||||||
import 'package:mcp_dart/mcp_dart.dart';
|
import 'package:mcp_dart/mcp_dart.dart';
|
||||||
import 'package:todo/mcp/board_client.dart';
|
import 'package:todo/mcp/board_client.dart';
|
||||||
|
import 'package:todo/mcp/task_service.dart';
|
||||||
|
|
||||||
import 'mcp_harness.dart';
|
import 'mcp_harness.dart';
|
||||||
|
|
||||||
@ -15,9 +16,14 @@ void main() {
|
|||||||
late McpHarness h;
|
late McpHarness h;
|
||||||
late McpClient c;
|
late McpClient c;
|
||||||
var cards = <Map<String, dynamic>>[];
|
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;
|
var up = true;
|
||||||
final posts = <String>[];
|
final posts = <String>[];
|
||||||
final board = BoardClient(
|
final board = BoardClient(
|
||||||
|
wait: const Duration(seconds: 2),
|
||||||
|
poll: const Duration(milliseconds: 5),
|
||||||
client: MockClient((req) async {
|
client: MockClient((req) async {
|
||||||
if (!up) throw http.ClientException('connection refused');
|
if (!up) throw http.ClientException('connection refused');
|
||||||
if (req.method == 'POST') {
|
if (req.method == 'POST') {
|
||||||
@ -25,17 +31,26 @@ void main() {
|
|||||||
return http.Response('{"ok":true}', 200);
|
return http.Response('{"ok":true}', 200);
|
||||||
}
|
}
|
||||||
if (req.url.path == '/api/board') {
|
if (req.url.path == '/api/board') {
|
||||||
|
if (lag-- == 0) {
|
||||||
|
cards = [...cards, ...late_];
|
||||||
|
late_ = [];
|
||||||
|
}
|
||||||
|
final shown = [...cards];
|
||||||
return http.Response.bytes(
|
return http.Response.bytes(
|
||||||
utf8.encode(jsonEncode({'tasks': cards})),
|
utf8.encode(jsonEncode({'tasks': shown})),
|
||||||
200,
|
200,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return http.Response('<meta name="todoimpl-csrf" content="t">', 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',
|
'title': 'Młody',
|
||||||
'column': column,
|
'column': column,
|
||||||
'actions': [
|
'actions': [
|
||||||
@ -46,6 +61,8 @@ void main() {
|
|||||||
|
|
||||||
setUp(() async {
|
setUp(() async {
|
||||||
cards = [];
|
cards = [];
|
||||||
|
late_ = [];
|
||||||
|
lag = 0;
|
||||||
up = true;
|
up = true;
|
||||||
posts.clear();
|
posts.clear();
|
||||||
h = McpHarness(board: board);
|
h = McpHarness(board: board);
|
||||||
@ -78,16 +95,22 @@ void main() {
|
|||||||
expect(posts, isEmpty);
|
expect(posts, isEmpty);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('C3/C6: a duplicate merges and Start is POSTed', () async {
|
test(
|
||||||
final x = idIn(await add({'done_check': 'true'}));
|
'C3/C6/T1863: a duplicate merges, Start waits for the new item',
|
||||||
cards = cardsOf(x, 'backlog');
|
() async {
|
||||||
final r = await add({'done_check': 'true'});
|
final x = idIn(await add({'done_check': 'true'}));
|
||||||
expect(r, startsWith('merged into $x'));
|
cards = cardsOf(x, 'backlog');
|
||||||
expect(r, contains('start: ok ${x.substring(0, 8)}-0'));
|
// The merged section shows up one read later than the merge itself.
|
||||||
expect(posts, ['/api/task/${x.substring(0, 8)}-0/start']);
|
late_ = cardsOf(x, 'backlog', 1);
|
||||||
final listed = await h.call(c, 'list_tasks', {'repo': _repo});
|
lag = 2; // past the guard's running read and the first Start read
|
||||||
expect(listed.trim().split('\n'), hasLength(1));
|
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 {
|
test('C6: done_check none or low priority merges without Start', () async {
|
||||||
final x = idIn(await add({}));
|
final x = idIn(await add({}));
|
||||||
@ -120,6 +143,22 @@ void main() {
|
|||||||
expect(r, contains('click Start on the board by hand'));
|
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 {
|
test('the add_task schema offers new_note', () async {
|
||||||
final tools = (await c.listTools()).tools;
|
final tools = (await c.listTools()).tools;
|
||||||
final add = tools.singleWhere((t) => t.name == 'add_task');
|
final add = tools.singleWhere((t) => t.name == 'add_task');
|
||||||
|
|||||||
@ -38,7 +38,7 @@ void main() {
|
|||||||
await pumpCapture(tester);
|
await pumpCapture(tester);
|
||||||
await _type(tester);
|
await _type(tester);
|
||||||
|
|
||||||
await tester.tap(find.byTooltip('New note'));
|
await tester.tap(find.bySemanticsLabel('New note'));
|
||||||
await tester.pump(); // one frame: the cue must not wait for anything
|
await tester.pump(); // one frame: the cue must not wait for anything
|
||||||
|
|
||||||
expect(haptics, [_lightImpact]);
|
expect(haptics, [_lightImpact]);
|
||||||
@ -52,7 +52,7 @@ void main() {
|
|||||||
await pumpCapture(tester);
|
await pumpCapture(tester);
|
||||||
await _type(tester);
|
await _type(tester);
|
||||||
|
|
||||||
await tester.tap(find.byTooltip('New note'));
|
await tester.tap(find.bySemanticsLabel('New note'));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
await tester.pump(const Duration(milliseconds: 100));
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
expect(_mark, findsOneWidget);
|
expect(_mark, findsOneWidget);
|
||||||
@ -74,7 +74,7 @@ void main() {
|
|||||||
final haptics = recordHaptics(tester);
|
final haptics = recordHaptics(tester);
|
||||||
await pumpCapture(tester);
|
await pumpCapture(tester);
|
||||||
|
|
||||||
await tester.tap(find.byTooltip('New note'));
|
await tester.tap(find.bySemanticsLabel('New note'));
|
||||||
await tester.pump(const Duration(milliseconds: 100));
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
|
||||||
expect(haptics, isEmpty);
|
expect(haptics, isEmpty);
|
||||||
@ -87,16 +87,18 @@ void main() {
|
|||||||
final haptics = recordHaptics(tester);
|
final haptics = recordHaptics(tester);
|
||||||
await pumpCapture(tester);
|
await pumpCapture(tester);
|
||||||
await _type(tester);
|
await _type(tester);
|
||||||
await tester.tap(find.byTooltip('New note'));
|
await tester.tap(find.bySemanticsLabel('New note'));
|
||||||
await tester.pump(); // the ticker's clock starts on its first frame
|
await tester.pump(); // the ticker's clock starts on its first frame
|
||||||
await tester.pump(const Duration(milliseconds: 700));
|
await tester.pump(const Duration(milliseconds: 700));
|
||||||
await tester.tap(find.byTooltip('New note')); // nothing new to capture
|
await tester.tap(
|
||||||
|
find.bySemanticsLabel('New note'),
|
||||||
|
); // nothing new to capture
|
||||||
await tester.pump(const Duration(milliseconds: 100));
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
expect(haptics, hasLength(1));
|
expect(haptics, hasLength(1));
|
||||||
expect(_mark, findsNothing);
|
expect(_mark, findsNothing);
|
||||||
|
|
||||||
await _type(tester);
|
await _type(tester);
|
||||||
await tester.tap(find.byTooltip('New note'));
|
await tester.tap(find.bySemanticsLabel('New note'));
|
||||||
await tester.pump(const Duration(milliseconds: 100));
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
expect(haptics, hasLength(2));
|
expect(haptics, hasLength(2));
|
||||||
expect(_mark, findsOneWidget);
|
expect(_mark, findsOneWidget);
|
||||||
@ -111,7 +113,7 @@ void main() {
|
|||||||
await pumpCapture(tester);
|
await pumpCapture(tester);
|
||||||
await _type(tester);
|
await _type(tester);
|
||||||
|
|
||||||
await tester.tap(find.byTooltip('New note'));
|
await tester.tap(find.bySemanticsLabel('New note'));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
expect(haptics, [_lightImpact]);
|
expect(haptics, [_lightImpact]);
|
||||||
|
|
||||||
@ -130,7 +132,7 @@ void main() {
|
|||||||
await pumpCapture(tester);
|
await pumpCapture(tester);
|
||||||
final before = tester.getTopLeft(find.text('0'));
|
final before = tester.getTopLeft(find.text('0'));
|
||||||
await _type(tester);
|
await _type(tester);
|
||||||
await tester.tap(find.byTooltip('New note'));
|
await tester.tap(find.bySemanticsLabel('New note'));
|
||||||
await tester.pump(const Duration(milliseconds: 300));
|
await tester.pump(const Duration(milliseconds: 300));
|
||||||
expect(tester.getTopLeft(find.text('1')), before);
|
expect(tester.getTopLeft(find.text('1')), before);
|
||||||
await tester.pump(const Duration(milliseconds: 400));
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
|||||||
@ -13,7 +13,7 @@ double _barValue(WidgetTester tester) => tester
|
|||||||
.widget<LinearProgressIndicator>(find.byType(LinearProgressIndicator))
|
.widget<LinearProgressIndicator>(find.byType(LinearProgressIndicator))
|
||||||
.value!;
|
.value!;
|
||||||
|
|
||||||
Future<void> _pushPage(WidgetTester tester) async {
|
Future<Route<void>> _pushPage(WidgetTester tester) async {
|
||||||
late BuildContext ctx;
|
late BuildContext ctx;
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
MaterialApp(
|
MaterialApp(
|
||||||
@ -25,12 +25,10 @@ Future<void> _pushPage(WidgetTester tester) async {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
unawaited(
|
final route = appRoute<void>(ctx, (_) => const Scaffold(body: Text('page')));
|
||||||
Navigator.of(
|
unawaited(Navigator.of(ctx).push<void>(route));
|
||||||
ctx,
|
|
||||||
).push<void>(appRoute(ctx, (_) => const Scaffold(body: Text('page')))),
|
|
||||||
);
|
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
return route;
|
||||||
}
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
@ -46,22 +44,20 @@ void main() {
|
|||||||
expect(offenders, isEmpty);
|
expect(offenders, isEmpty);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('a pushed page eases in over AppDuration.slow', (tester) async {
|
testWidgets('a pushed page is instant: no fade or slide', (tester) async {
|
||||||
await _pushPage(tester);
|
final route = await _pushPage(tester) as PageRoute<void>;
|
||||||
await tester.pump(AppDuration.slow ~/ 4);
|
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);
|
expect(find.text('page'), findsOneWidget);
|
||||||
final fade = tester.widget<FadeTransition>(
|
final page = find.ancestor(
|
||||||
find
|
of: find.text('page'),
|
||||||
.ancestor(
|
matching: find.byType(FadeTransition),
|
||||||
of: find.text('page'),
|
|
||||||
matching: find.byType(FadeTransition),
|
|
||||||
)
|
|
||||||
.first,
|
|
||||||
);
|
);
|
||||||
expect(fade.opacity.value, inExclusiveRange(0, 1));
|
for (final f in tester.widgetList<FadeTransition>(page)) {
|
||||||
|
expect(f.opacity.value, 1);
|
||||||
await tester.pump(AppDuration.slow);
|
}
|
||||||
expect(find.text('home'), findsNothing); // fully covered, route settled
|
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('remove-animations: the page is there at once', (tester) async {
|
testWidgets('remove-animations: the page is there at once', (tester) async {
|
||||||
@ -77,7 +73,7 @@ void main() {
|
|||||||
final total = spec.sections.length;
|
final total = spec.sections.length;
|
||||||
expect(_barValue(tester), closeTo(1 / total, 1e-9));
|
expect(_barValue(tester), closeTo(1 / total, 1e-9));
|
||||||
|
|
||||||
await tester.tap(find.text('Next'));
|
await tester.tap(find.bySemanticsLabel('Next'));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
await tester.pump(AppDuration.base ~/ 2);
|
await tester.pump(AppDuration.base ~/ 2);
|
||||||
expect(_barValue(tester), inExclusiveRange(1 / total, 2 / total));
|
expect(_barValue(tester), inExclusiveRange(1 / total, 2 / total));
|
||||||
@ -89,7 +85,7 @@ void main() {
|
|||||||
testWidgets('remove-animations: the progress bar jumps', (tester) async {
|
testWidgets('remove-animations: the progress bar jumps', (tester) async {
|
||||||
disableAnimations(tester);
|
disableAnimations(tester);
|
||||||
await pumpEditor(tester, initialTemplate: spec);
|
await pumpEditor(tester, initialTemplate: spec);
|
||||||
await tester.tap(find.text('Next'));
|
await tester.tap(find.bySemanticsLabel('Next'));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
expect(_barValue(tester), closeTo(2 / spec.sections.length, 1e-9));
|
expect(_barValue(tester), closeTo(2 / spec.sections.length, 1e-9));
|
||||||
});
|
});
|
||||||
|
|||||||
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -61,7 +61,7 @@ void main() {
|
|||||||
find.byWidgetPredicate((w) => w is DropdownButton<Priority>),
|
find.byWidgetPredicate((w) => w is DropdownButton<Priority>),
|
||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.text('High').last);
|
await tester.tap(find.bySemanticsLabel('High').last);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect((await repo.listNotes()).single.priority, Priority.high);
|
expect((await repo.listNotes()).single.priority, Priority.high);
|
||||||
@ -74,7 +74,7 @@ void main() {
|
|||||||
find.byWidgetPredicate((w) => w is DropdownButton<Status>),
|
find.byWidgetPredicate((w) => w is DropdownButton<Status>),
|
||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.text('Done').last);
|
await tester.tap(find.bySemanticsLabel('Done').last);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect((await repo.listNotes()).single.status, Status.done);
|
expect((await repo.listNotes()).single.status, Status.done);
|
||||||
@ -85,7 +85,7 @@ void main() {
|
|||||||
) async {
|
) async {
|
||||||
final repo = await pumpDetail(tester, seedNote('# T\n\n## what\nold'));
|
final repo = await pumpDetail(tester, seedNote('# T\n\n## what\nold'));
|
||||||
|
|
||||||
await tester.tap(find.text('Raw'));
|
await tester.tap(find.bySemanticsLabel('Raw'));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
await tester.enterText(find.byType(TextField), '# T\n\n## what\nnew body');
|
await tester.enterText(find.byType(TextField), '# T\n\n## what\nnew body');
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
@ -101,20 +101,20 @@ void main() {
|
|||||||
await tester.enterText(find.byType(TextField), '');
|
await tester.enterText(find.byType(TextField), '');
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
await tester.tap(find.text('Guided'));
|
await tester.tap(find.bySemanticsLabel('Guided'));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
expect(find.text('Step 1 of 2'), findsOneWidget);
|
expect(find.bySemanticsLabel('Step 1 of 2'), findsOneWidget);
|
||||||
// Wizard hides the Status dropdown (onChromeVisibleChanged(false)).
|
// Wizard hides the Status dropdown (onChromeVisibleChanged(false)).
|
||||||
expect(find.text('Status'), findsNothing);
|
expect(find.bySemanticsLabel('Status'), findsNothing);
|
||||||
|
|
||||||
await tester.tap(find.byType(DropdownButtonFormField<Priority>));
|
await tester.tap(find.byType(DropdownButtonFormField<Priority>));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.text('High').last);
|
await tester.tap(find.bySemanticsLabel('High').last);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.tap(find.text('Next'));
|
await tester.tap(find.bySemanticsLabel('Next'));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
await tester.tap(find.text('Start'));
|
await tester.tap(find.bySemanticsLabel('Start'));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
expect((await repo.listNotes()).single.priority, Priority.high);
|
expect((await repo.listNotes()).single.priority, Priority.high);
|
||||||
@ -134,11 +134,11 @@ void main() {
|
|||||||
advancedMode: false,
|
advancedMode: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(find.text('Template'), findsNothing);
|
expect(find.bySemanticsLabel('Template'), findsNothing);
|
||||||
expect(find.text('Guided'), findsNothing);
|
expect(find.bySemanticsLabel('Guided'), findsNothing);
|
||||||
expect(find.text('Raw'), findsNothing);
|
expect(find.bySemanticsLabel('Raw'), findsNothing);
|
||||||
expect(find.text('Priority'), findsNothing);
|
expect(find.bySemanticsLabel('Priority'), findsNothing);
|
||||||
expect(find.text('Status'), findsNothing);
|
expect(find.bySemanticsLabel('Status'), findsNothing);
|
||||||
// The body itself is still editable — only the chrome is gone.
|
// The body itself is still editable — only the chrome is gone.
|
||||||
expect(find.byType(TextField), findsOneWidget);
|
expect(find.byType(TextField), findsOneWidget);
|
||||||
});
|
});
|
||||||
@ -146,13 +146,13 @@ void main() {
|
|||||||
testWidgets('the delete action removes the note and pops', (tester) async {
|
testWidgets('the delete action removes the note and pops', (tester) async {
|
||||||
final repo = await pumpDetail(tester, seedNote('# Bye'));
|
final repo = await pumpDetail(tester, seedNote('# Bye'));
|
||||||
|
|
||||||
await tester.tap(find.byTooltip('Delete note'));
|
await tester.tap(find.bySemanticsLabel('Delete note'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
// Delete is now confirmed. Deleting a note is irreversible and there is no
|
// Delete is now confirmed. Deleting a note is irreversible and there is no
|
||||||
// undo, so a single Return on a focused control must not destroy it.
|
// undo, so a single Return on a focused control must not destroy it.
|
||||||
expect(find.text('Delete note?'), findsOneWidget);
|
expect(find.bySemanticsLabel('Delete note?'), findsOneWidget);
|
||||||
await tester.tap(find.text('Delete'));
|
await tester.tap(find.byKey(const ValueKey('confirm-delete')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(await repo.listNotes(), isEmpty);
|
expect(await repo.listNotes(), isEmpty);
|
||||||
@ -162,9 +162,9 @@ void main() {
|
|||||||
testWidgets('cancelling the delete keeps the note', (tester) async {
|
testWidgets('cancelling the delete keeps the note', (tester) async {
|
||||||
final repo = await pumpDetail(tester, seedNote('# Keep me'));
|
final repo = await pumpDetail(tester, seedNote('# Keep me'));
|
||||||
|
|
||||||
await tester.tap(find.byTooltip('Delete note'));
|
await tester.tap(find.bySemanticsLabel('Delete note'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.text('Cancel'));
|
await tester.tap(find.byKey(const ValueKey('confirm-cancel')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(await repo.listNotes(), hasLength(1));
|
expect(await repo.listNotes(), hasLength(1));
|
||||||
|
|||||||
@ -4,8 +4,10 @@ import 'package:todo/data/note.dart';
|
|||||||
import 'package:todo/data/note_template.dart';
|
import 'package:todo/data/note_template.dart';
|
||||||
import 'package:todo/ui/markdown_view.dart';
|
import 'package:todo/ui/markdown_view.dart';
|
||||||
import 'package:todo/ui/note_editor.dart';
|
import 'package:todo/ui/note_editor.dart';
|
||||||
|
import 'package:todo/ui/template_glyphs.dart';
|
||||||
|
|
||||||
import 'note_editor_harness.dart';
|
import 'note_editor_harness.dart';
|
||||||
|
import 'text_free_guard.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('guided: typing the title emits an assembled # heading', (
|
testWidgets('guided: typing the title emits an assembled # heading', (
|
||||||
@ -13,7 +15,7 @@ void main() {
|
|||||||
) async {
|
) async {
|
||||||
final emitted = await pumpEditor(tester, initialTemplate: spec);
|
final emitted = await pumpEditor(tester, initialTemplate: spec);
|
||||||
|
|
||||||
expect(find.byTooltip('Exit guided'), findsOneWidget);
|
expect(find.bySemanticsLabel('Exit guided'), findsOneWidget);
|
||||||
await tester.enterText(find.byType(TextField).first, 'Dark mode');
|
await tester.enterText(find.byType(TextField).first, 'Dark mode');
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
@ -28,11 +30,11 @@ void main() {
|
|||||||
expect(find.byType(LinearProgressIndicator), findsOneWidget);
|
expect(find.byType(LinearProgressIndicator), findsOneWidget);
|
||||||
expect(find.text('1 / ${spec.sections.length}'), findsOneWidget);
|
expect(find.text('1 / ${spec.sections.length}'), findsOneWidget);
|
||||||
|
|
||||||
await tester.tap(find.text('Next'));
|
await tester.tap(find.bySemanticsLabel('Next'));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
expect(find.text('2 / ${spec.sections.length}'), findsOneWidget);
|
expect(find.text('2 / ${spec.sections.length}'), findsOneWidget);
|
||||||
|
|
||||||
await tester.tap(find.text('Back'));
|
await tester.tap(find.bySemanticsLabel('Back'));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
expect(find.text('1 / ${spec.sections.length}'), findsOneWidget);
|
expect(find.text('1 / ${spec.sections.length}'), findsOneWidget);
|
||||||
expect(find.byType(LinearProgressIndicator), findsOneWidget);
|
expect(find.byType(LinearProgressIndicator), findsOneWidget);
|
||||||
@ -49,21 +51,22 @@ void main() {
|
|||||||
for (var i = 0; i < spec.sections.length; i++) {
|
for (var i = 0; i < spec.sections.length; i++) {
|
||||||
final section = spec.sections[i];
|
final section = spec.sections[i];
|
||||||
expect(find.text('${i + 1} / ${spec.sections.length}'), findsOneWidget);
|
expect(find.text('${i + 1} / ${spec.sections.length}'), findsOneWidget);
|
||||||
|
// Each step paints its section glyph; label + helper are spoken.
|
||||||
expect(
|
expect(
|
||||||
find.text(section.isTitle ? 'title' : section.label),
|
find.byIcon(sectionIcon(section)),
|
||||||
findsWidgets,
|
findsOneWidget,
|
||||||
reason: 'step ${i + 1} should show "${section.label}"',
|
reason: 'step ${i + 1} should show "${section.label}"',
|
||||||
);
|
);
|
||||||
expect(find.text(section.helper), findsOneWidget);
|
expect(spoken(section.helper), findsOneWidget);
|
||||||
if (i < spec.sections.length - 1) {
|
if (i < spec.sections.length - 1) {
|
||||||
await tester.tap(find.text('Next'));
|
await tester.tap(find.bySemanticsLabel('Next'));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The last step is `read first`; there is no Next past it.
|
// The last step is `read first`; there is no Next past it.
|
||||||
expect(spec.sections.last.label, 'read first');
|
expect(spec.sections.last.label, 'read first');
|
||||||
expect(find.text('Next'), findsNothing);
|
expect(find.bySemanticsLabel('Next'), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('pasting a bare link drops the draft to the blank template', (
|
testWidgets('pasting a bare link drops the draft to the blank template', (
|
||||||
@ -74,7 +77,7 @@ void main() {
|
|||||||
initialTemplate: spec,
|
initialTemplate: spec,
|
||||||
initialMode: NoteEditorMode.raw,
|
initialMode: NoteEditorMode.raw,
|
||||||
);
|
);
|
||||||
expect(find.text('LLM design spec'), findsOneWidget);
|
expect(find.byIcon(Icons.assignment_outlined), findsOneWidget);
|
||||||
|
|
||||||
await tester.enterText(
|
await tester.enterText(
|
||||||
find.byType(TextField).first,
|
find.byType(TextField).first,
|
||||||
@ -83,8 +86,8 @@ void main() {
|
|||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
// Freeform now, so there is no seven-step stepper to walk for a link.
|
// Freeform now, so there is no seven-step stepper to walk for a link.
|
||||||
expect(find.text('Blank'), findsOneWidget);
|
expect(find.byIcon(Icons.note_outlined), findsOneWidget);
|
||||||
expect(find.text('Guided'), findsNothing);
|
expect(find.bySemanticsLabel('Guided'), findsNothing);
|
||||||
|
|
||||||
// Adding prose puts the spec template back — lossless in both directions.
|
// Adding prose puts the spec template back — lossless in both directions.
|
||||||
await tester.enterText(
|
await tester.enterText(
|
||||||
@ -92,8 +95,8 @@ void main() {
|
|||||||
'https://example.com/x — port this to Dart',
|
'https://example.com/x — port this to Dart',
|
||||||
);
|
);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
expect(find.text('LLM design spec'), findsOneWidget);
|
expect(find.byIcon(Icons.assignment_outlined), findsOneWidget);
|
||||||
expect(find.text('Guided'), findsOneWidget);
|
expect(find.bySemanticsLabel('Guided'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('an explicit template choice survives further typing', (
|
testWidgets('an explicit template choice survives further typing', (
|
||||||
@ -105,16 +108,16 @@ void main() {
|
|||||||
initialMode: NoteEditorMode.raw,
|
initialMode: NoteEditorMode.raw,
|
||||||
);
|
);
|
||||||
|
|
||||||
await tester.tap(find.text('LLM design spec'));
|
await tester.tap(find.byIcon(Icons.assignment_outlined));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.text('Blank').last);
|
await tester.tap(find.byIcon(Icons.note_outlined).last);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('Blank'), findsOneWidget);
|
expect(find.byIcon(Icons.note_outlined), findsOneWidget);
|
||||||
|
|
||||||
// Prose would normally re-select the spec template; an explicit pick wins.
|
// Prose would normally re-select the spec template; an explicit pick wins.
|
||||||
await tester.enterText(find.byType(TextField).first, 'build a thing');
|
await tester.enterText(find.byType(TextField).first, 'build a thing');
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
expect(find.text('Blank'), findsOneWidget);
|
expect(find.byIcon(Icons.note_outlined), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('switching to View renders the note as Markdown', (tester) async {
|
testWidgets('switching to View renders the note as Markdown', (tester) async {
|
||||||
@ -126,7 +129,7 @@ void main() {
|
|||||||
await tester.enterText(find.byType(TextField).first, '# Render me');
|
await tester.enterText(find.byType(TextField).first, '# Render me');
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
await tester.tap(find.text('View'));
|
await tester.tap(find.bySemanticsLabel('View'));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
expect(find.byType(MarkdownView), findsOneWidget);
|
expect(find.byType(MarkdownView), findsOneWidget);
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import 'package:todo/ui/markdown_view.dart';
|
|||||||
import 'package:todo/ui/note_editor.dart';
|
import 'package:todo/ui/note_editor.dart';
|
||||||
|
|
||||||
import 'note_editor_harness.dart';
|
import 'note_editor_harness.dart';
|
||||||
|
import 'text_free_guard.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets(
|
testWidgets(
|
||||||
@ -15,7 +16,7 @@ void main() {
|
|||||||
await tester.enterText(find.byType(TextField).first, 'T');
|
await tester.enterText(find.byType(TextField).first, 'T');
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
await tester.tap(find.byTooltip('Exit guided'));
|
await tester.tap(find.bySemanticsLabel('Exit guided'));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
final raw = tester.widget<TextField>(find.byType(TextField));
|
final raw = tester.widget<TextField>(find.byType(TextField));
|
||||||
@ -38,10 +39,11 @@ void main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Structured template + non-conforming raw text → switching is refused.
|
// Structured template + non-conforming raw text → switching is refused.
|
||||||
await tester.tap(find.text('Guided'));
|
await tester.tap(find.bySemanticsLabel('Guided'));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 500)); // snack fades in
|
||||||
|
|
||||||
expect(find.textContaining("doesn't match the template"), findsOneWidget);
|
expect(spoken("doesn't match the template"), findsOneWidget);
|
||||||
expect(find.byType(LinearProgressIndicator), findsNothing); // still raw
|
expect(find.byType(LinearProgressIndicator), findsNothing); // still raw
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -56,25 +58,28 @@ void main() {
|
|||||||
|
|
||||||
// Bare guided -> raw via the back arrow makes the (still conforming)
|
// Bare guided -> raw via the back arrow makes the (still conforming)
|
||||||
// body the source…
|
// body the source…
|
||||||
await tester.tap(find.byTooltip('Exit guided'));
|
await tester.tap(find.bySemanticsLabel('Exit guided'));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
expect(find.byType(LinearProgressIndicator), findsNothing);
|
expect(find.byType(LinearProgressIndicator), findsNothing);
|
||||||
|
|
||||||
// …then raw -> guided re-parses the conforming body straight back into
|
// …then raw -> guided re-parses the conforming body straight back into
|
||||||
// the bare stepper — no wizard, since the content isn't empty.
|
// the bare stepper — no wizard, since the content isn't empty.
|
||||||
await tester.tap(find.text('Guided'));
|
await tester.tap(find.bySemanticsLabel('Guided'));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
expect(find.byType(LinearProgressIndicator), findsOneWidget);
|
expect(find.byType(LinearProgressIndicator), findsOneWidget);
|
||||||
expect(find.text('Step 1 of 2'), findsNothing); // wizard was skipped
|
expect(
|
||||||
|
find.bySemanticsLabel('Step 1 of 2'),
|
||||||
|
findsNothing,
|
||||||
|
); // wizard was skipped
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
testWidgets('freeform template offers only View and Raw', (tester) async {
|
testWidgets('freeform template offers only View and Raw', (tester) async {
|
||||||
final emitted = await pumpEditor(tester, initialTemplate: blank);
|
final emitted = await pumpEditor(tester, initialTemplate: blank);
|
||||||
|
|
||||||
expect(find.text('Guided'), findsNothing);
|
expect(find.bySemanticsLabel('Guided'), findsNothing);
|
||||||
expect(find.text('View'), findsOneWidget);
|
expect(find.bySemanticsLabel('View'), findsOneWidget);
|
||||||
expect(find.text('Raw'), findsOneWidget);
|
expect(find.bySemanticsLabel('Raw'), findsOneWidget);
|
||||||
|
|
||||||
await tester.enterText(find.byType(TextField), 'free text');
|
await tester.enterText(find.byType(TextField), 'free text');
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
@ -89,15 +94,20 @@ void main() {
|
|||||||
initialTemplate: spec,
|
initialTemplate: spec,
|
||||||
initialMode: NoteEditorMode.raw,
|
initialMode: NoteEditorMode.raw,
|
||||||
);
|
);
|
||||||
expect(find.text('Guided'), findsOneWidget); // structured: Guided offered
|
expect(
|
||||||
|
find.bySemanticsLabel('Guided'),
|
||||||
|
findsOneWidget,
|
||||||
|
); // structured: Guided offered
|
||||||
|
|
||||||
await tester.tap(find.text('LLM design spec').first); // open the menu
|
await tester.tap(
|
||||||
|
find.byIcon(Icons.assignment_outlined).first,
|
||||||
|
); // open the menu
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.text('Blank').last);
|
await tester.tap(find.byIcon(Icons.note_outlined).last);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
// Freeform now: Guided is no longer offered.
|
// Freeform now: Guided is no longer offered.
|
||||||
expect(find.text('Guided'), findsNothing);
|
expect(find.bySemanticsLabel('Guided'), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('detects a conforming note (no template given) as guided', (
|
testWidgets('detects a conforming note (no template given) as guided', (
|
||||||
@ -110,7 +120,7 @@ void main() {
|
|||||||
find.byType(LinearProgressIndicator),
|
find.byType(LinearProgressIndicator),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
); // guided by default
|
); // guided by default
|
||||||
expect(find.byTooltip('Exit guided'), findsOneWidget);
|
expect(find.bySemanticsLabel('Exit guided'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('detects a legacy note (no template given) as freeform raw', (
|
testWidgets('detects a legacy note (no template given) as freeform raw', (
|
||||||
@ -120,7 +130,7 @@ void main() {
|
|||||||
|
|
||||||
// Non-conforming → blank/raw, no guided stepper offered.
|
// Non-conforming → blank/raw, no guided stepper offered.
|
||||||
expect(find.byType(LinearProgressIndicator), findsNothing);
|
expect(find.byType(LinearProgressIndicator), findsNothing);
|
||||||
expect(find.text('Guided'), findsNothing);
|
expect(find.bySemanticsLabel('Guided'), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('initialMode preview opens directly in the rendered view', (
|
testWidgets('initialMode preview opens directly in the rendered view', (
|
||||||
@ -147,7 +157,7 @@ void main() {
|
|||||||
initialMode: NoteEditorMode.preview,
|
initialMode: NoteEditorMode.preview,
|
||||||
);
|
);
|
||||||
|
|
||||||
await tester.tap(find.text('Raw'));
|
await tester.tap(find.bySemanticsLabel('Raw'));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
final raw = tester.widget<TextField>(find.byType(TextField));
|
final raw = tester.widget<TextField>(find.byType(TextField));
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user