📎 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
ruff and mypy were still grading `mcp/` against 3.10 while `requires-python`
had already moved to 3.14, so `datetime.timezone.utc` survived where
`datetime.UTC` is now correct (UP017). `pin_ci_python.py` rewrites the knobs;
ruff's `--select UP,I,F401 --fix` did the rest.
`mcp/` has no ruff hook in `.pre-commit-config.yaml`, so it also carries six
pre-existing CPY001 (missing copyright notice) findings. Those fire identically
at py310 and are left alone -- they are not this change's to fix.
24 tests pass at 100% line and branch coverage.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfnJmxxsZ534AKk16rC2Gr
`.fvmrc` says 3.47.2 and nothing read it: `subosito/flutter-action` takes its
own `flutter-version:`, and the fleet had drifted to 3.44.6, 3.44.8, 3.44.9,
3.47.0 and 3.47.1 with no one tracking it. kuhylog is the proof it matters --
its runner on 3.44.9 predates the `@awaitNotRequired` annotation, so the same
`unawaited(Navigator.push(...))` is a violation on one SDK and required on the
other, and CI went red on a commit that is clean locally.
The freshness gate reads these pins now (utils@98213cb), so this cannot drift
again in silence.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRykabMwPgDzFHcARoqpaK
Three git-tag pins move (crdt_sync_dart v0.10.0 -> v0.11.1, design_system
v0.1.0 -> v0.2.1, github_device_auth v0.3.0 -> v0.3.1) and every caret in the
pubspec becomes an exact pin, flutter_secure_storage 10.3.1 -> 11.0.0 being
the one that matters. That version demands compileSdk 37, which needs AGP >
9.1.0, which needs Gradle >= 9.5.0, so those three move in this commit too.
**mcp 1.x -> 2.1.1 in the MCP subproject is a real breaking change.** FastMCP
was renamed MCPServer and moved to `mcp.server.mcpserver`; the decorator and
`run()` API are unchanged, so the migration is the import and the constructor.
Verified by driving the server over stdio: `initialize` and `tools/list` both
answer, all three tools still enumerate. 24 tests at 100% coverage.
`very_good_analysis` 10.3 adds a `cascade_invocations` finding on the
port-in-use message in bin/todo_desktop.dart. Fixed, not suppressed.
300 Dart tests green, `flutter analyze` clean, release APK builds.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRykabMwPgDzFHcARoqpaK
connectWithPassword() had no try/catch around firebaseFactory(), unlike
connectWithGoogle() right next to it. A thrown FirebaseAuthError (wrong
password, 400 from identitytoolkit) escaped uncaught out of the button's
async onPressed, so the screen sat on "Signing in..." forever with no
error shown -- reproduced live via the browser console. Fixed upstream in
kuhyx/utils (sync_settings_ui-v0.4.0), which also required moving
crdt_sync onto v0.11.0. Verified: wrong password now shows
"Firebase rejected that account: <reason>" and re-enables the button
instead of hanging.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Renames this repo's markdown onto README.md / CLAUDE*.md / DOCS*.md /
TODO*.md so a future session can tell a live task from a finished
record without reading the file to find out.
TODO files carry the literal "REMOVE ME AFTER FINISH" and are deleted
when the work lands. Enforced by the md-naming pre-commit hook and CI
workflow, both delegating to the shared gate in kuhyx/utils so this
repo's idea of a valid name cannot drift from every other repo's.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A47aX5DJwmA3QFn3pkEWi7
Copy of the canonical prompt in ~/utils/dopamine-ux/, distributed here so the
repo can be opened and the prompt run directly. Derived from a survey of this
repo plus "The Dopamine Architecture" (london-post.co.uk).
Each prompt states its own completion protocol: when finished, the session
deletes this copy in the implementation commit and logs a DONE row in
~/utils/dopamine-ux/00-INDEX.md. Partial completion is never recorded as done.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FdApz7EZ1zDfvtvvi1idDv
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>
analysis_options.yaml: keep the analyzer out of android/ios/web/windows/
macos/linux. A no-op today — those directories hold no Dart (checked: 0
files each, and `flutter analyze` reports the same two pre-existing infos
either way) — but it stops generated platform Dart from ever entering the
lint surface.
pubspec.lock: transitive bumps (matcher 0.12.19->0.12.20, test_api,
leak_tracker and friends) picked up by the `flutter pub get` runs during
the file-cap refactor. No direct dependency changed.
Both had sat uncommitted through that refactor because I misattributed
them to kuhy and deliberately left them unstaged; they are this machine's
work, so they belong in the history.
Verified: flutter analyze clean (same 2 pre-existing infos), 313 tests
pass, file-length gate exits 0.
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>
The survey counts at the top described a state that no longer exists, so the
file contradicted the repo it lives in. The counts stay as the record of what
was refactored; a banner above them states the outcome and how it was
verified.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CLAUDE.md 925 -> 165. 789 of those lines were a verbatim paste of upstream's
flutter/flutter rules.md -- 85% of an always-loaded file given over to a
document nobody edits. It moves to five topic files under docs/, linked
rather than @-imported: inlining it back would pass the cap while keeping it
in context on every turn, which is the cost the cap exists to remove.
Every `###`/`####` heading in the upstream block survives the split; the two
that do not are its own wrapper headings, replaced by the per-file titles
(verified by diffing the sorted heading lists).
desktop-performance-findings.md 278 -> 73 + 216. It was already narratively
two-part; the author signposted "Part 2" explicitly at the seam.
Also updates two things that had drifted: the suite is 313 tests at 100%
coverage (not 238), and Conventions now documents the cap, where the gate
lives, and the two patterns used to split without breaking callers
(re-export barrels, and `part` files where a private State must stay in one
library).
`bash ~/utils/scripts/check_file_length.sh --all` now exits 0.
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>
429 -> 249 lines:
note_query.dart NoteSort, NoteFilter, and the pure predicates behind
them (matchesFilter, comparatorFor). All of it takes
notes and a filter and returns a decision, which is what
made it the natural seam.
note_import.dart ImportOutcome and the in-memory LogPersistence used by
openInMemory.
note_repository.dart re-exports both, so no call site changed.
Also drops ImportOutcome.total, which nothing called. It was uncovered
before this split too -- the larger file's percentage hid it. With it gone
the suite reports 100% line coverage (2005 lines), up from the 99.9%
baseline measured at the start of this refactor.
lib/ and bin/ are now fully under the cap. 313 tests pass.
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>
Git-ignored files, vendored agent-skill bundles and the CI checkout dir are
now exempt, since no commit could ever fix them. Repo counts and the ranked
violation lists are regenerated accordingly; the global baseline drops from
657 to 597.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7yZz45ezmzCB89Cv5gAtZ
A ready-to-use prompt describing the goal (every file at most 250 lines,
code and prose alike), this repo's actual violations ranked by Fowler ROI
(lines x commits/yr), how to split each kind, and the requirement to wire in
the shared pre-commit gate so files stay small permanently.
Generated from a measured survey of every repo; the gate itself lives in
~/utils (file_length/ + scripts/check_file_length.sh).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7yZz45ezmzCB89Cv5gAtZ
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
The "Build signed APK" step was ~292s of the ~350s total run, entirely
spent on a cold Gradle build (no dependency/build cache) compiling and
dexing a fat APK with armeabi-v7a and x86_64 native code that no device
this app has ever run on needs — BL9000, the only install target, is
arm64-v8a (confirmed via adb).
Adds gradle/actions/setup-gradle for Gradle's dependency+build cache
(flutter-action's cache only covers the Flutter SDK/pub packages, not
Gradle) and restricts the build to --target-platform android-arm64.
Verified locally: build succeeds, signer unchanged (CN=kuhy), output
APK 22.1MB vs 61.6MB for the fat build.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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
Routine follow-up to the per-commit version bump: the clean vX.Y.Z tag moved,
so the template's pkgver follows. sha256sums stays 'SKIP' -- the tarball for a
tag contains this file, so the real hash can only be filled in by updpkgsums in
the AUR checkout after the tag exists.
Note this line is inherently a lagging indicator: publishing runs updpkgsums
against whatever tag is newest at that moment, so the AUR may be a release
ahead of what is written here. ~/utils/aur-publish/publish.sh resolves the tag
itself and does not read this value.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aoa1nKWRrAbowz7Hfd3tgL
The AUR does not review uploads, but packages that violate the rules "may be
deleted without warning", so these were audited against the submission
guidelines rather than assumed compliant.
- pkgdesc now says what the app actually does, in terms a stranger can judge.
"Usefulness" is the one rule these packages are genuinely exposed to, and
the old wording led with the implementation detail.
- optdepends lists every browser the wrapper actually probes for. It searched
Thorium and Brave but only advertised Chrome and Chromium; all four names
were checked to resolve in the official repos or the AUR.
Verified compliant on the rules that can be checked mechanically: not in an
official repo, name free in the AUR, x86_64 declared, no replaces= (the AUR
reserves it for renames), Maintainer line present, license installed. The
conflicts=('todo') on todo-flutter is justified, not defensive: AUR 'todo'
2.4-1 installs its own /usr/bin/todo, confirmed by reading its PKGBUILD.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aoa1nKWRrAbowz7Hfd3tgL
The per-commit patch bump means the clean vX.Y.Z tag now moves with HEAD, so
the template's pkgver has to follow it. sha256sums stays 'SKIP' here: the
tarball for a tag contains this file, so the hash can only be filled in by
updpkgsums in the AUR checkout after the tag exists.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aoa1nKWRrAbowz7Hfd3tgL
The AUR package sources the clean vX.Y.Z tag the release workflow cuts, but
that tag only moves when pubspec.yaml's version changes by hand -- so it sat on
v1.0.0 while HEAD moved 8 commits ahead, and the package advertised a stale
tree. Bumping the patch per commit keeps that tag tracking HEAD.
Patch, not minor: minor/major are the "how much broke" signal, and
auto-incrementing them on every commit throws that away (and reaches absurd
numbers within a year).
Two guards keep the number honest, both covered by a scratch-repo run:
re-running the hook over the same staged change is idempotent, because the bump
is computed from the last COMMITTED version rather than the working tree; and a
rebase/merge/cherry-pick in progress is skipped, so replayed commits don't each
inflate it.
.git/hooks is untracked, so scripts/install_hooks.sh makes the hook
reproducible on a fresh clone -- this repo has no pre-commit framework.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aoa1nKWRrAbowz7Hfd3tgL
The template previously carried the real hash of the current tag's tarball.
That tarball contains this very file, so the hash is stale the moment the next
tag is cut -- and a stale 64-hex string looks complete enough that someone
copying the template to the AUR checkout could skip updpkgsums and then debug a
checksum mismatch instead of the actual step they missed. 'SKIP' is
unmistakably a placeholder. The AUR checkouts under ~/aur keep the real hashes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aoa1nKWRrAbowz7Hfd3tgL
install_arch.sh generates a PKGBUILD into a mktemp dir with no source=() and a
package() that copies from hardcoded absolute paths under /home/kuhy. That only
ever works on one machine, so it cannot be published. This PKGBUILD builds from
the GitHub release tarball instead, and fixes what AUR review would reject:
license=('MIT') with the text actually installed to /usr/share/licenses, real
depends (glibc/bash/hicolor-icon-theme, found by namcap), and the desktop entry
plus icons installed into the package rather than into ~/.local/share after the
fact.
install_arch.sh keeps its working-tree dev loop and is deliberately not wired to
this file. Verified: makepkg -Cf from a clean dir with only packaged Flutter on
PATH, namcap clean, pacman -U, and `todo` serving on port 8730.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aoa1nKWRrAbowz7Hfd3tgL
--provision-sync-account exits before the normal windowed launch, so it
looked headless -- but only on the happy path. When port 8730 is already
held, main() detours through _recoverPort first, and AttachToExisting opens
a browser window unless --no-browser was passed. It did not know about
--provision-sync-account, so the second and later provisioning runs opened a
full --app= window on the primary monitor: exactly what the headless flag
exists to avoid.
Found by running the feature twice rather than once. The first run binds the
port and stays headless; only the second reveals it.
Verified after the fix: two consecutive provisioning runs, both exit 0, zero
windows opened.
diet-guard is unaffected -- its wrapper has no port-recovery path.
256 tests, analyzer clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177xwzcpfpWG2qca8Mr39q9
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
--virtual-time-budget makes headless Chrome SIGKILL itself when the budget
expires: the run exits -9, which reads as a failure, and it can cut a sync
short mid-flight. Found while debugging diet-guard's provisioning; todo
carried the same flag.
The page now gets a real wall-clock window and is closed deliberately, so a
non-zero exit means something actually went wrong. --enable-logging=stderr
surfaces the page's own log() output in the terminal that started the run,
which is what makes an in-app failure visible at all.
Re-verified after the change: provisioning exits 0 and todo-sync still holds
the desktop (b58facc7) alongside the live device.
256 tests, analyzer clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177xwzcpfpWG2qca8Mr39q9
Provisioning still required launching the app, which opens a Chrome window --
it lands on whichever monitor i3 picks and steals focus, which is exactly
what you do not want from a one-shot setup step.
The account lives in origin-keyed localStorage, so only code running in the
page can write it; the page therefore has to run. But it does not have to be
*seen*: --headless=new loads the app, its own startup auto-sync calls
loadAccount(), the wrapper fallback provisions it, and the process exits. No
window is ever mapped.
Verified end to end, not just by exit code: after the run,
FlutterSecureStorage.firebase.account holds a real AES payload in the
profile's localStorage, and todo-sync in the RTDB gained a second device
(b58facc7) -- the desktop, now pushing to Firebase rather than the GitHub
mirror.
Usage: CRDT_SYNC_SERVE_ACCOUNT=1 todo --provision-sync-account
Also pulls the browser-candidate list out into _findBrowser() so the headless
and windowed paths cannot drift apart.
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 desktop app is a Flutter web build in a Chrome --app window, so
FlutterSecureStorage resolves to the web backend: AES-encrypted localStorage
keyed inside the browser profile. That left the sync account settable only by
typing it into the settings screen — nothing outside the page can write that
store (verified: `secret-tool` targets libsecret, which only a Linux build
would read, and a Dart CLI cannot even import flutter_secure_storage since it
is a Flutter plugin that fails to resolve on the VM).
So the account now arrives the same way the GitHub token already does: from
the wrapper, which owns the filesystem and serves the page. loadAccount()
falls back to GET /sync-account when the store is empty and persists the
result, so the route is consulted once per install rather than being
load-bearing forever.
The route reads ~/.config/crdt-sync/{firebase.json,password} — the same pair
the Python daemons already use — so there is one source of truth per machine
instead of a per-app copy.
Off unless CRDT_SYNC_SERVE_ACCOUNT is set, because it hands a credential with
database write access to anything that can reach the port. Verified both
ways against the running wrapper: 404 without the env var, 200 with it, and
the app serves normally either way. It answers 404 rather than 403 when
disabled, so a probe cannot tell the route exists.
clearAccount() now writes an opt-out marker that the fallback checks —
without it, Disconnect would delete the account and the next launch would
silently re-adopt it, making the button look broken.
252 tests, analyzer clean. Pinned to crdt_sync_dart-v0.8.0 for the shared
path/env-var constants and accountFromWrapper().
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177xwzcpfpWG2qca8Mr39q9