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>
- 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>
Filter-sheet tests now scroll Apply into view (tapApply): the sheet is
scrollable and Apply legitimately sits below the fold on a 600px viewport.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Four optional note fields (repo, done_check, by, evidence) in the crdt_sync
record, written only when set, so editing a plain idea stays wire-identical
to v3 (note_repository_sync_compat_test). The log is schemaless: no stored
schema version; the legacy sqlite reader stays at v3.
NoteFilter gains a sources facet (ideas / every repo task / one repo);
search also matches the repo. The notes list shows only open ideas by
default (no badge); the filter sheet adds Ideas, Repo tasks and one chip
per repo when repo tasks exist. The per-note sheet shows the task fields
read-only. BACKLOG.md markers carry the new attributes, entity-escaped.
CLI: --repo --done-check --by on add, --close <id> --evidence (refused
without), --list [--repo] [--status], --search, and every id flag takes a
unique 8-character prefix. --list prints id8, status, priority, repo, title.
install_hooks.sh: the wrapper finds pre-commit's generated hook next to
itself, so commits from a git worktree (where .git is a file) work.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
📎 now picks any file (Android: Camera / Gallery / File; desktop: an
unfiltered dialog) and the desktop page accepts any dropped file. Photo and
screenshot formats up to 64 MB are still re-encoded and shown inline;
everything else is stored byte for byte on dufs beside the images and linked
as [name](url), shown as a row with its size that opens in the platform's
app (Android) or a new browser tab (desktop). BACKLOG.md rewrites file links
to ~/data/cloud/todo-images/ like image links.
There is no size cap, so no layer holds a file in memory: uploads and
downloads stream through DufsImageClient, writes land in a .tmp renamed when
whole (an interrupted download no longer leaves a truncated file that looks
done), the desktop page POSTs the browser's own Blob with fetch, and Android's
"File" uses file_picker, because file_selector's Android side reads the whole
file into memory on the UI thread. Verified with a 1 GB file end to end on
desktop (peak RSS +36 MB) and on an API 36 emulator.
The wrapper serves attachments from the origin that holds the GitHub token
and the notes, so only script-free types open inline (nosniff), the rest
download; writes carrying another site's Origin are refused (this also
closes cross-site overwrites of the backup files); and attachment routes run
concurrently so a long upload or a playing video cannot stall the page.
Also: cupertino_icons 2.0.0 (unused, but the freshness gate blocks the
pubspec otherwise), open_file 4.0.0 and file_picker 13.1.0 pinned.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A ref is about to name any file stored under todo-images/, not only a
re-encoded JPEG. Mechanical rename of the type and its file; no behaviour
change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both desktop writers build the directory by string interpolation
(`Directory('$home/todo')`), which the ~ reorganisation's reference
rewriter cannot see. The first export after the move recreated a stray
~/todo and tripped the home-tidy root allowlist.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwtowrowDXx6ccmWVPkCaF
Also bumps Flutter 3.47.3, flutter_secure_storage 11.1.0, idb_shim 2.9.9,
sqflite_common_ffi 2.4.3, very_good_analysis 11 and mcp 2.2.0 so the
freshness gate lets this through; v11 adds async_return_with_no_await, so
the 18 flagged return sites now await explicitly.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwtowrowDXx6ccmWVPkCaF
todo was the donor for both halves of the GitHub device flow -- its
`lib/sync/github_device_auth.dart` was byte-identical to home_inventory's,
and `lib/ui/device_code_dialog.dart` was the public version of the modal
diet-guard and wake-alarm each carried privately. All of it now lives in
`github_device_auth` in the kuhyx/utils monorepo.
The package is not a straight copy of what left here: it takes the more
capable variant of each divergence found across the four apps, so this app
gains two fixes it never had --
* `pollForToken` retries a transient `ClientException` instead of
failing, because GitHub can close the connection at the moment the
user authorizes (wake-alarm's fix)
* the dialog catches `on Object`, not `on Exception`, so an
`ArgumentError` surfaces as an error message instead of leaving the
dialog spinning forever
The local `test/github_device_auth_test.dart` is deleted; its cases live in
the package now, where they gate all four consumers. 300 tests pass and
`test/github_mirror_device_flow_test.dart` -- which drives the shared
dialog end to end -- is among them.
Deployed to the phone as versionCode 17 via phone_deploy.sh (signer
verified, `adb install -r`, no uninstall): launches with all 109 notes
intact.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno
Both files were donors for the new package rather than casualties of it:
theme.dart was byte-identical to untools' copy (md5 6cf8e82d), and
confirm.dart's confirmDestructive() was the ready-made pattern for the
~35 AlertDialogs inlined across the fleet. Keeping local copies after
promoting them upstream is exactly how the two would drift apart again,
so they are deleted and imported from design_system-v0.1.0 instead.
test/confirm_test.dart moves upstream with its subject -- the package
covers that behaviour (Escape and the barrier both refuse, Cancel is
autofocused so a reflexive Return cannot delete) as part of its own
100% gate. test/theme_test.dart stays as a consumer-side smoke check
that the dependency actually resolves to the frozen palette.
Verified: flutter analyze clean (bar one pre-existing bin/ style info),
308 tests pass at 100% coverage (1962/1962 -- the drop from 2005 is
exactly the promoted lines), and the release APK is installed and
running on the Pixel 6a with all 108 notes intact, the gold #B8862E
focus ring rendering from the package's inputDecorationTheme, and no
crashes in logcat.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XS1m7b1piBtAL22USLW6jL
Editing an existing note looked nothing like capturing a new one: it
always showed the template dropdown, the View/Guided/Raw pills and the
priority/status row. The cause was one missing argument -- both screens
already embedded the same NoteEditor, but NoteDetailScreen never passed
advancedMode, which defaults to true in the widget while the app-wide
AppSettings.advancedMode defaults to false.
Extract the shared form as three single-purpose components rather than
one widget owning several jobs:
- note_meta_row.dart renders the priority/status row (was
capture_meta_row.dart; drops the private _MetaDropdown duplicate the
detail screen carried, which used DropdownButtonFormField where the
shared one uses DropdownButton).
- note_form_chrome_gate.dart owns chrome visibility and nothing else,
replacing the _chromeVisible flag both screens copy-pasted.
- note_form.dart composes those two with NoteEditor.
appSettings is now a required parameter through NotesListScreen and
NoteDetailScreen. Required, not defaulted: a silent default is exactly
what caused this, so it must not be reintroducible.
Also in the capture app bar: drop the decorative Icons.list_alt_outlined
beside the note count, since the adjacent Icons.list button already names
what is counted, and move the count to the right of that button. The
finalize-and-clear action becomes Icons.check instead of Icons.add -- it
never saved anything (every keystroke is already persisted), so a plus
sign misdescribed it.
314 tests, 100% line coverage. Verified in a running web build that edit
and capture render identically with advanced mode both off and on.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
356 -> sync_service_test (133) + sync_service_revision_test (96) +
sync_service_fakes (163).
The underscores came off every shared fake: Dart privacy is library-scoped,
so `_FakeRemote` declared in one file is invisible to the next. `_peerLog`
became `buildPeerLog` rather than `peerLog`, because two tests declare a
local variable of that name and the helper would have been shadowed by its
own caller.
The `_revisionTrackingTests()` indirection is gone -- that function existed
only to keep main() readable, which is now the file boundary's job.
Verified: 313 tests pass, 100% coverage. Only CLAUDE.md and
docs/desktop-performance-findings.md remain over 250 lines.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every split is by test-group/concern, with the shared setup moved to a
sibling helper the splits import. All 313 tests are preserved -- the count
is unchanged and `test('…')` names are byte-identical to the originals
(verified by diffing the sorted name lists).
note_repository_test 677 -> 4 files + note_repository_helpers
capture_screen_test 660 -> 4 files + capture_screen_harness
note_editor_test 502 -> 3 files + note_editor_harness
port_guard_test 405 -> 3 files + port_guard_fakes
wrapper_server_test 402 -> 2 files + wrapper_server_harness
settings_screen_test 391 -> 2 files + settings_screen_harness
github_mirror_screen_test 365 -> 2 files + github_mirror_harness
notes_list_screen_test 347 -> 2 files + notes_list_harness
analytics_service_test 300 -> 2 files + analytics_helpers
Helpers are deliberately NOT named `*_test.dart`; the runner would collect
them and fail on the missing `main()`.
Three things did not survive a mechanical split and were handled explicitly:
* wrapper_server's `enabledOrigin` closes over the `late root` that setUp
assigns, so a top-level function cannot see it. It is now a
WrapperHarness class with an install() that registers the setUp/tearDown
pair -- the only structural change in this commit.
* note_repository's splits each repeat `setUpAll(sqfliteFfiInit)`, and
analytics' each repeat `@TestOn('vm')`. Omitting either fails quietly
(uninitialised sqflite / wrong platform) rather than loudly.
* capture_screen's `recordingMock` was declared mid-file between two
tests, so it moved to the harness rather than being dropped.
Verified: 313 tests pass (same as before the refactor), 100% coverage.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Splits the old single Settings screen into two: "Enable advanced" plus
links to a Sync settings route (Firebase + notes backup, now the shared
sync_settings_ui package used across every kuhy app that syncs) and an
Advanced sync (GitHub) route. GitHub stays app-local rather than moving
into the shared package: connecting it here also triggers a real note sync
via runSync(), not just a settings save, which the generic package
intentionally doesn't do for any app.
313/313 tests passing, 100% line coverage on both new/changed screens,
flutter analyze clean.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KdSp6Nbrs9xvsWkCMDN9a1
Hide priority/status/template/mode chrome and routine sync-status text
behind a Firebase-synced "Enable advanced" setting (default off); remove
the Capture title and Save button in favor of autosave + a plain reset
icon; sort notes by priority then recency; add interaction-only usage
analytics buffered locally and flushed to Firebase alongside note sync.
Code review (self + follow-up verification pass) found and fixed 3 real
bugs during this work: a widget-key placement bug that discarded an
in-progress draft when advancedMode was toggled mid-typing, a data-loss
bug in the analytics buffer's flush-and-clear logic under concurrent
cap-eviction, and two spots where an expired/wrong-account Firebase
session (FirebaseAuthError) escaped a narrower catch clause and would
have failed an otherwise-successful sync pass.
Also fixes an unrelated pre-existing test-isolation bug in
wrapper_server_test.dart, which was reading the real
~/.config/todo/firebase_auth.json instead of a test-scoped path.
320 tests, 100% line coverage on all touched files (one pre-existing,
out-of-scope 5-line gap in settings_screen.dart unrelated to this
change).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The account's password grant is retired fleet-wide (see link_google.py /
seed_session.py in ~/utils/crdt-sync), so desktop's Firebase form could never
have connected regardless of provisioning. Desktop-as-web can't use the
in-browser Google Sign-In button either (google_platform_web.dart hard-codes
googleSignInSupported = false), so it needs the same session-seeding path the
Python daemons already use: seed_session.py now includes todo, and the
wrapper's new /sync-credentials route serves that seeded session to
loadAccount() once per launch, then closes for the rest of the process.
Also stops the status line reporting a bare "Synced" while running over the
GitHub mirror alone with Firebase disconnected -- SyncResult now carries
firebaseConnected so the UI can say "GitHub-only (Firebase not connected)".
Includes the prior session's Google Sign-In one-tap addition
(google_sign_in_backend.dart and friends), which this builds on.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FPsyKQRX8ZwJpUqg37jp83
Fleet audit for the bugs found on diet-guard's desktop; two were live here.
`_autoSync` and the manual `_sync` both gated on `settings.isConfigured`,
which means "has a GitHub token". A device connected only to Firebase would
therefore never sync and would be told to "Add a GitHub token" -- advice that
is wrong now and will be wrong for every device once the mirror retires. Both
accept either backend, and the prompt names Firebase first.
This only escaped notice because this machine happens to hold a GitHub token;
diet-guard's desktop, which does not, never synced at all.
`loadAccount()` also swallowed its exception whole, hiding why a device could
not read its stored account -- indistinguishable from "no account set", and
the single most useful diagnostic when provisioning fails. It now logs at
error level with the stack trace and still returns null, so the settings
screen behaves exactly as before.
The unconfigured-Sync test now asserts the routing (unconfigured -> settings)
rather than one snackbar phrasing, which is the behaviour that actually
matters and does not break on wording.
256 tests, analyzer clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177xwzcpfpWG2qca8Mr39q9
The route read its own configuration from Platform.environment and $HOME, so
the only branch a test could reach was "disabled" -- the serving path, the
missing-config path and the malformed-email path were all unreachable without
mutating the process environment.
Both are now constructor parameters defaulting to the environment, so
production behaviour is unchanged and tests drive all four outcomes: 404 when
disabled, 200 with the account when enabled, 404 when ~/.config/crdt-sync/ is
incomplete, and 404 when firebase.json has no usable email. The password's
trailing newline is stripped, which the test pins.
256 tests, analyzer clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177xwzcpfpWG2qca8Mr39q9
The Firebase backend has been wired up since the cutover commit, but nothing
ever called saveAccount(). openFirebase() therefore read an account that was
never written, returned null every time, and the app quietly synced over
GitHub forever -- on real hardware, regardless of what the tests said. The
same hole was found and fixed in diet-guard; it is still open in
home_inventory, workout_app, build_your_x and leetcode-guard.
Settings now has a Firebase section that stores the account and signs in
immediately, so a wrong password shows up on screen instead of as a silent
no-op later. A rejected sign-in clears the account rather than leaving it
half-stored.
The three UI problems kuhy hit in diet-guard are avoided rather than
repeated: GitHub moves into "Advanced (GitHub mirror)" instead of sitting
beside Firebase as a peer choice, and a connected account renders as
read-only text with a Disconnect button rather than an empty-looking form
that implies you still have to type something.
The keystore accessors are injectable, like firebaseFactory already was, so
the connect/reject/disconnect branches are testable without a platform
channel. 252 tests, back to 100% line coverage.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017FH32BgGQbQLDstBhR26Xa
Firebase becomes the primary backend when this device has been set up for it;
GitHub stays as a mirror so a device that has not moved yet still converges.
Not being set up is a normal state, not an error -- sync runs over GitHub
exactly as before, which is also the rollback.
This app hand-rolled its own pull-merge-push instead of calling syncLog, so it
had no revision tracking at all: every sync downloaded all four peer note logs
-- roughly 150 KB each -- whether or not anything had changed. It now reads one
small revision map first and skips any peer whose hash is unchanged, and
suppresses its own push when its log is unchanged. That is the single largest
traffic saving in the fleet and what keeps this inside the free tier.
sync() now takes a RemoteStore rather than a GitHubClient, so choosing a
backend is a change at the call site and nothing more.
runSync() is the one place that decides how a sync runs. The capture screen
syncs on a timer and on demand and the settings screen on a button; each used
to build its own client, so a backend change meant editing four call sites and
hoping none was missed.
Credentials are split by what is safe to publish, because this repo is public:
the Web API key and database URL ship in source (both already ship inside the
APK, and the security rules protect the data), while the account email and
password are entered per device and live in the OS keystore.
247 tests at 100% line coverage. Two assertions changed from `contains('PUT')`
to `isNotEmpty`: those tests sync twice, and the second push is now correctly
suppressed as a no-op -- that suppression is the point.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SJ9sxEw3ymzXNjL7SHNpJc
Launching the app died with an unhandled SocketException whenever anything
already held the wrapper port -- from `todo` and from `./run.sh` alike, since
run.sh execs the same entry point. What was holding it was a wrapper from an
earlier session that had outlived its window: /proc/<pid>/exe read
"/opt/todo/bin/todo_desktop (deleted)", i.e. install_arch.sh had replaced the
binary underneath a process that kept serving anyway.
That orphan is manufactured by the handoff branch in bin/todo_desktop.dart.
When the launched browser exits within 5s the launcher assumes it handed the
URL to an existing window and keeps serving rather than pulling the server out
from under it -- right at that moment, but nothing ever reaps the server once
that window closes. It then survives an upgrade and blocks every later launch.
The port cannot simply move: the browser keys localStorage (the GitHub token)
and IndexedDB (the notes) by origin, so a fallback port would present an empty,
logged-out app. lib/desktop/port_guard.dart therefore decides who owns 8730 and
acts: a wrapper with no window attached is stale, so it is terminated
(SIGTERM, escalating to SIGKILL) and the port rebound with the current build; a
wrapper that still has a window is joined, leaving exactly one server; anything
else is named and refused. Reaping is what makes an upgrade self-healing, so
install_arch.sh needs no restart step of its own -- one mechanism, not two.
It fails closed at every step. `ss` hides the owning pid for another user's
socket, and that is a real branch rather than an impossible one: it resolves to
"refuse", never to a blind kill or a retry loop.
Two things measurement contradicted, both of which had to change the design:
Re-running the Chrome command does not focus the existing --app window, it
opens a second one (verified on Thorium: the process returns in ~180ms and the
window count goes 1 -> 2). So the join path opens a window instead of printing
and exiting; both windows share the one server and one IndexedDB, and typing
`todo` to get nothing visible would be the worse outcome.
Chrome also rewrites its own argument vector into a single space-joined string,
so /proc/<pid>/cmdline for a browser contains no NUL separators at all. Exact
token matching therefore never fired, and the first run of the live-window case
reaped the server under an open window. Both matchers now tolerate either
layout. The direction of that error matters and is commented as such: a false
negative kills a server someone is using, a false positive merely opens a
spare window.
The decision logic lives in lib/ rather than in bin/, which is
coverage:ignore-file -- otherwise the one path that has to work would be the
only untested code in the repo. Host access sits behind an injected SystemProbe,
and the ss parser is additionally exercised against genuine `ss` output by
binding a real socket, since a fixture cannot catch a format change.
Verified by running it, not only by tests: orphaned dev wrapper, orphaned AOT
wrapper from the installed package, live window, a foreign owner (python
http.server -- named, exit 1, and confirmed still alive afterwards), ./run.sh,
and plain `todo`. 238 tests, 100% line coverage held.
CLAUDE.md picks up the port guard, and drops two stale claims: the suite is 238
tests taking 1-2min, not 191 tests in ~5s -- that drift predates this change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both delete paths -- the per-note actions sheet and the detail screen -- deleted
immediately, with no confirmation and no undo. With a pointer that is merely
risky: you have to travel to the control and press it. With a keyboard it is
one Tab away from a Return you were about to press anyway, and the note is gone
with nothing to recover it.
The confirmation is built so the SAFE option is what a reflexive keypress hits:
Cancel is autofocused, Escape dismisses as "no", and the destructive button is
error-coloured but deliberately not the default action. Escape is bound
explicitly rather than relying on the barrier, because the desktop surface here
is a Chrome --app window where that cannot be assumed -- and a confirmation the
keyboard cannot back out of is worse than no confirmation at all.
The actions sheet also gains isScrollControlled. Default bottom sheets cap at
9/16 of the viewport, i.e. ~432px on a 768px screen, and this sheet's height
grows with the note title and its chip rows -- so Delete could be clipped out of
reach on the very screen this app is used on.
The two existing delete tests correctly failed once confirmation landed; both
now go through the dialog, and each gains a cancel-keeps-the-note counterpart so
the negative path is covered too. 204 tests, 100% line coverage held.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A third of the freeform notes in the backlog are nothing but a pasted
URL, and the capture screen still offered them the seven-step spec
stepper. NoteTemplate.forDraft now picks `blank` for a draft that is
only links, so filing a link costs one paste.
The switch runs on every raw keystroke and is lossless in both
directions — it only ever fires while the raw body is the source, so
there are no section values to convert. Type prose alongside the link
and the spec template comes straight back. An explicit choice from the
dropdown or the guided wizard sets _templatePickedByUser and is never
overridden.
isBareLink is deliberately strict: one word of context ("read this
https://…") means the note has prose in it and keeps the spec template.
Verified on the phone (release APK, adb install -r): typing a bare URL
flips Template to Blank and drops the Guided segment; appending " port
this to Dart" restores LLM design spec and Guided. Test note created by
that check was deleted and the deletion synced (back to 49 notes).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Audited the template against 514 real Claude sessions (1503 top-level
prompts) joined to the 49 notes in BACKLOG.md. Sections were judged on
whether the agent actually read them, not on fill rate — four of them
said "leave blank if none" in their own helper, so a low fill rate was
the section working correctly.
Measured over the 22 note/session pairs:
estimate filled 22, referenced 1 (4%)
done filled 22, quoted back 4 (18%)
refs filled 14, URLs fetched 5 (35%)
tech the agent opened pubspec.yaml/CLAUDE.md anyway in 54%
ask 100% ask rate with it, 92% without — no measurable effect
Kept: title, what, where, must, done. Dropped: tech (into `where`),
ask, nice and never (into `must` as `optional:` / `must not:` lines),
depends, estimate. Renamed `refs` -> `read first`. Added `verify`, the
only addition: "test it yourself, on the phone" was the single most
repeated correction in the corpus, and 3 of the 7 filled `never`
sections were already smuggling it in.
Fixes a latent data-loss bug found while testing this. parse() treats
an unknown `## heading` as content on purpose (values may contain
subheadings), so a legacy `## tech` block was folded into the preceding
section's value AND the note still reported conforming — opening any
old note in the stepper would have swallowed it on the next save.
NoteTemplate.retiredLabels now makes those notes explicitly
non-conforming, so they open raw and untouched.
tool/migrate_backlog.dart converts an old export (export -> migrate ->
import). It is idempotent and gated: it throws if a rewritten note does
not conform or if any source line vanishes. All 49 notes migrated on
the desktop and merged to the phone (0 new, 25 updated, 24 unchanged).
Also formats the few files dart format had never been run over, and
extracts a local in frame_stats_io.dart so the reflowed line stays
under 80 chars.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replaces the inline ColorScheme.fromSeed(Colors.deepPurple) theme with a new
lib/ui/theme.dart built from explicit light/dark ColorSchemes on the frozen
unified-design-system tokens (same shared spacing/radius/type-scale constants
as diet-guard/habit_stack's theme.dart), and adds darkTheme (previously
absent).
Fixes 2 of 3 violations in DESIGN_AUDIT_TODO.md:
- settings_screen.dart's one hardcoded Colors.red -> colorScheme.error,
matching every other error-state text in the app
- markdown_view.dart and note_editor.dart's raw-mode TextField now cap their
reading/editing column at kProseMaxWidth (640) via Center+ConstrainedBox --
the desktop build is an arbitrarily wide Chrome --app window, so text was
running the full viewport. Deliberately no extra Column introduced around
the raw-mode TextField, per its own documented expands:true/nested-Column
regression history.
The 3rd violation (a few off-scale SizedBox/EdgeInsets values, e.g. 6px vs
the file's dominant 4px scale) was attempted but reverted: bumping the
_EnumChips/_MultiChips/_DateRangeField 6px gaps to 8px broke 3 real tests --
the filter bottom sheet's "Apply" button is tight enough against its edge
that even a 2px shift pushes it out of the hit-testable area. Not worth
trading a working control for a cosmetic 2px scale nitpick; left at 6px.
Verified: flutter analyze clean, dart format clean on all touched files,
188/188 tests passing (added test/theme_test.dart for the new file, which
had 0% coverage on its own), 100% total line coverage maintained, re-verified
identical in an isolated worktree at HEAD (narrowly staged around this
repo's own pre-existing uncommitted CLAUDE.md edit, left untouched). A
Flutter-web headless-Chrome screenshot check for the desktop target was
attempted but blocked by a CanvasKit/WebGL rendering issue in this sandbox,
unrelated to the app itself; the widget test suite (which does catch real
RenderFlex overflow, as confirmed during this same rollout) passed clean for
both touched screens instead. Mobile on-device verification (phone-deploy)
is pending -- the phone isn't connected via ADB right now.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Backup recovery and file import both wrote restored notes through `upsert`,
which stamps a fresh clock. Under crdt_sync's per-field last-writer-wins that
makes a *restore* outrank every other device: the restoring device claims it
edited every field just now, so it silently overwrites newer edits made
elsewhere on the next sync.
This is not hypothetical. The new desktop app seeded itself from BACKLOG.md and
rewrote 245 of 330 field clocks to today under a fresh node id, then synced --
taking authority over the phone for ~74% of all fields. Content happened to
survive (the copy came from the same data; only a trailing newline on 5 notes
differed via the Markdown round-trip), but nothing about the mechanism
guaranteed that.
The legacy sqlite migration already did the right thing for exactly this
reason, so the fix is to use that same helper everywhere a note is *restored*
rather than newly written. Renamed legacyRecordFor -> recordAtNoteTime, since
it is no longer migration-specific.
The freshness check in importNotes is unchanged: it decides whether the import
wins *locally*. The clock decides whether it wins on *other* devices, and an
import must not win there merely by being recent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The desktop app is a browser now, so it cannot write files. This adds the other
half: a loopback HTTP server that serves the web build and owns the two files
the browser cannot touch -- `~/todo/BACKLOG.md`, which the user's tooling and
the todo MCP server read, and a disk copy of the note log so a wiped Chrome
profile is not a total-loss event for unsynced notes.
Notable details:
- Binds to 127.0.0.1 only. The endpoints overwrite files in the user's home
with no authentication, so a routable bind would let anything on the network
rewrite the backlog.
- Serves explicit content types. CanvasKit refuses a `.wasm` labelled anything
else and the app then renders nothing at all.
- Mirroring to disk is debounced. The store persists on every keystroke, so
mirroring eagerly meant an HTTP request plus a whole-log write per character;
IndexedDB still gets every keystroke, only the durability copy waits.
Separately, this fixes a pre-existing destructive bug found while testing:
`settings_screen_test` did real file I/O and overwrote the user's canonical
`~/todo/BACKLOG.md` with a single fake note on *every* `flutter test` run. It
went unnoticed because the app rewrites the file whenever it launches. The
export destination is now injectable and the test redirects it to a temp dir;
verified that a full suite run leaves the real file byte-identical.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
First step of moving the desktop app off Flutter's Linux embedder, which tops
out at ~20fps at 4K (see docs/desktop-performance-findings.md). Android is
unaffected and keeps every existing code path.
The blocker was that `dart:io` was imported by note_repository, capture_screen
and settings_screen. On web it compiles to a stub and then throws at runtime --
the first web build rendered a blank white window. So each `dart:io` use now
sits behind a conditional export, with the shared logic hoisted into pure Dart:
- note_repository loses `dart:io`/sqlite entirely. Platform wiring moves to
repository_factory{,_io,_web}, and the legacy sqlite import moves to its own
io-only library. The HLC-from-updated_at seeding is preserved verbatim, so
independently-migrating devices still resolve pre-cutover edits by real edit
time.
- Notes persist to IndexedDB on web, not localStorage: the log is the primary
copy of the backlog and localStorage caps at ~5-10MB and is evicted eagerly.
- BACKLOG.md and the explicit Export keep working through a local wrapper
(added next). A browser cannot write `~/todo/BACKLOG.md`, and the user's
tooling plus the todo MCP server read that path, so leaving it to rot was not
an option. Writes are best-effort so the app still works with no wrapper.
- Notes are also mirrored to the wrapper's on-disk copy, so a wiped Chrome
profile is not a total-loss event for notes that have not reached GitHub yet.
`openRepository` is split so `openRepositoryIn` keeps the migration tests; the
suite stays at 174 green and 100% coverage.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The desktop and phone split-brained for 5 days (39 vs 30 notes) after the
crdt_sync cutover because one device kept running the pre-migration build
— and nothing in the UI ever said a sync had failed or skipped a peer:
- SyncResult now reports peer files that were listed but couldn't be
fetched/decoded (skippedFiles) instead of dropping them silently.
- Auto-sync no longer swallows errors: every sync (manual or automatic)
records a persisted last-sync status shown in a status line under the
editor, with failures in the error color.
- Desktop actually background-syncs now: focus loss (inactive/hidden,
debounced 5s) triggers a sync — Flutter Linux rarely delivers the
`paused` event the old code relied on. `paused` still syncs immediately
for Android.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K5C5jZ2c4mTaGedK7C1dDv
crdt_sync v0.3.0 now provides canAccessRepo() + deleteFile(), so todo syncs
through the one shared client like the other apps instead of its own copy.
SyncService loses its SHA-tracking (crdt_sync's putFileText self-resolves the
sha); lib/sync/github_client.dart + its test are deleted; capture/settings/
sync_smoke repoint to the shared client. flutter analyze clean, 169 tests at
100% coverage, device-verified (27-note backlog intact, no crash).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019KwUCPESvE5CjCtmUFz8xQ
NoteRepository is rewritten on crdt_sync's LogStore: each Note is stored *as*
a Record (per-field LWW + sticky tombstone), so the on-disk log IS the sync
wire format. Its public API is unchanged (upsert/delete/listNotes/watchNotes/
watchCount/importNotes/changes/nodeId), so the UI and most tests are
untouched; only the internals, the two sync methods (getChangeset/merge ->
exportLog/importLog over a Log), and the changeset-specific tests change.
Filtering/sorting now run in Dart over the in-memory log; watchNotes/watchCount
seed synchronously in onListen to avoid a subscription race.
SyncService moves from per-device sqlite changesets to per-device crdt_sync
Logs under a new todo-sync/notes/ path (clean format cutover; old clients
can't misread a new file). todo's own GitHubClient transport is kept.
One-time migration in NoteRepository.open: reads the legacy sqlite DB (kept
untouched as a safety net; the dep stays only for this), seeds every note --
tombstones included -- into the log with each field's HLC derived from its
updated_at (so a pre-cutover edit still wins by real edit time across two
independently-migrating devices), reuses the legacy node id, and sets a
persisted "migrated" flag so it never re-runs.
Verified: flutter analyze + dart format clean; 178 tests at 100% coverage
(incl. migration, tombstone, restore-race, and corrupt-peer cases). Device
verified on dev.kuhy.todo: real backlog of 27 live notes (+5 tombstones)
migrated intact -- confirmed against a pulled sqlite backup, the pulled
LogStore JSON, and the app's own "27 saved" -- notes render with status/
priority/dates, no crash, migration one-time.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019KwUCPESvE5CjCtmUFz8xQ
The capture screen fed the on-disk BACKLOG.md backup off an always-live
watchNotes() subscription, so every keystroke re-queried the whole table,
re-parsed every row, and rebuilt the full Markdown export on the UI
isolate — O(notes) per character. It also fired a whole-screen setState
just to update the "Saved locally" timestamp.
- Add NoteRepository.changes: an O(1) broadcast tick after each write
(upsert/delete/merge; importNotes transitively). LocalBackup now pulls
notes via an injected fetcher only when its 2s debounce fires, so the
query + parse + Markdown export runs at most once per idle window
instead of on every keystroke. Capture listens to changes, not
watchNotes; a launch-time schedule() keeps BACKLOG.md fresh.
- Scope the save indicator to a ValueNotifier/ValueListenableBuilder so a
keystroke updates one line of text, not the AppBar/dropdowns/editor.
- Key list tiles by note id for identity-based reorder diffing.
Measured on a 4K/144Hz desktop the UI-thread build stays <=3.3ms;
coverage held at 100% on all changed files.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
todo-sync, diet-guard-sync, and screen-locker-sync were folded into
one private github.com/kuhyx/syncs monorepo, each keeping its own
subdirectory as the Contents-API path prefix. Update the default
owner/repo, the changeset path prefix, and add a persisted-value
migration so an already-installed device that saved the old
'todo-sync' repo name picks up 'syncs' on next load.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Replace the vertical Stepper widget with a full-screen single-step
view: progress bar, label, helper text, and a tall TextField that
expands to fill the available height. Navigation is Next/Back buttons
only; the final step's Next becomes Done which exits to Raw.
Removes _stepKeys (scroll-into-view keys) and the async _goToStep
(Future.delayed + Scrollable.ensureVisible) since neither is needed
without a collapsing Stepper. _goToStep is now synchronous.
Nav buttons sit below an Expanded area so they stay above the soft
keyboard when resizeToAvoidBottomInset resizes the Scaffold.
Tests: replace find.byType(Stepper) with find.byType(LinearProgressIndicator),
rewrite the navigation test, add three new tests (progress counter,
last-step Done, Done exits to Raw).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Cb7oE5Xsc8zSBHHtT6qwa
Entering Guided on an empty draft now runs a two-step wizard (priority,
then template) before showing the stepper. CaptureScreen defaults to Raw
mode so a quick capture stays in the single-field flow; switching to
Guided opens the wizard. Chrome (template/mode bar and priority/status
row) is hidden while Guided or the wizard is active.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Cb7oE5Xsc8zSBHHtT6qwa
Scrolling on the next post-frame callback raced the Stepper's own
200ms expand/collapse transition, so ensureVisible measured a layout
that hadn't settled yet — the tapped step ended up hidden under the
fixed Priority/Status/Template bar. Delaying past kThemeAnimationDuration
before scrolling fixes it; verified on-device via ADB screenshots.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The token moved from plaintext SharedPreferences into flutter_secure_storage
(Android Keystore / libsecret). Only the non-secret owner/repo/clientId stay in
prefs. Migration is confirm-before-delete: load() reads the keystore first and
falls back to the legacy plaintext token, migrating it only once a secure write
succeeds; save() likewise keeps writing plaintext if no secret service is
available, so we degrade to — never below — the old behaviour.
157 tests, 100% line coverage, analyze clean. Verified on-device: Settings
stays connected and sync succeeds after the one-time migration.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Connecting via the device flow saved the token to prefs but had no effect:
the capture screen cached its settings from launch and only re-adopted them
when Settings was popped *with a result*, which the connect flow never did.
So auto-sync kept using stale (token-less) settings (notes never downloaded)
and reopening Settings re-seeded the fields from the stale settings (empty
token → Test connection failed, Connect restarted every time).
- settings: on a successful connect, save then run a sync right away and
report the result ("Connected and synced …") so notes download and the
user gets real confirmation, instead of the inert "Token saved on Save".
- capture: always reload settings from storage after returning from Settings,
so a device-flow connect (which saves without popping a result) is picked up.
- tool/device_flow_check.dart: standalone end-to-end device-flow probe used to
confirm the OAuth App + token + repo-access chain is healthy (it is); the bug
was purely app-side token application.
152 tests, 100% line coverage.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Previously "Connect GitHub" (OAuth device flow) still required entering an
OAuth App client id and owner/repo — friction that returned on every
reinstall once shared_prefs were wiped.
- Bake the app's own device-flow OAuth App client id in as
SyncSettings.defaultClientId and default to it in load() (alongside the
existing kuhyx/todo-sync repo default). A device-flow client id is a
public identifier, not a secret, so it is safe to commit.
- Settings now leads with a single "Connect GitHub" button; the manual
client-id / token fields and Test connection move under an "Advanced"
expander. Result: fresh install (or post-reinstall) is one tap →
authorize the code in the browser → synced. No tokens, no setup.
Note: an OAuth App authorizes with the classic `repo` scope (all repos),
broader than the prior fine-grained PAT — the trade-off for one-tap
device-flow convenience. 151 tests, 100% line coverage.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Third durability layer beside GitHub auto-sync and Android Auto Backup:
a plain, human/LLM-readable Markdown file kept current on local disk.
- LocalBackup (lib/sync): pure, injectable file IO. scheduleExport()
debounces writes (a burst of keystrokes → one export); recover() parses
the file back into notes. Reused NotesMarkdown serializer.
- CaptureScreen wires it: on launch, recover into an *empty* DB only (so a
stale backup never clobbers existing notes), then keep the backup current
as notes change. Platform path = ~/todo/BACKLOG.md on desktop (the path
the user's workflow already reads) or the app documents dir on mobile
(covered by Android Auto Backup). File IO is injected in tests.
- Added fake_async dev dep to unit-test the debounce with a virtual clock.
151 tests, 100% line coverage.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Manual sync left a large window in which local edits had not reached
the remote, and a reinstalled device only recovered after a manual
sync. Make sync automatic so the GitHub repo (the durable store) stays
near-current and recovery is near-automatic.
- Pull on launch (once settings load) so a reinstalled device pulls its
old changeset back without user action.
- Push when the app is backgrounded (AppLifecycleState.paused) so edits
leave the device promptly. Lifecycle-based, not per-keystroke, to keep
CPU/battery and GitHub API usage low.
- Best-effort: silent, skips when unconfigured, single-flight guard so a
launch sync and a background sync never overlap; failures are swallowed
(the manual Sync button still surfaces errors).
100% line coverage maintained.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Notes were previously only openable via a quick-actions sheet; you
could not read or edit a note in full. Add a shared NoteEditor used by
both the capture and detail screens, plus selectable templates and a
rendered Markdown view.
- note_template.dart: pure assemble/parse layer over a Markdown subset
(# title, ## sections + italic guidance, dropping empty sections).
assemble(parse(text)) is idempotent for conforming text; non-conforming
/ legacy / freeform text is reported so the UI falls back to raw,
untouched. Two templates: llm-design-spec (default) and blank.
- note_editor.dart: View / Guided / Raw modes. Guided is an inline
Stepper (one step per section with its guidance); View renders the
note via MarkdownView; Raw is the verbatim text. Guided is offered
only for structured templates; switching to it is blocked when the
raw text no longer conforms.
- markdown_view.dart: lean read-only renderer for the note subset,
wrapped in a SelectionArea for copy-out.
- note_detail_screen.dart: full-screen note; opens in View, edits
persist immediately, priority/status dropdowns, delete.
- capture_screen / notes_list_screen wired to the new editor and detail
screen (tap a note opens it; quick actions move to the overflow button).
The editor is a view over plain text, so CRDT storage and Markdown
export/sync are unaffected. 138 tests, 100% line coverage.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Inject an optional http.Client into CaptureScreen (mirroring SettingsScreen)
so the configured sync path runs against a MockClient instead of the network;
capture_screen.dart now 100%.
- Mock the file_selector and url_launcher platform interfaces and the clipboard
channel so the import flow, _openPage launch, and the device-code dialog's
error/Cancel/Open paths are exercised deterministically (no hangs, no timers).
- Add unit tests for the remaining fallbacks/defaults: copyWith no-arg paths,
GitHubApiException.toString, default-constructed clients, empty NoteFilter,
the v1->v2 status-column migration, and the export/import error branches.
- coverage:ignore the private static-only NotesMarkdown ctor.
101 tests, all green in ~5.5s. Line coverage 96.2% -> 100%.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Notes list & filtering:
- Text-search filter plus independent date-range filters for both created
and last-updated (AND-combined), a priority filter, and a new status
filter. Default view hides Done/Abandoned and renders as "unfiltered"
(no badge for the default state); fixed badge clipping.
- NoteSort options wired into the list UI; watchCount() for the "N saved".
Status & priority:
- New Status enum (toDo/inProgress/Done/Abandoned) as a settable + filterable
attribute on every note, with capture-screen dropdown.
- Removed "None" priority: every note is Low/Medium/High, default Medium.
Schema migration v2->v3 rewrites legacy priority 0 -> Medium.
Export / import:
- NotesMarkdown round-trippable single-file format with HTML-comment markers.
- Settings "Export notes" (mobile share sheet / desktop writes ~/todo/BACKLOG.md)
and "Import notes" (file picker + safe newer-wins merge by id).
Structured template:
- Every new note pre-fills the richer what/where/must/nice/out/done/depends/
estimate/refs scaffold.
Tests:
- New fast (~5s), deterministic suite via FakeNoteRepository (no DB timers) and
injected http/file-selector/url-launcher fakes. 86 tests, 96.2% line coverage
(note.dart & sync_service.dart at 100%, settings 98.7%). Mobile-only share
branch excluded via coverage:ignore (unreachable on the Linux test host).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>