mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-14 13:10:46 +01:00
Compare commits
46 Commits
v1.0.49-bu
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 19f930a93a | |||
| f7e24bbb1a | |||
| 4423937f5c | |||
| f4913daaa8 | |||
| c9eb4d6cff | |||
| 1010d96426 | |||
| 319f18d33a | |||
| c3ac6ec05d | |||
| 0e56cddd47 | |||
| 203e61d6ac | |||
| edf6969949 | |||
| 5ea7ba7b73 | |||
| 091e68a2d9 | |||
| a30e7b34a9 | |||
| 17d6d2a533 | |||
| 4e8ccd6bfd | |||
| 6bd2923f0a | |||
| 4c77dd5d2e | |||
| 757c54beda | |||
| 5c1bf75bf9 | |||
| 57019eec30 | |||
| cc9789f530 | |||
| 135f3f07ea | |||
| ba25e8f176 | |||
| beb1d1a49f | |||
| 75248e9cde | |||
| 9a8e7aefd2 | |||
| 75788c7bdb | |||
| 1baaf236d4 | |||
| dc96bff3df | |||
| b92d133c17 | |||
| a0c1938d8d | |||
| cbe3d9be59 | |||
| 67212cd7ec | |||
| 9b42ff8ba2 | |||
| dc547763fc | |||
| 4a30a52375 | |||
| 860d789864 | |||
| d09978a338 | |||
| cf01784241 | |||
| 0ff3d6f95d | |||
| d14b7e03e3 | |||
| 2a9332094a | |||
| 9e18821c7b | |||
| f7ea0ffb3d | |||
| 2ffae26f93 |
2
.github/workflows/release-apk.yml
vendored
2
.github/workflows/release-apk.yml
vendored
@ -47,7 +47,7 @@ jobs:
|
||||
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: 3.47.7
|
||||
flutter-version: 3.47.9
|
||||
channel: stable
|
||||
cache: true
|
||||
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"todo": {
|
||||
"type": "stdio",
|
||||
"command": "${HOME}/.venvs/todo-mcp/bin/python",
|
||||
"args": ["-m", "todo_mcp._mcp"],
|
||||
"env": {}
|
||||
"type": "http",
|
||||
"url": "http://127.0.0.1:8735/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${TODO_MCP_TOKEN}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -73,6 +73,30 @@ repos:
|
||||
language: system
|
||||
files: \.(md|markdown)$
|
||||
|
||||
- id: todo-closer
|
||||
name: close Todo trailer tasks
|
||||
entry: scripts/todo_hooks.sh closer
|
||||
language: system
|
||||
stages: [post-commit]
|
||||
always_run: true
|
||||
pass_filenames: false
|
||||
|
||||
- id: todo-import-pr-merge
|
||||
name: import PR Todo sections (pull)
|
||||
entry: scripts/todo_hooks.sh import-pr post-merge
|
||||
language: system
|
||||
stages: [post-merge]
|
||||
always_run: true
|
||||
pass_filenames: false
|
||||
|
||||
- id: todo-import-pr-rewrite
|
||||
name: import PR Todo sections (pull --rebase)
|
||||
entry: scripts/todo_hooks.sh import-pr post-rewrite
|
||||
language: system
|
||||
stages: [post-rewrite]
|
||||
always_run: true
|
||||
pass_filenames: false
|
||||
|
||||
# Every dependency on its ecosystem's newest STABLE release, exact-pinned
|
||||
# so the question is mechanically answerable at all.
|
||||
#
|
||||
|
||||
16
AGENTS.md
16
AGENTS.md
@ -43,12 +43,14 @@ and sync peer-to-peer through a GitHub repo used as dumb storage.
|
||||
- Release APK: `flutter build apk --release` (signs with the debug key; debug
|
||||
builds are janky — always measure smoothness on a release build).
|
||||
- Sync smoke test (hits real GitHub, needs a token): `dart run tool/sync_smoke.dart`.
|
||||
- MCP server (`lib/mcp/`, :8735): tests `flutter test test/mcp/`, deploy `mcp/install.sh`; see `mcp/README.md`.
|
||||
- run: `./run.sh` (desktop; mobile: `flutter run`)
|
||||
- test: `flutter test --reporter=compact`
|
||||
- test-changed: `scripts/test_changed.sh`
|
||||
- lint: `flutter analyze`
|
||||
- coverage: `flutter test --coverage --reporter=compact`
|
||||
- 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
|
||||
|
||||
@ -65,7 +67,7 @@ Three layers under `lib/`, each single-purpose:
|
||||
`by`, `evidence`, written only when set, so an idea's record stays
|
||||
v3-identical: `note_repository_sync_compat_test.dart`). The old sqlite
|
||||
schema (v3) survives only in `legacy_sqlite_migration.dart`. Also defines
|
||||
`NoteFilter` (query / priorities / statuses / sources / date ranges,
|
||||
`NoteFilter` (query / priorities / statuses / repo / date ranges,
|
||||
AND-combined), `NoteSort`, `watchNotes`/`watchCount` streams, and
|
||||
`importNotes` (safe newer-wins merge by id).
|
||||
- `sync/` — GitHub-as-storage sync.
|
||||
@ -84,8 +86,12 @@ Three layers under `lib/`, each single-purpose:
|
||||
- `capture_screen.dart`: landing screen; always-focused text box pre-filled
|
||||
with the structured template; lazy note creation on first keystroke.
|
||||
- `notes_list_screen.dart`: list + search + sort + filter sheet + per-note
|
||||
sheet. **Default view hides Done/Abandoned and shows no filter badge**
|
||||
(looks unfiltered).
|
||||
sheet. One open note per repo (`data/repo_note.dart`). **Default view hides Done/Abandoned and shows no filter badge**
|
||||
(looks unfiltered). Under 600dp wide (`kNotesGridMaxWidth`) the notes are
|
||||
a 2-column `NotesGrid` of compact `NoteGridTile`s (title + priority/status
|
||||
cues; body and dates are in the long-press sheet). The tile height is
|
||||
derived so whole rows exactly fill the viewport; past one screenful it
|
||||
scrolls vertically (no paging). Wider windows (desktop) keep the list.
|
||||
- `settings_screen.dart`: GitHub connect/test, device-flow auth, and the
|
||||
Export/Import backup actions.
|
||||
- `main.dart`: bootstrap only (`// coverage:ignore-file`) — wires platform DB
|
||||
@ -140,8 +146,8 @@ rest of the wrapper one request at a time. BACKLOG.md rewrites links to
|
||||
|
||||
## Testing
|
||||
|
||||
- **The suite must stay quick and fully green.** It is currently **587 tests at
|
||||
100% line coverage** (3213 lines), ~40s on this machine with
|
||||
- **The suite must stay quick and fully green.** It is currently **604 tests at
|
||||
100% line coverage** (3305 lines), ~40s on this machine with
|
||||
`--concurrency=3` (unbounded concurrency under `capped.sh`'s 4 GiB gets its
|
||||
test processes SIGTERMed while the wrapper still exits 0 — check the last
|
||||
line says "All tests passed"). Don't regress either.
|
||||
|
||||
@ -1,27 +0,0 @@
|
||||
# Fit every main screen on one phone screen
|
||||
|
||||
## what
|
||||
Todo's main screen is the notes list (ListView.separated). Convert it to a dense note-tile grid that fits the screen, paged by swipe.
|
||||
|
||||
## where
|
||||
/home/kuhy/src/todo; screens to convert:
|
||||
- lib/ui/notes_list_screen.dart: Notes — ListView.separated of notes -> 2-column title tiles with priority/status color, filter chips, paged; body/meta in long-press sheet (note_actions_sheet.dart)
|
||||
Exempt: note editor and capture form (free-text editor), settings, GitHub mirror and cloud-image settings (settings)
|
||||
|
||||
## must
|
||||
- no vertical scrolling on converted screens at 1080x2400 with status bar + nav inset
|
||||
- secondary metadata/actions behind long-press or a tap sheet, not dropped
|
||||
- must not: change data/sync/storage code
|
||||
- optional: reuse `FitGrid` from ~/src/utils/design_system (first tag after design_system-v0.2.1); bump the app's design_system pin to that tag
|
||||
|
||||
## done
|
||||
A widget test per converted screen at Pixel 6a geometry (`physicalSize = Size(1080, 2400)`, `devicePixelRatio = 2.625`, `padding = FakeViewPadding(top: 132, bottom: 63)`, real nav bar + mini-player/bottom chrome pumped) asserts no vertical Scrollable has maxScrollExtent > 0 and the primary actions are hit-testable; `flutter test --reporter=compact` exits 0.
|
||||
|
||||
## verify
|
||||
Pixel 6a 23181JEGR08034, release build via ~/.claude/scripts/phone_deploy.sh, screenshot from phone_vd.sh per converted screen.
|
||||
|
||||
## read first
|
||||
- ~/.claude/skills/build-mobile-app/SKILL.md (R7)
|
||||
- ~/src/tunedeck Home tab implementation (reference pattern)
|
||||
|
||||
REMOVE ME AFTER FINISH
|
||||
@ -1,4 +1,7 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
org.gradle.jvmargs=-Xmx2G -XX:MaxMetaspaceSize=1G -XX:ReservedCodeCacheSize=256m -XX:+UseSerialGC -XX:TieredStopAtLevel=1
|
||||
org.gradle.caching=true
|
||||
org.gradle.parallel=true
|
||||
kotlin.compiler.execution.strategy=in-process
|
||||
android.useAndroidX=true
|
||||
# This newDsl flag was added by the Flutter template
|
||||
android.newDsl=false
|
||||
|
||||
@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.1-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.9.0-all.zip
|
||||
|
||||
16
bin/merge_repo_notes.dart
Normal file
16
bin/merge_repo_notes.dart
Normal file
@ -0,0 +1,16 @@
|
||||
// One-time: one open note per repo, holding all of that repo's todos.
|
||||
//
|
||||
// dart run bin/merge_repo_notes.dart # dry run, prints the map
|
||||
// dart run bin/merge_repo_notes.dart --apply # does it and pushes
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:todo/cli/merge_repo_notes.dart';
|
||||
|
||||
Future<void> main(List<String> argv) async {
|
||||
exitCode = await runMergeRepoNotes(
|
||||
argv,
|
||||
env: Platform.environment,
|
||||
out: stdout,
|
||||
err: stderr,
|
||||
);
|
||||
}
|
||||
@ -4,49 +4,12 @@
|
||||
/// exact-pinned dependency under the freshness gate.
|
||||
library;
|
||||
|
||||
import 'package:todo/cli/cli_mode.dart';
|
||||
import 'package:todo/data/note.dart';
|
||||
|
||||
export 'package:todo/cli/cli_mode.dart';
|
||||
export 'package:todo/cli/cli_usage.dart';
|
||||
|
||||
/// What the CLI was asked to do.
|
||||
enum CliMode {
|
||||
/// Create a note and push it.
|
||||
add,
|
||||
|
||||
/// Print every live note, one per line.
|
||||
list,
|
||||
|
||||
/// Print one note in full.
|
||||
get,
|
||||
|
||||
/// Tombstone one note and push the deletion.
|
||||
delete,
|
||||
|
||||
/// Change one note's status and push it.
|
||||
setStatus,
|
||||
|
||||
/// Set a task done with evidence and push it.
|
||||
close,
|
||||
|
||||
/// Print the notes matching a query, as [list] does.
|
||||
search,
|
||||
|
||||
/// Print usage.
|
||||
help,
|
||||
}
|
||||
|
||||
/// Thrown for a malformed command line; maps to exit code 2.
|
||||
class UsageError implements Exception {
|
||||
/// Creates a [UsageError] carrying [message].
|
||||
UsageError(this.message);
|
||||
|
||||
/// What was wrong with the arguments.
|
||||
final String message;
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
/// The parsed command line.
|
||||
class CliArgs {
|
||||
/// Creates parsed arguments; see [parse].
|
||||
@ -57,6 +20,8 @@ class CliArgs {
|
||||
this.priority = Priority.medium,
|
||||
this.status,
|
||||
this.dataDir,
|
||||
this.newNote = false,
|
||||
this.dryRun = false,
|
||||
this.opts = (
|
||||
repo: null,
|
||||
query: null,
|
||||
@ -79,6 +44,8 @@ class CliArgs {
|
||||
}
|
||||
|
||||
switch (arg) {
|
||||
case '--new-note':
|
||||
opts[arg] = '';
|
||||
case '-h' || '--help':
|
||||
modes.add(CliMode.help);
|
||||
case '--text' || '--priority' || '--status' || '--data-dir':
|
||||
@ -86,12 +53,19 @@ class CliArgs {
|
||||
opts[arg] = value();
|
||||
case '--list':
|
||||
modes.add(CliMode.list);
|
||||
case '--backfill-created-by':
|
||||
modes.add(CliMode.backfill);
|
||||
case '--dry-run':
|
||||
opts[arg] = 'true';
|
||||
case '--search':
|
||||
modes.add(CliMode.search);
|
||||
opts[arg] = value();
|
||||
case '--get' || '--delete' || '--close':
|
||||
modes.add(CliMode.values.byName(arg.substring(2)));
|
||||
id = value();
|
||||
case '--set-text':
|
||||
modes.add(CliMode.setText);
|
||||
id = value();
|
||||
case '--set-status':
|
||||
modes.add(CliMode.setStatus);
|
||||
id = value();
|
||||
@ -103,8 +77,9 @@ class CliArgs {
|
||||
if (modes.contains(CliMode.help)) return const CliArgs(mode: CliMode.help);
|
||||
if (modes.length > 1) {
|
||||
throw UsageError(
|
||||
'--list, --search, --get, --delete, --set-status and --close are '
|
||||
'mutually exclusive',
|
||||
'--list, --search, --get, --delete, --set-status, --set-text, '
|
||||
'--close and '
|
||||
'--backfill-created-by are mutually exclusive',
|
||||
);
|
||||
}
|
||||
final mode = modes.isEmpty ? CliMode.add : modes.single;
|
||||
@ -121,6 +96,11 @@ class CliArgs {
|
||||
? null
|
||||
: _byName(Status.values, rawStatus, '--status', _statusName),
|
||||
dataDir: opts['--data-dir'],
|
||||
newNote:
|
||||
opts.containsKey('--new-note') &&
|
||||
(opts['--repo'] != null ||
|
||||
(throw UsageError('--new-note needs --repo'))),
|
||||
dryRun: opts.containsKey('--dry-run'),
|
||||
opts: (
|
||||
repo: _repo(opts['--repo']),
|
||||
query: _nonBlank(opts['--search'], '--search'),
|
||||
@ -134,7 +114,8 @@ class CliArgs {
|
||||
/// The selected mode.
|
||||
final CliMode mode;
|
||||
|
||||
/// Note text from `--text`; null means "read stdin" in [CliMode.add].
|
||||
/// Note text from `--text`; null means "read stdin" in [CliMode.add] and
|
||||
/// [CliMode.setText].
|
||||
final String? text;
|
||||
|
||||
/// Full id or 8+ character prefix of the note an id mode acts on.
|
||||
@ -149,6 +130,12 @@ class CliArgs {
|
||||
/// Overrides the default data dir (a second, independent node).
|
||||
final String? dataDir;
|
||||
|
||||
/// `--new-note`: the `--repo` task gets its own note.
|
||||
final bool newNote;
|
||||
|
||||
/// Whether `--dry-run` was given: report what would change, change nothing.
|
||||
final bool dryRun;
|
||||
|
||||
/// `--repo`, `--search`, `--done-check`, `--by` and `--evidence` values.
|
||||
final ({
|
||||
String? repo,
|
||||
@ -164,7 +151,7 @@ class CliArgs {
|
||||
|
||||
static const _adding = 'when adding a note';
|
||||
static const Map<String, (String, Set<CliMode>)> _scopes = {
|
||||
'--text': (_adding, {CliMode.add}),
|
||||
'--text': ('$_adding or to --set-text', {CliMode.add, CliMode.setText}),
|
||||
'--done-check': (_adding, {CliMode.add}),
|
||||
'--by': (_adding, {CliMode.add}),
|
||||
'--repo': (
|
||||
@ -172,6 +159,8 @@ class CliArgs {
|
||||
{CliMode.add, CliMode.list, CliMode.search},
|
||||
),
|
||||
'--evidence': ('to --close', {CliMode.close}),
|
||||
'--new-note': (_adding, {CliMode.add}),
|
||||
'--dry-run': ('to --backfill-created-by', {CliMode.backfill}),
|
||||
};
|
||||
|
||||
static void _checkScopes(Iterable<String> flags, CliMode mode) {
|
||||
|
||||
@ -3,6 +3,8 @@ import 'package:todo/cli/cli_session.dart';
|
||||
import 'package:todo/cli/note_lookup.dart';
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:todo/data/note_repository.dart';
|
||||
import 'package:todo/data/repo_note.dart';
|
||||
import 'package:todo/data/repo_note_split.dart';
|
||||
import 'package:todo/sync/sync_service.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
@ -18,19 +20,25 @@ class CliCommands {
|
||||
final StringSink _out;
|
||||
final StringSink _err;
|
||||
|
||||
/// Creates a note (a repo task when `--repo` is set) and pushes it.
|
||||
/// Creates a note and pushes it. A `--repo` add joins the repo's open note.
|
||||
Future<int> add(CliArgs args, String text) async {
|
||||
final repo = args.opts.repo;
|
||||
final status = args.status ?? Status.todo;
|
||||
if (repo != null && kOpenStatuses.contains(status)) {
|
||||
return await _addRepoTask(args, repo, text);
|
||||
}
|
||||
final now = DateTime.now();
|
||||
final note = Note(
|
||||
id: const Uuid().v4(),
|
||||
text: text,
|
||||
priority: args.priority,
|
||||
status: args.status ?? Status.todo,
|
||||
status: status,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
repo: args.opts.repo,
|
||||
repo: repo,
|
||||
doneCheck: args.opts.doneCheck,
|
||||
by: args.opts.by,
|
||||
createdBy: 'claude',
|
||||
);
|
||||
await session.repository.upsert(note);
|
||||
if (!await _push('note')) return 1;
|
||||
@ -38,6 +46,55 @@ class CliCommands {
|
||||
return 0;
|
||||
}
|
||||
|
||||
Future<int> _addRepoTask(CliArgs args, String repo, String text) async {
|
||||
_report(await session.sync(push: true));
|
||||
final (:title, :body) = splitNote(text);
|
||||
if (args.newNote) return await _addSeparate(args, repo, title, body);
|
||||
final added = await addRepoTask(
|
||||
session.repository,
|
||||
repo: repo,
|
||||
title: title,
|
||||
body: body,
|
||||
doneCheck: args.opts.doneCheck,
|
||||
priority: args.priority,
|
||||
by: args.opts.by,
|
||||
createdBy: 'claude',
|
||||
);
|
||||
await _save(added.note);
|
||||
if (!await _push('note')) return 1;
|
||||
if (added.appended) _err.writeln('appended to the open note for $repo');
|
||||
_out.writeln(added.note.id);
|
||||
return 0;
|
||||
}
|
||||
|
||||
Future<int> _addSeparate(
|
||||
CliArgs args,
|
||||
String repo,
|
||||
String title,
|
||||
String body,
|
||||
) async {
|
||||
final added = await addSeparateTask(
|
||||
session.repository,
|
||||
repo: repo,
|
||||
title: title,
|
||||
body: body,
|
||||
doneCheck: args.opts.doneCheck,
|
||||
priority: args.priority,
|
||||
by: args.opts.by,
|
||||
createdBy: 'claude',
|
||||
);
|
||||
await _save(added.note);
|
||||
if (!await _push('note')) return 1;
|
||||
if (added.kind == SplitKind.merged) {
|
||||
_err.writeln(
|
||||
'merged into the separate note with that title; '
|
||||
'click Start on the board by hand',
|
||||
);
|
||||
}
|
||||
_out.writeln(added.note.id);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// Prints [listLine] for each note matching the filters in [args].
|
||||
Future<int> list(CliArgs args) async {
|
||||
_report(await session.sync(push: false));
|
||||
@ -45,7 +102,7 @@ class CliCommands {
|
||||
filter: NoteFilter(
|
||||
query: args.opts.query ?? '',
|
||||
statuses: {?args.status},
|
||||
sources: {?args.opts.repo},
|
||||
repo: args.opts.repo,
|
||||
),
|
||||
);
|
||||
notes.map(listLine).forEach(_out.writeln);
|
||||
@ -55,24 +112,9 @@ class CliCommands {
|
||||
/// Prints one note in full.
|
||||
Future<int> get(String ref) async {
|
||||
_report(await session.sync(push: false));
|
||||
final note = findNote(await session.repository.listNotes(), ref);
|
||||
_out
|
||||
..writeln('id: ${note.id}')
|
||||
..writeln('priority: ${note.priority.name}')
|
||||
..writeln('status: ${CliArgs.statusName(note.status)}')
|
||||
..writeln('created: ${note.createdAt.toIso8601String()}')
|
||||
..writeln('updated: ${note.updatedAt.toIso8601String()}');
|
||||
for (final (name, value) in [
|
||||
('repo', note.repo),
|
||||
('done_check', note.doneCheck),
|
||||
('by', note.by),
|
||||
('evidence', note.evidence),
|
||||
]) {
|
||||
if (value != null) _out.writeln('$name: $value');
|
||||
}
|
||||
_out
|
||||
..writeln('---')
|
||||
..writeln(note.text);
|
||||
_out.writeln(
|
||||
noteDetail(findNote(await session.repository.listNotes(), ref)),
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@ -94,6 +136,15 @@ class CliCommands {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// Replaces a note's whole text (title is its first line) and pushes it.
|
||||
Future<int> setText(String ref, String text) async {
|
||||
final note = await _pullAndFind(ref);
|
||||
await _save(note.copyWith(text: text));
|
||||
if (!await _push('text change')) return 1;
|
||||
_out.writeln('${note.id}\ttext');
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// Marks a note done with [evidence], keeping it, and pushes it.
|
||||
Future<int> close(String ref, String evidence) async {
|
||||
final note = await _pullAndFind(ref);
|
||||
@ -103,6 +154,35 @@ class CliCommands {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// Sets `created_by` where unset (see [NoteRepository.backfillCreatedBy]).
|
||||
/// Prints one `<id>\t<value>\t<title>` line per note, then the totals. With
|
||||
/// [dryRun] nothing is written or pushed, and peers are only read.
|
||||
Future<int> backfillCreatedBy(
|
||||
Set<String> claudeNodes, {
|
||||
required bool dryRun,
|
||||
}) async {
|
||||
_report(await session.sync(push: !dryRun));
|
||||
final plan = await session.repository.backfillCreatedBy(
|
||||
claudeNodes,
|
||||
dryRun: dryRun,
|
||||
);
|
||||
final titles = {
|
||||
for (final n in await session.repository.listNotes()) n.id: n,
|
||||
};
|
||||
for (final MapEntry(:key, :value) in plan.entries) {
|
||||
final note = titles[key];
|
||||
final title = note == null ? '' : note.text.split('\n').first;
|
||||
_out.writeln(
|
||||
'${key.substring(0, key.length.clamp(0, 8))}\t$value\t$title',
|
||||
);
|
||||
}
|
||||
final claude = plan.values.where((v) => v == 'claude').length;
|
||||
final kuhy = plan.length - claude;
|
||||
_out.writeln('${dryRun ? 'dry run' : 'set'}: claude $claude, kuhy $kuhy');
|
||||
if (dryRun || plan.isEmpty) return 0;
|
||||
return await _push('backfill') ? 0 : 1;
|
||||
}
|
||||
|
||||
Future<Note> _pullAndFind(String ref) async {
|
||||
_report(await session.sync(push: true));
|
||||
return findNote(await session.repository.listNotes(), ref);
|
||||
|
||||
47
lib/cli/cli_mode.dart
Normal file
47
lib/cli/cli_mode.dart
Normal file
@ -0,0 +1,47 @@
|
||||
/// What the CLI can be asked to do, and how a bad command line is reported.
|
||||
library;
|
||||
|
||||
/// What the CLI was asked to do.
|
||||
enum CliMode {
|
||||
/// Create a note and push it.
|
||||
add,
|
||||
|
||||
/// Print every live note, one per line.
|
||||
list,
|
||||
|
||||
/// Print one note in full.
|
||||
get,
|
||||
|
||||
/// Tombstone one note and push the deletion.
|
||||
delete,
|
||||
|
||||
/// Change one note's status and push it.
|
||||
setStatus,
|
||||
|
||||
/// Replace one note's whole text and push it.
|
||||
setText,
|
||||
|
||||
/// Set a task done with evidence and push it.
|
||||
close,
|
||||
|
||||
/// Print the notes matching a query, as [list] does.
|
||||
search,
|
||||
|
||||
/// Set `created_by` on notes that lack it.
|
||||
backfill,
|
||||
|
||||
/// Print usage.
|
||||
help,
|
||||
}
|
||||
|
||||
/// Thrown for a malformed command line; maps to exit code 2.
|
||||
class UsageError implements Exception {
|
||||
/// Creates a [UsageError] carrying [message].
|
||||
UsageError(this.message);
|
||||
|
||||
/// What was wrong with the arguments.
|
||||
final String message;
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
@ -54,7 +54,7 @@ Future<int> runCli(
|
||||
return 0;
|
||||
}
|
||||
String? text;
|
||||
if (args.mode == CliMode.add) {
|
||||
if (args.mode == CliMode.add || args.mode == CliMode.setText) {
|
||||
text = args.text ?? await readStdin();
|
||||
if (text.trim().isEmpty) {
|
||||
err.writeln('error: empty note (pass --text or pipe text on stdin)');
|
||||
@ -79,7 +79,12 @@ Future<int> runCli(
|
||||
CliMode.list || CliMode.search => await cli.list(args),
|
||||
CliMode.get => await cli.get(args.id!),
|
||||
CliMode.setStatus => await cli.setStatus(args.id!, args.status!),
|
||||
CliMode.setText => await cli.setText(args.id!, text!),
|
||||
CliMode.close => await cli.close(args.id!, args.opts.evidence!),
|
||||
CliMode.backfill => await cli.backfillCreatedBy(
|
||||
claudeNodeIds(env),
|
||||
dryRun: args.dryRun,
|
||||
),
|
||||
// Only delete is left: help returned above.
|
||||
_ => await cli.delete(args.id!),
|
||||
};
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
/// records hand-written into RTDB.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
@ -29,11 +30,38 @@ String defaultDataDir(Map<String, String> env) {
|
||||
return p.join(base, 'todo-cli');
|
||||
}
|
||||
|
||||
/// Node ids of the installs Claude files notes through: the MCP server and
|
||||
/// the CLI under the same data root as [defaultDataDir]. Missing ones are
|
||||
/// skipped.
|
||||
Set<String> claudeNodeIds(Map<String, String> env) {
|
||||
final cli = defaultDataDir(env);
|
||||
return {
|
||||
for (final dir in [cli, p.join(p.dirname(cli), 'todo-mcp')])
|
||||
if (File(p.join(dir, CliSession.nodeIdFileName)) case final f
|
||||
when f.existsSync())
|
||||
f.readAsStringSync().trim(),
|
||||
}..remove('');
|
||||
}
|
||||
|
||||
/// The open state of one CLI invocation. Call [close] when done.
|
||||
class CliSession {
|
||||
/// Wraps already-open parts; [open] is the normal way in. Public so a test
|
||||
/// can subclass the session and script [sync]'s outcome.
|
||||
CliSession(this.dataDir, this.repository, this._firebase);
|
||||
CliSession(
|
||||
this.dataDir,
|
||||
this.repository,
|
||||
this._firebase, {
|
||||
this.passphraseFile,
|
||||
this.sharedPasswordFile,
|
||||
});
|
||||
|
||||
/// The fleet-wide crdt-sync password the Python daemons read too
|
||||
/// (`~/.config/crdt-sync/password`), found from the seed's config root.
|
||||
final File? sharedPasswordFile;
|
||||
|
||||
/// Where the passphrase is read from besides the environment: next to the
|
||||
/// seeded Firebase session, e.g. `~/.config/todo/e2e_passphrase`.
|
||||
final File? passphraseFile;
|
||||
|
||||
/// File holding this install's persisted CRDT node id.
|
||||
static const nodeIdFileName = 'node_id';
|
||||
@ -59,6 +87,7 @@ class CliSession {
|
||||
static Future<CliSession> open(
|
||||
String dataDir, {
|
||||
required String seedCredentials,
|
||||
String seedApp = 'todo',
|
||||
http.Client? httpClient,
|
||||
}) async {
|
||||
final dir = Directory(dataDir);
|
||||
@ -83,7 +112,7 @@ class CliSession {
|
||||
await repository.close();
|
||||
throw FirebaseAuthError(
|
||||
'no Firebase session: $seedCredentials is missing; re-seed it with '
|
||||
'`python3 ~/src/utils/crdt-sync/tool/seed_session.py --app todo`',
|
||||
'`python3 ~/src/utils/crdt-sync/tool/seed_session.py --app $seedApp`',
|
||||
);
|
||||
}
|
||||
final firebase = FirebaseRestClient(
|
||||
@ -91,7 +120,17 @@ class CliSession {
|
||||
auth: auth,
|
||||
httpClient: httpClient,
|
||||
);
|
||||
return CliSession(dataDir, repository, firebase);
|
||||
return CliSession(
|
||||
dataDir,
|
||||
repository,
|
||||
firebase,
|
||||
passphraseFile: File(
|
||||
p.join(p.dirname(seedCredentials), e2ePassphraseFileName),
|
||||
),
|
||||
sharedPasswordFile: File(
|
||||
p.join(p.dirname(p.dirname(seedCredentials)), 'crdt-sync', 'password'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Pull every peer's log and merge it; with [push], also publish this
|
||||
@ -99,15 +138,91 @@ class CliSession {
|
||||
///
|
||||
/// Without [push] nothing is written anywhere remote, and the revision cache
|
||||
/// is bypassed so a read always sees every peer's current log.
|
||||
Future<SyncResult> sync({required bool push}) {
|
||||
Future<SyncResult> sync({required bool push}) async {
|
||||
final plainService = SyncService(
|
||||
stateStore: push
|
||||
? PersistedSyncStateStore(
|
||||
FileLogPersistence(File(p.join(dataDir, syncStateFileName))),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
final e2e = await openEncryptedStore(
|
||||
store: _remote(push: push),
|
||||
oldPrefix: plainService.notesDir,
|
||||
readPassphrase: _readPassphrase,
|
||||
// Argon2id once per install, not on every CLI/MCP call.
|
||||
keyCache: E2eKeyCache(read: _readCachedKey, write: _writeCachedKey),
|
||||
);
|
||||
if (!push) {
|
||||
return const SyncService().sync(repository, PullOnlyStore(_firebase));
|
||||
// Read-only during the migration: old builds still write the plaintext
|
||||
// prefix, so merge it, then the encrypted one when this host has a key.
|
||||
final plain = await const SyncService().sync(
|
||||
repository,
|
||||
_remote(push: false),
|
||||
);
|
||||
if (e2e is! E2eReady) return plain;
|
||||
final layout = E2eLayout.of(plainService.notesDir);
|
||||
return await SyncService(
|
||||
notesDir: layout.prefix,
|
||||
revsPath: layout.revsPath,
|
||||
).sync(repository, e2e.store);
|
||||
}
|
||||
return SyncService(
|
||||
stateStore: PersistedSyncStateStore(
|
||||
FileLogPersistence(File(p.join(dataDir, syncStateFileName))),
|
||||
),
|
||||
).sync(repository, _firebase);
|
||||
if (e2e is! E2eReady) {
|
||||
throw StateError(
|
||||
'sync is locked: put the crdt-sync passphrase in '
|
||||
'$e2ePassphraseEnv or ${passphraseFile?.path ?? e2ePassphraseFileName}',
|
||||
);
|
||||
}
|
||||
final layout = E2eLayout.of(plainService.notesDir);
|
||||
return await SyncService(
|
||||
notesDir: layout.prefix,
|
||||
revsPath: layout.revsPath,
|
||||
stateStore: plainService.stateStore,
|
||||
).sync(repository, e2e.store);
|
||||
}
|
||||
|
||||
RemoteStore _remote({required bool push}) =>
|
||||
push ? _firebase : PullOnlyStore(_firebase);
|
||||
|
||||
/// Owner-only file in the data dir caching the derived E2E key.
|
||||
static const e2eKeyFileName = 'e2e_key';
|
||||
|
||||
File get _keyFile => File(p.join(dataDir, e2eKeyFileName));
|
||||
|
||||
Future<List<int>?> _readCachedKey() async {
|
||||
final file = _keyFile;
|
||||
if (!file.existsSync()) return null;
|
||||
try {
|
||||
return base64.decode((await file.readAsString()).trim());
|
||||
} on FormatException {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _writeCachedKey(List<int> bytes) async {
|
||||
final file = _keyFile;
|
||||
// Restrict before the key is written, never after.
|
||||
await file.writeAsString('');
|
||||
await Process.run('chmod', ['600', file.path]);
|
||||
await file.writeAsString(base64.encode(bytes), flush: true);
|
||||
}
|
||||
|
||||
/// Environment variable holding the shared crdt-sync passphrase.
|
||||
static const e2ePassphraseEnv = 'TODO_E2E_PASSPHRASE';
|
||||
|
||||
/// File name of the passphrase, beside the seeded Firebase session.
|
||||
static const e2ePassphraseFileName = 'e2e_passphrase';
|
||||
|
||||
Future<String?> _readPassphrase() async {
|
||||
final fromEnv = Platform.environment[e2ePassphraseEnv];
|
||||
if (fromEnv != null && fromEnv.isNotEmpty) return fromEnv;
|
||||
for (final file in [passphraseFile, sharedPasswordFile]) {
|
||||
if (file != null && file.existsSync()) {
|
||||
final text = (await file.readAsString()).trim();
|
||||
if (text.isNotEmpty) return text;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Releases the log and the HTTP client.
|
||||
|
||||
@ -13,6 +13,8 @@ A note with --repo is a repo task; one without is a plain idea.
|
||||
--repo <owner/name> repo task's repository, e.g. kuhyx/todo;
|
||||
with --list/--search: show only that repo's tasks
|
||||
--done-check <cmd> shell command, run from the repo root, exit 0 = done
|
||||
--new-note with --repo: own note, not the shared one (an exact
|
||||
title in another separate note merges into it)
|
||||
--by <who> claude|kuhy
|
||||
--list pull, then print live notes, one per line:
|
||||
8-char id, status, priority, repo (- for none), title
|
||||
@ -20,9 +22,14 @@ A note with --repo is a repo task; one without is a plain idea.
|
||||
--get <id> pull, then print one note in full (exit 1 if absent)
|
||||
--delete <id> pull, tombstone the note, push
|
||||
--set-status <id> <s> pull, change the note's status (as --status), push
|
||||
--set-text <id> pull, replace the note's whole text with --text (or
|
||||
stdin; first line = title), keep its other fields, push
|
||||
--close <id> --evidence <s>
|
||||
pull, set status done + evidence (commit sha + done
|
||||
check exit code), push; refused without --evidence
|
||||
--backfill-created-by [--dry-run]
|
||||
pull, set created_by (claude|kuhy) on every note that
|
||||
has none, push; --dry-run prints the plan only
|
||||
--data-dir <dir> local log + node id (default
|
||||
$XDG_DATA_HOME/todo-cli or ~/.local/share/todo-cli)
|
||||
-h, --help this text
|
||||
|
||||
78
lib/cli/merge_repo_notes.dart
Normal file
78
lib/cli/merge_repo_notes.dart
Normal file
@ -0,0 +1,78 @@
|
||||
/// `bin/merge_repo_notes.dart`: collapse each repo's open notes into one.
|
||||
/// Dry run unless `--apply`; stdout is the `old_id<TAB>kept_id<TAB>repo` map.
|
||||
library;
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:todo/cli/cli_runner.dart';
|
||||
import 'package:todo/cli/cli_session.dart';
|
||||
import 'package:todo/data/repo_note_merge.dart';
|
||||
|
||||
const _usage = 'usage: merge_repo_notes [--dry-run|--apply] [--data-dir D]';
|
||||
|
||||
/// Runs the merge for [argv]; returns the exit code.
|
||||
Future<int> runMergeRepoNotes(
|
||||
List<String> argv, {
|
||||
required Map<String, String> env,
|
||||
required StringSink out,
|
||||
required StringSink err,
|
||||
SessionOpener open = CliSession.open,
|
||||
}) async {
|
||||
var apply = false;
|
||||
String? dataDir;
|
||||
for (var i = 0; i < argv.length; i++) {
|
||||
switch (argv[i]) {
|
||||
case '--apply':
|
||||
apply = true;
|
||||
case '--dry-run':
|
||||
apply = false;
|
||||
case '--data-dir' when i + 1 < argv.length:
|
||||
dataDir = argv[++i];
|
||||
default:
|
||||
err.writeln(_usage);
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
CliSession? session;
|
||||
try {
|
||||
session = await open(
|
||||
dataDir ?? defaultDataDir(env),
|
||||
seedCredentials: p.join(
|
||||
env['HOME'] ?? '',
|
||||
'.config',
|
||||
'todo',
|
||||
'firebase_auth.json',
|
||||
),
|
||||
);
|
||||
await session.sync(push: apply);
|
||||
final plan = planRepoMerges(await session.repository.listNotes());
|
||||
for (final (:kept, :removed) in plan) {
|
||||
if (apply) {
|
||||
await session.repository.upsert(
|
||||
kept.copyWith(updatedAt: DateTime.now()),
|
||||
);
|
||||
for (final n in removed) {
|
||||
await session.repository.delete(n.id);
|
||||
}
|
||||
}
|
||||
for (final n in removed) {
|
||||
out.writeln('${n.id}\t${kept.id}\t${kept.repo}');
|
||||
}
|
||||
}
|
||||
final deleted = plan.fold(0, (sum, m) => sum + m.removed.length);
|
||||
err.writeln(
|
||||
'${apply ? 'applied' : 'dry run'}: ${plan.length} repos, '
|
||||
'$deleted notes deleted',
|
||||
);
|
||||
if (apply && plan.isNotEmpty && !(await session.sync(push: true)).pushed) {
|
||||
err.writeln('error: saved locally but nothing was pushed');
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
} on RemoteSyncError catch (error) {
|
||||
err.writeln('error: $error');
|
||||
return 1;
|
||||
} finally {
|
||||
await session?.close();
|
||||
}
|
||||
}
|
||||
@ -32,7 +32,7 @@ Note findNote(Iterable<Note> notes, String ref) {
|
||||
return matches.single;
|
||||
}
|
||||
|
||||
/// `<8-char id>\t<status>\t<priority>\t<repo or ->\t<title>`.
|
||||
/// `<8-char id>\t<status>\t<priority>\t<repo or ->\t<title>\t<created_by>`.
|
||||
String listLine(Note note) => [
|
||||
if (note.id.length <= _minPrefix)
|
||||
note.id
|
||||
@ -42,6 +42,23 @@ String listLine(Note note) => [
|
||||
note.priority.name,
|
||||
note.repo ?? '-',
|
||||
_title(note),
|
||||
note.createdBy ?? 'kuhy',
|
||||
].join('\t');
|
||||
|
||||
String _title(Note note) => note.text.split('\n').first;
|
||||
|
||||
/// The note's metadata lines, `---`, then its text.
|
||||
String noteDetail(Note note) => [
|
||||
'id: ${note.id}',
|
||||
'priority: ${note.priority.name}',
|
||||
'status: ${CliArgs.statusName(note.status)}',
|
||||
'created: ${note.createdAt.toIso8601String()}',
|
||||
'updated: ${note.updatedAt.toIso8601String()}',
|
||||
if (note.repo case final v?) 'repo: $v',
|
||||
if (note.doneCheck case final v?) 'done_check: $v',
|
||||
if (note.by case final v?) 'by: $v',
|
||||
if (note.evidence case final v?) 'evidence: $v',
|
||||
'created_by: ${note.createdBy ?? 'kuhy'}',
|
||||
'---',
|
||||
note.text,
|
||||
].join('\n');
|
||||
|
||||
@ -19,10 +19,14 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
/// sibling to `changesets/<nodeId>.json`.
|
||||
const String kAdvancedModeRemotePath = 'settings/advancedMode';
|
||||
|
||||
/// App-wide, non-sync preferences (currently just `advancedMode`).
|
||||
/// App-wide, non-sync preferences (`advancedMode`, `showClaudeNotes`).
|
||||
class AppSettings {
|
||||
/// Creates an [AppSettings] from its stored fields.
|
||||
const AppSettings({required this.advancedMode, this.advancedModeUpdatedAt});
|
||||
const AppSettings({
|
||||
required this.advancedMode,
|
||||
this.advancedModeUpdatedAt,
|
||||
this.showClaudeNotes = false,
|
||||
});
|
||||
|
||||
/// Whether the priority/status/template/mode chrome and the routine
|
||||
/// save/sync status text are shown. Off by default: casual capture needs
|
||||
@ -35,6 +39,11 @@ class AppSettings {
|
||||
/// a value that has never round-tripped through Firebase.
|
||||
final DateTime? advancedModeUpdatedAt;
|
||||
|
||||
/// Whether the notes list shows notes Claude filed. Per device: it lives
|
||||
/// only in this device's preferences and is never mirrored to Firebase.
|
||||
final bool showClaudeNotes;
|
||||
|
||||
static const _kShowClaude = 'app.showClaudeNotes';
|
||||
static const _kAdvancedMode = 'app.advancedMode';
|
||||
static const _kAdvancedModeUpdatedAt = 'app.advancedMode.updatedAt';
|
||||
|
||||
@ -44,6 +53,7 @@ class AppSettings {
|
||||
final updatedAtMillis = prefs.getInt(_kAdvancedModeUpdatedAt);
|
||||
return AppSettings(
|
||||
advancedMode: prefs.getBool(_kAdvancedMode) ?? false,
|
||||
showClaudeNotes: prefs.getBool(_kShowClaude) ?? false,
|
||||
advancedModeUpdatedAt: updatedAtMillis == null
|
||||
? null
|
||||
: DateTime.fromMillisecondsSinceEpoch(updatedAtMillis),
|
||||
@ -71,6 +81,7 @@ class AppSettings {
|
||||
final updated = AppSettings(
|
||||
advancedMode: value,
|
||||
advancedModeUpdatedAt: updatedAt,
|
||||
showClaudeNotes: showClaudeNotes,
|
||||
);
|
||||
if (client != null) {
|
||||
try {
|
||||
@ -89,6 +100,17 @@ class AppSettings {
|
||||
return updated;
|
||||
}
|
||||
|
||||
/// Persists the per-device Claude-notes choice; nothing leaves the device.
|
||||
Future<AppSettings> withShowClaudeNotes({required bool value}) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_kShowClaude, value);
|
||||
return AppSettings(
|
||||
advancedMode: advancedMode,
|
||||
advancedModeUpdatedAt: advancedModeUpdatedAt,
|
||||
showClaudeNotes: value,
|
||||
);
|
||||
}
|
||||
|
||||
/// Pulls the remote value from [client] and, if it is newer than the
|
||||
/// local one, applies it locally and returns the reconciled settings.
|
||||
/// Returns this instance unchanged when [client] is null, there is
|
||||
@ -100,9 +122,7 @@ class AppSettings {
|
||||
try {
|
||||
final remote = await client.getStringMap(kAdvancedModeRemotePath);
|
||||
if (!remote.containsKey('value')) return this;
|
||||
final remoteUpdatedMillis = int.tryParse(
|
||||
remote['updatedAtMillis'] ?? '',
|
||||
);
|
||||
final remoteUpdatedMillis = int.tryParse(remote['updatedAtMillis'] ?? '');
|
||||
if (remoteUpdatedMillis == null) return this;
|
||||
final remoteUpdatedAt = DateTime.fromMillisecondsSinceEpoch(
|
||||
remoteUpdatedMillis,
|
||||
@ -110,6 +130,7 @@ class AppSettings {
|
||||
final candidate = AppSettings(
|
||||
advancedMode: remote['value'] == 'true',
|
||||
advancedModeUpdatedAt: remoteUpdatedAt,
|
||||
showClaudeNotes: showClaudeNotes,
|
||||
);
|
||||
final adopted = adopt(candidate);
|
||||
if (identical(adopted, this)) return this;
|
||||
|
||||
44
lib/data/created_by_backfill.dart
Normal file
44
lib/data/created_by_backfill.dart
Normal file
@ -0,0 +1,44 @@
|
||||
/// Who filed a note, for notes written before `created_by` existed.
|
||||
///
|
||||
/// The `created_at` field's clock names the node that last wrote it. The MCP
|
||||
/// and the CLI are the only nodes Claude writes through, so a note created
|
||||
/// there carries one of their node ids; anything else came from kuhy's own
|
||||
/// devices.
|
||||
library;
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:todo/data/note_record.dart';
|
||||
|
||||
const _createdAt = 'created_at';
|
||||
|
||||
/// `claude` when [record]'s `created_at` was written by one of [claudeNodes],
|
||||
/// else `kuhy`.
|
||||
String classifyCreatedBy(Record record, Set<String> claudeNodes) {
|
||||
final node = record.fields[_createdAt]?.$2.nodeId;
|
||||
return node != null && claudeNodes.contains(node) ? 'claude' : 'kuhy';
|
||||
}
|
||||
|
||||
/// Live records of [log] with no `created_by` yet, mapped to their value.
|
||||
Map<String, String> planCreatedByBackfill(Log log, Set<String> claudeNodes) => {
|
||||
for (final record in log.values)
|
||||
if (!record.deleted &&
|
||||
((record.fields[kCreatedByField]?.$1 as String?) ?? '').isEmpty)
|
||||
record.id: classifyCreatedBy(record, claudeNodes),
|
||||
};
|
||||
|
||||
/// [incoming], carrying [existing]'s `created_by` when [incoming] has none.
|
||||
///
|
||||
/// A store replaces a record wholesale, so an edit that does not know the
|
||||
/// field (an import of an old backup) would otherwise drop it locally.
|
||||
Record keepCreatedBy(Record incoming, Record? existing) {
|
||||
final kept = existing?.fields[kCreatedByField];
|
||||
if (kept == null || incoming.fields.containsKey(kCreatedByField)) {
|
||||
return incoming;
|
||||
}
|
||||
return Record(
|
||||
id: incoming.id,
|
||||
fields: {...incoming.fields, kCreatedByField: kept},
|
||||
deleted: incoming.deleted,
|
||||
deletedHlc: incoming.deletedHlc,
|
||||
);
|
||||
}
|
||||
@ -73,11 +73,7 @@ class LegacySqliteMigration {
|
||||
}
|
||||
// coverage:ignore-end
|
||||
|
||||
static Future<void> _onUpgrade(
|
||||
CrdtTableExecutor db,
|
||||
int from,
|
||||
int to,
|
||||
) async {
|
||||
static Future<void> _onUpgrade(CrdtTableExecutor db, int from, int to) async {
|
||||
if (from < 2) {
|
||||
await db.execute(
|
||||
'ALTER TABLE notes ADD COLUMN status INTEGER NOT NULL DEFAULT 0',
|
||||
|
||||
38
lib/data/log_watch.dart
Normal file
38
lib/data/log_watch.dart
Normal file
@ -0,0 +1,38 @@
|
||||
/// A seeded, coalesced stream over the note log's change events.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
/// A stream that emits `compute()` when first listened to (the seed), then
|
||||
/// again after [changes] fires. Seeding in `onListen` -- rather than lazily in
|
||||
/// an `async*` body -- avoids a race where a write between `listen` and the
|
||||
/// first generator tick would swallow the initial snapshot.
|
||||
///
|
||||
/// Re-emits are coalesced: the first change of a burst arms one
|
||||
/// zero-duration timer and every further change before it fires is absorbed,
|
||||
/// so N back-to-back changes cost ONE `compute()` carrying the latest state
|
||||
/// instead of N full re-queries (each of which repaints the whole list).
|
||||
/// The timer fires on the next event-loop turn, so tests stay deterministic
|
||||
/// under `pumpEventQueue` and the widget tester's `pump`.
|
||||
Stream<T> watchLog<T>(Stream<void> changes, T Function() compute) {
|
||||
StreamSubscription<void>? sub;
|
||||
Timer? pending;
|
||||
late final StreamController<T> controller;
|
||||
controller = StreamController<T>(
|
||||
onListen: () {
|
||||
controller.add(compute());
|
||||
sub = changes.listen((_) {
|
||||
pending ??= Timer(Duration.zero, () {
|
||||
pending = null;
|
||||
controller.add(compute());
|
||||
});
|
||||
});
|
||||
},
|
||||
onCancel: () {
|
||||
pending?.cancel();
|
||||
pending = null;
|
||||
return sub?.cancel();
|
||||
},
|
||||
);
|
||||
return controller.stream;
|
||||
}
|
||||
@ -76,7 +76,7 @@ enum Status {
|
||||
}
|
||||
}
|
||||
|
||||
/// The values the optional `by` field may hold: who owns a repo task.
|
||||
/// The values the optional `by` and `created_by` fields may hold.
|
||||
const Set<String> kNoteAuthors = {'claude', 'kuhy'};
|
||||
|
||||
/// An immutable idea/note record.
|
||||
@ -99,6 +99,7 @@ class Note {
|
||||
this.doneCheck,
|
||||
this.by,
|
||||
this.evidence,
|
||||
this.createdBy,
|
||||
});
|
||||
|
||||
/// Builds a [Note] from a raw CRDT query row.
|
||||
@ -146,9 +147,18 @@ class Note {
|
||||
/// Proof the task was closed (commit sha + the done check's exit code).
|
||||
final String? evidence;
|
||||
|
||||
/// Who filed the note: one of [kNoteAuthors]; null (older notes) means kuhy.
|
||||
final String? createdBy;
|
||||
|
||||
/// Whether Claude filed this note (hidden by default, sorted last).
|
||||
bool get isClaudeMade => createdBy == 'claude';
|
||||
|
||||
/// Whether this note is a repo task rather than a plain idea.
|
||||
bool get isRepoTask => repo != null;
|
||||
|
||||
/// The repo without its owner ("kartka" for "kuhyx/kartka"), or null.
|
||||
String? get repoName => repo?.split('/').last;
|
||||
|
||||
/// Returns a copy with selected fields replaced.
|
||||
Note copyWith({
|
||||
String? text,
|
||||
@ -159,6 +169,7 @@ class Note {
|
||||
String? doneCheck,
|
||||
String? by,
|
||||
String? evidence,
|
||||
String? createdBy,
|
||||
}) {
|
||||
return Note(
|
||||
id: id,
|
||||
@ -171,6 +182,7 @@ class Note {
|
||||
doneCheck: doneCheck ?? this.doneCheck,
|
||||
by: by ?? this.by,
|
||||
evidence: evidence ?? this.evidence,
|
||||
createdBy: createdBy ?? this.createdBy,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
63
lib/data/note_bulk.dart
Normal file
63
lib/data/note_bulk.dart
Normal file
@ -0,0 +1,63 @@
|
||||
/// Partial logs for the multi-note writes: merged in one store write.
|
||||
///
|
||||
/// Each write re-serialises the whole log (over a megabyte on a real
|
||||
/// device), so a bulk action must be one partial log merged once, never one
|
||||
/// upsert per note. Merging is per field, so a field left out keeps its value
|
||||
/// and its clock (`created_by` included).
|
||||
library;
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:todo/data/note_record.dart';
|
||||
|
||||
/// Partial records setting [priority] and/or [status] on the live notes of
|
||||
/// [log] among [ids], all at [hlc].
|
||||
///
|
||||
/// Only fields whose value changes are written, plus `updated_at` = [now]
|
||||
/// (so "last updated" sorting and newer-wins import see the edit). A note
|
||||
/// with nothing to change, an unknown id and a deleted note are left out.
|
||||
Log planBulkUpdate(
|
||||
Log log,
|
||||
Set<String> ids, {
|
||||
required Hlc hlc,
|
||||
required DateTime now,
|
||||
Priority? priority,
|
||||
Status? status,
|
||||
}) {
|
||||
final plan = <String, Record>{};
|
||||
for (final id in ids) {
|
||||
final current = log[id];
|
||||
if (current == null || current.deleted) continue;
|
||||
final note = noteFromRecord(current);
|
||||
final next = note.copyWith(priority: priority, status: status);
|
||||
if (next.priority == note.priority && next.status == note.status) continue;
|
||||
// Generated against generated, never against the stored text: a peer's
|
||||
// `...00Z` (or a web build's millisecond date) would otherwise read as
|
||||
// a change and get a fresh clock, erasing the `created_at` node signal.
|
||||
final before = noteFields(note, hlc);
|
||||
plan[id] = Record(
|
||||
id: id,
|
||||
fields: {
|
||||
for (final MapEntry(:key, :value) in noteFields(
|
||||
next.copyWith(updatedAt: now),
|
||||
hlc,
|
||||
).entries)
|
||||
if (before[key]?.$1 != value.$1) key: value,
|
||||
},
|
||||
);
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
/// [note] at [hlc] plus a tombstone for each live note of [log] in
|
||||
/// [removed] (other than [note] itself).
|
||||
///
|
||||
/// A merge, not a replace: a field [note] leaves unset keeps its stored
|
||||
/// value, so this suits notes that only gain content (the repo note an
|
||||
/// assign appends to), not edits that clear a field.
|
||||
Log planReplace(Log log, Note note, Iterable<String> removed, Hlc hlc) => {
|
||||
for (final id in removed)
|
||||
if (log[id] case final r? when !r.deleted && id != note.id)
|
||||
id: Record(id: id, fields: const {}, deleted: true, deletedHlc: hlc),
|
||||
note.id: Record(id: note.id, fields: noteFields(note, hlc)),
|
||||
};
|
||||
@ -9,12 +9,6 @@ library;
|
||||
|
||||
import 'package:todo/data/note.dart';
|
||||
|
||||
/// [NoteFilter.sources] entry for plain ideas (notes without a repo).
|
||||
const kIdeasSource = '';
|
||||
|
||||
/// [NoteFilter.sources] entry admitting every repo task.
|
||||
const kRepoTasksSource = '*';
|
||||
|
||||
/// How the history list should be ordered.
|
||||
enum NoteSort {
|
||||
/// Newest created first.
|
||||
@ -41,7 +35,9 @@ class NoteFilter {
|
||||
this.query = '',
|
||||
this.priorities = const {},
|
||||
this.statuses = const {},
|
||||
this.sources = const {},
|
||||
this.repo,
|
||||
this.repoTasksOnly = false,
|
||||
this.hideClaude = false,
|
||||
this.createdFrom,
|
||||
this.createdTo,
|
||||
this.updatedFrom,
|
||||
@ -57,9 +53,15 @@ class NoteFilter {
|
||||
/// Notes must have one of these statuses. Empty means "any status".
|
||||
final Set<Status> statuses;
|
||||
|
||||
/// Which notes may show: [kIdeasSource], [kRepoTasksSource] and/or repo
|
||||
/// names. Empty means every note.
|
||||
final Set<String> sources;
|
||||
/// Only notes whose repo equals this. Null means any repo (or none).
|
||||
final String? repo;
|
||||
|
||||
/// Only notes that have a repo (repo tasks). Ideas are excluded.
|
||||
final bool repoTasksOnly;
|
||||
|
||||
/// Leave out notes Claude filed. A view preference rather than a user
|
||||
/// facet, so it never counts toward [isEmpty] or [activeCount].
|
||||
final bool hideClaude;
|
||||
|
||||
/// Inclusive lower bound (by calendar day) on the creation date.
|
||||
final DateTime? createdFrom;
|
||||
@ -78,7 +80,8 @@ class NoteFilter {
|
||||
query.trim().isEmpty &&
|
||||
priorities.isEmpty &&
|
||||
statuses.isEmpty &&
|
||||
sources.isEmpty &&
|
||||
repo == null &&
|
||||
!repoTasksOnly &&
|
||||
createdFrom == null &&
|
||||
createdTo == null &&
|
||||
updatedFrom == null &&
|
||||
@ -90,7 +93,8 @@ class NoteFilter {
|
||||
if (query.trim().isNotEmpty) n++;
|
||||
if (priorities.isNotEmpty) n++;
|
||||
if (statuses.isNotEmpty) n++;
|
||||
if (sources.isNotEmpty) n++;
|
||||
if (repo != null) n++;
|
||||
if (repoTasksOnly) n++;
|
||||
if (createdFrom != null || createdTo != null) n++;
|
||||
if (updatedFrom != null || updatedTo != null) n++;
|
||||
return n;
|
||||
@ -103,7 +107,9 @@ class NoteFilter {
|
||||
String? query,
|
||||
Set<Priority>? priorities,
|
||||
Set<Status>? statuses,
|
||||
Set<String>? sources,
|
||||
String? repo,
|
||||
bool? repoTasksOnly,
|
||||
bool? hideClaude,
|
||||
DateTime? createdFrom,
|
||||
DateTime? createdTo,
|
||||
DateTime? updatedFrom,
|
||||
@ -115,7 +121,9 @@ class NoteFilter {
|
||||
query: query ?? this.query,
|
||||
priorities: priorities ?? this.priorities,
|
||||
statuses: statuses ?? this.statuses,
|
||||
sources: sources ?? this.sources,
|
||||
repo: repo ?? this.repo,
|
||||
repoTasksOnly: repoTasksOnly ?? this.repoTasksOnly,
|
||||
hideClaude: hideClaude ?? this.hideClaude,
|
||||
createdFrom: clearCreated ? null : (createdFrom ?? this.createdFrom),
|
||||
createdTo: clearCreated ? null : (createdTo ?? this.createdTo),
|
||||
updatedFrom: clearUpdated ? null : (updatedFrom ?? this.updatedFrom),
|
||||
@ -135,9 +143,9 @@ bool matchesFilter(Note note, NoteFilter filter) {
|
||||
!(note.repo?.toLowerCase().contains(query) ?? false)) {
|
||||
return false;
|
||||
}
|
||||
if (filter.sources.isNotEmpty && !_sourceAllowed(note, filter.sources)) {
|
||||
return false;
|
||||
}
|
||||
if (filter.repo != null && note.repo != filter.repo) return false;
|
||||
if (filter.repoTasksOnly && note.repo == null) return false;
|
||||
if (filter.hideClaude && note.isClaudeMade) return false;
|
||||
if (filter.priorities.isNotEmpty &&
|
||||
!filter.priorities.contains(note.priority)) {
|
||||
return false;
|
||||
@ -154,12 +162,6 @@ bool matchesFilter(Note note, NoteFilter filter) {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool _sourceAllowed(Note note, Set<String> sources) {
|
||||
final repo = note.repo;
|
||||
if (repo == null) return sources.contains(kIdeasSource);
|
||||
return sources.contains(kRepoTasksSource) || sources.contains(repo);
|
||||
}
|
||||
|
||||
/// Inclusive day-granularity range check, matching the old SQL bounds:
|
||||
/// `from` includes its whole day; `to` includes its whole day.
|
||||
bool _withinDays(DateTime value, DateTime? from, DateTime? to) {
|
||||
@ -171,8 +173,17 @@ bool _withinDays(DateTime value, DateTime? from, DateTime? to) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/// The comparator implementing [sort].
|
||||
/// The comparator implementing [sort]: human-made notes first, Claude-made
|
||||
/// ones after, and [sort] orders each group.
|
||||
Comparator<Note> comparatorFor(NoteSort sort) {
|
||||
final within = _within(sort);
|
||||
return (a, b) {
|
||||
if (a.isClaudeMade != b.isClaudeMade) return a.isClaudeMade ? 1 : -1;
|
||||
return within(a, b);
|
||||
};
|
||||
}
|
||||
|
||||
Comparator<Note> _within(NoteSort sort) {
|
||||
switch (sort) {
|
||||
case NoteSort.createdDesc:
|
||||
return (a, b) => b.createdAt.compareTo(a.createdAt);
|
||||
|
||||
@ -14,6 +14,9 @@ const _doneCheck = 'done_check';
|
||||
const _by = 'by';
|
||||
const _evidence = 'evidence';
|
||||
|
||||
/// Record field naming who filed the note (`kuhy` or `claude`).
|
||||
const kCreatedByField = 'created_by';
|
||||
|
||||
/// Record fields for [note], all at [hlc]. Unset task fields are omitted, so
|
||||
/// an idea's record is exactly the v3 field set (and cannot clear a peer's).
|
||||
Map<String, Field> noteFields(Note note, Hlc hlc) => {
|
||||
@ -26,12 +29,18 @@ Map<String, Field> noteFields(Note note, Hlc hlc) => {
|
||||
if (note.doneCheck case final v?) _doneCheck: (v, hlc),
|
||||
if (note.by case final v?) _by: (v, hlc),
|
||||
if (note.evidence case final v?) _evidence: (v, hlc),
|
||||
if (note.createdBy case final v?) kCreatedByField: (v, hlc),
|
||||
};
|
||||
|
||||
/// Reads [record] as a [Note], ignoring unknown fields.
|
||||
Note noteFromRecord(Record record) {
|
||||
final fields = record.fields;
|
||||
String? str(String name) => fields[name]?.$1 as String?;
|
||||
// '' is how a peer clears a task field: omitting it never clears.
|
||||
String? task(String name) => switch (str(name)) {
|
||||
'' => null,
|
||||
final v => v,
|
||||
};
|
||||
return Note(
|
||||
id: record.id,
|
||||
text: str(_text) ?? '',
|
||||
@ -39,10 +48,11 @@ Note noteFromRecord(Record record) {
|
||||
status: Status.fromValue(fields[_status]?.$1 as int?),
|
||||
createdAt: _parseDate(str(_createdAt)),
|
||||
updatedAt: _parseDate(str(_updatedAt)),
|
||||
repo: str(_repo),
|
||||
doneCheck: str(_doneCheck),
|
||||
by: str(_by),
|
||||
evidence: str(_evidence),
|
||||
repo: task(_repo),
|
||||
doneCheck: task(_doneCheck),
|
||||
by: task(_by),
|
||||
evidence: task(_evidence),
|
||||
createdBy: task(kCreatedByField),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:todo/data/created_by_backfill.dart';
|
||||
import 'package:todo/data/log_watch.dart';
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:todo/data/note_bulk.dart';
|
||||
import 'package:todo/data/note_import.dart';
|
||||
import 'package:todo/data/note_query.dart';
|
||||
import 'package:todo/data/note_record.dart';
|
||||
@ -114,9 +115,57 @@ class NoteRepository {
|
||||
/// character-by-character autosave: it is cheap and idempotent. All fields
|
||||
/// share one fresh monotonic clock (they are written together).
|
||||
Future<void> upsert(Note note) async {
|
||||
await _store.upsert(
|
||||
Record(id: note.id, fields: noteFields(note, _store.nextHlc())),
|
||||
);
|
||||
await _put(Record(id: note.id, fields: noteFields(note, _store.nextHlc())));
|
||||
}
|
||||
|
||||
Future<void> _put(Record record) =>
|
||||
_store.upsert(keepCreatedBy(record, _store.get(record.id)));
|
||||
|
||||
/// Sets `created_by` on every live note that has none, from the node that
|
||||
/// wrote its `created_at` (see [classifyCreatedBy]); returns id -> value.
|
||||
///
|
||||
/// Writes only that field, at a fresh clock, so no other field's clock
|
||||
/// moves (a rewrite of `created_at` would erase the signal itself). With
|
||||
/// [dryRun] nothing is written.
|
||||
Future<Map<String, String>> backfillCreatedBy(
|
||||
Set<String> claudeNodes, {
|
||||
bool dryRun = false,
|
||||
}) async {
|
||||
final plan = planCreatedByBackfill(_store.snapshot(), claudeNodes);
|
||||
if (dryRun || plan.isEmpty) return plan;
|
||||
final hlc = _store.nextHlc();
|
||||
await _patch({
|
||||
for (final e in plan.entries)
|
||||
e.key: Record(id: e.key, fields: {kCreatedByField: (e.value, hlc)}),
|
||||
});
|
||||
return plan;
|
||||
}
|
||||
|
||||
/// Sets [priority] and/or [status] on the live notes [ids] in ONE store
|
||||
/// write (see [planBulkUpdate]); a no-op when nothing changes.
|
||||
Future<void> bulkUpdate(
|
||||
Set<String> ids, {
|
||||
Priority? priority,
|
||||
Status? status,
|
||||
}) => _patch(
|
||||
planBulkUpdate(
|
||||
_store.snapshot(),
|
||||
ids,
|
||||
hlc: _store.nextHlc(),
|
||||
now: DateTime.now(),
|
||||
priority: priority,
|
||||
status: status,
|
||||
),
|
||||
);
|
||||
|
||||
/// Saves [note] and deletes [removed] in ONE store write ([planReplace]).
|
||||
Future<void> saveReplacing(Note note, Iterable<String> removed) =>
|
||||
_patch(planReplace(_store.snapshot(), note, removed, _store.nextHlc()));
|
||||
|
||||
/// Merges a partial log in one write: untouched fields keep their clocks.
|
||||
Future<void> _patch(Log partial) async {
|
||||
if (partial.isEmpty) return;
|
||||
await _store.replaceAll(mergeLogs(_store.snapshot(), partial));
|
||||
}
|
||||
|
||||
/// Soft-deletes a note. The log keeps a sticky tombstone so the deletion
|
||||
@ -142,10 +191,10 @@ class NoteRepository {
|
||||
// the import wins locally; the clock decides whether it wins on *other*
|
||||
// devices, and an import must not win there simply by being recent.
|
||||
if (local == null) {
|
||||
await _store.upsert(recordAtNoteTime(note, _nodeId));
|
||||
await _put(recordAtNoteTime(note, _nodeId));
|
||||
added++;
|
||||
} else if (note.updatedAt.isAfter(local.updatedAt)) {
|
||||
await _store.upsert(recordAtNoteTime(note, _nodeId));
|
||||
await _put(recordAtNoteTime(note, _nodeId));
|
||||
updated++;
|
||||
} else {
|
||||
skipped++;
|
||||
@ -165,27 +214,10 @@ class NoteRepository {
|
||||
Stream<List<Note>> watchNotes({
|
||||
NoteSort sort = NoteSort.priorityDesc,
|
||||
NoteFilter filter = const NoteFilter(),
|
||||
}) => _watch(() => _query(sort, filter));
|
||||
}) => watchLog(_store.changes, () => _query(sort, filter));
|
||||
|
||||
/// Emits the live count of non-deleted notes immediately and on each change.
|
||||
Stream<int> watchCount() => _watch(_liveCount);
|
||||
|
||||
/// A stream that emits `compute()` synchronously when first listened to (the
|
||||
/// seed), then again on every log change. Seeding in `onListen` — rather than
|
||||
/// lazily in an `async*` body — avoids a race where a write between `listen`
|
||||
/// and the first generator tick would swallow the initial snapshot.
|
||||
Stream<T> _watch<T>(T Function() compute) {
|
||||
StreamSubscription<void>? sub;
|
||||
late final StreamController<T> controller;
|
||||
controller = StreamController<T>(
|
||||
onListen: () {
|
||||
controller.add(compute());
|
||||
sub = _store.changes.listen((_) => controller.add(compute()));
|
||||
},
|
||||
onCancel: () => sub?.cancel(),
|
||||
);
|
||||
return controller.stream;
|
||||
}
|
||||
Stream<int> watchCount() => watchLog(_store.changes, _liveCount);
|
||||
|
||||
/// This device's stable node id. Names its file in the sync repo so two
|
||||
/// devices never write the same path.
|
||||
|
||||
75
lib/data/repo_assign.dart
Normal file
75
lib/data/repo_assign.dart
Normal file
@ -0,0 +1,75 @@
|
||||
/// Assigning plain notes to a repo from the UI: each becomes a `## title`
|
||||
/// section of the repo's one open note ([addRepoTask]), and the original goes.
|
||||
///
|
||||
/// Only the data layer: nothing here talks to the board or starts a task.
|
||||
library;
|
||||
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:todo/data/note_repository.dart';
|
||||
import 'package:todo/data/repo_note.dart';
|
||||
|
||||
final _repoShape = RegExp(r'^[\w.-]+/[\w.-]+$');
|
||||
final _heading = RegExp(r'^(#+)(\s.*)$');
|
||||
|
||||
/// Whether [value] looks like `owner/name`.
|
||||
bool isRepoName(String value) => _repoShape.hasMatch(value.trim());
|
||||
|
||||
/// [body] with every heading outside code fences pushed below `##`.
|
||||
///
|
||||
/// A repo note splits into tasks at `##` headings, so a template note's own
|
||||
/// `## what` / `## must` would otherwise turn into tasks of their own.
|
||||
String demoteHeadings(String body) {
|
||||
var fenced = false;
|
||||
final out = <String>[];
|
||||
for (final line in body.split('\n')) {
|
||||
if (line.trimLeft().startsWith('```')) fenced = !fenced;
|
||||
final m = fenced ? null : _heading.firstMatch(line);
|
||||
if (m == null) {
|
||||
out.add(line);
|
||||
continue;
|
||||
}
|
||||
final level = m.group(1)!.length;
|
||||
out.add('${'#' * (level < 2 ? 3 : level + 1)}${m.group(2)}');
|
||||
}
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
/// Moves the plain [notes] into [repo]'s open note in one write, creating
|
||||
/// that note when the repo has none; returns the notes refused because
|
||||
/// they already belong to a repo (left untouched).
|
||||
Future<List<Note>> assignToRepo(
|
||||
NoteRepository repository,
|
||||
List<Note> notes,
|
||||
String repo,
|
||||
) async {
|
||||
final refused = [
|
||||
for (final n in notes)
|
||||
if (n.repo != null) n,
|
||||
];
|
||||
final plain = [
|
||||
for (final n in notes)
|
||||
if (n.repo == null) n,
|
||||
];
|
||||
if (plain.isEmpty) return refused;
|
||||
final first = splitNote(plain.first.text);
|
||||
var target = (await addRepoTask(
|
||||
repository,
|
||||
repo: repo,
|
||||
title: first.title,
|
||||
body: demoteHeadings(first.body),
|
||||
priority: plain.first.priority,
|
||||
createdBy: 'kuhy',
|
||||
)).note;
|
||||
for (final n in plain.skip(1)) {
|
||||
final (:title, :body) = splitNote(n.text);
|
||||
target = target.copyWith(
|
||||
text: '${target.text}\n\n${repoSection(title, demoteHeadings(body))}',
|
||||
priority: maxPriority(target.priority, n.priority),
|
||||
);
|
||||
}
|
||||
await repository.saveReplacing(
|
||||
target.copyWith(updatedAt: DateTime.now()),
|
||||
plain.map((n) => n.id),
|
||||
);
|
||||
return refused;
|
||||
}
|
||||
107
lib/data/repo_note.dart
Normal file
107
lib/data/repo_note.dart
Normal file
@ -0,0 +1,107 @@
|
||||
/// The one-open-note-per-repo invariant. Single add path for the MCP
|
||||
/// `add_task` and the CLI `--repo` add; callers persist and push.
|
||||
library;
|
||||
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:todo/data/note_repository.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// Statuses that count as open.
|
||||
const Set<Status> kOpenStatuses = {Status.todo, Status.inProgress};
|
||||
|
||||
/// The repo note's title.
|
||||
String repoNoteTitle(String repo) => '${repo.split('/').last} todos';
|
||||
|
||||
/// A `new_note` task's own note title: `<name> todos: <title>`.
|
||||
String separateNoteTitle(String repo, String title) =>
|
||||
'${repoNoteTitle(repo)}: ${title.trim()}';
|
||||
|
||||
/// Whether [note] is a `new_note` note, which the shared add path and the
|
||||
/// one-time merge leave alone.
|
||||
bool isSeparateNote(Note note) {
|
||||
final repo = note.repo;
|
||||
return repo != null &&
|
||||
splitNote(note.text).title.startsWith('${repoNoteTitle(repo)}: ');
|
||||
}
|
||||
|
||||
/// Joins two checks with `&&`; either may be absent.
|
||||
String? composeDoneCheck(String? older, String? newer) {
|
||||
final a = (older ?? '').trim();
|
||||
final b = (newer ?? '').trim();
|
||||
if (a.isEmpty) return b.isEmpty ? null : b;
|
||||
return b.isEmpty ? a : '( $a ) && ( $b )';
|
||||
}
|
||||
|
||||
/// A `## title` section.
|
||||
String repoSection(String title, String body) {
|
||||
final text = body.trim();
|
||||
return text.isEmpty ? '## $title' : '## $title\n$text';
|
||||
}
|
||||
|
||||
/// The higher priority.
|
||||
Priority maxPriority(Priority a, Priority b) => a.value >= b.value ? a : b;
|
||||
|
||||
/// Title line and rest of a note's text.
|
||||
({String title, String body}) splitNote(String text) {
|
||||
final lines = text.split('\n');
|
||||
final first = lines.indexWhere((l) => l.trim().isNotEmpty);
|
||||
if (first < 0) return (title: '', body: '');
|
||||
return (
|
||||
title: lines[first].trim().replaceFirst(RegExp(r'^#+\s*'), ''),
|
||||
body: lines.skip(first + 1).join('\n').trim(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Oldest first; ties broken by id so the choice is deterministic.
|
||||
int byAge(Note a, Note b) {
|
||||
final c = a.createdAt.compareTo(b.createdAt);
|
||||
return c != 0 ? c : a.id.compareTo(b.id);
|
||||
}
|
||||
|
||||
/// The note to save and whether it was appended.
|
||||
typedef RepoAdd = ({Note note, bool appended});
|
||||
|
||||
/// Appends the task to [repo]'s oldest open shared note, or builds that
|
||||
/// note; separate (`new_note`) notes are never joined.
|
||||
Future<RepoAdd> addRepoTask(
|
||||
NoteRepository repository, {
|
||||
required String repo,
|
||||
required String title,
|
||||
String body = '',
|
||||
String? doneCheck,
|
||||
Priority priority = Priority.medium,
|
||||
String? by,
|
||||
String? createdBy,
|
||||
}) async {
|
||||
final section = repoSection(title, body);
|
||||
final open = (await repository.listNotes(
|
||||
filter: NoteFilter(statuses: kOpenStatuses, repo: repo),
|
||||
)).where((n) => !isSeparateNote(n)).toList();
|
||||
if (open.isNotEmpty) {
|
||||
final existing = (open..sort(byAge)).first;
|
||||
return (
|
||||
note: existing.copyWith(
|
||||
text: '${existing.text}\n\n$section',
|
||||
doneCheck: composeDoneCheck(existing.doneCheck, doneCheck),
|
||||
priority: maxPriority(existing.priority, priority),
|
||||
),
|
||||
appended: true,
|
||||
);
|
||||
}
|
||||
final now = DateTime.now();
|
||||
return (
|
||||
note: Note(
|
||||
id: const Uuid().v4(),
|
||||
text: '${repoNoteTitle(repo)}\n\n$section',
|
||||
priority: priority,
|
||||
status: Status.todo,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
repo: repo,
|
||||
doneCheck: composeDoneCheck(null, doneCheck),
|
||||
by: by,
|
||||
createdBy: createdBy,
|
||||
),
|
||||
appended: false,
|
||||
);
|
||||
}
|
||||
53
lib/data/repo_note_merge.dart
Normal file
53
lib/data/repo_note_merge.dart
Normal file
@ -0,0 +1,53 @@
|
||||
/// One-time migration plan: one open note per repo, oldest id kept.
|
||||
library;
|
||||
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:todo/data/repo_note.dart';
|
||||
|
||||
/// The rewritten oldest note and the originals to delete.
|
||||
typedef RepoMerge = ({Note kept, List<Note> removed});
|
||||
|
||||
/// A merge for every repo whose open notes are not yet one `<name> todos`
|
||||
/// note; running it on its own result yields an empty plan. Separate
|
||||
/// (`new_note`) notes stay out of it.
|
||||
List<RepoMerge> planRepoMerges(Iterable<Note> notes) {
|
||||
final byRepo = <String, List<Note>>{};
|
||||
for (final n in notes) {
|
||||
final repo = n.repo;
|
||||
if (repo != null &&
|
||||
kOpenStatuses.contains(n.status) &&
|
||||
!isSeparateNote(n)) {
|
||||
byRepo.putIfAbsent(repo, () => []).add(n);
|
||||
}
|
||||
}
|
||||
final plan = <RepoMerge>[];
|
||||
for (final MapEntry(key: repo, value: group) in byRepo.entries) {
|
||||
group.sort(byAge);
|
||||
final title = repoNoteTitle(repo);
|
||||
if (group.length == 1 && splitNote(group.single.text).title == title) {
|
||||
continue;
|
||||
}
|
||||
plan.add((kept: _merged(title, group), removed: group.sublist(1)));
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
Note _merged(String title, List<Note> group) {
|
||||
var doneCheck = group.first.doneCheck;
|
||||
var priority = group.first.priority;
|
||||
final sections = <String>[];
|
||||
for (final (i, n) in group.indexed) {
|
||||
final (title: t, :body) = splitNote(n.text);
|
||||
// An already-merged note keeps its sections as they are.
|
||||
sections.add(t == title ? body : repoSection(t, body));
|
||||
if (i > 0) {
|
||||
doneCheck = composeDoneCheck(doneCheck, n.doneCheck);
|
||||
priority = maxPriority(priority, n.priority);
|
||||
}
|
||||
}
|
||||
return group.first.copyWith(
|
||||
text: '$title\n\n${sections.where((s) => s.isNotEmpty).join('\n\n')}',
|
||||
doneCheck: doneCheck,
|
||||
priority: priority,
|
||||
);
|
||||
}
|
||||
122
lib/data/repo_note_split.dart
Normal file
122
lib/data/repo_note_split.dart
Normal file
@ -0,0 +1,122 @@
|
||||
/// `new_note`: a repo task in its own open note, `<name> todos: <title>`,
|
||||
/// so it can close on its own done_check. A title that already heads an open
|
||||
/// section of another separate note of the repo merges into that note
|
||||
/// instead (it keeps its id); the shared `<name> todos` note never counts.
|
||||
library;
|
||||
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:todo/data/note_repository.dart';
|
||||
import 'package:todo/data/repo_note.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// How a separate add landed.
|
||||
enum SplitKind {
|
||||
/// A new separate note.
|
||||
added,
|
||||
|
||||
/// Appended to the separate note that already had the title.
|
||||
merged,
|
||||
|
||||
/// A new separate note because the duplicate's note was busy.
|
||||
fellBack,
|
||||
}
|
||||
|
||||
/// The note to save, how it landed, and why it fell back.
|
||||
typedef SplitAdd = ({Note note, SplitKind kind, String? why});
|
||||
|
||||
final _heading = RegExp(r'^##\s+(\[x\]\s+)?(.*)$');
|
||||
|
||||
/// Every `## title` heading of [text] outside code fences, open or done.
|
||||
Iterable<RegExpMatch> _sections(String text) sync* {
|
||||
var fenced = false;
|
||||
for (final line in text.split('\n')) {
|
||||
if (line.trimLeft().startsWith('```')) fenced = !fenced;
|
||||
final m = fenced ? null : _heading.firstMatch(line);
|
||||
if (m != null) yield m;
|
||||
}
|
||||
}
|
||||
|
||||
/// The open `## title` headings of [text], outside code fences.
|
||||
Iterable<String> openSections(String text) => _sections(
|
||||
text,
|
||||
).where((m) => m.group(1) == null).map((m) => m.group(2)!.trim());
|
||||
|
||||
/// How many board items [text] splits into: one per section, done or not.
|
||||
int sectionCount(String text) => _sections(text).length;
|
||||
|
||||
/// The separate open note of [repo] with an open section titled [title].
|
||||
Note? duplicateTarget(Iterable<Note> open, String repo, String title) {
|
||||
final hits =
|
||||
open
|
||||
.where((n) => n.repo == repo && isSeparateNote(n))
|
||||
.where((n) => openSections(n.text).contains(title.trim()))
|
||||
.toList()
|
||||
..sort(byAge);
|
||||
return hits.isEmpty ? null : hits.first;
|
||||
}
|
||||
|
||||
/// Why a merged task must not be started, or null when it may be.
|
||||
String? startBlock(String? doneCheck, String body) {
|
||||
final check = (doneCheck ?? '').trim().toLowerCase();
|
||||
if (check.isEmpty || check == 'none') return 'done_check is none';
|
||||
final first = body
|
||||
.split('\n')
|
||||
.firstWhere((l) => l.trim().isNotEmpty, orElse: () => '');
|
||||
return first.trim().toLowerCase().startsWith('low priority')
|
||||
? 'marked low priority'
|
||||
: null;
|
||||
}
|
||||
|
||||
/// Adds the task as its own note, or merges it into the duplicate's note
|
||||
/// unless [busy] says a board session is working on it.
|
||||
Future<SplitAdd> addSeparateTask(
|
||||
NoteRepository repository, {
|
||||
required String repo,
|
||||
required String title,
|
||||
String body = '',
|
||||
String? doneCheck,
|
||||
Priority priority = Priority.medium,
|
||||
String? by,
|
||||
String? createdBy,
|
||||
Future<bool> Function(Note note)? busy,
|
||||
}) async {
|
||||
final section = repoSection(title, body);
|
||||
final open = await repository.listNotes(
|
||||
filter: NoteFilter(statuses: kOpenStatuses, repo: repo),
|
||||
);
|
||||
final target = duplicateTarget(open, repo, title);
|
||||
String? why;
|
||||
if (target != null) {
|
||||
if (busy == null || !await busy(target)) {
|
||||
return (
|
||||
note: target.copyWith(
|
||||
text: '${target.text}\n\n$section',
|
||||
doneCheck: composeDoneCheck(target.doneCheck, doneCheck),
|
||||
priority: maxPriority(target.priority, priority),
|
||||
),
|
||||
kind: SplitKind.merged,
|
||||
why: null,
|
||||
);
|
||||
}
|
||||
why =
|
||||
'not merged into ${target.id.substring(0, 8)}: '
|
||||
'a board session is running on it';
|
||||
}
|
||||
final now = DateTime.now();
|
||||
return (
|
||||
note: Note(
|
||||
id: const Uuid().v4(),
|
||||
text: '${separateNoteTitle(repo, title)}\n\n$section',
|
||||
priority: priority,
|
||||
status: Status.todo,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
repo: repo,
|
||||
doneCheck: composeDoneCheck(null, doneCheck),
|
||||
by: by,
|
||||
createdBy: createdBy,
|
||||
),
|
||||
kind: why == null ? SplitKind.added : SplitKind.fellBack,
|
||||
why: why,
|
||||
);
|
||||
}
|
||||
@ -7,6 +7,11 @@ import 'package:path/path.dart' as p;
|
||||
|
||||
import 'package:todo/desktop/static_content_type.dart';
|
||||
import 'package:todo/desktop/wrapper_image_routes.dart';
|
||||
import 'package:todo/sync/e2e_passphrase_source.dart'
|
||||
show kSyncE2ePassphrasePath;
|
||||
|
||||
export 'package:todo/sync/e2e_passphrase_source.dart'
|
||||
show kSyncE2ePassphrasePath;
|
||||
|
||||
export 'wrapper_paths.dart';
|
||||
|
||||
@ -86,6 +91,9 @@ class WrapperServer {
|
||||
/// wrappers also serve, which stays opt-in.
|
||||
bool _todoCredentialsServed = false;
|
||||
|
||||
/// Whether [kSyncE2ePassphrasePath] has served the passphrase yet.
|
||||
bool _e2ePassphraseServed = false;
|
||||
|
||||
/// `todo`'s own seeded Firebase session, written by
|
||||
/// `crdt-sync/tool/seed_session.py --app todo` in the same
|
||||
/// `{id_token, refresh_token, expires_at}` shape every other app's
|
||||
@ -184,6 +192,9 @@ class WrapperServer {
|
||||
if (path == kSyncCredentialsPath) {
|
||||
return await _syncCredentials(request);
|
||||
}
|
||||
if (path == kSyncE2ePassphrasePath) {
|
||||
return await _syncE2ePassphrase(request);
|
||||
}
|
||||
if (await images.handle(request)) return;
|
||||
return await _static(request, path);
|
||||
}
|
||||
|
||||
@ -121,4 +121,23 @@ extension _SyncRoutes on WrapperServer {
|
||||
// through must remain retryable rather than burning the one serve.
|
||||
_todoCredentialsServed = true;
|
||||
}
|
||||
|
||||
/// Serves the shared crdt-sync password as the E2E passphrase, once per
|
||||
/// wrapper process, like [_syncCredentials]: the web app stores it in its
|
||||
/// keystore on first sync and never asks for it.
|
||||
Future<void> _syncE2ePassphrase(HttpRequest request) async {
|
||||
final passwordFile = File(p.join(syncConfigDir, 'password'));
|
||||
if (_e2ePassphraseServed || !passwordFile.existsSync()) {
|
||||
request.response.statusCode = HttpStatus.notFound;
|
||||
return;
|
||||
}
|
||||
final text = (await passwordFile.readAsString()).trim();
|
||||
if (text.isEmpty) {
|
||||
request.response.statusCode = HttpStatus.notFound;
|
||||
return;
|
||||
}
|
||||
request.response.headers.contentType = ContentType.text;
|
||||
request.response.write(text);
|
||||
_e2ePassphraseServed = true;
|
||||
}
|
||||
}
|
||||
|
||||
@ -132,10 +132,16 @@ class ImageCacheStore {
|
||||
for (final ref in pending()) {
|
||||
final file = fileFor(ref);
|
||||
if (!file.existsSync()) continue;
|
||||
final length = await file.length();
|
||||
if (await client.upload(ref, file.openRead(), length)) {
|
||||
await _marker(ref).delete();
|
||||
uploaded++;
|
||||
try {
|
||||
final length = await file.length();
|
||||
if (await client.upload(ref, file.openRead(), length)) {
|
||||
await _marker(ref).delete();
|
||||
uploaded++;
|
||||
}
|
||||
} on FileSystemException {
|
||||
// Deleted mid-pass (note removed, cache cleared): nothing left to
|
||||
// upload. A surviving marker is skipped by the check above next time.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
for (final ref in pass.referenced) {
|
||||
|
||||
@ -11,6 +11,7 @@ import 'package:todo/data/note_repository.dart';
|
||||
import 'package:todo/data/repository_factory.dart';
|
||||
import 'package:todo/frame_stats.dart';
|
||||
import 'package:todo/ui/capture_screen.dart';
|
||||
import 'package:todo/ui/text_free_localizations.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
@ -69,6 +70,7 @@ class TodoApp extends StatelessWidget {
|
||||
store: imageStore,
|
||||
child: MaterialApp(
|
||||
title: 'todo',
|
||||
localizationsDelegates: kTextFreeLocalizations,
|
||||
theme: buildLightTheme(),
|
||||
darkTheme: buildDarkTheme(),
|
||||
home: CaptureScreen(
|
||||
|
||||
167
lib/mcp/board_client.dart
Normal file
167
lib/mcp/board_client.dart
Normal file
@ -0,0 +1,167 @@
|
||||
/// The todoImplementer board as `add_task new_note` sees it: whether a
|
||||
/// note has a session running, and a Start click on a merged task. Start
|
||||
/// goes through the board's own page (CSRF meta tag, same Origin), so the
|
||||
/// board needs no new endpoint. Failures come back as reasons, never throws:
|
||||
/// a board that is down must not lose the task that was just saved.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// Where the board listens (loopback only).
|
||||
final Uri kBoardBase = Uri.parse('http://127.0.0.1:8786');
|
||||
|
||||
/// A Start attempt: the item started, or why not.
|
||||
typedef StartResult = ({bool ok, String? reason, String? item});
|
||||
|
||||
final _csrf = RegExp(r'<meta\s+name="todoimpl-csrf"\s+content="([^"]+)"');
|
||||
|
||||
/// Talks to the board over [client] at [base].
|
||||
class BoardClient {
|
||||
/// [client] is injectable so tests need no network. [wait] bounds how
|
||||
/// long Start waits for the board to see a merged section (its note cache
|
||||
/// lags ~10 s, T1863) and to confirm a Start whose reply outran
|
||||
/// [startTimeout] (T1864); [poll] is the gap between board reads.
|
||||
BoardClient({
|
||||
http.Client? client,
|
||||
Uri? base,
|
||||
this.wait = const Duration(seconds: 30),
|
||||
this.poll = const Duration(seconds: 1),
|
||||
this.startTimeout = const Duration(seconds: 30),
|
||||
}) : _http = client ?? http.Client(),
|
||||
base = base ?? kBoardBase;
|
||||
|
||||
final http.Client _http;
|
||||
|
||||
/// The board's origin; also the `Origin` header of a POST.
|
||||
final Uri base;
|
||||
|
||||
/// Longest wait for the board to catch up with the note.
|
||||
final Duration wait;
|
||||
|
||||
/// Gap between two board reads while waiting.
|
||||
final Duration poll;
|
||||
|
||||
/// Longest wait for the reply to a Start POST.
|
||||
final Duration startTimeout;
|
||||
|
||||
static const _timeout = Duration(seconds: 10);
|
||||
|
||||
Future<List<Map<String, dynamic>>> _cards(String noteId) async {
|
||||
final res = await _http.get(base.resolve('/api/board')).timeout(_timeout);
|
||||
if (res.statusCode != 200) {
|
||||
throw http.ClientException('GET /api/board ${res.statusCode}');
|
||||
}
|
||||
final tasks = _json(res)['tasks'];
|
||||
final prefix = '${noteId.substring(0, 8)}-';
|
||||
return [
|
||||
for (final t in (tasks as List).cast<Map<String, dynamic>>())
|
||||
if ((t['id']! as String).startsWith(prefix)) t,
|
||||
];
|
||||
}
|
||||
|
||||
/// Whether any item of the note is in the board's Running column; false
|
||||
/// when the board cannot be reached.
|
||||
Future<bool> running(String noteId) async {
|
||||
try {
|
||||
return (await _cards(noteId)).any((c) => c['column'] == 'running');
|
||||
} on Object {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Clicks Start on the note's last startable item titled [title]. With
|
||||
/// [sections], that is the note's last section (`<id8>-<sections - 1>`),
|
||||
/// which a merge just added: waits until the board shows it.
|
||||
Future<StartResult> start(
|
||||
String noteId,
|
||||
String title, {
|
||||
int? sections,
|
||||
}) async {
|
||||
try {
|
||||
final last = sections == null ? null : _item(noteId, sections - 1);
|
||||
bool seen(List<Map<String, dynamic>> cs) =>
|
||||
last == null || cs.any((c) => c['id'] == last);
|
||||
final cards = await _until(noteId, seen);
|
||||
// Exactly the merged item: never an older same-titled one, which the
|
||||
// board may list last (it orders by column, T1866).
|
||||
final card = seen(cards)
|
||||
? cards.reversed
|
||||
.where((c) => last == null || c['id'] == last)
|
||||
.where((c) => c['title'] == title && _startable(c))
|
||||
.firstOrNull
|
||||
: null;
|
||||
if (card == null) return _no('no startable item');
|
||||
final page = await _http.get(base).timeout(_timeout);
|
||||
final token = _csrf.firstMatch(page.body)?.group(1);
|
||||
if (token == null) return _no('no CSRF token on the board page');
|
||||
final id = card['id']! as String;
|
||||
return await _post(noteId, id, token);
|
||||
} on Object catch (e) {
|
||||
return _no('board unreachable: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<StartResult> _post(String noteId, String id, String token) async {
|
||||
final http.Response res;
|
||||
try {
|
||||
res = await _http
|
||||
.post(
|
||||
base.resolve('/api/task/$id/start'),
|
||||
headers: {'Origin': base.origin, 'X-Todoimpl-Token': token},
|
||||
)
|
||||
.timeout(startTimeout);
|
||||
} on TimeoutException catch (e) {
|
||||
// The board may still be starting it: a card that left Start took it.
|
||||
final cards = await _until(
|
||||
noteId,
|
||||
(cs) => cs.any((c) => c['id'] == id && !_startable(c)),
|
||||
);
|
||||
if (cards.any((c) => c['id'] == id && !_startable(c))) {
|
||||
return (ok: true, reason: null, item: id);
|
||||
}
|
||||
return _no('board unreachable: $e');
|
||||
}
|
||||
if (res.statusCode == 200) return (ok: true, reason: null, item: id);
|
||||
return _no('board refused start: ${res.statusCode} ${_message(res)}');
|
||||
}
|
||||
|
||||
/// The note's cards once [done] holds, or the last read after [wait].
|
||||
Future<List<Map<String, dynamic>>> _until(
|
||||
String noteId,
|
||||
bool Function(List<Map<String, dynamic>>) done,
|
||||
) async {
|
||||
final deadline = DateTime.now().add(wait);
|
||||
var cards = await _cards(noteId);
|
||||
while (!done(cards) && DateTime.now().isBefore(deadline)) {
|
||||
await Future<void>.delayed(poll);
|
||||
cards = await _cards(noteId);
|
||||
}
|
||||
return cards;
|
||||
}
|
||||
|
||||
static String _item(String noteId, int index) =>
|
||||
'${noteId.substring(0, 8)}-$index';
|
||||
|
||||
static bool _startable(Map<String, dynamic> card) => (card['actions'] as List)
|
||||
.cast<Map<String, dynamic>>()
|
||||
.any((a) => a['id'] == 'start' && a['enabled'] == true);
|
||||
|
||||
static String _message(http.Response res) {
|
||||
try {
|
||||
final error = _json(res)['error'];
|
||||
return (error as Map<String, dynamic>)['message'] as String;
|
||||
} on Object {
|
||||
return res.body;
|
||||
}
|
||||
}
|
||||
|
||||
// The board's JSON is UTF-8 whatever the Content-Type says.
|
||||
static Map<String, dynamic> _json(http.Response res) =>
|
||||
jsonDecode(utf8.decode(res.bodyBytes)) as Map<String, dynamic>;
|
||||
|
||||
static StartResult _no(String reason) =>
|
||||
(ok: false, reason: reason, item: null);
|
||||
}
|
||||
49
lib/mcp/mcp_main.dart
Normal file
49
lib/mcp/mcp_main.dart
Normal file
@ -0,0 +1,49 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:todo/cli/cli_session.dart';
|
||||
import 'package:todo/mcp/board_client.dart';
|
||||
import 'package:todo/mcp/task_service.dart';
|
||||
import 'package:todo/mcp/todo_mcp_server.dart';
|
||||
|
||||
/// Starts the server from `$HOME` paths and returns its stop function, or
|
||||
/// null after writing to [err] why it cannot run (no tokens, seed, network).
|
||||
Future<Future<void> Function()?> startTodoMcp(
|
||||
Map<String, String> env,
|
||||
StringSink err, {
|
||||
http.Client? httpClient,
|
||||
}) async {
|
||||
final home = env['HOME'] ?? '';
|
||||
final config = p.join(home, '.config', 'todo-mcp');
|
||||
final tokens = File(p.join(config, 'tokens'));
|
||||
if (!tokens.existsSync()) {
|
||||
err.writeln('error: ${tokens.path} missing; run mcp/mint_token.sh pc');
|
||||
return null;
|
||||
}
|
||||
CliSession? session;
|
||||
try {
|
||||
session = await CliSession.open(
|
||||
env['TODO_MCP_DATA_DIR'] ?? p.join(home, '.local', 'share', 'todo-mcp'),
|
||||
seedCredentials: p.join(config, 'firebase_auth.json'),
|
||||
seedApp: 'todo-mcp',
|
||||
httpClient: httpClient,
|
||||
);
|
||||
await session.sync(push: true);
|
||||
final server = await TodoMcpServer.start(
|
||||
tasks: TaskService(session, board: BoardClient()),
|
||||
tokens: tokens,
|
||||
port: int.tryParse(env['TODO_MCP_PORT'] ?? '') ?? kTodoMcpPort,
|
||||
);
|
||||
err.writeln('todo-mcp: serving ${server.uri}');
|
||||
final open = session;
|
||||
return () async {
|
||||
await server.close();
|
||||
await open.close();
|
||||
};
|
||||
} on Object catch (e) {
|
||||
err.writeln('error: $e');
|
||||
await session?.close();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
211
lib/mcp/task_service.dart
Normal file
211
lib/mcp/task_service.dart
Normal file
@ -0,0 +1,211 @@
|
||||
import 'package:todo/cli/cli_args.dart';
|
||||
import 'package:todo/cli/cli_session.dart';
|
||||
import 'package:todo/cli/note_lookup.dart';
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:todo/data/note_repository.dart';
|
||||
import 'package:todo/data/repo_note.dart';
|
||||
import 'package:todo/mcp/board_client.dart';
|
||||
import 'package:todo/mcp/task_split.dart';
|
||||
|
||||
/// The six task tools over one persistent sync node; calls run one at a time.
|
||||
class TaskService {
|
||||
/// Serves [_session]'s notes; every call pulls first, writes then push.
|
||||
/// [board] is the todoImplementer board `new_note` checks and starts on.
|
||||
TaskService(this._session, {this._board});
|
||||
|
||||
final CliSession _session;
|
||||
final BoardClient? _board;
|
||||
Future<void> _tail = Future.value();
|
||||
|
||||
Future<T> _serial<T>(Future<T> Function() body) {
|
||||
final run = _tail.then((_) => body());
|
||||
_tail = run.then((_) {}, onError: (_) {});
|
||||
return run;
|
||||
}
|
||||
|
||||
Future<List<Note>> _pull() async {
|
||||
final skipped = (await _session.sync(push: true)).skippedFiles;
|
||||
if (skipped.isNotEmpty) throw StateError('sync: unmergeable $skipped');
|
||||
return await _session.repository.listNotes();
|
||||
}
|
||||
|
||||
Future<String> _save(Note note, String verb) async {
|
||||
await _session.repository.upsert(note.copyWith(updatedAt: DateTime.now()));
|
||||
await _session.sync(push: true);
|
||||
return '$verb ${note.id}\n${listLine(note)}';
|
||||
}
|
||||
|
||||
Future<Note> _find(_Args a) async {
|
||||
final id = a.required('id');
|
||||
try {
|
||||
return findNote(await _pull(), id);
|
||||
} on NoteLookupError catch (e) {
|
||||
throw FormatException('id: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// `add_task`: joins the repo's open note (by: claude), or creates it. A
|
||||
/// board Start after a merge runs once the other calls may go again.
|
||||
Future<String> add(Map<String, dynamic> args) async {
|
||||
final (:reply, :start) = await _serial(() => _add(args));
|
||||
return start == null ? reply : '$reply\n${await start()}';
|
||||
}
|
||||
|
||||
Future<SplitReply> _add(Map<String, dynamic> args) async {
|
||||
final a = _Args(args);
|
||||
final repo = a.repo(required: true)!;
|
||||
final title = a.required('title');
|
||||
await _pull();
|
||||
if (a.flag('new_note')) {
|
||||
return await addSplit(
|
||||
_session.repository,
|
||||
_board,
|
||||
_save,
|
||||
repo: repo,
|
||||
title: title,
|
||||
body: a.optional('body') ?? '',
|
||||
doneCheck: a.optional('done_check'),
|
||||
priority: a.priority() ?? Priority.medium,
|
||||
);
|
||||
}
|
||||
final added = await addRepoTask(
|
||||
_session.repository,
|
||||
repo: repo,
|
||||
title: title,
|
||||
body: a.optional('body') ?? '',
|
||||
doneCheck: a.optional('done_check'),
|
||||
priority: a.priority() ?? Priority.medium,
|
||||
by: 'claude',
|
||||
createdBy: 'claude',
|
||||
);
|
||||
final verb = added.appended ? 'appended to' : 'added';
|
||||
return (reply: await _save(added.note, verb), start: null);
|
||||
}
|
||||
|
||||
/// `list_tasks`: repo tasks, open ones unless `status` says otherwise.
|
||||
Future<String> list(Map<String, dynamic> args) => _serial(() async {
|
||||
final a = _Args(args);
|
||||
final repo = a.repo();
|
||||
final statuses = a.statuses();
|
||||
await _pull();
|
||||
return _lines(
|
||||
await _session.repository.listNotes(
|
||||
filter: NoteFilter(statuses: statuses, repo: repo, repoTasksOnly: true),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
/// `search_tasks`: repo tasks of any status whose text or repo matches.
|
||||
Future<String> search(Map<String, dynamic> args) => _serial(() async {
|
||||
final query = _Args(args).required('query');
|
||||
await _pull();
|
||||
return _lines(
|
||||
await _session.repository.listNotes(
|
||||
filter: NoteFilter(query: query, repoTasksOnly: true),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
/// `get_task`: one note in full.
|
||||
Future<String> get(Map<String, dynamic> args) =>
|
||||
_serial(() async => noteDetail(await _find(_Args(args))));
|
||||
|
||||
/// `update_task`: changes only the keys given; `done_check: ""` clears it.
|
||||
Future<String> update(Map<String, dynamic> args) => _serial(() async {
|
||||
final a = _Args(args);
|
||||
final note = await _find(a);
|
||||
final status = a.status();
|
||||
if (status == Status.done) {
|
||||
throw const FormatException('status: use close_task with evidence');
|
||||
}
|
||||
final lines = note.text.split('\n');
|
||||
final title = a.has('title') ? a.required('title') : lines.first;
|
||||
final body = a.has('body')
|
||||
? (a.optional('body') ?? '')
|
||||
: lines.skip(1).join('\n').trimLeft();
|
||||
final reopened = status != null && note.status == Status.done;
|
||||
return await _save(
|
||||
note.copyWith(
|
||||
text: body.isEmpty ? title : '$title\n\n$body',
|
||||
priority: a.priority(),
|
||||
status: status,
|
||||
repo: a.repo(),
|
||||
doneCheck: a.has('done_check')
|
||||
? (a.optional('done_check') ?? '')
|
||||
: null,
|
||||
evidence: reopened ? '' : null,
|
||||
),
|
||||
'updated',
|
||||
);
|
||||
});
|
||||
|
||||
/// `close_task`: status done plus evidence; refused without evidence.
|
||||
Future<String> close(Map<String, dynamic> args) => _serial(() async {
|
||||
final a = _Args(args);
|
||||
final evidence = a.required('evidence');
|
||||
final note = await _find(a);
|
||||
return await _save(
|
||||
note.copyWith(status: Status.done, evidence: evidence),
|
||||
'closed',
|
||||
);
|
||||
});
|
||||
|
||||
String _lines(List<Note> notes) =>
|
||||
notes.isEmpty ? 'no tasks match' : notes.map(listLine).join('\n');
|
||||
}
|
||||
|
||||
final _repoShape = RegExp(r'^[\w.-]+/[\w.-]+$');
|
||||
final Map<String, Status> _statuses = {
|
||||
for (final s in Status.values) CliArgs.statusName(s): s,
|
||||
};
|
||||
|
||||
// Every refusal is a FormatException whose message starts with the field.
|
||||
extension type _Args(Map<String, dynamic> _args) {
|
||||
bool has(String name) => _args.containsKey(name);
|
||||
|
||||
bool flag(String name) => switch (_args[name]) {
|
||||
null || false => false,
|
||||
true => true,
|
||||
_ => throw FormatException('$name: must be a boolean'),
|
||||
};
|
||||
|
||||
String? optional(String name) {
|
||||
final value = _args[name];
|
||||
if (value == null) return null;
|
||||
if (value is! String) throw FormatException('$name: must be a string');
|
||||
final trimmed = value.trim();
|
||||
return trimmed.isEmpty ? null : trimmed;
|
||||
}
|
||||
|
||||
String required(String name) =>
|
||||
optional(name) ?? (throw FormatException('$name: required, not blank'));
|
||||
|
||||
String? repo({bool required = false}) {
|
||||
if (!required && !has('repo')) return null;
|
||||
final value = optional('repo') ?? '';
|
||||
if (!_repoShape.hasMatch(value)) {
|
||||
throw FormatException('repo: must look like owner/name, got "$value"');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
Priority? priority() =>
|
||||
_pick('priority', {for (final p in Priority.values) p.name: p});
|
||||
|
||||
Status? status() => _pick('status', _statuses);
|
||||
|
||||
Set<Status> statuses() => switch (optional('status')) {
|
||||
null || 'open' => {Status.todo, Status.inProgress},
|
||||
'all' => const {},
|
||||
_ => {status()!},
|
||||
};
|
||||
|
||||
T? _pick<T>(String name, Map<String, T> choices) {
|
||||
final value = optional(name);
|
||||
if (value == null) return null;
|
||||
return choices[value] ??
|
||||
(throw FormatException(
|
||||
'$name: must be one of ${choices.keys.join('|')}, got "$value"',
|
||||
));
|
||||
}
|
||||
}
|
||||
67
lib/mcp/task_split.dart
Normal file
67
lib/mcp/task_split.dart
Normal file
@ -0,0 +1,67 @@
|
||||
/// `add_task` with `new_note: true`: the separate note, the guard merge and
|
||||
/// the board Start that follows a merge, as one reply text.
|
||||
library;
|
||||
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:todo/data/note_repository.dart';
|
||||
import 'package:todo/data/repo_note_split.dart';
|
||||
import 'package:todo/mcp/board_client.dart';
|
||||
|
||||
/// Saves [note] and returns `<verb> <id>` plus its list line.
|
||||
typedef SaveNote = Future<String> Function(Note note, String verb);
|
||||
|
||||
/// A saved split add: its reply so far, and the board Start still to run.
|
||||
/// The caller runs [start] after releasing its lock, since the board learns
|
||||
/// about the merged section by calling this same server (T1865).
|
||||
typedef SplitReply = ({String reply, Future<String> Function()? start});
|
||||
|
||||
/// Adds a separate task, merging into a duplicate's note when it is idle;
|
||||
/// a merge that may run comes back with the Start to click on the board.
|
||||
Future<SplitReply> addSplit(
|
||||
NoteRepository repository,
|
||||
BoardClient? board,
|
||||
SaveNote save, {
|
||||
required String repo,
|
||||
required String title,
|
||||
required String body,
|
||||
required String? doneCheck,
|
||||
required Priority priority,
|
||||
}) async {
|
||||
final added = await addSeparateTask(
|
||||
repository,
|
||||
repo: repo,
|
||||
title: title,
|
||||
body: body,
|
||||
doneCheck: doneCheck,
|
||||
priority: priority,
|
||||
by: 'claude',
|
||||
createdBy: 'claude',
|
||||
busy: board == null ? null : (n) => board.running(n.id),
|
||||
);
|
||||
if (added.kind != SplitKind.merged) {
|
||||
final reply = await save(added.note, 'added');
|
||||
return (
|
||||
reply: added.why == null ? reply : '$reply\n${added.why}',
|
||||
start: null,
|
||||
);
|
||||
}
|
||||
final reply = await save(added.note, 'merged into');
|
||||
final block = startBlock(doneCheck, body);
|
||||
if (block != null) {
|
||||
return (reply: '$reply\nstart: skipped ($block)', start: null);
|
||||
}
|
||||
Future<String> start() async {
|
||||
final s = board == null
|
||||
? (ok: false, reason: 'no board configured', item: null)
|
||||
: await board.start(
|
||||
added.note.id,
|
||||
title.trim(),
|
||||
sections: sectionCount(added.note.text),
|
||||
);
|
||||
if (s.ok) return 'start: ok ${s.item}';
|
||||
return 'start: {ok: false, reason: "${s.reason}"}; '
|
||||
'the merge is saved, click Start on the board by hand';
|
||||
}
|
||||
|
||||
return (reply: reply, start: start);
|
||||
}
|
||||
191
lib/mcp/todo_mcp_server.dart
Normal file
191
lib/mcp/todo_mcp_server.dart
Normal file
@ -0,0 +1,191 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:mcp_dart/mcp_dart.dart';
|
||||
import 'package:todo/mcp/task_service.dart';
|
||||
import 'package:todo/mcp/token_file.dart';
|
||||
|
||||
/// Loopback port; Caddy proxies todo.kuhy.duckdns.org/mcp here.
|
||||
const kTodoMcpPort = 8735;
|
||||
|
||||
const _publicHost = 'todo.kuhy.duckdns.org';
|
||||
|
||||
const _instructions = '''
|
||||
Tasks for kuhy's repos live in the todo app (not in TODO*.md files, which are
|
||||
banned). Use this server instead of writing a TODO file.
|
||||
- At session start in a repo, call list_tasks(repo: "kuhyx/<name>") to see
|
||||
its open tasks.
|
||||
- A repo has ONE open note, "<repo> todos", holding all its tasks as
|
||||
"## title" sections. add_task appends to it (or creates it) and returns its id.
|
||||
- When you find work you will not finish now, call add_task with the repo,
|
||||
a one-line title, the body (what / where / done / verify) and a done_check:
|
||||
a shell command run from the repo root that exits 0 once the work is done.
|
||||
Done checks of appended tasks are joined with &&.
|
||||
- When a task's work lands, run its done_check; only if it exits 0 call
|
||||
close_task with evidence "<commit sha> done_check exit 0". Never close
|
||||
without running it.
|
||||
- Ids accept an 8-character prefix. Errors start with the argument to fix.''';
|
||||
|
||||
/// The task tools over Streamable HTTP, behind sha256-hashed bearer tokens.
|
||||
class TodoMcpServer {
|
||||
TodoMcpServer._(this._http);
|
||||
|
||||
final StreamableMcpServer _http;
|
||||
|
||||
/// `http://<host>:<bound port>/mcp`.
|
||||
Uri get uri => Uri.parse('http://${_http.host}:${_http.boundPort}/mcp');
|
||||
|
||||
/// Binds [host]:[port] (0 = any free port); [tokens] is read per request.
|
||||
static Future<TodoMcpServer> start({
|
||||
required TaskService tasks,
|
||||
required File tokens,
|
||||
String host = '127.0.0.1',
|
||||
int port = kTodoMcpPort,
|
||||
}) async {
|
||||
final http = StreamableMcpServer(
|
||||
serverFactory: (_) => _server(tasks),
|
||||
host: host,
|
||||
port: port,
|
||||
authenticator: (request) => tokenAccepted(
|
||||
tokens,
|
||||
request is HttpRequest
|
||||
? request.headers.value(HttpHeaders.authorizationHeader)
|
||||
: null,
|
||||
),
|
||||
allowedHosts: const {'localhost', '127.0.0.1', '::1', _publicHost},
|
||||
);
|
||||
await http.start();
|
||||
return TodoMcpServer._(http);
|
||||
}
|
||||
|
||||
/// Stops listening and closes every session.
|
||||
Future<void> close() => _http.stop();
|
||||
}
|
||||
|
||||
McpServer _server(TaskService tasks) {
|
||||
final server = McpServer(
|
||||
const Implementation(name: 'todo', version: '1.0.0'),
|
||||
options: const McpServerOptions(instructions: _instructions),
|
||||
);
|
||||
final id = JsonSchema.string(
|
||||
description: 'Task id, or a unique prefix of at least 8 characters.',
|
||||
);
|
||||
final repo = JsonSchema.string(
|
||||
description: 'GitHub repo as owner/name, e.g. kuhyx/todo.',
|
||||
);
|
||||
final priority = JsonSchema.string(description: 'low | medium | high.');
|
||||
final doneCheck = JsonSchema.string(
|
||||
description:
|
||||
'Shell command run from the repo root; exit 0 means done. '
|
||||
'In update_task, "" clears it.',
|
||||
);
|
||||
void tool(
|
||||
String name,
|
||||
String description,
|
||||
Map<String, JsonSchema> props,
|
||||
List<String> required,
|
||||
Future<String> Function(Map<String, dynamic>) run,
|
||||
) => server.registerTool(
|
||||
name,
|
||||
description: description,
|
||||
inputSchema: JsonSchema.object(properties: props, required: required),
|
||||
callback: (args, _) => _result(() => run(args)),
|
||||
);
|
||||
|
||||
tool(
|
||||
'add_task',
|
||||
"Add a task to the repo's single open note (by: claude): appended as a "
|
||||
'"## title" section, or the note is created. Returns "added <full id>" '
|
||||
'or "appended to <full id>", then its list line. Use when you find '
|
||||
'work you will not finish now. With new_note: true the task gets '
|
||||
'its own note ("added"), or, when another separate note of the repo '
|
||||
'already has that exact title, "merged into" that note, followed by '
|
||||
'a "start:" line for the todoImplementer board.',
|
||||
{
|
||||
'repo': repo,
|
||||
'title': JsonSchema.string(description: 'One-line title.'),
|
||||
'body': JsonSchema.string(description: 'Markdown: what/where/verify.'),
|
||||
'done_check': doneCheck,
|
||||
'priority': priority,
|
||||
'new_note': JsonSchema.boolean(
|
||||
description: 'Own note instead of joining the shared one.',
|
||||
),
|
||||
},
|
||||
['repo', 'title'],
|
||||
tasks.add,
|
||||
);
|
||||
tool(
|
||||
'list_tasks',
|
||||
'List repo tasks, one line each: 8-char id, status, priority, repo, '
|
||||
'title, created_by (kuhy|claude). Default status "open" '
|
||||
'(todo + in_progress).',
|
||||
{
|
||||
'repo': repo,
|
||||
'status': JsonSchema.string(
|
||||
description: 'open | all | todo | in_progress | done | abandoned.',
|
||||
),
|
||||
},
|
||||
[],
|
||||
tasks.list,
|
||||
);
|
||||
tool(
|
||||
'get_task',
|
||||
'One task in full: metadata lines, then --- and the text.',
|
||||
{'id': id},
|
||||
['id'],
|
||||
tasks.get,
|
||||
);
|
||||
tool(
|
||||
'update_task',
|
||||
'Change only the arguments given; others stay as they are. Marking done '
|
||||
'is close_task. Returns "updated <id>" and its list line.',
|
||||
{
|
||||
'id': id,
|
||||
'title': JsonSchema.string(description: 'New one-line title.'),
|
||||
'body': JsonSchema.string(description: 'New body; "" empties it.'),
|
||||
'done_check': doneCheck,
|
||||
'priority': priority,
|
||||
'status': JsonSchema.string(
|
||||
description: 'todo | in_progress | abandoned.',
|
||||
),
|
||||
'repo': repo,
|
||||
},
|
||||
['id'],
|
||||
tasks.update,
|
||||
);
|
||||
tool(
|
||||
'close_task',
|
||||
'Mark a task done, keeping it. Refused without evidence. Run the '
|
||||
"task's done_check first and close only when it exits 0.",
|
||||
{
|
||||
'id': id,
|
||||
'evidence': JsonSchema.string(
|
||||
description:
|
||||
'Commit sha and done_check exit code, e.g. "abc1234 exit 0".',
|
||||
),
|
||||
},
|
||||
['id', 'evidence'],
|
||||
tasks.close,
|
||||
);
|
||||
tool(
|
||||
'search_tasks',
|
||||
'Repo tasks of any status whose text or repo contains the query '
|
||||
'(case-insensitive), one line each as list_tasks.',
|
||||
{'query': JsonSchema.string(description: 'Text to look for.')},
|
||||
['query'],
|
||||
tasks.search,
|
||||
);
|
||||
return server;
|
||||
}
|
||||
|
||||
Future<CallToolResult> _result(Future<String> Function() run) async {
|
||||
try {
|
||||
return CallToolResult.fromContent([TextContent(text: await run())]);
|
||||
} on FormatException catch (e) {
|
||||
return _error(e.message);
|
||||
} on Object catch (e) {
|
||||
return _error('$e');
|
||||
}
|
||||
}
|
||||
|
||||
CallToolResult _error(String text) =>
|
||||
CallToolResult(content: [TextContent(text: 'error: $text')], isError: true);
|
||||
31
lib/mcp/token_file.dart
Normal file
31
lib/mcp/token_file.dart
Normal file
@ -0,0 +1,31 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
|
||||
/// sha256 hex of [token] (crdt_sync's `revisionOf` is exactly that).
|
||||
String tokenHash(String token) => revisionOf(token);
|
||||
|
||||
Set<String> _hashes(File tokens) {
|
||||
final String text;
|
||||
try {
|
||||
text = tokens.readAsStringSync();
|
||||
} on FileSystemException {
|
||||
return const {};
|
||||
}
|
||||
return {
|
||||
for (final line in text.split('\n'))
|
||||
if (line.trim() case final t when t.isNotEmpty && !t.startsWith('#'))
|
||||
t.split(RegExp(r'\s+')).first.toLowerCase(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Whether [authorization] bears a token hashed in [tokens], read per call.
|
||||
bool tokenAccepted(File tokens, String? authorization) {
|
||||
const scheme = 'bearer ';
|
||||
if (authorization == null ||
|
||||
!authorization.toLowerCase().startsWith(scheme)) {
|
||||
return false;
|
||||
}
|
||||
final token = authorization.substring(scheme.length).trim();
|
||||
return token.isNotEmpty && _hashes(tokens).contains(tokenHash(token));
|
||||
}
|
||||
38
lib/sync/e2e_passphrase_source.dart
Normal file
38
lib/sync/e2e_passphrase_source.dart
Normal file
@ -0,0 +1,38 @@
|
||||
/// Where todo gets the shared crdt-sync passphrase its E2E key comes from.
|
||||
///
|
||||
/// The keystore first. On the desktop (a web build behind the local
|
||||
/// wrapper) an empty keystore adopts the wrapper's one-shot
|
||||
/// `/sync-e2e-passphrase` route, which serves `~/.config/crdt-sync/password`,
|
||||
/// so the desktop app never asks. Android has no wrapper (`Uri.base` is
|
||||
/// `file:///`) and is unlocked through the settings lock tile instead.
|
||||
library;
|
||||
|
||||
import 'package:crdt_sync_flutter/crdt_sync_flutter.dart'
|
||||
show kE2ePassphraseKey, kSecureStorage, saveE2ePassphrase;
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// Path of the wrapper's one-shot passphrase route.
|
||||
const kSyncE2ePassphrasePath = '/sync-e2e-passphrase';
|
||||
|
||||
/// Returns the passphrase, adopting the wrapper's once; null when none.
|
||||
Future<String?> readE2ePassphrase({Uri? base, http.Client? client}) async {
|
||||
final stored = await kSecureStorage.read(key: kE2ePassphraseKey);
|
||||
if (stored != null && stored.isNotEmpty) return stored;
|
||||
final origin = base ?? Uri.base;
|
||||
if (origin.scheme != 'http' && origin.scheme != 'https') return null;
|
||||
final httpClient = client ?? http.Client();
|
||||
try {
|
||||
final response = await httpClient.get(
|
||||
origin.resolve(kSyncE2ePassphrasePath),
|
||||
);
|
||||
final text = response.body.trim();
|
||||
if (response.statusCode != 200 || text.isEmpty) return null;
|
||||
await saveE2ePassphrase(text);
|
||||
return text;
|
||||
} on Object {
|
||||
// No wrapper, or it already served: this device is simply locked.
|
||||
return null;
|
||||
} finally {
|
||||
if (client == null) httpClient.close();
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
/// Whether the programmatic Google sign-in flow exists on this platform.
|
||||
///
|
||||
/// The io half: Android has the Credential Manager flow; Linux desktop does
|
||||
/// The io half: Android has the AccountManager flow; Linux desktop does
|
||||
/// not, and this app's desktop build is the web one anyway.
|
||||
library;
|
||||
|
||||
|
||||
@ -2,9 +2,9 @@
|
||||
///
|
||||
/// Why this is its own file: `crdt_sync` is deliberately pure Dart (only
|
||||
/// `crypto` and `http`), because the same library runs on Linux desktop and
|
||||
/// headless under systemd, where `google_sign_in` does not exist. So the
|
||||
/// plugin lives here, in the app, and the library only ever receives a token
|
||||
/// string through a closure.
|
||||
/// headless under systemd, where Android's AccountManager does not exist. So
|
||||
/// the platform channel lives in the google_account_token plugin, and the
|
||||
/// library only ever receives a token string through a closure.
|
||||
///
|
||||
/// What this buys: on a fresh install the sync account is reached by tapping
|
||||
/// one account-picker row instead of typing a long password on a phone
|
||||
@ -14,7 +14,7 @@ library;
|
||||
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:google_sign_in/google_sign_in.dart';
|
||||
import 'package:google_account_token/google_account_token.dart' as plugin;
|
||||
import 'package:todo/sync/google_platform.dart' as platform_gate;
|
||||
|
||||
/// The account the security rules pin.
|
||||
@ -78,7 +78,7 @@ Future<String?> googleIdToken({
|
||||
if (signInFn != null) return await signInFn();
|
||||
if (serverClientId.isEmpty) {
|
||||
// A build without --dart-define=GOOGLE_SERVER_CLIENT_ID would otherwise
|
||||
// fail inside the plugin with a far less obvious message.
|
||||
// fail on the platform side with a far less obvious message.
|
||||
log(
|
||||
'Google sign-in unavailable: GOOGLE_SERVER_CLIENT_ID was not set at '
|
||||
'build time; falling back to the password path',
|
||||
@ -86,37 +86,5 @@ Future<String?> googleIdToken({
|
||||
);
|
||||
return null;
|
||||
}
|
||||
// The plugin reaches the OS account picker through a platform channel,
|
||||
// which `flutter test` has no binding for -- the same reason main.dart and
|
||||
// firebase_backend.dart's keystore adapters are excluded. Everything above
|
||||
// this point is pure Dart and is covered.
|
||||
// coverage:ignore-start
|
||||
try {
|
||||
await GoogleSignIn.instance.initialize(serverClientId: serverClientId);
|
||||
if (!GoogleSignIn.instance.supportsAuthenticate()) {
|
||||
// Web/desktop: sign-in must go through the rendered GIS button, which
|
||||
// this app does not host yet. Fall back rather than throwing.
|
||||
log(
|
||||
'Google sign-in is not available on this platform; falling back to '
|
||||
'the password path',
|
||||
level: 900,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
final account = await GoogleSignIn.instance.authenticate();
|
||||
return account.authentication.idToken;
|
||||
} on GoogleSignInException catch (error, stackTrace) {
|
||||
// Includes the user simply dismissing the picker, which is not an error
|
||||
// worth surfacing -- but log it, because a *configuration* failure
|
||||
// (unregistered SHA-1, wrong client id) arrives through the same path and
|
||||
// is otherwise indistinguishable from "the user changed their mind".
|
||||
log(
|
||||
'Google sign-in did not complete',
|
||||
level: 900,
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
// coverage:ignore-end
|
||||
return await plugin.googleIdToken(serverClientId: serverClientId);
|
||||
}
|
||||
|
||||
@ -92,6 +92,7 @@ class NotesMarkdown {
|
||||
doneCheck: attrs['done_check'],
|
||||
by: attrs['by'],
|
||||
evidence: attrs['evidence'],
|
||||
createdBy: attrs['created_by'],
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -113,6 +114,7 @@ class NotesMarkdown {
|
||||
('done_check', note.doneCheck),
|
||||
('by', note.by),
|
||||
('evidence', note.evidence),
|
||||
('created_by', note.createdBy),
|
||||
])
|
||||
if (value != null) ' $name="${_escape(value)}"',
|
||||
].join();
|
||||
|
||||
@ -7,15 +7,26 @@
|
||||
library;
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:crdt_sync_flutter/crdt_sync_flutter.dart' show keystoreKeyCache;
|
||||
import 'package:flutter/foundation.dart' show ValueNotifier, visibleForTesting;
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:todo/analytics/analytics_service.dart';
|
||||
import 'package:todo/data/app_settings.dart';
|
||||
import 'package:todo/data/note_repository.dart';
|
||||
import 'package:todo/sync/e2e_passphrase_source.dart';
|
||||
import 'package:todo/sync/firebase_backend.dart';
|
||||
import 'package:todo/sync/sync_service.dart';
|
||||
import 'package:todo/sync/sync_settings.dart';
|
||||
import 'package:todo/sync/sync_state_factory.dart';
|
||||
|
||||
/// Argon2id cost for new headers; only tests lower it.
|
||||
@visibleForTesting
|
||||
Argon2Params e2eArgon2Params = Argon2Params.recommended;
|
||||
|
||||
/// Whether the last sync found no usable end-to-end key; drives the lock
|
||||
/// tile in settings.
|
||||
final ValueNotifier<bool> e2eLocked = ValueNotifier<bool>(false);
|
||||
|
||||
/// Bundles a [SyncResult] with the [AppSettings] as reconciled against the
|
||||
/// same Firebase session — so a settings change made on another device is
|
||||
/// picked up without a second authenticated round trip.
|
||||
@ -44,8 +55,12 @@ class SyncRunResult {
|
||||
/// rather than each concern (notes, settings, analytics) opening and
|
||||
/// closing its own.
|
||||
///
|
||||
/// [firebaseFactory] and [stateStore] exist for tests, which must not reach
|
||||
/// the OS keystore or an application-support directory.
|
||||
/// [firebaseFactory], [stateStore] and [readPassphrase] exist for tests, which
|
||||
/// must not reach the OS keystore or an application-support directory.
|
||||
///
|
||||
/// Everything pushed is end-to-end encrypted under the shared crdt-sync
|
||||
/// passphrase; without a usable key the run is a no-op flagged
|
||||
/// [SyncResult.locked] rather than a plaintext sync.
|
||||
Future<SyncRunResult> runSync(
|
||||
NoteRepository repository,
|
||||
SyncSettings settings, {
|
||||
@ -54,6 +69,7 @@ Future<SyncRunResult> runSync(
|
||||
http.Client? httpClient,
|
||||
Future<FirebaseRestClient?> Function()? firebaseFactory,
|
||||
SyncStateStore? stateStore,
|
||||
Future<String?> Function()? readPassphrase,
|
||||
}) async {
|
||||
final github = GitHubClient(
|
||||
owner: settings.owner,
|
||||
@ -72,12 +88,29 @@ Future<SyncRunResult> runSync(
|
||||
stateStore: stateStore ?? await openSyncStateStore(),
|
||||
// coverage:ignore-end
|
||||
);
|
||||
final result = await service.sync(
|
||||
repository,
|
||||
firebase == null
|
||||
? github
|
||||
: MirrorStore(primary: firebase, mirror: github),
|
||||
final backend = firebase == null
|
||||
? github
|
||||
: MirrorStore(primary: firebase, mirror: github);
|
||||
final e2e = await openEncryptedStore(
|
||||
store: backend,
|
||||
oldPrefix: service.notesDir,
|
||||
readPassphrase: readPassphrase ?? readE2ePassphrase,
|
||||
params: e2eArgon2Params,
|
||||
// Argon2id once per device, not once per tick.
|
||||
keyCache: keystoreKeyCache(service.notesDir),
|
||||
);
|
||||
e2eLocked.value = e2e is! E2eReady;
|
||||
if (e2e is! E2eReady) {
|
||||
return const SyncRunResult(
|
||||
syncResult: SyncResult(mergedDevices: 0, pushed: false, locked: true),
|
||||
);
|
||||
}
|
||||
final layout = E2eLayout.of(service.notesDir);
|
||||
final result = await SyncService(
|
||||
notesDir: layout.prefix,
|
||||
revsPath: layout.revsPath,
|
||||
stateStore: service.stateStore,
|
||||
).sync(repository, e2e.store);
|
||||
await analytics?.flush(firebase);
|
||||
return SyncRunResult(
|
||||
syncResult: result.withFirebaseConnected(value: firebase != null),
|
||||
|
||||
@ -11,6 +11,7 @@ class SyncResult {
|
||||
this.skippedFiles = const [],
|
||||
this.skippedUnchanged = 0,
|
||||
this.firebaseConnected = false,
|
||||
this.locked = false,
|
||||
});
|
||||
|
||||
/// How many other devices' logs were pulled and merged.
|
||||
@ -41,6 +42,10 @@ class SyncResult {
|
||||
/// device dropping out.
|
||||
final int skippedUnchanged;
|
||||
|
||||
/// True when end-to-end encryption has no usable key on this device, so
|
||||
/// nothing was pulled, merged or pushed.
|
||||
final bool locked;
|
||||
|
||||
/// Returns a copy with [firebaseConnected] set. [SyncService] never sets
|
||||
/// it -- only [runSync], which is the layer that knows which [RemoteStore]
|
||||
/// it built -- so this is the seam that lets it do that without
|
||||
@ -51,6 +56,7 @@ class SyncResult {
|
||||
skippedFiles: skippedFiles,
|
||||
skippedUnchanged: skippedUnchanged,
|
||||
firebaseConnected: value,
|
||||
locked: locked,
|
||||
);
|
||||
|
||||
@override
|
||||
@ -73,7 +79,11 @@ class SyncResult {
|
||||
/// two never share a path and an old client can't misread a new file.
|
||||
class SyncService {
|
||||
/// Creates a [SyncService], optionally overriding [notesDir].
|
||||
const SyncService({this.notesDir = 'todo-sync/notes', this.stateStore});
|
||||
const SyncService({
|
||||
this.notesDir = 'todo-sync/notes',
|
||||
this.stateStore,
|
||||
this.revsPath,
|
||||
});
|
||||
|
||||
/// Directory in the repo under which per-device note-log files live.
|
||||
final String notesDir;
|
||||
@ -87,7 +97,11 @@ class SyncService {
|
||||
final SyncStateStore? stateStore;
|
||||
|
||||
/// Where revisions live, as a sibling of [notesDir].
|
||||
String get revsDir => defaultRevsPath(notesDir);
|
||||
/// Revision cache directory; null derives it from [notesDir].
|
||||
final String? revsPath;
|
||||
|
||||
/// Where peers publish their revisions.
|
||||
String get revsDir => revsPath ?? defaultRevsPath(notesDir);
|
||||
|
||||
/// Runs a full pull-merge-push cycle. Safe to call repeatedly.
|
||||
///
|
||||
@ -123,7 +137,15 @@ class SyncService {
|
||||
skippedUnchanged++;
|
||||
continue;
|
||||
}
|
||||
final text = await remote.getFileText('$notesDir/$name');
|
||||
final String? text;
|
||||
try {
|
||||
text = await remote.getFileText('$notesDir/$name');
|
||||
} on E2eDecryptError {
|
||||
// Wrong key, tampered blob or a newer format: skipped and reported,
|
||||
// never merged and never fatal.
|
||||
skipped.add(name);
|
||||
continue;
|
||||
}
|
||||
final remoteLog = text == null ? null : _decodeLog(text);
|
||||
if (remoteLog == null) {
|
||||
// A vanished or undecodable peer file must not abort the sync, but
|
||||
|
||||
32
lib/ui/app_route.dart
Normal file
32
lib/ui/app_route.dart
Normal file
@ -0,0 +1,32 @@
|
||||
/// The one page route every screen is pushed with.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A route for [builder] that switches screens instantly: no fade, no slide.
|
||||
///
|
||||
/// Without a GPU the desktop web build repaints the whole 4K canvas in
|
||||
/// software on every frame, so a 320 ms transition cost ~600 ms of main
|
||||
/// thread and a 260-300 ms frame stall on every tab switch and note open.
|
||||
/// The same instant switch applies on the phone. The context is unused but
|
||||
/// kept so every call site stays as it was.
|
||||
Route<T> appRoute<T>(BuildContext _, WidgetBuilder builder) =>
|
||||
_AppPageRoute<T>(builder: builder);
|
||||
|
||||
class _AppPageRoute<T> extends MaterialPageRoute<T> {
|
||||
_AppPageRoute({required super.builder});
|
||||
|
||||
@override
|
||||
Duration get transitionDuration => Duration.zero;
|
||||
|
||||
@override
|
||||
Duration get reverseTransitionDuration => Duration.zero;
|
||||
|
||||
@override
|
||||
Widget buildTransitions(
|
||||
BuildContext context,
|
||||
Animation<double> animation,
|
||||
Animation<double> secondaryAnimation,
|
||||
Widget child,
|
||||
) => child;
|
||||
}
|
||||
@ -44,18 +44,24 @@ Future<List<XFile>> pickAttachments(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_camera_outlined),
|
||||
title: const Text('Camera'),
|
||||
leading: const Icon(
|
||||
Icons.photo_camera_outlined,
|
||||
semanticLabel: 'Camera',
|
||||
),
|
||||
onTap: () => Navigator.pop(context, AttachmentPickSource.camera),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_library_outlined),
|
||||
title: const Text('Gallery'),
|
||||
leading: const Icon(
|
||||
Icons.photo_library_outlined,
|
||||
semanticLabel: 'Gallery',
|
||||
),
|
||||
onTap: () => Navigator.pop(context, AttachmentPickSource.gallery),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.insert_drive_file_outlined),
|
||||
title: const Text('File'),
|
||||
leading: const Icon(
|
||||
Icons.insert_drive_file_outlined,
|
||||
semanticLabel: 'File',
|
||||
),
|
||||
onTap: () => Navigator.pop(context, AttachmentPickSource.file),
|
||||
),
|
||||
],
|
||||
@ -102,8 +108,7 @@ class AttachButton extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IconButton(
|
||||
tooltip: 'Attach',
|
||||
icon: const Icon(Icons.attach_file),
|
||||
icon: const Icon(Icons.attach_file, semanticLabel: 'Attach'),
|
||||
onPressed: () async {
|
||||
final files = await (picking ?? pickAttachments)(context);
|
||||
if (files.isNotEmpty) onFiles(files);
|
||||
|
||||
@ -4,11 +4,13 @@
|
||||
/// screen still owns every action; this just lays the row out.
|
||||
library;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:todo/data/note_repository.dart';
|
||||
import 'package:todo/frame_stats.dart';
|
||||
import 'package:todo/ui/attachment_picking.dart';
|
||||
import 'package:todo/ui/capture_confirm_mark.dart';
|
||||
|
||||
/// App bar for the capture screen.
|
||||
class CaptureAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
@ -22,6 +24,7 @@ class CaptureAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
required this.onOpenList,
|
||||
required this.onOpenSettings,
|
||||
required this.onAttachFiles,
|
||||
required this.captured,
|
||||
this.attachmentPicking,
|
||||
super.key,
|
||||
});
|
||||
@ -50,6 +53,9 @@ class CaptureAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
/// Receives files picked with the 📎 action.
|
||||
final ValueChanged<List<XFile>> onAttachFiles;
|
||||
|
||||
/// Counts committed captures; each change plays the confirmation mark.
|
||||
final ValueListenable<int> captured;
|
||||
|
||||
/// The picker behind 📎; null uses the platform one.
|
||||
final AttachmentPicking? attachmentPicking;
|
||||
|
||||
@ -80,13 +86,11 @@ class CaptureAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
// Not gated on advanced mode: attaching is basic capture.
|
||||
AttachButton(onFiles: onAttachFiles, picking: attachmentPicking),
|
||||
IconButton(
|
||||
tooltip: 'New note',
|
||||
onPressed: onNewNote,
|
||||
icon: const Icon(Icons.check),
|
||||
icon: const Icon(Icons.check, semanticLabel: 'New note'),
|
||||
),
|
||||
if (advanced)
|
||||
IconButton(
|
||||
tooltip: 'Sync',
|
||||
onPressed: syncing ? null : onSync,
|
||||
icon: syncing
|
||||
? const SizedBox(
|
||||
@ -94,12 +98,11 @@ class CaptureAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.sync),
|
||||
: const Icon(Icons.sync, semanticLabel: 'Sync'),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Notes',
|
||||
onPressed: onOpenList,
|
||||
icon: const Icon(Icons.list),
|
||||
icon: const Icon(Icons.list, semanticLabel: 'Notes'),
|
||||
),
|
||||
// Live count of stored notes, proving local persistence. Sits directly
|
||||
// right of the Notes button it belongs to, and carries no icon of its
|
||||
@ -114,10 +117,10 @@ class CaptureAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
);
|
||||
},
|
||||
),
|
||||
CaptureConfirmMark(captured: captured),
|
||||
IconButton(
|
||||
tooltip: 'Settings',
|
||||
onPressed: onOpenSettings,
|
||||
icon: const Icon(Icons.settings),
|
||||
icon: const Icon(Icons.settings, semanticLabel: 'Settings'),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
91
lib/ui/capture_confirm_mark.dart
Normal file
91
lib/ui/capture_confirm_mark.dart
Normal file
@ -0,0 +1,91 @@
|
||||
/// The checkmark confirming a capture; static under "remove animations".
|
||||
library;
|
||||
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Plays once each time [captured] changes value.
|
||||
class CaptureConfirmMark extends StatefulWidget {
|
||||
/// Creates the mark.
|
||||
const CaptureConfirmMark({required this.captured, super.key});
|
||||
|
||||
/// Key of the checkmark icon while it is visible.
|
||||
static const ValueKey<String> iconKey = ValueKey('capture-confirm-icon');
|
||||
|
||||
/// Width always reserved, so the counter beside it never moves.
|
||||
static const double slotWidth = 24;
|
||||
|
||||
/// Fade in, hold and fade out: three [AppDuration.base] thirds.
|
||||
static final Duration duration = AppDuration.base * 3;
|
||||
|
||||
/// Counts captures; each change plays the confirmation.
|
||||
final ValueListenable<int> captured;
|
||||
|
||||
@override
|
||||
State<CaptureConfirmMark> createState() => _CaptureConfirmMarkState();
|
||||
}
|
||||
|
||||
class _CaptureConfirmMarkState extends State<CaptureConfirmMark>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: CaptureConfirmMark.duration,
|
||||
animationBehavior: AnimationBehavior.preserve,
|
||||
);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.captured.addListener(_play);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.captured.removeListener(_play);
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _play() => _controller.forward(from: 0);
|
||||
|
||||
double _opacity({required bool reduced}) {
|
||||
final t = _controller.value;
|
||||
if (reduced || (t >= 1 / 3 && t < 2 / 3)) return 1;
|
||||
if (t < 1 / 3) return AppCurve.decelerate.transform(t * 3);
|
||||
return 1 - AppCurve.accelerate.transform((t - 2 / 3) * 3);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final reduced = MediaQuery.disableAnimationsOf(context);
|
||||
return SizedBox(
|
||||
width: CaptureConfirmMark.slotWidth,
|
||||
child: AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, _) {
|
||||
if (!_controller.isAnimating) return const SizedBox.shrink();
|
||||
return Opacity(
|
||||
opacity: _opacity(reduced: reduced),
|
||||
child: const _CheckIcon(),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CheckIcon extends StatelessWidget {
|
||||
const _CheckIcon();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = Theme.of(context).colorScheme.primary;
|
||||
return Icon(
|
||||
Icons.check_circle,
|
||||
key: CaptureConfirmMark.iconKey,
|
||||
size: 18,
|
||||
color: color,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -38,6 +38,10 @@ class CaptureDraft {
|
||||
/// yet, so the first write still reports the note as created.
|
||||
bool _reserved = false;
|
||||
|
||||
/// Whether a note with content has been persisted for this draft, i.e.
|
||||
/// whether committing it is a real capture.
|
||||
bool get hasCapture => _id != null && !_reserved && _text.trim().isNotEmpty;
|
||||
|
||||
/// The draft's priority, applied to every write.
|
||||
Priority priority = Priority.defaultValue;
|
||||
|
||||
@ -98,6 +102,7 @@ class CaptureDraft {
|
||||
status: status,
|
||||
createdAt: _createdAt!,
|
||||
updatedAt: now,
|
||||
createdBy: 'kuhy',
|
||||
),
|
||||
);
|
||||
if (live()) lastSavedAt.value = now;
|
||||
|
||||
@ -1,9 +1,8 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:todo/analytics/analytics_event.dart';
|
||||
import 'package:todo/analytics/analytics_service.dart';
|
||||
@ -15,12 +14,14 @@ import 'package:todo/data/note_template.dart';
|
||||
import 'package:todo/sync/firebase_backend.dart';
|
||||
import 'package:todo/sync/local_backup.dart';
|
||||
import 'package:todo/sync/sync_settings.dart';
|
||||
import 'package:todo/ui/app_route.dart';
|
||||
import 'package:todo/ui/attachment_picking.dart';
|
||||
import 'package:todo/ui/capture_app_bar.dart';
|
||||
import 'package:todo/ui/capture_draft.dart';
|
||||
import 'package:todo/ui/capture_status.dart';
|
||||
import 'package:todo/ui/capture_status_footer.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_input.dart';
|
||||
import 'package:todo/ui/note_form.dart';
|
||||
@ -71,6 +72,9 @@ class _CaptureScreenState extends State<CaptureScreen>
|
||||
|
||||
final SyncStatusStore _syncStatus = SyncStatusStore();
|
||||
|
||||
/// Bumped once per committed capture; drives the confirmation checkmark.
|
||||
final ValueNotifier<int> _captures = ValueNotifier<int>(0);
|
||||
|
||||
/// Runs sync passes and records their outcome; see [CaptureSyncRunner].
|
||||
late final CaptureSyncRunner _runner = CaptureSyncRunner(
|
||||
repository: widget.repository,
|
||||
@ -130,6 +134,7 @@ class _CaptureScreenState extends State<CaptureScreen>
|
||||
_localBackup.dispose();
|
||||
_draft.dispose();
|
||||
_syncStatus.dispose();
|
||||
_captures.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@ -150,6 +155,7 @@ class _CaptureScreenState extends State<CaptureScreen>
|
||||
onOpenSettings: _openSettings,
|
||||
onAttachFiles: _attachFiles,
|
||||
attachmentPicking: widget.attachmentPicking,
|
||||
captured: _captures,
|
||||
),
|
||||
body: _captureBody(advanced: advanced),
|
||||
);
|
||||
@ -188,7 +194,14 @@ class _CaptureScreenState extends State<CaptureScreen>
|
||||
Future<void> _sync() async {
|
||||
final settings = _settings ?? await SyncSettings.load();
|
||||
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();
|
||||
return;
|
||||
}
|
||||
@ -209,12 +222,7 @@ class _CaptureScreenState extends State<CaptureScreen>
|
||||
if (outcome.ok) 'detail': outcome.detail,
|
||||
},
|
||||
);
|
||||
_showSnack(
|
||||
outcome.ok
|
||||
? 'Synced: ${outcome.detail}'
|
||||
: 'Sync failed: ${outcome.detail}',
|
||||
isError: !outcome.ok,
|
||||
);
|
||||
_showSyncCue(outcome);
|
||||
} finally {
|
||||
if (mounted) setState(() => _syncing = false);
|
||||
}
|
||||
|
||||
@ -66,8 +66,9 @@ extension _CaptureSyncBehaviour on _CaptureScreenState {
|
||||
final current = _settings ?? await SyncSettings.load();
|
||||
if (!mounted) return;
|
||||
await Navigator.of(context).push<SyncSettings>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => SettingsScreen(
|
||||
appRoute(
|
||||
context,
|
||||
(_) => SettingsScreen(
|
||||
initial: current,
|
||||
repository: widget.repository,
|
||||
appSettings: widget.appSettings,
|
||||
@ -87,8 +88,9 @@ extension _CaptureSyncBehaviour on _CaptureScreenState {
|
||||
void _openList() {
|
||||
_logEvent('screen_view', params: {'screen': 'notes_list'});
|
||||
Navigator.of(context).push<void>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => NotesListScreen(
|
||||
appRoute(
|
||||
context,
|
||||
(_) => NotesListScreen(
|
||||
repository: widget.repository,
|
||||
appSettings: widget.appSettings,
|
||||
),
|
||||
@ -102,13 +104,20 @@ extension _CaptureSyncBehaviour on _CaptureScreenState {
|
||||
/// replace-don't-queue behaviour match every other app; [isError] picks
|
||||
/// the danger hue and the longer duration, because an error the user
|
||||
/// 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 (isError) {
|
||||
showError(context, message);
|
||||
} else {
|
||||
showToast(context, message);
|
||||
}
|
||||
final spoken = outcome.ok
|
||||
? 'Synced: ${outcome.detail}'
|
||||
: 'Sync failed: ${outcome.detail}';
|
||||
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
|
||||
@ -206,10 +215,15 @@ extension _CaptureSyncBehaviour on _CaptureScreenState {
|
||||
}
|
||||
|
||||
/// Finalises the current idea and resets the field to a fresh template.
|
||||
/// The field going blank is the only feedback needed — autosave already
|
||||
/// persisted every keystroke, so there is nothing left to confirm.
|
||||
/// Autosave already persisted every keystroke, so the only thing left to
|
||||
/// give is the confirmation -- and only when a note was really captured.
|
||||
/// The haptic fires here, on the tap, before anything touches the network.
|
||||
void _saveAndReset() {
|
||||
_logEvent('new_note_reset');
|
||||
if (_draft.hasCapture) {
|
||||
unawaited(HapticFeedback.lightImpact());
|
||||
_captures.value++;
|
||||
}
|
||||
_rebuild(() {
|
||||
// Bumping the generation remounts NoteForm, which resets the chrome
|
||||
// gate to visible for free -- no separate flag to keep in step.
|
||||
|
||||
@ -98,6 +98,7 @@ class SyncStatusStore {
|
||||
/// precisely how a desktop stuck syncing over the GitHub mirror alone
|
||||
/// went unnoticed for days.
|
||||
String describeSyncResult(SyncResult result) {
|
||||
if (result.locked) return '🔒';
|
||||
final skipped = result.skippedFiles.length;
|
||||
final backend = result.firebaseConnected
|
||||
? ''
|
||||
|
||||
@ -44,16 +44,16 @@ class CaptureStatusFooter extends StatelessWidget {
|
||||
if (status == null || status.ok) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
// Glyph + time in the danger hue; the error itself is spoken.
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
child: _cueLine(
|
||||
Icons.sync_problem,
|
||||
formatSyncTime(status.time),
|
||||
'Sync failed at ${formatSyncTime(status.time)} · '
|
||||
'${status.detail}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
theme.textTheme.bodySmall,
|
||||
theme.colorScheme.error,
|
||||
),
|
||||
);
|
||||
},
|
||||
@ -63,15 +63,45 @@ class CaptureStatusFooter extends StatelessWidget {
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: ValueListenableBuilder<DateTime?>(
|
||||
valueListenable: lastSavedAt,
|
||||
builder: (context, savedAt, _) => Text(
|
||||
savedAt == null
|
||||
? 'Autosaves as you type'
|
||||
: 'Saved locally at ${formatSyncTime(savedAt)}',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
// Before the first save: the save glyph alone, muted.
|
||||
builder: (context, savedAt, _) => savedAt == null
|
||||
? _cueLine(
|
||||
Icons.save_outlined,
|
||||
'',
|
||||
'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.detail,
|
||||
this.reconciled,
|
||||
this.degraded = false,
|
||||
});
|
||||
|
||||
/// Whether the pass completed without throwing.
|
||||
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.
|
||||
final String detail;
|
||||
|
||||
@ -90,10 +95,15 @@ class CaptureSyncRunner {
|
||||
);
|
||||
final detail = describeSyncResult(result.syncResult);
|
||||
await status.record(ok: true, detail: detail, live: live());
|
||||
final synced = result.syncResult;
|
||||
return CaptureSyncOutcome(
|
||||
ok: true,
|
||||
detail: detail,
|
||||
reconciled: result.appSettings,
|
||||
degraded:
|
||||
synced.locked ||
|
||||
!synced.firebaseConnected ||
|
||||
synced.skippedFiles.isNotEmpty,
|
||||
);
|
||||
} on Exception catch (e) {
|
||||
await status.record(ok: false, detail: '$e', live: live());
|
||||
|
||||
@ -11,12 +11,15 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import 'package:todo/attachments/image_password_store.dart';
|
||||
import 'package:todo/attachments/image_store_api.dart';
|
||||
import 'package:todo/attachments/image_store_scope.dart';
|
||||
import 'package:todo/images/dufs_image_client.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.
|
||||
const kAddLoginCommand =
|
||||
@ -33,13 +36,10 @@ class CloudImagesTile extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Cloud images'),
|
||||
subtitle: const Text('Where attached images and files upload (dufs)'),
|
||||
leading: const Icon(Icons.cloud, semanticLabel: 'Cloud images'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => Navigator.of(context).push<void>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => CloudImagesScreen(httpClient: httpClient),
|
||||
),
|
||||
appRoute(context, (_) => CloudImagesScreen(httpClient: httpClient)),
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -70,7 +70,7 @@ class CloudImagesScreen extends StatefulWidget {
|
||||
class _CloudImagesScreenState extends State<CloudImagesScreen> {
|
||||
final _password = TextEditingController();
|
||||
ImageStoreStatus? _status;
|
||||
String? _checkResult;
|
||||
StatusCue? _checkResult;
|
||||
bool _checking = false;
|
||||
|
||||
@override
|
||||
@ -99,7 +99,11 @@ class _CloudImagesScreenState extends State<CloudImagesScreen> {
|
||||
final password = _password.text.trim();
|
||||
setState(() => _checking = true);
|
||||
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) {
|
||||
final client = DufsImageClient(
|
||||
DufsLogin(user: kDufsImagesUser, password: password),
|
||||
@ -107,9 +111,17 @@ class _CloudImagesScreenState extends State<CloudImagesScreen> {
|
||||
);
|
||||
final check = await client.check();
|
||||
result = switch (check) {
|
||||
DufsCheck.ok => 'Saved — dufs accepted the login',
|
||||
DufsCheck.rejected => 'Saved, but dufs rejected the password',
|
||||
DufsCheck.unreachable => 'Saved — dufs is unreachable right now',
|
||||
DufsCheck.ok => const StatusCue.ok('Saved — dufs accepted the login'),
|
||||
DufsCheck.rejected => const StatusCue(
|
||||
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
|
||||
// on the next sync. No note texts: an empty ref set only uploads, it
|
||||
@ -130,7 +142,9 @@ class _CloudImagesScreenState extends State<CloudImagesScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
final status = _status;
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Cloud images')),
|
||||
appBar: AppBar(
|
||||
title: const Icon(Icons.cloud_outlined, semanticLabel: 'Cloud images'),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
@ -139,36 +153,78 @@ class _CloudImagesScreenState extends State<CloudImagesScreen> {
|
||||
controller: _password,
|
||||
obscureText: true,
|
||||
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),
|
||||
FilledButton(
|
||||
onPressed: _checking ? null : _saveAndTest,
|
||||
child: const Text('Save & test'),
|
||||
child: const Icon(Icons.save, semanticLabel: 'Save & test'),
|
||||
),
|
||||
if (_checkResult case final result?) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(result),
|
||||
StatusCueView(result),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
if (status != null) ...[
|
||||
Text(
|
||||
status.configured
|
||||
? 'Login: configured'
|
||||
: 'Login: missing — create it on the PC with',
|
||||
// Shape + colour: a filled check, or a red error ring followed by
|
||||
// a copy button that puts the fixing command on the clipboard.
|
||||
Row(
|
||||
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),
|
||||
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),
|
||||
Text(
|
||||
'Images are shrunk to 2560px JPEGs without location data before '
|
||||
'they leave this device. Other files upload unchanged, at any '
|
||||
'size.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
// Shrunk + no location, as glyphs; the sentence is spoken only.
|
||||
const Row(
|
||||
children: [
|
||||
Icon(
|
||||
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/sync/run_sync.dart';
|
||||
import 'package:todo/sync/sync_settings.dart';
|
||||
import 'package:todo/ui/status_cue.dart';
|
||||
|
||||
part 'github_mirror_screen_widget.dart';
|
||||
|
||||
@ -27,7 +28,7 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
|
||||
);
|
||||
|
||||
bool _testing = false;
|
||||
String? _status;
|
||||
StatusCue? _status;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@ -49,8 +50,13 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
|
||||
Future<void> _connectGitHub() async {
|
||||
final clientId = _clientId.text.trim();
|
||||
if (clientId.isEmpty) {
|
||||
setState(() => _status = 'Enter the OAuth App client id first.');
|
||||
return;
|
||||
return _show(
|
||||
const StatusCue(
|
||||
Icons.key_off,
|
||||
'Enter the OAuth App client id first.',
|
||||
error: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
final auth = GitHubDeviceAuth(
|
||||
clientId: clientId,
|
||||
@ -67,20 +73,19 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
|
||||
if (token != null && token.isNotEmpty) {
|
||||
setState(() {
|
||||
_token.text = token;
|
||||
_status = 'Connected — syncing…';
|
||||
_status = const StatusCue(Icons.sync, 'Connected — syncing…');
|
||||
});
|
||||
await _current.save();
|
||||
await _syncAfterConnect();
|
||||
}
|
||||
} on Exception catch (e) {
|
||||
if (mounted) setState(() => _status = 'Could not start device flow: $e');
|
||||
_show(StatusCue.failed('Could not start device flow: $e'));
|
||||
} finally {
|
||||
auth.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs a sync right after connecting so the user's notes download
|
||||
/// immediately and they get clear confirmation it worked.
|
||||
/// Syncs right after connecting so notes download at once, with a cue.
|
||||
Future<void> _syncAfterConnect() async {
|
||||
final s = _current;
|
||||
try {
|
||||
@ -96,19 +101,29 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
|
||||
widget.appSettings.value = widget.appSettings.value.adopt(
|
||||
run.appSettings,
|
||||
);
|
||||
if (mounted) {
|
||||
setState(
|
||||
() => _status =
|
||||
'Connected and synced (merged '
|
||||
'${run.syncResult.mergedDevices} device(s)). Your notes are '
|
||||
'up to date.',
|
||||
);
|
||||
}
|
||||
_show(
|
||||
StatusCue.ok(
|
||||
'Connected and synced (merged '
|
||||
'${run.syncResult.mergedDevices} device(s)). Your notes are '
|
||||
'up to date.',
|
||||
),
|
||||
);
|
||||
} 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 {
|
||||
setState(() {
|
||||
_testing = true;
|
||||
@ -123,13 +138,17 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
|
||||
);
|
||||
try {
|
||||
final ok = await client.canAccessRepo();
|
||||
setState(
|
||||
() => _status = ok
|
||||
? 'Connected — repo is reachable.'
|
||||
: 'Could not access ${s.owner}/${s.repo}. Check token scope.',
|
||||
_show(
|
||||
ok
|
||||
? const StatusCue.ok('Connected — repo is reachable.')
|
||||
: StatusCue(
|
||||
Icons.lock,
|
||||
'Could not access ${s.owner}/${s.repo}. Check token scope.',
|
||||
error: true,
|
||||
),
|
||||
);
|
||||
} on Exception catch (e) {
|
||||
setState(() => _status = 'Error: $e');
|
||||
_show(StatusCue.failed('Error: $e'));
|
||||
} finally {
|
||||
client.close();
|
||||
if (mounted) setState(() => _testing = false);
|
||||
@ -145,29 +164,27 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Advanced sync (GitHub)')),
|
||||
appBar: AppBar(
|
||||
title: const Icon(
|
||||
Icons.cloud_sync,
|
||||
semanticLabel: 'Advanced sync (GitHub)',
|
||||
),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
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(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: FilledButton.icon(
|
||||
child: IconButton.filled(
|
||||
onPressed: _connectGitHub,
|
||||
icon: const Icon(Icons.login),
|
||||
label: const Text('Connect GitHub'),
|
||||
icon: const Icon(Icons.login, semanticLabel: 'Connect GitHub'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _owner,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'GitHub owner',
|
||||
prefixIcon: Icon(Icons.person, semanticLabel: 'GitHub owner'),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
@ -175,7 +192,7 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
|
||||
TextField(
|
||||
controller: _repo,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Repository name',
|
||||
prefixIcon: Icon(Icons.folder, semanticLabel: 'Repository name'),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
@ -183,8 +200,7 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
|
||||
TextField(
|
||||
controller: _clientId,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'OAuth App client id',
|
||||
helperText: 'Leave as the baked-in default unless self-hosting',
|
||||
prefixIcon: Icon(Icons.key, semanticLabel: 'OAuth App client id'),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
@ -193,15 +209,17 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
|
||||
controller: _token,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Access token (fallback)',
|
||||
helperText: 'Contents: read/write on the sync repo only',
|
||||
prefixIcon: Icon(
|
||||
Icons.vpn_key,
|
||||
semanticLabel: 'Access token (fallback)',
|
||||
),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
IconButton.outlined(
|
||||
onPressed: _testing ? null : _test,
|
||||
icon: _testing
|
||||
? const SizedBox(
|
||||
@ -209,20 +227,21 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.wifi_tethering),
|
||||
label: const Text('Test connection'),
|
||||
: const Icon(
|
||||
Icons.wifi_tethering,
|
||||
semanticLabel: 'Test connection',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
FilledButton.icon(
|
||||
IconButton.filled(
|
||||
onPressed: _save,
|
||||
icon: const Icon(Icons.save),
|
||||
label: const Text('Save'),
|
||||
icon: const Icon(Icons.save, semanticLabel: 'Save'),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_status != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(_status!, style: Theme.of(context).textTheme.bodyMedium),
|
||||
StatusCueView(_status!),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
||||
@ -9,12 +9,12 @@ library;
|
||||
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:todo/attachments/image_store_api.dart';
|
||||
import 'package:todo/attachments/image_store_scope.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.
|
||||
class ImageAttachController {
|
||||
@ -73,11 +73,15 @@ Future<void> attachImages(
|
||||
target.insert([for (final ref in refs) ref.imageMarkdown]);
|
||||
if (!context.mounted) return;
|
||||
if (failure != null) {
|
||||
showError(context, failure);
|
||||
showCue(context, Icons.cloud_off, failure, error: true);
|
||||
} else if (notImages > 0) {
|
||||
showError(
|
||||
// A broken-image glyph plus how many were refused.
|
||||
showCue(
|
||||
context,
|
||||
Icons.broken_image_outlined,
|
||||
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]);
|
||||
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_scope.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/image_embed.dart';
|
||||
|
||||
@ -51,7 +52,9 @@ class _FileEmbedState extends State<FileEmbed> {
|
||||
|
||||
Future<void> _open(ImageSource source) async {
|
||||
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
|
||||
@ -73,9 +76,10 @@ class _FileEmbedState extends State<FileEmbed> {
|
||||
style: widget.compact ? theme.textTheme.bodySmall : null,
|
||||
);
|
||||
final pending = store.isPending(attachment.ref)
|
||||
? const Tooltip(
|
||||
message: kImageWaitingToUpload,
|
||||
child: Icon(Icons.cloud_upload_outlined, size: 18),
|
||||
? const Icon(
|
||||
Icons.cloud_upload_outlined,
|
||||
size: 18,
|
||||
semanticLabel: kImageWaitingToUpload,
|
||||
)
|
||||
: null;
|
||||
final content = widget.compact
|
||||
@ -104,15 +108,21 @@ class _FileEmbedState extends State<FileEmbed> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
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,
|
||||
],
|
||||
);
|
||||
// Spoken only: nothing paints this string.
|
||||
final semanticLabel = 'Attached file ${attachment.name}, $detail';
|
||||
return Semantics(
|
||||
label: 'Attached file ${attachment.name}, $detail',
|
||||
label: semanticLabel,
|
||||
button: size != null,
|
||||
child: Material(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
|
||||
@ -7,6 +7,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:todo/attachments/image_store_api.dart';
|
||||
import 'package:todo/attachments/image_store_scope.dart';
|
||||
import 'package:todo/images/attachment_ref.dart';
|
||||
import 'package:todo/ui/app_route.dart';
|
||||
import 'package:todo/ui/images/image_viewer_screen.dart';
|
||||
import 'package:todo/ui/images/local_image.dart';
|
||||
|
||||
@ -32,16 +33,16 @@ class ImageEmbed extends StatelessWidget {
|
||||
final store = ImageStoreScope.of(context);
|
||||
final source = store.sourceFor(ref);
|
||||
final picture = imageFor(source, fit: fit);
|
||||
// Spoken only: nothing paints this string.
|
||||
const semanticLabel = 'Attached image';
|
||||
return Semantics(
|
||||
label: 'Attached image',
|
||||
label: semanticLabel,
|
||||
button: source is! MissingImageSource,
|
||||
child: GestureDetector(
|
||||
onTap: source is MissingImageSource
|
||||
? null
|
||||
: () => Navigator.of(context).push<void>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ImageViewerScreen(source: source),
|
||||
),
|
||||
appRoute(context, (_) => ImageViewerScreen(source: source)),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
@ -53,9 +54,10 @@ class ImageEmbed extends StatelessWidget {
|
||||
const Positioned(
|
||||
right: 4,
|
||||
top: 4,
|
||||
child: Tooltip(
|
||||
message: kImageWaitingToUpload,
|
||||
child: Icon(Icons.cloud_upload_outlined, size: 18),
|
||||
child: Icon(
|
||||
Icons.cloud_upload_outlined,
|
||||
size: 18,
|
||||
semanticLabel: kImageWaitingToUpload,
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -86,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 {
|
||||
/// Creates the placeholder.
|
||||
const ImagePlaceholder({super.key});
|
||||
@ -99,10 +101,10 @@ class ImagePlaceholder extends StatelessWidget {
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Text(
|
||||
kImageNotDownloaded,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodySmall,
|
||||
child: Icon(
|
||||
Icons.cloud_download_outlined,
|
||||
color: theme.colorScheme.outline,
|
||||
semanticLabel: kImageNotDownloaded,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
10
lib/ui/motion.dart
Normal file
10
lib/ui/motion.dart
Normal file
@ -0,0 +1,10 @@
|
||||
/// Reduced-motion handling shared by every animated surface.
|
||||
library;
|
||||
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// [token] unless the OS "remove animations" setting is on, in which case
|
||||
/// [AppDuration.instant]. Haptics are not motion and never go through this.
|
||||
Duration motionDuration(BuildContext context, Duration token) =>
|
||||
MediaQuery.disableAnimationsOf(context) ? AppDuration.instant : token;
|
||||
@ -6,10 +6,12 @@ library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:todo/data/note.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_task_details.dart';
|
||||
|
||||
/// The per-note bottom sheet: status/priority chips, and delete.
|
||||
@ -60,52 +62,54 @@ class _NoteActionsSheetState extends State<NoteActionsSheet> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
firstLine.isEmpty ? '(empty)' : firstLine,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
// User content; untitled shows nothing, not a placeholder.
|
||||
if (firstLine.isNotEmpty)
|
||||
Text(
|
||||
firstLine,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
NoteSheetDetails(note: widget.note),
|
||||
const SizedBox(height: 16),
|
||||
_EnumChips<Status>(
|
||||
label: 'Status',
|
||||
GlyphChips<Status>(
|
||||
heading: kStatusHeadingIcon,
|
||||
semanticLabel: 'Status',
|
||||
values: Status.values,
|
||||
selected: {_status},
|
||||
labelOf: (s) => s.label,
|
||||
onSelected: (s) {
|
||||
glyphOf: statusGlyph,
|
||||
onTap: (s) {
|
||||
setState(() => _status = s);
|
||||
unawaited(_persist());
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_EnumChips<Priority>(
|
||||
label: 'Priority',
|
||||
GlyphChips<Priority>(
|
||||
heading: kPriorityHeadingIcon,
|
||||
semanticLabel: 'Priority',
|
||||
values: Priority.values,
|
||||
selected: {_priority},
|
||||
labelOf: (p) => p.label,
|
||||
onSelected: (p) {
|
||||
glyphOf: (p) => priorityGlyph(context, p),
|
||||
onTap: (p) {
|
||||
setState(() => _priority = p);
|
||||
unawaited(_persist());
|
||||
},
|
||||
),
|
||||
NoteTaskDetails(note: widget.note),
|
||||
const SizedBox(height: 16),
|
||||
OutlinedButton.icon(
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
label: const Text('Delete note'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Theme.of(context).colorScheme.error,
|
||||
Align(
|
||||
child: IconButton.outlined(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
icon: const Icon(
|
||||
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();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -114,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),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,21 +5,33 @@
|
||||
/// different file.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A labelled date range shown as presets plus a custom-range picker.
|
||||
/// 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.
|
||||
class DateRangeField extends StatelessWidget {
|
||||
/// Creates a labelled date-range control.
|
||||
const DateRangeField({
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.semanticLabel,
|
||||
required this.from,
|
||||
required this.to,
|
||||
required this.onChanged,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// Heading shown above the control (e.g. 'Created').
|
||||
final String label;
|
||||
/// Glyph shown in the control's border in place of a written heading.
|
||||
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.
|
||||
final DateTime? from;
|
||||
@ -52,49 +64,60 @@ class DateRangeField extends StatelessWidget {
|
||||
if (picked != null) onChanged(picked.start, picked.end);
|
||||
}
|
||||
|
||||
/// Which menu entry describes the current range.
|
||||
_Preset get _current {
|
||||
if (!_hasRange) return _Preset.any;
|
||||
final now = DateTime.now();
|
||||
final today = DateTime(now.year, now.month, now.day);
|
||||
for (final p in _Preset.dayPresets) {
|
||||
final days = p.days!;
|
||||
if (to == today && from == today.subtract(Duration(days: days - 1))) {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
return _Preset.custom;
|
||||
}
|
||||
|
||||
void _select(BuildContext context, _Preset preset) {
|
||||
switch (preset) {
|
||||
case _Preset.any:
|
||||
onChanged(null, null);
|
||||
case _Preset.custom:
|
||||
unawaited(_pickCustom(context));
|
||||
case _Preset.today || _Preset.week || _Preset.month:
|
||||
_applyDays(preset.days!);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(label, style: theme.textTheme.labelLarge),
|
||||
const Spacer(),
|
||||
if (_hasRange)
|
||||
Text(_rangeLabel(), style: theme.textTheme.bodySmall),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
ActionChip(
|
||||
label: const Text('Today'),
|
||||
onPressed: () => _applyDays(1),
|
||||
),
|
||||
ActionChip(
|
||||
label: const Text('7 days'),
|
||||
onPressed: () => _applyDays(7),
|
||||
),
|
||||
ActionChip(
|
||||
label: const Text('30 days'),
|
||||
onPressed: () => _applyDays(30),
|
||||
),
|
||||
ActionChip(
|
||||
label: const Text('Custom…'),
|
||||
onPressed: () => _pickCustom(context),
|
||||
),
|
||||
if (_hasRange)
|
||||
ActionChip(
|
||||
avatar: const Icon(Icons.clear, size: 16),
|
||||
label: const Text('Any'),
|
||||
onPressed: () => onChanged(null, null),
|
||||
),
|
||||
],
|
||||
),
|
||||
final current = _current;
|
||||
// A popup menu rather than DropdownButton: re-picking "Custom…" while a
|
||||
// custom range is shown must still reopen the picker.
|
||||
return PopupMenuButton<_Preset>(
|
||||
// Empty, not the label: a tooltip is painted on long-press.
|
||||
tooltip: '',
|
||||
onSelected: (p) => _select(context, p),
|
||||
itemBuilder: (_) => [
|
||||
for (final p in _Preset.values)
|
||||
PopupMenuItem(value: p, child: p.glyph()),
|
||||
],
|
||||
child: InputDecorator(
|
||||
decoration: InputDecoration(
|
||||
prefixIcon: Icon(icon, semanticLabel: semanticLabel),
|
||||
isDense: true,
|
||||
border: const OutlineInputBorder(),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
suffixIcon: const Icon(Icons.arrow_drop_down),
|
||||
),
|
||||
child: current == _Preset.custom
|
||||
? Text(_rangeLabel(), style: theme.textTheme.bodyMedium)
|
||||
: current.glyph(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -107,3 +130,37 @@ class DateRangeField extends StatelessWidget {
|
||||
|
||||
String _two(int n) => n.toString().padLeft(2, '0');
|
||||
}
|
||||
|
||||
/// Entries of the range menu, in display order.
|
||||
enum _Preset {
|
||||
any('Any', null),
|
||||
today('Today', 1),
|
||||
week('7 days', 7),
|
||||
month('30 days', 30),
|
||||
custom('Custom…', null);
|
||||
|
||||
const _Preset(this.label, this.days);
|
||||
|
||||
/// Spoken name only; what is painted is [glyph].
|
||||
final String label;
|
||||
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];
|
||||
}
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:todo/data/app_settings.dart';
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:todo/data/note_repository.dart';
|
||||
import 'package:todo/data/note_template.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_input.dart';
|
||||
import 'package:todo/ui/note_form.dart';
|
||||
@ -52,6 +52,14 @@ class _NoteDetailScreenState extends State<NoteDetailScreen> {
|
||||
/// Inserts attached-image links into the editor.
|
||||
final ImageAttachController _attach = ImageAttachController();
|
||||
|
||||
bool _copied = false;
|
||||
|
||||
Future<void> _copy() async {
|
||||
await Clipboard.setData(ClipboardData(text: _note.text));
|
||||
await HapticFeedback.lightImpact();
|
||||
if (mounted) setState(() => _copied = true);
|
||||
}
|
||||
|
||||
Future<void> _attachImages(List<Uint8List> images, {int rejected = 0}) =>
|
||||
attachImages(
|
||||
context,
|
||||
@ -77,12 +85,7 @@ class _NoteDetailScreenState extends State<NoteDetailScreen> {
|
||||
_persist(_note.copyWith(text: text, updatedAt: DateTime.now()));
|
||||
|
||||
Future<void> _delete() async {
|
||||
final confirmed = await confirmDestructive(
|
||||
context,
|
||||
title: 'Delete note?',
|
||||
message: 'This cannot be undone.',
|
||||
);
|
||||
if (!confirmed) return;
|
||||
if (!await confirmDeleteNote(context)) return;
|
||||
await widget.repository.delete(_note.id);
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
}
|
||||
@ -92,15 +95,25 @@ class _NoteDetailScreenState extends State<NoteDetailScreen> {
|
||||
final title = noteTitle(_note.text);
|
||||
return Scaffold(
|
||||
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: [
|
||||
AttachButton(
|
||||
onFiles: _attachFiles,
|
||||
picking: widget.attachmentPicking,
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Delete note',
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
icon: Icon(
|
||||
_copied ? Icons.check : Icons.copy_all,
|
||||
semanticLabel: 'Copy',
|
||||
),
|
||||
onPressed: _copy,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
Icons.delete_outline,
|
||||
semanticLabel: 'Delete note',
|
||||
),
|
||||
onPressed: _delete,
|
||||
),
|
||||
],
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:todo/data/note.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/markdown_view.dart';
|
||||
import 'package:todo/ui/note_editor_chrome.dart';
|
||||
@ -127,9 +127,12 @@ class _NoteEditorState extends State<NoteEditor> {
|
||||
case NoteEditorMode.guided:
|
||||
// raw -> guided: only if the edited text still fits the template.
|
||||
if (!_doc.tryAdoptGuided()) {
|
||||
showError(
|
||||
// The mode toggle staying on raw is the second channel.
|
||||
showCue(
|
||||
context,
|
||||
Icons.rule,
|
||||
"Text doesn't match the template — staying in raw",
|
||||
error: true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:todo/data/note_template.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.
|
||||
class NoteEditorChrome extends StatelessWidget {
|
||||
@ -43,20 +44,20 @@ class NoteEditorChrome extends StatelessWidget {
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: template.id,
|
||||
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,
|
||||
border: OutlineInputBorder(),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
),
|
||||
items: [
|
||||
for (final t in NoteTemplate.all)
|
||||
DropdownMenuItem(value: t.id, child: Text(t.label)),
|
||||
DropdownMenuItem(value: t.id, child: templateGlyph(t)),
|
||||
],
|
||||
onChanged: (id) {
|
||||
if (id == null) return;
|
||||
onTemplateSelected(
|
||||
NoteTemplate.all.firstWhere((t) => t.id == id),
|
||||
);
|
||||
onTemplateSelected(NoteTemplate.all.firstWhere((t) => t.id == id));
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
@ -67,8 +68,7 @@ class NoteEditorChrome extends StatelessWidget {
|
||||
segments: [
|
||||
const ButtonSegment(
|
||||
value: NoteEditorMode.preview,
|
||||
icon: Icon(Icons.visibility_outlined),
|
||||
label: Text('View'),
|
||||
icon: Icon(Icons.visibility_outlined, semanticLabel: 'View'),
|
||||
),
|
||||
// Guided is offered for any structured template; tapping it
|
||||
// on an empty draft opens the wizard, and is blocked at
|
||||
@ -76,13 +76,11 @@ class NoteEditorChrome extends StatelessWidget {
|
||||
if (!template.isFreeform)
|
||||
const ButtonSegment(
|
||||
value: NoteEditorMode.guided,
|
||||
icon: Icon(Icons.checklist),
|
||||
label: Text('Guided'),
|
||||
icon: Icon(Icons.checklist, semanticLabel: 'Guided'),
|
||||
),
|
||||
const ButtonSegment(
|
||||
value: NoteEditorMode.raw,
|
||||
icon: Icon(Icons.notes),
|
||||
label: Text('Raw'),
|
||||
icon: Icon(Icons.notes, semanticLabel: 'Raw'),
|
||||
),
|
||||
],
|
||||
selected: {mode},
|
||||
|
||||
@ -44,10 +44,8 @@ class NoteEditorRawField extends StatelessWidget {
|
||||
textAlignVertical: TextAlignVertical.top,
|
||||
keyboardType: TextInputType.multiline,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
decoration: const InputDecoration(
|
||||
border: InputBorder.none,
|
||||
hintText: 'Write your idea…',
|
||||
),
|
||||
// No hint: the cursor in an empty full-height box says "type".
|
||||
decoration: const InputDecoration(border: InputBorder.none),
|
||||
onChanged: (_) => onChanged(),
|
||||
),
|
||||
),
|
||||
|
||||
@ -11,9 +11,12 @@
|
||||
/// structure here is load-bearing rather than incidental.
|
||||
library;
|
||||
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:todo/data/note_template.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.
|
||||
class NoteEditorStepPage extends StatelessWidget {
|
||||
@ -64,8 +67,7 @@ class NoteEditorStepPage extends StatelessWidget {
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
tooltip: 'Exit guided',
|
||||
icon: const Icon(Icons.arrow_back, semanticLabel: 'Exit guided'),
|
||||
onPressed: onExit,
|
||||
),
|
||||
),
|
||||
@ -77,7 +79,13 @@ class NoteEditorStepPage extends StatelessWidget {
|
||||
Text('${index + 1} / $total', style: theme.textTheme.labelMedium),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: LinearProgressIndicator(value: (index + 1) / total),
|
||||
child: TweenAnimationBuilder<double>(
|
||||
tween: Tween<double>(end: (index + 1) / total),
|
||||
duration: motionDuration(context, AppDuration.base),
|
||||
curve: AppCurve.standard,
|
||||
builder: (_, value, _) =>
|
||||
LinearProgressIndicator(value: value),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -88,16 +96,14 @@ class NoteEditorStepPage extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
section.isTitle ? 'title' : section.label,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
section.helper,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
// A glyph per section; its label (the note's `## heading`) and
|
||||
// the helper sentence are spoken only.
|
||||
Icon(
|
||||
sectionIcon(section),
|
||||
color: theme.colorScheme.primary,
|
||||
semanticLabel: section.isTitle
|
||||
? section.helper
|
||||
: '${section.label}: ${section.helper}',
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
@ -115,9 +121,10 @@ class NoteEditorStepPage extends StatelessWidget {
|
||||
maxLines: null,
|
||||
keyboardType: TextInputType.multiline,
|
||||
textAlignVertical: TextAlignVertical.top,
|
||||
decoration: InputDecoration(
|
||||
hintText: section.hint,
|
||||
border: const OutlineInputBorder(),
|
||||
// No hintText: a hint is painted text. The section glyph
|
||||
// above already names the field.
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
onChanged: (_) => onChanged(),
|
||||
@ -132,16 +139,22 @@ class NoteEditorStepPage extends StatelessWidget {
|
||||
if (index > 0)
|
||||
TextButton(
|
||||
onPressed: () => onGoToStep(index - 1),
|
||||
child: const Text('Back'),
|
||||
child: const Icon(
|
||||
Icons.navigate_before,
|
||||
semanticLabel: 'Back',
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (index < total - 1)
|
||||
FilledButton(
|
||||
onPressed: () => onGoToStep(index + 1),
|
||||
child: const Text('Next'),
|
||||
child: const Icon(Icons.navigate_next, semanticLabel: 'Next'),
|
||||
)
|
||||
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_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].
|
||||
class NoteEditorWizard extends StatefulWidget {
|
||||
@ -48,7 +50,7 @@ class _NoteEditorWizardState extends State<NoteEditorWizard> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final semanticLabel = 'Step ${_step + 1} of 2';
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@ -56,11 +58,24 @@ class _NoteEditorWizardState extends State<NoteEditorWizard> {
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
tooltip: 'Cancel',
|
||||
icon: const Icon(Icons.close, semanticLabel: 'Cancel'),
|
||||
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),
|
||||
@ -74,14 +89,14 @@ class _NoteEditorWizardState extends State<NoteEditorWizard> {
|
||||
DropdownButtonFormField<Priority>(
|
||||
initialValue: _priority,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Priority',
|
||||
prefixIcon: Icon(kPriorityHeadingIcon, semanticLabel: 'Priority'),
|
||||
isDense: true,
|
||||
border: OutlineInputBorder(),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
),
|
||||
items: [
|
||||
for (final p in Priority.values)
|
||||
DropdownMenuItem(value: p, child: Text(p.label)),
|
||||
DropdownMenuItem(value: p, child: priorityGlyph(context, p)),
|
||||
],
|
||||
onChanged: (p) {
|
||||
if (p != null) setState(() => _priority = p);
|
||||
@ -92,7 +107,7 @@ class _NoteEditorWizardState extends State<NoteEditorWizard> {
|
||||
alignment: Alignment.centerRight,
|
||||
child: FilledButton(
|
||||
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>(
|
||||
initialValue: _template.id,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Template',
|
||||
prefixIcon: Icon(Icons.description, semanticLabel: 'Template'),
|
||||
isDense: true,
|
||||
border: OutlineInputBorder(),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
),
|
||||
items: [
|
||||
for (final t in templates)
|
||||
DropdownMenuItem(value: t.id, child: Text(t.label)),
|
||||
DropdownMenuItem(value: t.id, child: templateGlyph(t)),
|
||||
],
|
||||
onChanged: (id) {
|
||||
if (id == null) return;
|
||||
@ -124,12 +139,12 @@ class _NoteEditorWizardState extends State<NoteEditorWizard> {
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => setState(() => _step = 0),
|
||||
child: const Text('Back'),
|
||||
child: const Icon(Icons.arrow_back, semanticLabel: 'Back'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton(
|
||||
onPressed: () => widget.onStart(_priority, _template),
|
||||
child: const Text('Start'),
|
||||
child: const Icon(Icons.play_arrow, semanticLabel: 'Start'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
145
lib/ui/note_grid_tile.dart
Normal file
145
lib/ui/note_grid_tile.dart
Normal file
@ -0,0 +1,145 @@
|
||||
/// One compact tile of the notes grid: title plus priority and status cues.
|
||||
library;
|
||||
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:todo/data/note.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.
|
||||
class NoteGridTile extends StatelessWidget {
|
||||
/// Creates a tile for [note].
|
||||
const NoteGridTile({
|
||||
required this.note,
|
||||
required this.onTap,
|
||||
required this.onActions,
|
||||
this.selected = false,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// Whether the note is part of the multi-selection: a tint and a check.
|
||||
final bool selected;
|
||||
|
||||
/// The note this tile displays.
|
||||
final Note note;
|
||||
|
||||
/// Open the full note.
|
||||
final VoidCallback onTap;
|
||||
|
||||
/// Open the per-note sheet (body, dates, priority/status, delete).
|
||||
final VoidCallback onActions;
|
||||
|
||||
/// Danger for high, the accent for medium, muted for low.
|
||||
Color accentFor(ColorScheme scheme) => priorityColor(note.priority, scheme);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final title = noteTitle(note.text);
|
||||
final cue = theme.textTheme.labelSmall;
|
||||
final closed =
|
||||
note.status == Status.done || note.status == Status.abandoned;
|
||||
return Material(
|
||||
color: selected ? scheme.secondaryContainer : scheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
onLongPress: onActions,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
left: BorderSide(color: accentFor(scheme), width: 4),
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.sm,
|
||||
AppSpacing.sm,
|
||||
AppSpacing.sm,
|
||||
AppSpacing.xs,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Expanded: a big text scale shortens the title, never overflows.
|
||||
Expanded(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title.isEmpty ? kEmptyTitle : title,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: closed ? scheme.onSurfaceVariant : null,
|
||||
decoration: closed
|
||||
? TextDecoration.lineThrough
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (selected)
|
||||
Icon(
|
||||
Icons.check_circle,
|
||||
size: 20,
|
||||
color: scheme.primary,
|
||||
semanticLabel: 'Selected',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (note.repoName case final repo?)
|
||||
Text(
|
||||
repo,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: cue?.copyWith(color: scheme.onSurfaceVariant),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
// Shape + the tile's accent border carry status/priority;
|
||||
// the enum words survive only as spoken labels.
|
||||
Icon(
|
||||
statusIcon(note.status),
|
||||
size: 14,
|
||||
color: scheme.onSurfaceVariant,
|
||||
semanticLabel: note.status.label,
|
||||
),
|
||||
const Spacer(),
|
||||
Icon(
|
||||
priorityIcon(note.priority),
|
||||
size: 14,
|
||||
color: accentFor(scheme),
|
||||
semanticLabel: note.priority.label,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -8,24 +8,30 @@ library;
|
||||
import 'package:flutter/material.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
|
||||
/// without duplication; [labelOf] maps a value to its display string.
|
||||
/// without duplication; [glyphOf] maps a value to its glyph.
|
||||
class MetaDropdown<T> extends StatelessWidget {
|
||||
/// Creates a dropdown showing [value] out of [values].
|
||||
const MetaDropdown({
|
||||
required this.label,
|
||||
required this.heading,
|
||||
required this.semanticLabel,
|
||||
required this.value,
|
||||
required this.values,
|
||||
required this.labelOf,
|
||||
required this.glyphOf,
|
||||
required this.onChanged,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// Label shown above the control.
|
||||
final String label;
|
||||
/// Glyph in the control's border in place of a written heading.
|
||||
final IconData heading;
|
||||
|
||||
/// Spoken name of the control; never painted.
|
||||
final String semanticLabel;
|
||||
|
||||
/// Currently selected value.
|
||||
final T value;
|
||||
@ -33,8 +39,8 @@ class MetaDropdown<T> extends StatelessWidget {
|
||||
/// Every selectable value.
|
||||
final List<T> values;
|
||||
|
||||
/// Maps a value to its display string.
|
||||
final String Function(T) labelOf;
|
||||
/// Maps a value to its glyph.
|
||||
final Widget Function(T) glyphOf;
|
||||
|
||||
/// Called with the newly picked value.
|
||||
final ValueChanged<T> onChanged;
|
||||
@ -43,7 +49,7 @@ class MetaDropdown<T> extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return InputDecorator(
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
prefixIcon: Icon(heading, semanticLabel: semanticLabel),
|
||||
isDense: true,
|
||||
border: const OutlineInputBorder(),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
@ -55,7 +61,7 @@ class MetaDropdown<T> extends StatelessWidget {
|
||||
isExpanded: true,
|
||||
items: [
|
||||
for (final v in values)
|
||||
DropdownMenuItem<T>(value: v, child: Text(labelOf(v))),
|
||||
DropdownMenuItem<T>(value: v, child: glyphOf(v)),
|
||||
],
|
||||
onChanged: (v) {
|
||||
if (v != null) onChanged(v);
|
||||
@ -98,20 +104,22 @@ class NoteMetaRow extends StatelessWidget {
|
||||
children: [
|
||||
Expanded(
|
||||
child: MetaDropdown<Priority>(
|
||||
label: 'Priority',
|
||||
heading: kPriorityHeadingIcon,
|
||||
semanticLabel: 'Priority',
|
||||
value: priority,
|
||||
values: Priority.values,
|
||||
labelOf: (p) => p.label,
|
||||
glyphOf: (p) => priorityGlyph(context, p),
|
||||
onChanged: onPriorityChanged,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: MetaDropdown<Status>(
|
||||
label: 'Status',
|
||||
heading: kStatusHeadingIcon,
|
||||
semanticLabel: 'Status',
|
||||
value: status,
|
||||
values: Status.values,
|
||||
labelOf: (s) => s.label,
|
||||
glyphOf: statusGlyph,
|
||||
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;
|
||||
}
|
||||
}
|
||||
63
lib/ui/note_sheet_details.dart
Normal file
63
lib/ui/note_sheet_details.dart
Normal file
@ -0,0 +1,63 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:todo/images/attachment_links.dart';
|
||||
|
||||
/// [text] without the line that serves as the title.
|
||||
String noteBodyOf(String text) {
|
||||
final lines = text.split('\n');
|
||||
final title = lines.indexWhere(
|
||||
(l) => l.trim().isNotEmpty && attachmentOfLine(l.trim()) == null,
|
||||
);
|
||||
if (title < 0) return text.trim();
|
||||
return [...lines.take(title), ...lines.skip(title + 1)].join('\n').trim();
|
||||
}
|
||||
|
||||
String _stamp(DateTime t) {
|
||||
String two(int n) => n.toString().padLeft(2, '0');
|
||||
final date = '${t.year}-${two(t.month)}-${two(t.day)}';
|
||||
return '$date ${two(t.hour)}:${two(t.minute)}';
|
||||
}
|
||||
|
||||
/// What a grid tile leaves out: the note body and its two dates.
|
||||
class NoteSheetDetails extends StatelessWidget {
|
||||
/// Shows the body and the dates of [note].
|
||||
const NoteSheetDetails({required this.note, super.key});
|
||||
|
||||
/// The note being shown.
|
||||
final Note note;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context).textTheme;
|
||||
final body = noteBodyOf(note.text);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (body.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(body, style: theme.bodyMedium),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
children: [
|
||||
_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
|
||||
Widget build(BuildContext context) {
|
||||
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 = [
|
||||
('Repo', note.repo),
|
||||
('Done check', note.doneCheck),
|
||||
('By', note.by),
|
||||
('Evidence', note.evidence),
|
||||
].where((row) => row.$2 != null);
|
||||
(Icons.folder_outlined, 'Repo', note.repo),
|
||||
(Icons.fact_check_outlined, 'Done check', note.doneCheck),
|
||||
(Icons.person_outline, 'By', note.by),
|
||||
(Icons.verified_outlined, 'Evidence', note.evidence),
|
||||
].where((row) => row.$3 != null);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final (label, value) in rows) ...[
|
||||
for (final (icon, label, value) in rows) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(label, style: theme.labelLarge),
|
||||
const SizedBox(height: 4),
|
||||
Text(value!, style: theme.bodyMedium),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
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_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.
|
||||
class NoteTile extends StatelessWidget {
|
||||
@ -16,9 +18,13 @@ class NoteTile extends StatelessWidget {
|
||||
required this.note,
|
||||
required this.onTap,
|
||||
required this.onActions,
|
||||
this.selected = false,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// Whether the row is part of the multi-selection: highlighted + a check.
|
||||
final bool selected;
|
||||
|
||||
/// The note this row displays.
|
||||
final Note note;
|
||||
|
||||
@ -33,22 +39,19 @@ class NoteTile extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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)}',
|
||||
].join(' · ');
|
||||
return ListTile(
|
||||
selected: selected,
|
||||
leading: selected
|
||||
? const Icon(Icons.check_circle, semanticLabel: 'Selected')
|
||||
: null,
|
||||
title: Text(
|
||||
firstLine.isEmpty ? '(empty)' : firstLine,
|
||||
firstLine.isEmpty ? kEmptyTitle : firstLine,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(meta),
|
||||
subtitle: _meta(context),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.more_vert),
|
||||
tooltip: 'Quick actions',
|
||||
icon: const Icon(Icons.more_vert, semanticLabel: 'Quick actions'),
|
||||
onPressed: onActions,
|
||||
),
|
||||
onTap: onTap,
|
||||
@ -56,14 +59,28 @@ class NoteTile extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// Compact relative time like "2m ago" for the list subtitle.
|
||||
String _relative(DateTime t) {
|
||||
final d = DateTime.now().difference(t);
|
||||
if (d.inMinutes < 1) return 'just now';
|
||||
if (d.inHours < 1) return '${d.inMinutes}m ago';
|
||||
if (d.inDays < 1) return '${d.inHours}h ago';
|
||||
return '${d.inDays}d ago';
|
||||
}
|
||||
/// Status and priority glyphs, the edit age, and the repo (content).
|
||||
/// Every note has a status and a priority, so both are always shown.
|
||||
Widget _meta(BuildContext context) => Row(
|
||||
children: [
|
||||
statusGlyph(note.status, size: 16),
|
||||
const SizedBox(width: 6),
|
||||
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,35 +10,27 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:todo/data/note.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/notes_list_defaults.dart';
|
||||
|
||||
/// The filter editing sheet: priority + status multi-select and Created /
|
||||
/// Last-updated date ranges (presets + a custom range picker). Edits a
|
||||
/// Last-updated date ranges (one compact preset menu + custom picker). Edits a
|
||||
/// working copy and returns it via [Navigator.pop] on "Apply".
|
||||
class FilterSheet extends StatefulWidget {
|
||||
/// Creates the sheet seeded with the currently applied [initial] filter.
|
||||
const FilterSheet({required this.initial, this.repos = const [], super.key});
|
||||
const FilterSheet({required this.initial, super.key});
|
||||
|
||||
/// The filter the sheet opens with; edits are made to a working copy.
|
||||
final NoteFilter initial;
|
||||
|
||||
/// Distinct repos of the stored repo tasks, one chip each.
|
||||
final List<String> repos;
|
||||
|
||||
@override
|
||||
State<FilterSheet> createState() => _FilterSheetState();
|
||||
}
|
||||
|
||||
const Map<String, String> _sourceLabels = {
|
||||
kIdeasSource: 'Ideas',
|
||||
kRepoTasksSource: 'Repo tasks',
|
||||
};
|
||||
|
||||
class _FilterSheetState extends State<FilterSheet> {
|
||||
late Set<Priority> _priorities = {...widget.initial.priorities};
|
||||
late Set<Status> _statuses = {...widget.initial.statuses};
|
||||
late Set<String> _sources = {...widget.initial.sources};
|
||||
late DateTime? _createdFrom = widget.initial.createdFrom;
|
||||
late DateTime? _createdTo = widget.initial.createdTo;
|
||||
late DateTime? _updatedFrom = widget.initial.updatedFrom;
|
||||
@ -54,7 +46,6 @@ class _FilterSheetState extends State<FilterSheet> {
|
||||
// Reset to the default view (hide Done/Abandoned), not an empty set,
|
||||
// so "Clear all" matches the app's unfiltered baseline.
|
||||
_statuses = {...kDefaultStatuses};
|
||||
_sources = {...kDefaultSources};
|
||||
_createdFrom = null;
|
||||
_createdTo = null;
|
||||
_updatedFrom = null;
|
||||
@ -67,7 +58,6 @@ class _FilterSheetState extends State<FilterSheet> {
|
||||
return NoteFilter(
|
||||
priorities: _priorities,
|
||||
statuses: _statuses,
|
||||
sources: _sources,
|
||||
createdFrom: _createdFrom,
|
||||
createdTo: _createdTo,
|
||||
updatedFrom: _updatedFrom,
|
||||
@ -77,7 +67,6 @@ class _FilterSheetState extends State<FilterSheet> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
@ -93,43 +82,38 @@ class _FilterSheetState extends State<FilterSheet> {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text('Filters', style: theme.textTheme.titleLarge),
|
||||
const Icon(Icons.filter_list, semanticLabel: 'Filters'),
|
||||
const Spacer(),
|
||||
TextButton(
|
||||
onPressed: _clearAll,
|
||||
child: const Text('Clear all'),
|
||||
child: const Icon(Icons.clear, semanticLabel: 'Clear all'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (widget.repos.isNotEmpty) ...[
|
||||
_MultiChips<String>(
|
||||
label: 'Show',
|
||||
values: [kIdeasSource, kRepoTasksSource, ...widget.repos],
|
||||
selected: _sources,
|
||||
labelOf: (s) => _sourceLabels[s] ?? s,
|
||||
onToggle: (s) => _toggle(_sources, s),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
_MultiChips<Status>(
|
||||
label: 'Status',
|
||||
GlyphChips<Status>(
|
||||
multi: true,
|
||||
heading: kStatusHeadingIcon,
|
||||
semanticLabel: 'Status',
|
||||
values: Status.values,
|
||||
selected: _statuses,
|
||||
labelOf: (s) => s.label,
|
||||
onToggle: (s) => _toggle(_statuses, s),
|
||||
glyphOf: statusGlyph,
|
||||
onTap: (s) => _toggle(_statuses, s),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_MultiChips<Priority>(
|
||||
label: 'Priority',
|
||||
GlyphChips<Priority>(
|
||||
multi: true,
|
||||
heading: kPriorityHeadingIcon,
|
||||
semanticLabel: 'Priority',
|
||||
values: Priority.values,
|
||||
selected: _priorities,
|
||||
labelOf: (p) => p.label,
|
||||
onToggle: (p) => _toggle(_priorities, p),
|
||||
glyphOf: (p) => priorityGlyph(context, p),
|
||||
onTap: (p) => _toggle(_priorities, p),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
DateRangeField(
|
||||
label: 'Created',
|
||||
icon: kCreatedIcon,
|
||||
semanticLabel: 'Created',
|
||||
from: _createdFrom,
|
||||
to: _createdTo,
|
||||
onChanged: (from, to) => setState(() {
|
||||
@ -139,7 +123,8 @@ class _FilterSheetState extends State<FilterSheet> {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
DateRangeField(
|
||||
label: 'Last updated',
|
||||
icon: kUpdatedIcon,
|
||||
semanticLabel: 'Last updated',
|
||||
from: _updatedFrom,
|
||||
to: _updatedTo,
|
||||
onChanged: (from, to) => setState(() {
|
||||
@ -150,7 +135,7 @@ class _FilterSheetState extends State<FilterSheet> {
|
||||
const SizedBox(height: 20),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(_build()),
|
||||
child: const Text('Apply'),
|
||||
child: const Icon(Icons.check, semanticLabel: 'Apply'),
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -160,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.
|
||||
///
|
||||
/// Reports the chosen [from]/[to] (day granularity, both inclusive) back to
|
||||
|
||||
81
lib/ui/notes_grid.dart
Normal file
81
lib/ui/notes_grid.dart
Normal file
@ -0,0 +1,81 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:todo/ui/note_grid_tile.dart';
|
||||
|
||||
/// Below this width the notes list is a grid; at or above it, the list.
|
||||
const double kNotesGridMaxWidth = 600;
|
||||
|
||||
const _columns = 2;
|
||||
const double _gap = AppSpacing.sm;
|
||||
const double _sidePadding = AppSpacing.md - AppSpacing.xs;
|
||||
const _minTileHeight = 96.0;
|
||||
|
||||
double _tileHeightFor(double height) {
|
||||
final rows = math.max(1, ((height + _gap) / (_minTileHeight + _gap)).floor());
|
||||
return (height + _gap) / rows - _gap;
|
||||
}
|
||||
|
||||
/// Phone notes list: two columns, scrolling only past one screenful.
|
||||
class NotesGrid extends StatelessWidget {
|
||||
/// Creates the grid; [onOpen] and [onActions] get the tapped note.
|
||||
const NotesGrid({
|
||||
required this.notes,
|
||||
required this.onOpen,
|
||||
required this.onActions,
|
||||
required this.isSelected,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// Whether the note with this id is in the multi-selection.
|
||||
final bool Function(String id) isSelected;
|
||||
|
||||
/// Notes in display order.
|
||||
final List<Note> notes;
|
||||
|
||||
/// Tap on a tile.
|
||||
final ValueChanged<Note> onOpen;
|
||||
|
||||
/// Long-press on a tile.
|
||||
final ValueChanged<Note> onActions;
|
||||
|
||||
Widget _tile(Note note) {
|
||||
return NoteGridTile(
|
||||
key: ValueKey(note.id),
|
||||
note: note,
|
||||
selected: isSelected(note.id),
|
||||
onTap: () => onOpen(note),
|
||||
onActions: () => onActions(note),
|
||||
);
|
||||
}
|
||||
|
||||
SliverGridDelegate _delegate(double viewportHeight) {
|
||||
return SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: _columns,
|
||||
mainAxisSpacing: _gap,
|
||||
crossAxisSpacing: _gap,
|
||||
mainAxisExtent: _tileHeightFor(viewportHeight),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: _gap),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, box) => GridView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: _sidePadding),
|
||||
gridDelegate: _delegate(box.maxHeight),
|
||||
itemCount: notes.length,
|
||||
itemBuilder: (context, i) => _tile(notes[i]),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
58
lib/ui/notes_list_body.dart
Normal file
58
lib/ui/notes_list_body.dart
Normal file
@ -0,0 +1,58 @@
|
||||
/// The notes list's body: phone grid or desktop list of notes.
|
||||
///
|
||||
/// Split out of `notes_list_screen.dart` for file size.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:todo/ui/note_tile.dart';
|
||||
import 'package:todo/ui/notes_grid.dart';
|
||||
|
||||
/// Lays [notes] out as a grid below [kNotesGridMaxWidth], else a list.
|
||||
class NotesListBody extends StatelessWidget {
|
||||
/// Creates the body; callbacks get the tapped note.
|
||||
const NotesListBody({
|
||||
required this.notes,
|
||||
required this.onOpen,
|
||||
required this.onActions,
|
||||
required this.isSelected,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// Whether the note with this id is in the multi-selection.
|
||||
final bool Function(String id) isSelected;
|
||||
|
||||
/// Notes in display order.
|
||||
final List<Note> notes;
|
||||
|
||||
/// Tap on a note.
|
||||
final ValueChanged<Note> onOpen;
|
||||
|
||||
/// Long-press (or the actions button) on a note.
|
||||
final ValueChanged<Note> onActions;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, box) => box.maxWidth < kNotesGridMaxWidth
|
||||
? NotesGrid(
|
||||
notes: notes,
|
||||
onOpen: onOpen,
|
||||
onActions: onActions,
|
||||
isSelected: isSelected,
|
||||
)
|
||||
: ListView.separated(
|
||||
itemCount: notes.length,
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (context, i) => NoteTile(
|
||||
key: ValueKey(notes[i].id),
|
||||
note: notes[i],
|
||||
selected: isSelected(notes[i].id),
|
||||
onTap: () => onOpen(notes[i]),
|
||||
onActions: () => onActions(notes[i]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -4,20 +4,13 @@ import 'package:todo/data/note_repository.dart';
|
||||
/// The default status selection: hide completed/dropped work.
|
||||
const Set<Status> kDefaultStatuses = {Status.todo, Status.inProgress};
|
||||
|
||||
/// The default source selection: plain ideas only, repo tasks hidden.
|
||||
const Set<String> kDefaultSources = {kIdeasSource};
|
||||
|
||||
/// The notes list's unfiltered baseline, which "Clear all" resets to.
|
||||
const kDefaultNoteFilter = NoteFilter(
|
||||
statuses: kDefaultStatuses,
|
||||
sources: kDefaultSources,
|
||||
);
|
||||
const kDefaultNoteFilter = NoteFilter(statuses: kDefaultStatuses);
|
||||
|
||||
/// Facets the user changed from [kDefaultNoteFilter], for the filter badge.
|
||||
int userFacetCount(NoteFilter filter) {
|
||||
var count = filter.activeCount;
|
||||
if (_same(filter.statuses, kDefaultStatuses)) count -= 1;
|
||||
if (_same(filter.sources, kDefaultSources)) count -= 1;
|
||||
return count;
|
||||
}
|
||||
|
||||
|
||||
@ -10,6 +10,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:todo/data/app_settings.dart';
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:todo/data/note_repository.dart';
|
||||
import 'package:todo/ui/app_route.dart';
|
||||
import 'package:todo/ui/note_actions_sheet.dart';
|
||||
import 'package:todo/ui/note_detail_screen.dart';
|
||||
|
||||
@ -24,8 +25,9 @@ Future<void> openNote(
|
||||
// re-focuses it on return, so backing out of a note would pop the keyboard.
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
await Navigator.of(context).push<void>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => NoteDetailScreen(
|
||||
appRoute(
|
||||
context,
|
||||
(_) => NoteDetailScreen(
|
||||
note: note,
|
||||
repository: repository,
|
||||
appSettings: appSettings,
|
||||
|
||||
@ -5,13 +5,19 @@ import 'package:flutter/material.dart';
|
||||
import 'package:todo/data/app_settings.dart';
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:todo/data/note_repository.dart';
|
||||
import 'package:todo/ui/note_tile.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_list_body.dart';
|
||||
import 'package:todo/ui/notes_list_defaults.dart';
|
||||
import 'package:todo/ui/notes_list_navigation.dart';
|
||||
import 'package:todo/ui/notes_selection_bar.dart';
|
||||
|
||||
export 'package:todo/ui/notes_list_defaults.dart';
|
||||
|
||||
part 'notes_list_screen_select.dart';
|
||||
|
||||
/// Searchable, filterable, sortable list of stored/synced notes.
|
||||
///
|
||||
/// The heavy lifting (WHERE/ORDER BY) lives in [NoteRepository]; this screen
|
||||
@ -47,31 +53,52 @@ class _NotesListScreenState extends State<NotesListScreen> {
|
||||
|
||||
NoteSort _sort = NoteSort.priorityDesc;
|
||||
|
||||
/// Default view: open ideas only (no Done/Abandoned, no repo tasks).
|
||||
/// Default view: open notes (no Done/Abandoned), ideas and repo tasks alike.
|
||||
NoteFilter _filter = kDefaultNoteFilter;
|
||||
|
||||
/// Memoised stream; only replaced when [_sort]/[_filter] actually change.
|
||||
late Stream<List<Note>> _stream;
|
||||
|
||||
/// Multi-select; any change rebuilds the screen (tiles + bottom bar).
|
||||
late final _selection = NoteSelection()..addListener(() => setState(() {}));
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_stream = widget.repository.watchNotes(sort: _sort, filter: _filter);
|
||||
_stream = _watch();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debounce?.cancel();
|
||||
_searchController.dispose();
|
||||
_selection.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Rebuilds the watch stream for the current sort + filter. Call only
|
||||
/// from handlers that change those, never from [build].
|
||||
void _applyState() {
|
||||
setState(() {
|
||||
_stream = widget.repository.watchNotes(sort: _sort, filter: _filter);
|
||||
});
|
||||
setState(() => _stream = _watch());
|
||||
}
|
||||
|
||||
/// The query for the current sort + filter, minus Claude-made notes unless
|
||||
/// this device's eye toggle is on.
|
||||
Stream<List<Note>> _watch() => widget.repository.watchNotes(
|
||||
sort: _sort,
|
||||
filter: _filter.copyWith(
|
||||
hideClaude: !widget.appSettings.value.showClaudeNotes,
|
||||
),
|
||||
);
|
||||
|
||||
Future<void> _toggleClaudeNotes() async {
|
||||
final settings = widget.appSettings.value;
|
||||
final next = await settings.withShowClaudeNotes(
|
||||
value: !settings.showClaudeNotes,
|
||||
);
|
||||
if (!mounted) return;
|
||||
widget.appSettings.value = next;
|
||||
_applyState();
|
||||
}
|
||||
|
||||
void _onSearchChanged(String value) {
|
||||
@ -91,15 +118,11 @@ class _NotesListScreenState extends State<NotesListScreen> {
|
||||
/// Opens the filter sheet and adopts the edited filter (text query is
|
||||
/// owned by the search box, so it is preserved across the round-trip).
|
||||
Future<void> _openFilters() async {
|
||||
final repos = {
|
||||
for (final n in await widget.repository.listNotes()) ?n.repo,
|
||||
}.toList()..sort();
|
||||
if (!mounted) return;
|
||||
final edited = await showModalBottomSheet<NoteFilter>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
builder: (_) => FilterSheet(initial: _filter, repos: repos),
|
||||
builder: (_) => FilterSheet(initial: _filter),
|
||||
);
|
||||
if (edited != null) {
|
||||
_filter = edited.copyWith(query: _searchController.text);
|
||||
@ -112,29 +135,41 @@ class _NotesListScreenState extends State<NotesListScreen> {
|
||||
final badgeCount = userFacetCount(_filter);
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Notes'),
|
||||
title: const Icon(Icons.note, semanticLabel: 'Notes'),
|
||||
actions: [
|
||||
_selectButton(),
|
||||
// Eye, no label: open = Claude-made notes shown, slashed = hidden.
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
widget.appSettings.value.showClaudeNotes
|
||||
? Icons.visibility
|
||||
: Icons.visibility_off,
|
||||
semanticLabel: 'Claude notes',
|
||||
),
|
||||
onPressed: _toggleClaudeNotes,
|
||||
),
|
||||
PopupMenuButton<NoteSort>(
|
||||
tooltip: 'Sort',
|
||||
icon: const Icon(Icons.sort),
|
||||
// Empty, not omitted: null falls back to the painted "Show menu".
|
||||
tooltip: '',
|
||||
icon: const Icon(Icons.sort, semanticLabel: 'Sort'),
|
||||
initialValue: _sort,
|
||||
onSelected: _setSort,
|
||||
itemBuilder: (_) => const [
|
||||
PopupMenuItem(
|
||||
value: NoteSort.modifiedDesc,
|
||||
child: Text('Last updated'),
|
||||
child: Icon(Icons.update, semanticLabel: 'Last updated'),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: NoteSort.createdDesc,
|
||||
child: Text('Newest created'),
|
||||
child: Icon(Icons.post_add, semanticLabel: 'Newest created'),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: NoteSort.alphabetical,
|
||||
child: Text('Alphabetical'),
|
||||
child: Icon(Icons.sort_by_alpha, semanticLabel: 'Alphabetical'),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: NoteSort.priorityDesc,
|
||||
child: Text('Priority'),
|
||||
child: Icon(Icons.flag_outlined, semanticLabel: 'Priority'),
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -148,8 +183,7 @@ class _NotesListScreenState extends State<NotesListScreen> {
|
||||
label: Text('$badgeCount'),
|
||||
offset: const Offset(-8, 4),
|
||||
child: IconButton(
|
||||
tooltip: 'Filter',
|
||||
icon: const Icon(Icons.filter_list),
|
||||
icon: const Icon(Icons.filter_list, semanticLabel: 'Filter'),
|
||||
onPressed: _openFilters,
|
||||
),
|
||||
),
|
||||
@ -165,15 +199,16 @@ class _NotesListScreenState extends State<NotesListScreen> {
|
||||
onChanged: _onSearchChanged,
|
||||
textInputAction: TextInputAction.search,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search notes…',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
isDense: true,
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: _searchController.text.isEmpty
|
||||
? null
|
||||
: IconButton(
|
||||
tooltip: 'Clear search',
|
||||
icon: const Icon(Icons.clear),
|
||||
icon: const Icon(
|
||||
Icons.clear,
|
||||
semanticLabel: 'Clear search',
|
||||
),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
_onSearchChanged('');
|
||||
@ -190,34 +225,21 @@ class _NotesListScreenState extends State<NotesListScreen> {
|
||||
final notes = snapshot.data ?? const <Note>[];
|
||||
if (notes.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
_filter.isEmpty
|
||||
? 'No notes yet'
|
||||
: 'No notes match these filters',
|
||||
),
|
||||
child: EmptyNotesCue(filtered: !_filter.isEmpty),
|
||||
);
|
||||
}
|
||||
return ListView.separated(
|
||||
itemCount: notes.length,
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (context, i) => NoteTile(
|
||||
key: ValueKey(notes[i].id),
|
||||
note: notes[i],
|
||||
onTap: () => openNote(
|
||||
context,
|
||||
notes[i],
|
||||
widget.repository,
|
||||
widget.appSettings,
|
||||
),
|
||||
onActions: () =>
|
||||
openNoteActions(context, notes[i], widget.repository),
|
||||
),
|
||||
return NotesListBody(
|
||||
notes: notes,
|
||||
onOpen: (n) => _tap(n, notes),
|
||||
onActions: _longPress,
|
||||
isSelected: _selection.contains,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: _selectionBar(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
43
lib/ui/notes_list_screen_select.dart
Normal file
43
lib/ui/notes_list_screen_select.dart
Normal file
@ -0,0 +1,43 @@
|
||||
/// The notes list's multi-select wiring: the app-bar toggle, taps, and the
|
||||
/// bottom action bar.
|
||||
///
|
||||
/// A `part` of `notes_list_screen.dart` because it reads the screen's
|
||||
/// private selection and repository; split out for file size.
|
||||
part of 'notes_list_screen.dart';
|
||||
|
||||
extension _Selecting on _NotesListScreenState {
|
||||
/// App-bar toggle into and out of selection mode (tinted while on).
|
||||
Widget _selectButton() => IconButton(
|
||||
key: const ValueKey('select-mode'),
|
||||
isSelected: _selection.active,
|
||||
onPressed: _selection.toggleMode,
|
||||
icon: const Icon(Icons.checklist, semanticLabel: 'Select'),
|
||||
);
|
||||
|
||||
/// A tap on [note] of [notes]: selects in the mode or with Shift/Ctrl,
|
||||
/// else opens it.
|
||||
void _tap(Note note, List<Note> notes) {
|
||||
if (_selection.handleTap(note, notes)) return;
|
||||
unawaited(openNote(context, note, widget.repository, widget.appSettings));
|
||||
}
|
||||
|
||||
/// A long-press: toggles in the mode, else the per-note sheet.
|
||||
void _longPress(Note note) {
|
||||
if (_selection.active) return _selection.toggle(note.id);
|
||||
unawaited(openNoteActions(context, note, widget.repository));
|
||||
}
|
||||
|
||||
/// The bar, only while something is selected. Status and priority are
|
||||
/// ONE store write for the whole selection ([NoteRepository.bulkUpdate]).
|
||||
Widget? _selectionBar() {
|
||||
if (_selection.ids.isEmpty) return null;
|
||||
final repo = widget.repository;
|
||||
return NotesSelectionBar(
|
||||
onStatus: (s) => unawaited(repo.bulkUpdate(_selection.ids, status: s)),
|
||||
onPriority: (p) =>
|
||||
unawaited(repo.bulkUpdate(_selection.ids, priority: p)),
|
||||
onAssign: () => assignSelection(context, repo, _selection),
|
||||
onClear: _selection.clear,
|
||||
);
|
||||
}
|
||||
}
|
||||
128
lib/ui/notes_selection_bar.dart
Normal file
128
lib/ui/notes_selection_bar.dart
Normal file
@ -0,0 +1,128 @@
|
||||
/// The icon-only action bar shown while notes are selected.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:todo/ui/note_grid_tile.dart';
|
||||
|
||||
/// How long the assign icon shows the refusal block.
|
||||
const kRefusalFlash = Duration(milliseconds: 1500);
|
||||
|
||||
/// Set status, set priority, assign to repo, clear: icons only.
|
||||
///
|
||||
/// Menus pass `tooltip: ''` because a tooltip is visible text; the icons
|
||||
/// carry semantic labels instead.
|
||||
class NotesSelectionBar extends StatefulWidget {
|
||||
/// Creates the bar; every callback acts on the whole selection.
|
||||
const NotesSelectionBar({
|
||||
required this.onStatus,
|
||||
required this.onPriority,
|
||||
required this.onAssign,
|
||||
required this.onClear,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// Sets every selected note's status.
|
||||
final ValueChanged<Status> onStatus;
|
||||
|
||||
/// Sets every selected note's priority.
|
||||
final ValueChanged<Priority> onPriority;
|
||||
|
||||
/// Runs the assign flow; true when some notes were refused.
|
||||
final Future<bool> Function() onAssign;
|
||||
|
||||
/// Drops the selection.
|
||||
final VoidCallback onClear;
|
||||
|
||||
@override
|
||||
State<NotesSelectionBar> createState() => _NotesSelectionBarState();
|
||||
}
|
||||
|
||||
class _NotesSelectionBarState extends State<NotesSelectionBar> {
|
||||
Timer? _flash;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_flash?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _assign() async {
|
||||
if (!await widget.onAssign() || !mounted) return;
|
||||
_flash?.cancel();
|
||||
setState(() => _flash = Timer(kRefusalFlash, () => setState(() {})));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final refused = _flash?.isActive ?? false;
|
||||
return Material(
|
||||
key: const ValueKey('selection-bar'),
|
||||
color: scheme.surfaceContainer,
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
PopupMenuButton<Status>(
|
||||
key: const ValueKey('bulk-status'),
|
||||
tooltip: '',
|
||||
icon: const Icon(Icons.task_alt, semanticLabel: 'Set status'),
|
||||
onSelected: widget.onStatus,
|
||||
itemBuilder: (_) => [
|
||||
for (final s in Status.values)
|
||||
PopupMenuItem(
|
||||
key: ValueKey('status-${s.name}'),
|
||||
value: s,
|
||||
child: Icon(statusIcon(s), semanticLabel: s.label),
|
||||
),
|
||||
],
|
||||
),
|
||||
PopupMenuButton<Priority>(
|
||||
key: const ValueKey('bulk-priority'),
|
||||
tooltip: '',
|
||||
icon: const Icon(
|
||||
Icons.flag_outlined,
|
||||
semanticLabel: 'Set priority',
|
||||
),
|
||||
onSelected: widget.onPriority,
|
||||
itemBuilder: (_) => [
|
||||
for (final p in Priority.values.reversed)
|
||||
PopupMenuItem(
|
||||
key: ValueKey('priority-${p.name}'),
|
||||
value: p,
|
||||
child: Icon(
|
||||
Icons.flag,
|
||||
color: priorityColor(p, scheme),
|
||||
semanticLabel: p.label,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
IconButton(
|
||||
key: const ValueKey('bulk-assign'),
|
||||
onPressed: _assign,
|
||||
icon: Icon(
|
||||
refused ? Icons.block : Icons.drive_file_move_outline,
|
||||
color: refused ? scheme.error : null,
|
||||
semanticLabel: 'Assign to repo',
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
key: const ValueKey('bulk-clear'),
|
||||
onPressed: widget.onClear,
|
||||
icon: const Icon(
|
||||
Icons.deselect,
|
||||
semanticLabel: 'Clear selection',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
97
lib/ui/repo_picker_sheet.dart
Normal file
97
lib/ui/repo_picker_sheet.dart
Normal file
@ -0,0 +1,97 @@
|
||||
/// Bottom sheet picking the repo selected notes are assigned to.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:todo/data/repo_assign.dart';
|
||||
|
||||
/// The repos already on notes, plus a field for a new `kuhyx/<name>`.
|
||||
///
|
||||
/// Pops with the chosen `owner/name`; dismissing it pops null. The field
|
||||
/// starts at `kuhyx/` (content, not a label) and refuses anything that is
|
||||
/// not `owner/name` with an error icon rather than words.
|
||||
class RepoPickerSheet extends StatefulWidget {
|
||||
/// Creates the picker listing [repos].
|
||||
const RepoPickerSheet({required this.repos, super.key});
|
||||
|
||||
/// Distinct repos already present on notes, in display order.
|
||||
final List<String> repos;
|
||||
|
||||
@override
|
||||
State<RepoPickerSheet> createState() => _RepoPickerSheetState();
|
||||
}
|
||||
|
||||
class _RepoPickerSheetState extends State<RepoPickerSheet> {
|
||||
final _field = TextEditingController(text: 'kuhyx/');
|
||||
bool _bad = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_field.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
final value = _field.text.trim();
|
||||
if (!isRepoName(value)) return setState(() => _bad = true);
|
||||
Navigator.of(context).pop(value);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
key: const ValueKey('repo-picker'),
|
||||
padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
for (final repo in widget.repos)
|
||||
ListTile(
|
||||
key: ValueKey('repo-$repo'),
|
||||
leading: const Icon(Icons.folder_outlined),
|
||||
title: Text(repo),
|
||||
onTap: () => Navigator.of(context).pop(repo),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: TextField(
|
||||
key: const ValueKey('repo-new'),
|
||||
controller: _field,
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => _submit(),
|
||||
onChanged: (_) {
|
||||
if (_bad) setState(() => _bad = false);
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: const Icon(Icons.create_new_folder_outlined),
|
||||
error: _bad
|
||||
? const Icon(
|
||||
Icons.error_outline,
|
||||
size: 16,
|
||||
semanticLabel: 'Not owner/name',
|
||||
)
|
||||
: null,
|
||||
suffixIcon: IconButton(
|
||||
key: const ValueKey('repo-new-submit'),
|
||||
onPressed: _submit,
|
||||
icon: const Icon(Icons.check, semanticLabel: 'Use repo'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,8 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:crdt_sync_flutter/crdt_sync_flutter.dart'
|
||||
show E2ePassphraseButton;
|
||||
import 'package:file_selector/file_selector.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
@ -13,7 +15,9 @@ import 'package:todo/sync/backlog_export.dart';
|
||||
import 'package:todo/sync/firebase_backend.dart';
|
||||
import 'package:todo/sync/google_sign_in_backend.dart';
|
||||
import 'package:todo/sync/notes_markdown.dart';
|
||||
import 'package:todo/sync/run_sync.dart';
|
||||
import 'package:todo/sync/sync_settings.dart';
|
||||
import 'package:todo/ui/app_route.dart';
|
||||
import 'package:todo/ui/cloud_images_settings.dart';
|
||||
import 'package:todo/ui/github_mirror_screen.dart';
|
||||
|
||||
@ -104,11 +108,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
/// session is available, best-effort mirrors it so the choice follows the
|
||||
/// user to their other devices.
|
||||
///
|
||||
/// The local write always lands — a signed-in-as-wrong-account device
|
||||
/// throwing [FirebaseAuthError] out of `openFirebase()` must not take the
|
||||
/// 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.
|
||||
/// The local write always lands: a [FirebaseAuthError] from opening the
|
||||
/// client (wrong account) is caught on its own, before the write.
|
||||
Future<void> _setAdvancedMode(bool value) async {
|
||||
final analytics = widget.analytics;
|
||||
if (analytics != null) {
|
||||
@ -166,10 +167,27 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
await widget.repository.importNotes(notes);
|
||||
}
|
||||
|
||||
/// Retries the sync after a passphrase is entered; the lock tile follows
|
||||
/// [e2eLocked], which the run updates.
|
||||
Future<void> _syncNow() async {
|
||||
try {
|
||||
await runSync(
|
||||
widget.repository,
|
||||
widget.initial,
|
||||
httpClient: widget.httpClient,
|
||||
firebaseFactory: widget.firebaseFactory,
|
||||
stateStore: widget.stateStore,
|
||||
);
|
||||
} on Exception {
|
||||
// The capture screen's status line reports sync failures.
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openSyncSettings() async {
|
||||
await Navigator.of(context).push<void>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => SyncSettingsScreen(
|
||||
appRoute(
|
||||
context,
|
||||
(_) => SyncSettingsScreen(
|
||||
accountLoader: widget.accountLoader ?? loadAccount,
|
||||
accountSaver: widget.accountSaver ?? saveAccount,
|
||||
accountClearer: widget.accountClearer ?? clearAccount,
|
||||
@ -186,8 +204,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
|
||||
Future<void> _openGitHubMirror() async {
|
||||
await Navigator.of(context).push<SyncSettings>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => GitHubMirrorScreen(
|
||||
appRoute(
|
||||
context,
|
||||
(_) => GitHubMirrorScreen(
|
||||
initial: widget.initial,
|
||||
repository: widget.repository,
|
||||
appSettings: widget.appSettings,
|
||||
@ -203,7 +222,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Settings')),
|
||||
appBar: AppBar(
|
||||
title: const Icon(Icons.settings, semanticLabel: 'Settings'),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
@ -211,33 +232,49 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
valueListenable: widget.appSettings,
|
||||
builder: (context, settings, _) => SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Enable advanced'),
|
||||
subtitle: const Text(
|
||||
'Priority/status, templates, view modes, and sync details '
|
||||
'in the capture screen',
|
||||
),
|
||||
secondary: const Icon(Icons.tune, semanticLabel: _advanced),
|
||||
value: settings.advancedMode,
|
||||
onChanged: (value) => unawaited(_setAdvancedMode(value)),
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Sync settings'),
|
||||
subtitle: const Text('Firebase sync and notes backup'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => unawaited(_openSyncSettings()),
|
||||
_link(
|
||||
const Icon(Icons.sync, semanticLabel: 'Sync settings: Firebase'),
|
||||
_openSyncSettings,
|
||||
),
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: e2eLocked,
|
||||
builder: (context, locked, _) => ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: E2ePassphraseButton(
|
||||
locked: locked,
|
||||
// A new passphrase is only proven by a sync that unlocks.
|
||||
onSaved: () => unawaited(_syncNow()),
|
||||
),
|
||||
),
|
||||
),
|
||||
CloudImagesTile(httpClient: widget.httpClient),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Advanced sync (GitHub)'),
|
||||
subtitle: const Text('Cutover mirror — not recommended'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => unawaited(_openGitHubMirror()),
|
||||
_link(
|
||||
Icon(
|
||||
Icons.cloud_sync,
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
semanticLabel: 'Advanced sync (GitHub): not recommended',
|
||||
),
|
||||
_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(),
|
||||
];
|
||||
6
mcp/.gitignore
vendored
6
mcp/.gitignore
vendored
@ -1,6 +0,0 @@
|
||||
__pycache__/
|
||||
*.egg-info/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
@ -1,47 +1,36 @@
|
||||
# todo backlog MCP server
|
||||
# todo MCP server
|
||||
|
||||
A small, standalone, **read-only** [MCP](https://modelcontextprotocol.io)
|
||||
server that lets Claude Code (and its subagents) query the todo app's backlog.
|
||||
Repo tasks for any Claude session (local or cloud): `add_task`, `list_tasks`,
|
||||
`get_task`, `update_task`, `close_task` (refused without evidence),
|
||||
`search_tasks`. Dart, Streamable HTTP on `127.0.0.1:8735/mcp`; Caddy
|
||||
publishes it as `https://todo.kuhy.duckdns.org/mcp`.
|
||||
|
||||
The todo app is Dart/Flutter and stores notes in a `sqlite_crdt` database that
|
||||
must never be touched from outside the app (CRDT writes corrupt cross-device
|
||||
merge). Instead, this server reads the app's exported `BACKLOG.md` — the
|
||||
canonical NotesMarkdown snapshot written by **Settings → "Export notes"**.
|
||||
The server is one more sync node: it reuses `lib/cli` (`CliSession`,
|
||||
`SyncService`) and writes its own CRDT log at
|
||||
`todo-sync/notes/<node id>.json` plus `todo-sync/revs/<node id>`.
|
||||
Code: `lib/mcp/`, entrypoint `mcp/todo_mcp.dart`, tests `test/mcp/`.
|
||||
|
||||
## Staleness note
|
||||
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`).
|
||||
|
||||
`BACKLOG.md` is a *manual* export, so it can be out of date. Every tool result
|
||||
includes the file's modification time (`mtime`) so you can judge freshness. If
|
||||
the file is missing, tools return `backlog_found: false` with a message telling
|
||||
you to export it from the app — they never crash.
|
||||
| What | Where |
|
||||
|---|---|
|
||||
| Firebase seed (read once) | `~/.config/todo-mcp/firebase_auth.json` |
|
||||
| Bearer token hashes | `~/.config/todo-mcp/tokens` (`<sha256> <env>`) |
|
||||
| Log, node id, session copy (0600) | `~/.local/share/todo-mcp/` |
|
||||
| Binary | `~/.local/lib/todo-mcp/todo_mcp` |
|
||||
|
||||
## Tools (all read-only)
|
||||
|
||||
- `list_backlog(priority: str | None = None, status: str | None = None, limit: int = 100)`
|
||||
— notes, optionally filtered by `priority` (`low`/`medium`/`high`) and/or
|
||||
`status` (`todo`/`inProgress`/`done`/`abandoned`), plus `total`, `returned`,
|
||||
and `mtime`.
|
||||
- `get_note(note_id: str)` — one note by id, or `found: false`.
|
||||
- `backlog_stats()` — counts `by_status` and `by_priority`, `total`, and `mtime`.
|
||||
|
||||
Nothing here writes to the database or to `BACKLOG.md`.
|
||||
|
||||
## Setup
|
||||
## One-time setup
|
||||
|
||||
```bash
|
||||
mcp/scripts/setup_mcp.sh
|
||||
python3 ~/src/utils/crdt-sync/tool/seed_session.py --app todo-mcp
|
||||
mcp/mint_token.sh pc # prints the token once; export it as TODO_MCP_TOKEN
|
||||
mcp/install.sh # build + install the unit
|
||||
systemctl --user enable --now todo-mcp.service
|
||||
```
|
||||
|
||||
This creates `~/.venvs/todo-mcp`, installs this package (which pulls in the
|
||||
`mcp` SDK), and verifies the imports. The server is registered via the
|
||||
repo-root `.mcp.json`; restart Claude Code in this repo and approve the project
|
||||
MCP server prompt. Override the backlog location with `TODO_BACKLOG_PATH`
|
||||
(defaults to `~/src/todo/BACKLOG.md`).
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
~/.venvs/todo-mcp/bin/pip install -e mcp pytest pytest-cov ruff mypy
|
||||
~/.venvs/todo-mcp/bin/python -m pytest mcp/todo_mcp/tests \
|
||||
--cov=todo_mcp --cov-branch --cov-report=term-missing --cov-fail-under=100
|
||||
```
|
||||
Deploy a new build: `mcp/install.sh` (restarts a running unit).
|
||||
Revoke a token: delete its line in `tokens`; no restart needed.
|
||||
|
||||
163
mcp/go_live.sh
Executable file
163
mcp/go_live.sh
Executable file
@ -0,0 +1,163 @@
|
||||
#!/bin/bash
|
||||
# One command that takes the todo MCP server live. Idempotent; rerun any time.
|
||||
#
|
||||
# mcp/go_live.sh # seed, token, install, publish, verify, cloud token
|
||||
# mcp/go_live.sh --verify-only # only step 5 (live MCP calls + 401 check)
|
||||
# mcp/go_live.sh --dry-run # print the plan, change nothing
|
||||
# mcp/go_live.sh --new-cloud-token # rotate the cloud token even if one exists
|
||||
#
|
||||
# Needs: python3, dart/flutter (install.sh), docker (Caddy), xclip, curl, ss.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
readonly ROOT
|
||||
readonly PORT=8735
|
||||
readonly LOCAL_URL="http://127.0.0.1:${PORT}/mcp"
|
||||
readonly PUBLIC_URL="https://todo.kuhy.duckdns.org/mcp"
|
||||
readonly CONFIG_DIR="${HOME}/.config/todo-mcp"
|
||||
readonly CRED_FILE="${CONFIG_DIR}/firebase_auth.json"
|
||||
readonly TOKENS="${CONFIG_DIR}/tokens"
|
||||
# Not under todo-mcp/: publish_caddy_site.sh would treat ~/.config/todo-mcp/token
|
||||
# as an edge bearer gate and break the cloud token.
|
||||
readonly CLIENT_TOKEN="${HOME}/.config/todo-mcp-client/token"
|
||||
readonly ZSHENV="${HOME}/.zshenv"
|
||||
# shellcheck disable=SC2016 # the line is written literally; zsh expands it later
|
||||
readonly ZSHENV_LINE='export TODO_MCP_TOKEN="$(cat ~/.config/todo-mcp-client/token)"'
|
||||
readonly SEED_DIR="${HOME}/src/utils/crdt-sync"
|
||||
readonly SECRET_FILE="${HOME}/.config/crdt-sync/oauth_client_secret"
|
||||
readonly CLIENT_ID="845446124781-prdoherj0v64vc6egvvcp3l0693khaur.apps.googleusercontent.com"
|
||||
readonly PUBLISH="${HOME}/src/testsAndMisc/linux_configuration/features/publish_caddy_site.sh"
|
||||
readonly VERIFY="${ROOT}/mcp/verify_mcp.py"
|
||||
|
||||
DRY_RUN=0
|
||||
VERIFY_ONLY=0
|
||||
NEW_CLOUD=0
|
||||
|
||||
say() { echo "[$1] $2"; }
|
||||
die() { echo "FAIL: $*" >&2; exit 1; }
|
||||
has_hash() { [[ -f "$TOKENS" ]] && awk -v e="$1" '$2 == e {f=1} END {exit !f}' "$TOKENS"; }
|
||||
|
||||
step_seed() {
|
||||
if [[ -f "$CRED_FILE" ]]; then say 1/6 "seed: credentials exist, skipped"; return; fi
|
||||
if ((DRY_RUN)); then say 1/6 "seed: would run seed_session --app todo-mcp (consent tap)"; return; fi
|
||||
[[ -f "$SECRET_FILE" ]] || die "no $SECRET_FILE; run ~/src/utils/crdt-sync/tool/refresh_oauth_secret.sh --app todo-mcp"
|
||||
say 1/6 "seed: sign in with Google when the browser opens"
|
||||
(cd "$SEED_DIR" && python3 -m tool.seed_session --client-id "$CLIENT_ID" \
|
||||
--client-secret "$(<"$SECRET_FILE")" --app todo-mcp) \
|
||||
|| die "seed_session failed; rerun with -v or: tool/refresh_oauth_secret.sh --app todo-mcp"
|
||||
[[ -f "$CRED_FILE" ]] || die "seed ran but $CRED_FILE is missing"
|
||||
say 1/6 "seed: done"
|
||||
}
|
||||
|
||||
step_token() {
|
||||
if has_hash pc && [[ -s "$CLIENT_TOKEN" ]]; then
|
||||
say 2/6 "pc token: exists, skipped"
|
||||
elif ((DRY_RUN)); then
|
||||
say 2/6 "pc token: would mint, store 0600 at $CLIENT_TOKEN"
|
||||
else
|
||||
# A hash without the raw token is unusable; minting replaces the pc line.
|
||||
umask 077
|
||||
mkdir -p "$(dirname "$CLIENT_TOKEN")"
|
||||
"${ROOT}/mcp/mint_token.sh" pc >"$CLIENT_TOKEN"
|
||||
chmod 600 "$CLIENT_TOKEN"
|
||||
say 2/6 "pc token: minted"
|
||||
fi
|
||||
if grep -qxF "$ZSHENV_LINE" "$ZSHENV" 2>/dev/null; then
|
||||
say 2/6 "zshenv: TODO_MCP_TOKEN line present"
|
||||
elif ((DRY_RUN)); then
|
||||
say 2/6 "zshenv: would append the TODO_MCP_TOKEN export"
|
||||
else
|
||||
printf '%s\n' "$ZSHENV_LINE" >>"$ZSHENV"
|
||||
say 2/6 "zshenv: appended (new shells and sessions only)"
|
||||
fi
|
||||
}
|
||||
|
||||
wait_listen() {
|
||||
local _
|
||||
for _ in $(seq 1 60); do
|
||||
if ss -ltnH "sport = :${PORT}" | grep -q .; then return 0; fi
|
||||
sleep 1
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
step_install() {
|
||||
if ((DRY_RUN)); then
|
||||
say 3/6 "install: would run mcp/install.sh, enable --now todo-mcp.service, wait for :${PORT}"
|
||||
return
|
||||
fi
|
||||
say 3/6 "install: building (dart compile, can take a minute)"
|
||||
"${ROOT}/mcp/install.sh" >/dev/null
|
||||
systemctl --user enable --now todo-mcp.service 2>/dev/null
|
||||
wait_listen || die "nothing listens on :${PORT}; see: journalctl --user -u todo-mcp -n 30"
|
||||
say 3/6 "install: unit active, :${PORT} listening"
|
||||
}
|
||||
|
||||
step_publish() {
|
||||
if ((DRY_RUN)); then
|
||||
if [[ -x "$PUBLISH" || -f "$PUBLISH" ]]; then
|
||||
say 4/6 "publish: would run publish_caddy_site.sh todo ${PORT}"
|
||||
else
|
||||
say 4/6 "publish: WOULD FAIL, $PUBLISH is missing"
|
||||
fi
|
||||
return
|
||||
fi
|
||||
[[ -f "$PUBLISH" ]] || die "missing $PUBLISH (untracked in testsAndMisc; restore it)"
|
||||
bash "$PUBLISH" todo "$PORT" >/dev/null
|
||||
say 4/6 "publish: https://todo.kuhy.duckdns.org -> 127.0.0.1:${PORT}"
|
||||
}
|
||||
|
||||
verify_one() {
|
||||
local url="$1" wait="$2"
|
||||
python3 "$VERIFY" "$url" --token-file "$CLIENT_TOKEN" --wait "$wait"
|
||||
python3 "$VERIFY" "$url" --expect-401 --wait "$wait"
|
||||
}
|
||||
|
||||
step_verify() {
|
||||
if ((DRY_RUN)); then
|
||||
say 5/6 "verify: would initialize + list_tasks and expect 401 without a token on $LOCAL_URL and $PUBLIC_URL"
|
||||
return
|
||||
fi
|
||||
[[ -s "$CLIENT_TOKEN" ]] || die "no client token at $CLIENT_TOKEN; run without --verify-only"
|
||||
local out
|
||||
out="$(verify_one "$LOCAL_URL" 20)" || die "local MCP check failed (journalctl --user -u todo-mcp -n 30)"
|
||||
say 5/6 "verify local: ${out//$'\n'/, }"
|
||||
# First public hit can wait on a fresh certificate (HTTP-01), so allow 60 s.
|
||||
out="$(verify_one "$PUBLIC_URL" 60)" || die "public MCP check failed (docker logs gitea-caddy --tail 30)"
|
||||
say 5/6 "verify public: ${out//$'\n'/, }"
|
||||
}
|
||||
|
||||
step_cloud() {
|
||||
if has_hash cloud && ((! NEW_CLOUD)); then
|
||||
say 6/6 "cloud token: exists (rerun with --new-cloud-token to rotate)"
|
||||
return
|
||||
fi
|
||||
if ((DRY_RUN)); then
|
||||
say 6/6 "cloud token: would mint, copy to clipboard (manual: paste as cloud secret TODO_MCP_TOKEN)"
|
||||
return
|
||||
fi
|
||||
command -v xclip >/dev/null && [[ -n "${DISPLAY:-}" ]] || die "xclip or DISPLAY missing; cloud token not minted"
|
||||
"${ROOT}/mcp/mint_token.sh" cloud | xclip -selection clipboard -in
|
||||
say 6/6 "MANUAL: cloud token is on the clipboard; paste it as the cloud environment secret TODO_MCP_TOKEN"
|
||||
}
|
||||
|
||||
main() {
|
||||
local arg
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--dry-run) DRY_RUN=1 ;;
|
||||
--verify-only) VERIFY_ONLY=1 ;;
|
||||
--new-cloud-token) NEW_CLOUD=1 ;;
|
||||
*) die "unknown option: $arg" ;;
|
||||
esac
|
||||
done
|
||||
if ((VERIFY_ONLY)); then step_verify; return; fi
|
||||
step_seed
|
||||
step_token
|
||||
step_install
|
||||
step_publish
|
||||
step_verify
|
||||
step_cloud
|
||||
}
|
||||
|
||||
main "$@"
|
||||
27
mcp/install.sh
Executable file
27
mcp/install.sh
Executable file
@ -0,0 +1,27 @@
|
||||
#!/bin/bash
|
||||
# Deploy: frozen binary + systemd --user unit; restarts the unit if running.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
readonly ROOT
|
||||
readonly BIN_DIR="${HOME}/.local/lib/todo-mcp"
|
||||
readonly UNIT_DIR="${HOME}/.config/systemd/user"
|
||||
readonly UNIT="todo-mcp.service"
|
||||
|
||||
main() {
|
||||
mkdir -p "$BIN_DIR" "$UNIT_DIR"
|
||||
(cd "$ROOT" && flutter pub get >/dev/null)
|
||||
(cd "$ROOT" && dart compile exe mcp/todo_mcp.dart -o "$BIN_DIR/todo_mcp.new")
|
||||
mv "$BIN_DIR/todo_mcp.new" "$BIN_DIR/todo_mcp"
|
||||
install -m 644 "$ROOT/mcp/$UNIT" "$UNIT_DIR/$UNIT"
|
||||
systemctl --user daemon-reload
|
||||
if systemctl --user is-active --quiet "$UNIT"; then
|
||||
systemctl --user restart "$UNIT"
|
||||
echo "restarted $UNIT"
|
||||
else
|
||||
echo "installed; start with: systemctl --user enable --now $UNIT"
|
||||
fi
|
||||
}
|
||||
|
||||
main "$@"
|
||||
29
mcp/mint_token.sh
Executable file
29
mcp/mint_token.sh
Executable file
@ -0,0 +1,29 @@
|
||||
#!/bin/bash
|
||||
# Prints a new <env> token once; ~/.config/todo-mcp/tokens keeps only its sha256.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
readonly CONFIG_DIR="${HOME}/.config/todo-mcp"
|
||||
readonly TOKENS="${CONFIG_DIR}/tokens"
|
||||
|
||||
main() {
|
||||
local env="${1:-}"
|
||||
if [[ ! "$env" =~ ^[a-z0-9_-]+$ ]]; then
|
||||
echo "usage: $(basename "$0") <env> (e.g. pc, cloud)" >&2
|
||||
exit 2
|
||||
fi
|
||||
umask 077
|
||||
mkdir -p "$CONFIG_DIR"
|
||||
local token hash tmp
|
||||
token="$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')"
|
||||
hash="$(printf '%s' "$token" | sha256sum | cut -d' ' -f1)"
|
||||
tmp="$(mktemp "${TOKENS}.XXXXXX")"
|
||||
if [[ -f "$TOKENS" ]]; then
|
||||
awk -v env="$env" '$2 != env' "$TOKENS" >"$tmp"
|
||||
fi
|
||||
printf '%s %s\n' "$hash" "$env" >>"$tmp"
|
||||
mv "$tmp" "$TOKENS"
|
||||
echo "$token"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@ -1,134 +0,0 @@
|
||||
[project]
|
||||
name = "todo-mcp"
|
||||
version = "1.0.0"
|
||||
description = "Read-only MCP server over the todo app's exported BACKLOG.md"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
# The MCP SDK is the only runtime dependency: FastMCP powers the stdio
|
||||
# server. It lives in a dedicated venv (see scripts/setup_mcp.sh /
|
||||
# ../.mcp.json), kept entirely separate from the Dart/Flutter app.
|
||||
"mcp==2.3.0",
|
||||
]
|
||||
|
||||
# Explicit package discovery so ``pip install -e .`` picks up the package and
|
||||
# not the scripts/ dir.
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["todo_mcp*"]
|
||||
|
||||
# ============================================================================
|
||||
# RUFF - Fast Python linter and formatter
|
||||
# ============================================================================
|
||||
[tool.ruff]
|
||||
target-version = "py314"
|
||||
include = ["*.py", "**/*.py"]
|
||||
exclude = [".git", ".venv", "__pycache__", "build", "dist", ".eggs"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["ALL"]
|
||||
ignore = [
|
||||
"D203", # conflicts with D211
|
||||
"D213", # conflicts with D212
|
||||
"COM812", # formatter handles this
|
||||
"ISC001", # formatter may create these
|
||||
"S603", # prone to false positives
|
||||
]
|
||||
fixable = ["ALL"]
|
||||
unfixable = []
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"**/tests/**/*.py" = ["ARG", "D", "PLC0415", "PLR2004", "S101", "SLF001"]
|
||||
"**/test_*.py" = ["ARG", "D", "PLC0415", "PLR2004", "S101", "SLF001"]
|
||||
|
||||
[tool.ruff.lint.pydocstyle]
|
||||
convention = "google"
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
force-single-line = false
|
||||
force-sort-within-sections = true
|
||||
known-first-party = ["todo_mcp"]
|
||||
|
||||
[tool.ruff.lint.flake8-quotes]
|
||||
docstring-quotes = "double"
|
||||
inline-quotes = "double"
|
||||
|
||||
[tool.ruff.lint.flake8-tidy-imports]
|
||||
ban-relative-imports = "all"
|
||||
|
||||
[tool.ruff.format]
|
||||
quote-style = "double"
|
||||
indent-style = "space"
|
||||
skip-magic-trailing-comma = false
|
||||
line-ending = "auto"
|
||||
docstring-code-format = true
|
||||
|
||||
# ============================================================================
|
||||
# MYPY - Static type checker
|
||||
# ============================================================================
|
||||
[tool.mypy]
|
||||
python_version = "3.14"
|
||||
strict = true
|
||||
warn_return_any = true
|
||||
warn_unused_configs = true
|
||||
disallow_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
check_untyped_defs = true
|
||||
disallow_untyped_decorators = true
|
||||
no_implicit_optional = true
|
||||
warn_redundant_casts = true
|
||||
warn_unused_ignores = true
|
||||
warn_no_return = true
|
||||
warn_unreachable = true
|
||||
disallow_any_unimported = true
|
||||
disallow_any_explicit = false
|
||||
disallow_any_generics = true
|
||||
disallow_subclassing_any = true
|
||||
strict_equality = true
|
||||
extra_checks = true
|
||||
ignore_missing_imports = true
|
||||
show_error_codes = true
|
||||
color_output = true
|
||||
exclude = [".venv/"]
|
||||
|
||||
# ============================================================================
|
||||
# PYTEST
|
||||
# ============================================================================
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["todo_mcp/tests"]
|
||||
python_files = ["test_*.py", "*_test.py"]
|
||||
python_classes = ["Test*"]
|
||||
python_functions = ["test_*"]
|
||||
addopts = [
|
||||
"-v",
|
||||
"--strict-markers",
|
||||
"--strict-config",
|
||||
"-ra",
|
||||
"--cov=todo_mcp",
|
||||
"--cov-branch",
|
||||
"--cov-report=term-missing",
|
||||
]
|
||||
filterwarnings = [
|
||||
"error",
|
||||
"ignore::DeprecationWarning",
|
||||
"default::pytest.PytestUnraisableExceptionWarning",
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# COVERAGE
|
||||
# ============================================================================
|
||||
[tool.coverage.run]
|
||||
source = ["todo_mcp"]
|
||||
branch = true
|
||||
omit = ["*/__pycache__/*", "*/tests/*", "*/.venv/*"]
|
||||
|
||||
[tool.coverage.report]
|
||||
fail_under = 100
|
||||
show_missing = true
|
||||
skip_covered = false
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
"raise NotImplementedError",
|
||||
"raise AssertionError",
|
||||
"if TYPE_CHECKING:",
|
||||
'if __name__ == "__main__":',
|
||||
]
|
||||
partial_branches = ["pragma: no branch"]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user