Compare commits

..

46 Commits

Author SHA1 Message Date
19f930a93a CLI caches the E2E key (0600)
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
file-length / file length <= 250 lines (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
release-apk / release (APK) (push) Has been cancelled
repo-contract / repo contract (push) Has been cancelled
Argon2id ran on every CLI/MCP call; the derived key is now cached
owner-only in the data dir.

Todo: fda71ed1

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 10:59:34 +01:00
f7e24bbb1a Desktop adopts the E2E passphrase once
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
The wrapper serves ~/.config/crdt-sync/password once on
/sync-e2e-passphrase; the web app stores it in its keystore, so desktop
todo never asks. Android still unlocks via the lock tile.

Todo: fda71ed1

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 09:56:07 +01:00
4423937f5c E2E: key cache, CLI password, lock tile
Argon2id runs once per device (keystore key cache); the CLI/MCP read
~/.config/crdt-sync/password; pull-only merges old and encrypted
prefixes; the settings lock tile follows the last sync and resyncs on
save. Pins crdt_sync 0.14.0, crdt_sync_flutter 0.8.0, sync_settings_ui
0.7.2.

Todo: fda71ed1

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 09:52:15 +01:00
f4913daaa8 E2E-encrypt todo sync
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Todo: fda71ed1

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 08:39:05 +01:00
c9eb4d6cff Bump sqflite_common_ffi to 2.4.7
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 16:17:09 +01:00
1010d96426 deploy.sh: wait out phone_deploy load guard
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 15:57:08 +01:00
319f18d33a deploy.sh: one phone lease owner per run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 15:53:55 +01:00
c3ac6ec05d deploy.sh: SDK env and release key for phone
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Ship and systemd units skip ~/.zshenv, so default ANDROID_HOME, and
copy the shared key.properties into a fresh checkout so the release
build installs over CI builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 15:47:43 +01:00
0e56cddd47 Add scripts/deploy.sh and the deploy command
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Deploys the MCP service, the desktop pacman package and the phone
release build, each behind its own health check; exits non-zero unless
all pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 15:15:23 +01:00
203e61d6ac Text-free sync settings via ssui 0.7.0
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Bump sync_settings_ui 0.7.0 and github_device_auth 0.4.0 (glyph +
spoken label). Guard covers the Sync settings screen; degraded sync
gets its own warning glyph; tooltip delegate and copy button tested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 01:32:02 +01:00
edf6969949 Template picker: glyph prefix, not float label
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 01:22:22 +01:00
5ea7ba7b73 Text-free list, editor, sync and image cues
Glyph + spoken label replaces every app-written string the guard
found: empty states, tile meta, sheet dates/fields, date presets,
template/section headings, status footer, snackbars, GitHub and
cloud-image results, upload/download badges, Material tooltips.
Guard tests (text_free_*_test) walk the painted tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 01:17:03 +01:00
091e68a2d9 Text-free UI: glyph chips, icon-only actions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 01:02:25 +01:00
a30e7b34a9 CLI: --set-text replaces a note's text
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Keeps every other field; text from --text or stdin, empty refused.

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

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

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

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

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

Todo: 64d39ab5

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

Todo: 64d39ab5

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

Todo: 64d39ab5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 18:18:30 +01:00
beb1d1a49f crdt_sync 0.12.1: serialized FileLogPersistence writes
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 15:29:03 +01:00
75248e9cde Add new_note to add_task with merge and start
new_note files a separate note per repo; an exact section-title
match in another open note merges into it and starts it via the
board, unless that note is running, none or low priority.

Todo: 64d39ab5

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 15:24:41 +01:00
9a8e7aefd2 Fix flaky image_store_io_test "without picker cop…
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Todo: 2961b5ff
2026-11-09 08:37:03 +01:00
75788c7bdb Bump Gradle wrapper to 9.9.0
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYDm7szKeXoi7rinEy
2026-11-09 08:22:15 +01:00
1baaf236d4 Bump design_system to 0.3.1
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-08 18:32:58 +01:00
dc96bff3df Bump crdt_sync, sync_settings_ui, deps
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-08 18:11:53 +01:00
b92d133c17 Add todo app git hooks
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
file-length / file length <= 250 lines (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
release-apk / release (APK) (push) Has been cancelled
repo-contract / repo contract (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 14:28:08 +01:00
a0c1938d8d Split notes on first non-blank line
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 14:14:16 +01:00
cbe3d9be59 One open note per repo, merge tool
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 14:11:21 +01:00
67212cd7ec One list: repo groups, repo search, compact filter
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 13:40:20 +01:00
9b42ff8ba2 Bump crdt_sync_dart, sync_settings_ui
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
crdt_sync_dart 0.11.3, sync_settings_ui 0.5.2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWY1k4muZfdVJfb7YGJ
2026-11-02 12:15:11 +01:00
dc547763fc Bump Flutter to 3.47.9, Gradle to 9.8.2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWY1k4muZfdVJfb7YGJ
2026-11-02 12:01:25 +01:00
4a30a52375 Move TODOs to todo app
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZ24SUudEMNQAxdYb8
2026-11-02 11:20:34 +01:00
860d789864 Use google_account_token plugin
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Replace the app-local AccountManager channel with the shared plugin
(google_account_token-v0.1.0); kServerClientId stays put.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWY1k4muZfdVJfb7YGJ
2026-11-02 11:15:28 +01:00
d09978a338 Add one-command MCP go-live + live verify
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWVUTaKFW8m7YrvWbzV
2026-11-02 10:45:45 +01:00
cf01784241 TODO: flaky image_store_io attach test
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWY1k4muZfdVJfb7YGJ
2026-11-02 10:28:26 +01:00
0ff3d6f95d Sign in via AccountManager, drop google_sign_in
System account chooser plus getAuthToken with an
audience:server:client_id token; no Google client library.
Pins bumped to newest stable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWY1k4muZfdVJfb7YGJ
2026-11-02 10:24:59 +01:00
d14b7e03e3 install_hooks: resolve hooks dir via common git dir
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWY1k4muZfdVJfb7YGJ
2026-11-02 10:24:59 +01:00
2a9332094a MCP: Dart task server replaces Python todo_mcp
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 09:36:44 +01:00
9e18821c7b Remove TODO-fit-to-screen-layout.md: implemented by the notes grid
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 09:28:27 +01:00
f7ea0ffb3d Notes list: 2-column tile grid on phones, whole rows fill the screen
Below 600dp the notes list is a compact tile grid (title + priority/status
cues). Tile height is derived so whole rows exactly fill the viewport between
the status and navigation bars; past a screenful it scrolls vertically (no
paging). Body and dates moved into the long-press sheet. Wider windows keep
the list. Not using design_system FitGrid: it is square-celled and pages
horizontally.

Pins: design_system v0.3.0 (newest tag); also bumped crdt_sync_dart 0.11.2,
sync_settings_ui 0.4.2, file_picker 13.3.1, idb_shim 2.11.1,
sqflite_common_ffi 2.4.5, web 1.1.2 because the freshness gate was already
red on them at HEAD.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 09:28:18 +01:00
2ffae26f93 Capture feedback: haptic + checkmark on commit, motion tokens for routes and wizard bar
- Committing a captured note (New note tap with a persisted draft) fires
  HapticFeedback.lightImpact and plays a ~600 ms checkmark beside the counter;
  empty drafts and keystrokes confirm nothing.
- appRoute: one shared page route (fade + rise, AppDuration.slow) replaces all
  seven MaterialPageRoute sites; guard test keeps it that way.
- Wizard progress bar animates on AppDuration.base / AppCurve.standard.
- Everything honours disableAnimations; the haptic and the (static) checkmark
  still fire. Removes the finished dopamine-ux prompt (sound step rejected).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 09:28:13 +01:00
206 changed files with 9665 additions and 2498 deletions

2
.fvmrc
View File

@ -1,3 +1,3 @@
{
"flutter": "3.47.7"
"flutter": "3.47.9"
}

View File

@ -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

View File

@ -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}"
}
}
}
}

