Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
13 KiB
CLAUDE.md — todo
Offline-first, CRDT-synced notes app. Flutter, targets Android + web.
The desktop app is the web build, served by a small local wrapper
(bin/todo_desktop.dart) and shown in a Chrome --app window. Flutter's Linux
embedder was removed: it manages only ~20fps at 4K while the same Dart code in
Chrome sustains ~144fps. See docs/DOCS-desktop-performance-findings.md for the
measurements. Do not re-add linux/ — flutter create --platforms linux would
silently bring the slow path back.
Capture an idea instantly (persisted on every keystroke), browse/filter notes,
and sync peer-to-peer through a GitHub repo used as dumb storage.
- Package name:
todo(Dart SDK^3.12.2). - Git remote:
origin→github.com/kuhyx/todo-app. - The note content also syncs to a separate private repo
kuhyx/syncs(undertodo-sync/) via the in-app GitHub sync (changeset files); that is data, not this codebase.
Git workflow (repo-specific — overrides global rules)
- Never open pull requests. Always commit and work directly on
mainandgit pushtoorigin/main. Do not create feature branches for normal work. - End commit messages with the standard
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>trailer.
Commands
- Run tests + coverage:
flutter test --coverage(suite runs in under a minute; keep it quick — see Testing below). - Coverage summary:
lcov --list coverage/lcov.info. - Static analysis:
flutter analyze(must be clean before committing). - Format:
dart format lib/ test/. - Run the desktop app:
./run.sh(builds the web bundle, starts the wrapper, opens Chrome). Mobile:flutter runwith a connected Android device. - The wrapper serves on a fixed port 8730 with a fixed Chrome profile
dir. Both must stay fixed: the GitHub token (
localStorage) and the notes (IndexedDB) are keyed by origin and live in that profile, so changing either silently logs you out and hides your notes. Because the port cannot move,lib/desktop/port_guard.dartdecides what to do when it is already taken: reap a wrapper with no window attached (so an upgrade can't leave you on old code), join one that still has a window, and refuse to touch anything else. - 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): testsflutter test test/mcp/, deploymcp/install.sh; seemcp/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
Architecture
Three layers under lib/, each single-purpose:
data/— domain + local storage.note.dart:Notemodel;Priorityenum (low/medium/high, default medium, no "none");Statusenum (todo/inProgress/done/abandoned). Optional repo-task fieldsrepo/doneCheck/by/evidence: a note withrepois a repo task, one without is a plain idea.note_repository.dart: CRDT storage over the schemalesscrdt_syncLogStore;note_record.dartmaps a note to record fieldstext,priority,status,created_at,updated_at(+repo,done_check,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 inlegacy_sqlite_migration.dart. Also definesNoteFilter(query / priorities / statuses / sources / date ranges, AND-combined),NoteSort,watchNotes/watchCountstreams, andimportNotes(safe newer-wins merge by id).
sync/— GitHub-as-storage sync.sync_service.dart: each device owns one filetodo-sync/changesets/<nodeId>.jsonholding its full CRDT changeset. No two devices write the same file ⇒ no git merge conflicts; convergence is the CRDT layer's job (pull every other device's changeset andmerge(), which is commutative + idempotent).github_client.dart: thin GitHub contents-API client (injectablehttp.Client).github_device_auth.dart: OAuth device flow to mint a token.sync_settings.dart: persisted owner/repo/token/clientId.notes_markdown.dart: round-trippable single-file export/import format (HTML-comment<!-- @note id=… priority=… status=… … -->markers).
images/+attachments/— attached images and files (see "Images and other attachments" below).ui/— screens, all take an injectedNoteRepository.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). Under 600dp wide (kNotesGridMaxWidth) the notes are a 2-columnNotesGridof compactNoteGridTiles (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 paths andrunApp.
Note template (default content of every new note)
lib/data/note_template.dart is the single source of truth — the stepper,
the editor and the tests are all template-driven, so changing that list changes
every surface. A new note pre-fills a title line plus
what / where / must / done / verify / read first.
The set was cut from twelve sections to seven in 2026-07 after auditing 514 real
sessions (docs/DOCS-llm-design-spec-audit.md): estimate, ask, nice, tech,
never and depends were measurably never read, and verify was added because
"test it yourself, on the phone" was the single most repeated correction in the
corpus. Do not re-add a section without evidence that it gets used.
NoteTemplate.retiredLabels lists the dropped headings. parse() reports any
note containing one as non-conforming so it opens in the raw editor — without
that, the stepper would fold a retired section into its neighbour on the next
save. tool/migrate_backlog.dart converts an old export (Settings → Export →
migrate → Settings → Import).
Images and other attachments
A note never holds attachment bytes, only a line pointing at
https://kuhy-cloud.duckdns.org/todo-images/<noteId>/<uuid>.<ext>: an image
as , any other file as [name](…) (lib/images/attachment_ref.dart
owns the path, attachment_links.dart the links), so sync and older builds are
untouched. Anything picked or dropped goes through AttachmentIngest: a
JPEG/PNG/GIF/WebP/BMP/TIFF of at most 64 MB is re-encoded (≤2560 px, JPEG q85,
EXIF stripped after applying its rotation — image_processing.dart); every
other file, of any size, is kept byte-for-byte. There is deliberately no
size cap, so nothing may hold a file in memory: uploads and downloads stream
(DufsImageClient), the desktop page POSTs the browser's own Blob with
fetch (file_post_web.dart), and writes land in a .tmp renamed when whole.
ImageCacheStore keeps every linked attachment on-device (offline) plus a
.pending marker per queued upload, and reconcile uploads, prefetches and
evicts (never an empty ref set, never within 10 min; stale .tmps too).
Android talks to dufs directly with the todo login's password (Settings →
Cloud images) and opens files with open_file; the desktop page never talks
to dufs — the wrapper's /images/* routes do, with
~/.config/dufs/logins/todo.env, created by
~/src/dufs-cloud/scripts/add_dufs_login.sh todo /todo-images rw. The wrapper
serves attachments from the same origin as the notes and the GitHub token, so
only script-free types (raster images, PDF, plain text, audio, video) are shown
inline, always nosniff; everything else is a download
(wrapper_attachment_serving.dart). Attachment routes run concurrently, the
rest of the wrapper one request at a time. BACKLOG.md rewrites links to
~/data/cloud/todo-images/… so an agent can open them.
Testing
- 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 undercapped.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. (The "~5s" figure this file used to quote had drifted well before the port guard was added; measure before quoting a new one.) - Widget tests use
test/fake_note_repository.dart— aFakeNoteRepositorybuilt onStreamControllers, not a real DB. A realsqlite_crdtDB schedules timers that never drain under the widget tester's fake clock ("A Timer is still pending"); the fake avoids that. - Pitfalls learned the hard way (keep following these):
- Avoid
pumpAndSettlewhen a widget animates forever — an autofocusedTextField's cursor blink never settles, and an open device-code dialog keeps a pending poll timer. Use explicitpump(Duration)there.pumpAndSettleis fine once nothing is animating (e.g. a route/dialog pop, or a dialog already showing a static error). - Inject fakes rather than touching the platform:
MockClient(package:http/testing.dart) for HTTP; thefile_selectorandurl_launcherplatform interfaces (MockPlatformInterfaceMixin) for the picker/launcher; stubSystemChannels.platformfor clipboard. - To exercise the configured sync path, pass a
MockClientviaCaptureScreen(httpClient: …)/SettingsScreen(httpClient: …)— the realSyncServicethen runs without network. getChangeset()serialises HLCs asString;merge()needsHlc.parseand fresh mutable maps (QueryRows are read-only) — see the changeset test.
- Avoid
- Use
// coverage:ignore-line/ignore-start/ignore-endonly for genuinely unreachable code (e.g. a private static-only constructor, or a mobile-onlyPlatform.isAndroidbranch that can't run on the Linux test host), with a one-line reason.
Conventions
- Run
dart format+flutter analyze(clean) before every commit. - Comments explain intent/trade-offs, not syntax.
- Keep the app buttery-smooth and low on CPU/RAM — it's a quick-capture tool.
- Every file is at most 250 lines, source and prose alike. Enforced by a
pre-commit hook (
scripts/check_file_length.sh, delegating to the shared gate in~/src/utils) and by.github/workflows/file-length.yml, so it fails the commit rather than being a note anyone can ignore. Install the hook on a fresh clone withscripts/install_hooks.sh. - Only non-mutating checks go in
.pre-commit-config.yaml. pre-commit stashes unstaged changes and restores them withgit apply; a hook that rewrites a tracked file inside that window makes the restore conflict, which aborts the commit and drops the developer's uncommitted edit. That is whybump_patch_version.shruns from the wrapper hook thatinstall_hooks.shwrites, after pre-commit has exited and only when it passed — see the comments in both files. There is deliberately no baseline and no allowlist — split the file instead. Exemptions (generated, vendored, data files) live with the checker so the gate and the survey cannot disagree. - Where a split would otherwise break callers, keep the original path as a
barrel that re-exports the parts (
note_template.dart,firebase_backend.dart,note_repository.dartall do this). Where a widget and its privateStatemust stay one library, usepartfiles (note_editor_*.dart,capture_screen_*.dart).
Flutter/Dart AI rules
Upstream's Flutter/Dart guidance used to be pasted in below this line, which
made 85% of this always-loaded file a document nobody edits. It now lives in
docs/, linked rather than inlined so the part loaded on every turn stays
about this repo:
- Language, style and architecture
- State, routing and data
- Code generation, testing, theming and assets
- UI: theming, layout and overlays
- Colour, type, documentation and accessibility
Sourced from flutter/flutter docs/rules/rules.md
via ~/.claude/CLAUDE.md's Flutter AI tooling setup. Re-fetch periodically.