mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-10 20:47:01 +01:00
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
220 lines
13 KiB
Markdown
220 lines
13 KiB
Markdown
# 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`
|
|
(under `todo-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 `main` and
|
|
`git push` to `origin/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 run` with 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.dart` decides 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): 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`
|
|
|
|
## Architecture
|
|
|
|
Three layers under `lib/`, each single-purpose:
|
|
|
|
- `data/` — domain + local storage.
|
|
- `note.dart`: `Note` model; `Priority` enum (low/medium/high, **default
|
|
medium**, no "none"); `Status` enum (todo/inProgress/done/abandoned).
|
|
Optional repo-task fields `repo`/`doneCheck`/`by`/`evidence`: a note
|
|
with `repo` is a repo task, one without is a plain idea.
|
|
- `note_repository.dart`: CRDT storage over the schemaless `crdt_sync`
|
|
`LogStore`; `note_record.dart` maps a note to record fields `text`,
|
|
`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 in `legacy_sqlite_migration.dart`. Also defines
|
|
`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.
|
|
- `sync_service.dart`: each device owns one file
|
|
`todo-sync/changesets/<nodeId>.json`
|
|
holding 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 and `merge()`, which is commutative + idempotent).
|
|
- `github_client.dart`: thin GitHub contents-API client (injectable
|
|
`http.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 injected `NoteRepository`.
|
|
- `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. 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
|
|
paths and `runApp`.
|
|
|
|
### 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 `.tmp`s 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 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.
|
|
(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` — a `FakeNoteRepository`
|
|
built on `StreamController`s, **not** a real DB. A real `sqlite_crdt` DB
|
|
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 `pumpAndSettle` when a widget animates forever** — an autofocused
|
|
`TextField`'s cursor blink never settles, and an open device-code dialog
|
|
keeps a pending poll timer. Use explicit `pump(Duration)` there.
|
|
`pumpAndSettle` is 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; the `file_selector` and
|
|
`url_launcher` **platform interfaces** (`MockPlatformInterfaceMixin`) for
|
|
the picker/launcher; stub `SystemChannels.platform` for clipboard.
|
|
- To exercise the configured sync path, pass a `MockClient` via
|
|
`CaptureScreen(httpClient: …)` / `SettingsScreen(httpClient: …)` — the real
|
|
`SyncService` then runs without network.
|
|
- `getChangeset()` serialises HLCs as `String`; `merge()` needs `Hlc.parse`
|
|
and fresh mutable maps (QueryRows are read-only) — see the changeset test.
|
|
- Use `// coverage:ignore-line` / `ignore-start`/`ignore-end` only for
|
|
genuinely unreachable code (e.g. a private static-only constructor, or a
|
|
mobile-only `Platform.isAndroid` branch 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 with `scripts/install_hooks.sh`.
|
|
- **Only non-mutating checks go in `.pre-commit-config.yaml`.** pre-commit
|
|
stashes unstaged changes and restores them with `git 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 why `bump_patch_version.sh` runs from the wrapper hook that
|
|
`install_hooks.sh` writes, 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.dart` all do this). Where a
|
|
widget and its private `State` must stay one library, use `part` files
|
|
(`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](docs/DOCS-flutter-rules-language.md)
|
|
- [State, routing and data](docs/DOCS-flutter-rules-state-and-data.md)
|
|
- [Code generation, testing, theming and assets](docs/DOCS-flutter-rules-testing-and-assets.md)
|
|
- [UI: theming, layout and overlays](docs/DOCS-flutter-rules-ui.md)
|
|
- [Colour, type, documentation and accessibility](docs/DOCS-flutter-rules-design.md)
|
|
|
|
Sourced from [flutter/flutter docs/rules/rules.md](https://github.com/flutter/flutter/blob/main/docs/rules/rules.md)
|
|
via `~/.claude/CLAUDE.md`'s Flutter AI tooling setup. Re-fetch periodically.
|