View File

@ -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.
#

View File

@ -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.

View File

@ -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

View File

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

View File

@ -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
View 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,
);
}

View File

@ -4,49 +4,12 @@
/// exact-pinned dependency under the freshness gate.
library;
import 'package:todo/cli/cli_mode.dart';
import 'package:todo/data/note.dart';
export 'package:todo/cli/cli_mode.dart';
export 'package:todo/cli/cli_usage.dart';
/// What the CLI was asked to do.
enum CliMode {
/// Create a note and push it.
add,
/// Print every live note, one per line.
list,
/// Print one note in full.
get,
/// Tombstone one note and push the deletion.
delete,
/// Change one note's status and push it.
setStatus,
/// Set a task done with evidence and push it.
close,
/// Print the notes matching a query, as [list] does.
search,
/// Print usage.
help,
}
/// Thrown for a malformed command line; maps to exit code 2.
class UsageError implements Exception {
/// Creates a [UsageError] carrying [message].
UsageError(this.message);
/// What was wrong with the arguments.
final String message;
@override
String toString() => message;
}
/// The parsed command line.
class CliArgs {
/// Creates parsed arguments; see [parse].
@ -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) {

View File

@ -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
View File

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

View File

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

View File

@ -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.

View File

@ -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

View 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();
}
}

