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>
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
The package shipped feedback helpers while todo still had a private
_showSnack and a raw SnackBar in note_editor -- exactly the duplication
the extraction was meant to end, left live in a repo that had just
adopted the package.
Two deliberate behaviour changes come with it: the duration goes 2s ->
3s for toasts and 5s for errors (an error the user missed is an error
they will hit again), and messages now replace rather than queue, since
a queued snackbar describes an action the user moved on from seconds
ago. No test asserted on either, and the one snackbar assertion in the
suite checks that capture is *not* interrupted by one, which still holds.
Failures now read as failures: the mode-switch rejection in note_editor
and a failed sync in capture_screen use showError, while a successful
sync and the not-configured hint stay neutral toasts. Spending the danger
hue on routine confirmations is how a real failure becomes invisible.
Verified: flutter analyze clean, 308 tests pass at 100% coverage
(1964/1964). The release APK built and installed, but on-device
re-verification could not complete: the 23:00 night curfew removed the
package ~80ms after install, because dev.kuhy.todo is in the focus-mode
day WHITELIST but not NIGHT_WHITELIST. Enforcer working as designed, not
an app fault -- reverify after 05:00.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XS1m7b1piBtAL22USLW6jL
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>
Moving bump_patch_version.sh into .pre-commit-config.yaml introduced three
bugs, all reproduced in clean clones of HEAD before fixing and re-verified
after:
1. DATA LOSS. pre-commit stashes unstaged changes and restores them with
`git apply`. The bump rewrites pubspec.yaml inside that window, so an
uncommitted edit near the `version:` line made the restore conflict:
the commit aborted AND the developer's edit vanished from the working
tree, recoverable only from $PRE_COMMIT_HOME. Only non-mutating checks
belong in that file.
2. A commit rejected by the file-length gate still had its version bumped
and staged, riding into the next successful commit. `always_run: true`
with no `fail_fast` meant "ordered last" did not mean "runs last only
on success" -- which is exactly what the comment above it claimed.
3. `files: ^pubspec\.yaml$` narrowed the crdt_sync override check from
unconditional to staged-only, so a poisoned pubspec arriving by pull or
merge was invisible to every later commit that did not touch pubspec.
The hand-written hook this replaced checked it every time; that
narrowing quietly widened the hole the check exists to close.
The fix keeps the framework for the read-only gates and puts the mutation
back outside it: install_hooks.sh now writes a wrapper that runs
pre-commit's generated hook first and bump_patch_version.sh only if it
passed. Plus fail_fast, and always_run on the crdt check.
Also fixes three stale references this refactor created: a load-bearing
layout comment citing the renamed _buildRaw, bin/todo_desktop.dart naming
two split-away test files, and CLAUDE.md giving two different suite
runtimes.
Verified: unstaged pubspec edit preserved through a commit; a rejected
commit leaves nothing staged; a merged-in override is caught on an
unrelated commit. 313 tests, 100% coverage, gate exits 0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
github_mirror_screen.dart 368 -> 232 + device_code_dialog.dart (109) and
a widget part
wrapper_server.dart 336 -> 223 + wrapper_sync_routes.dart, the two
credential-provisioning routes
bin/todo_desktop.dart 253 -> 199 + lib/desktop/browser_launcher.dart
The fixed port moved into browser_launcher.dart as kWrapperPort, with the
entry point aliasing it, so the "must not move" reason is stated once next
to the code that acts on it.
lib/ and bin/ are now entirely under the cap. The two remaining analyze
infos (bin/todo_desktop.dart cascade_invocations,
capture_screen_sync.dart unnecessary_unawaited) are pre-existing --
verified by analyzing e0eb8b9 in a worktree, which reports the same two.
Verified: 313 tests pass, coverage unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
739 -> 249 lines. As with note_editor, the ~600-line State class needed
decomposing rather than moving:
capture_draft.dart the note being captured and its write-through
to storage -- the model behind "every keystroke
is already persisted". Lazy row creation stays
here; the screen just logs that it happened.
capture_sync_runner.dart one sync pass, with no BuildContext. This also
de-duplicates the two callers: the manual button
and the background tick ran byte-identical
runSync blocks differing only in the snackbar
and the 'auto' log flag.
capture_status.dart SyncStatusStore (the persisted last outcome) and
describeSyncResult.
capture_app_bar.dart the app bar.
capture_meta_row.dart the priority/status pickers.
capture_status_footer.dart the two status lines.
capture_screen_{widget,sync}.dart
parts, for the same reason as note_editor's.
The mounted checks are preserved exactly, including the one in
SyncStatusStore.restore that must fire AFTER its await rather than before
(writing the notifier post-dispose is the bug it exists to prevent), which
is why it takes a stillLive callback instead of a bool.
Verified: 313 tests pass, analyze clean, every capture file at 100%
coverage. lib/ and bin/ are now down to three files over the cap.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
702 -> 220 lines, decomposed rather than just moved, because a single
~600-line State class was the actual problem:
note_editor_document.dart the text model -- template, controllers, and
which of the two sources (raw body vs. section
fields) is authoritative. None of it needed a
BuildContext or a setState, which is what made
it the honest seam. The raw<->guided conversions
and the initial-template detection moved here as
tryAdoptGuided/materialiseToRaw/forInitialText.
note_editor_wizard.dart the two-step priority+template wizard, which now
owns its own step state instead of parking three
fields on the editor.
note_editor_step_page.dart the guided per-step view.
note_editor_chrome.dart the template picker + mode toggle.
note_editor_raw_field.dart the raw-mode text field.
note_editor_mode.dart the mode enum, re-exported so callers are
unaffected.
note_editor_{widget,views} parts, not libraries: createState returns a
private class, so the widget and its State
cannot be split across libraries.
The layout comments about Expanded/expands:true are load-bearing (nesting
broke the constraint chain in release builds before), so they travelled with
the code they describe rather than being summarised away.
Verified: 313 tests pass, flutter analyze clean. Covered lines went UP,
1922 -> 1958, because the wizard's state is now exercised in a widget of its
own; every new file reports 100%.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wires the shared gate (~/utils/scripts/check_file_length.sh) into a new
.pre-commit-config.yaml as a local hook, checking only the files in the
commit and failing rather than warning. No baseline, no allowlist.
The repo had a hand-written .git/hooks/pre-commit running two checks;
both are ported verbatim into the config rather than dropped, with the
version bump ordered last so it cannot rewrite pubspec.yaml for a commit
a gate is still about to reject. scripts/install_hooks.sh now installs
pre-commit (and removes the legacy hook) instead of writing its own.
.github/workflows/file-length.yml runs the same check on push, kept
separate from release-apk.yml so an over-long file fails lint rather than
blocking an APK release.
Splits the four cleanest violations, all behaviour-preserving:
note_template.dart 370 -> 203 + parser (186)
firebase_backend.dart 308 -> barrel (24) + project/store/client
port_guard.dart 315 -> 232 + linux_system_probe (100)
notes_list_screen.dart 698 -> 225 + 5 widget/nav files
Where a file had external callers, the original path stays as a barrel
that re-exports the split parts, so no call site changed.
Verified: 313 tests pass, coverage identical at 99.9% / 1922 lines
(per-file too -- notes_list's 281 covered lines are now 81+75+51+41+22+11),
flutter analyze clean, and a staged 260-line file makes git commit fail.
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 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
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>
Found via on-device testing after the earlier warm-palette commit: the
View/Guided/Raw SegmentedButton's selected segment rendered in Flutter's
stock Material teal, not the warm palette. Neither ColorScheme.light()
nor .dark() defined secondary/tertiary (or their container/on- variants),
so any M3 widget reaching for secondaryContainer (SegmentedButton, Chips,
etc.) silently fell back to the framework default instead of erroring --
a gap invisible in static review or widget tests, only visible rendered.
The shared palette has one accent, not a separate secondary hue, so these
mirror primary; secondaryContainer/tertiaryContainer use the line-light/
line-dark border color as a distinguishable-but-subtle selected-state fill.
Verified: flutter analyze clean, 188/188 tests at 100% coverage, re-
verified identical in an isolated worktree, and confirmed live on the
physical phone (adb install -r + screenshot) -- the Raw tab now renders
gold/amber instead of teal.
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>
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>
Keeps the diagnostic used to produce docs/desktop-performance-findings.md, so
the web proof-of-concept can be measured against the same numbers rather than a
freshly-written and differently-behaving probe.
Armed only by TODO_FRAME_STATS=1, so it is inert in normal use. It reports:
- individual slow frames, not per-second percentiles -- a user-visible hitch is
one long frame, which averaging over a 61-frame window hides completely;
- event-loop blocking via a periodic timer, which is unambiguous where frame
gaps are not (a static UI produces no frames, so an idle user and a stalled
isolate look identical);
- every frame when TODO_FRAME_STATS_ALL=1, for raster-cost-vs-window-size
sweeps.
The capture screen gets a spinner under the same flag because cadence can only
be sampled while something animates continuously. Its branch is unreachable
under the test runner, hence coverage:ignore -- the suite stays at 100%.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Standardizes on the same stricter lint set + shared analysis_options.yaml
used by diet-guard/app and the new habit_stack app. Fixes all 127 issues
surfaced: dart fix --apply for imports/cascades/sorting, unawaited() on
fire-and-forget Futures, bare catch(e) narrowed to on Exception catch(e)
(matching diet_guard's existing pattern), one documented ignore for a
deliberate TypeError catch around untrusted peer JSON, and missing
public-member doc comments throughout. No behavior change: 174/174 tests
pass, 100% line coverage maintained.
Co-Authored-By: Claude Sonnet 5 <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>
Rework guided-mode layout so the text field fills the full available
screen height. The previous approach nested _buildStepPage (a Column
with its own Expanded) inside NoteEditor's outer Expanded — a
nested-Column/Expanded chain that collapses silently in release builds.
Fix: return _buildStepPage directly from build() when in guided mode,
making the TextField a first-level Expanded child of the same Column
as _buildRaw uses. This is the same depth at which expands:true works.
Also hide the Save FAB (_chromeVisible gate in CaptureScreen) while
guided mode is active so it cannot overlap the Next/Done button.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Cb7oE5Xsc8zSBHHtT6qwa
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>
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>
Flutter app for Android + Linux desktop. Captures ideas with per-keystroke local autosave to a CRDT-backed SQLite store (sqlite_crdt), and syncs through a private GitHub repo using per-device changeset files (conflict-free last-writer-wins merge). Includes GitHub OAuth device-flow sign-in with PAT fallback, a barebones notes list, and sync settings.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>