View File

@ -32,7 +32,7 @@ Note findNote(Iterable<Note> notes, String ref) {
return matches.single;
}
/// `<8-char id>\t<status>\t<priority>\t<repo or ->\t<title>`.
/// `<8-char id>\t<status>\t<priority>\t<repo or ->\t<title>\t<created_by>`.
String listLine(Note note) => [
if (note.id.length <= _minPrefix)
note.id
@ -42,6 +42,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');

View File

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

View File

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

View File

@ -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
View File

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

View File

@ -76,7 +76,7 @@ enum Status {
}
}
/// The values the optional `by` field may hold: who owns a repo task.
/// The values the optional `by` and `created_by` fields may hold.
const Set<String> kNoteAuthors = {'claude', 'kuhy'};
/// An immutable idea/note record.
@ -99,6 +99,7 @@ class Note {
this.doneCheck,
this.by,
this.evidence,
this.createdBy,
});
/// Builds a [Note] from a raw CRDT query row.
@ -146,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
View File

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

View File

@ -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);

View File

@ -14,6 +14,9 @@ const _doneCheck = 'done_check';
const _by = 'by';
const _evidence = 'evidence';
/// Record field naming who filed the note (`kuhy` or `claude`).
const kCreatedByField = 'created_by';
/// Record fields for [note], all at [hlc]. Unset task fields are omitted, so
/// an idea's record is exactly the v3 field set (and cannot clear a peer's).
Map<String, Field> noteFields(Note note, Hlc hlc) => {
@ -26,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),
);
}

View File

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

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

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

107
lib/data/repo_note.dart Normal file
View 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,
);
}

View 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,
);
}

View 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,
);
}

View File

@ -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);
}

View File

@ -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;
}
}

View File

@ -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) {

View File

@ -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
View 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
View 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
View 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
View 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);
}

View 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
View 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));
}

View 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();
}
}

View File

@ -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;

View File

@ -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);
}

View File

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

View File

@ -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),

View File

@ -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
View 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;
}

View File

@ -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);

View File

@ -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'),
),
],
);

View 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,
);
}
}

View File

@ -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;

View File

@ -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);
}

View File

@ -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.

View File

@ -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
? ''

View File

@ -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)),
],
),
);
}

View File

@ -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());

View File

@ -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),
],
),
],
),

View 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
View 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),
),
),
);
}

View 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
View 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),
),
],
),
),
],
);
}
}

View File

@ -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!),
],
],
),

View File

@ -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);
}
}

View File

@ -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,

View File

@ -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
View 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;

View File

@ -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),
),
],
),
],
);
}
}

View File

@ -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];
}

View File

@ -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,
),
],

View File

@ -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;
}

View File

@ -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},

View File

@ -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(),
),
),

View File

@ -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'),
),
],
),
),

View File

@ -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
View 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,
),
],
),
],
),
),
),
);
}
}

View File

@ -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,
),
),

View File

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

View File

@ -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),
],
);
}

View File

@ -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)),
],
),
],
],
);

View File

@ -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';
}

View File

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

View File

@ -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
View 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]),
),
),
),
);
}
}

View 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]),
),
),
);
}
}

View File

@ -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;
}

View File

@ -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,

View File

@ -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(),
);
}
}

View File

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

View File

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

View File

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

View File

@ -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
View 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,
),
);
}
}

View 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,
};

View 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
View File

@ -1,6 +0,0 @@
__pycache__/
*.egg-info/
*.pyc
.pytest_cache/
.mypy_cache/
.ruff_cache/

View File

@ -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
View 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
View 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
View 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 "$@"

View File

@ -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