Notechondria

Version: 0.1.80 Build Date: 2026-04-27T00:30

What's Changed

AppShellDraftHelpersMixin — sixth shared mixin shipped

Continues the cross-app deduplication started in 0.1.54 (Log), 0.1.55 (AuthActions), 0.1.56 (OAuth), 0.1.78 (LocalPersist), and 0.1.79 (CourseHelpers). One more mixin from docs/TODO.md lands; two remain (Session, HttpClientInternals).

  • New frontend/notechondria_shared/lib/src/app_shell/app_shell_draft_helpers_mixin.dart exposes two byte-identical offline-draft helpers as concrete mixin methods:
    • storeLocalDraft(draft, {incrementCreated}) — moves draft to the front of localDrafts if it's already there (matched by id), otherwise inserts it. Replaces localDrafts with an unmodifiable list view. Optionally bumps localStats['local_drafts_created']. Does NOT call setState or persist to disk; callers handle both.
    • buildOfflineFallbackDraft({sourceNote, payload}) — the fallback path when a cloud POST /notes/ or PATCH /notes/<id>/ fails (no token, network error, server 5xx). Looks for an existing local draft pointing at sourceNote.id (via metadata.offline_source_note_id), then constructs a fresh draft via the per-app buildLocalDraft hook with payload overlaying sourceNote defaults.

Setters added to the abstract surface

These methods MUTATE in-memory state, so the mixin needs writable access. Four new abstract members on the State class:

  • set localDrafts(List<Map<String, dynamic>>) — write-back to the private _localDrafts field. The corresponding getter already exists from AppShellLocalPersistMixin; both are satisfied by the same field.
  • set localStats(Map<String, dynamic>) — same pattern.
  • Map<String, dynamic> decodeNoteMetadata(String raw) — forwards to each app's top-level _decodeNoteMetadata (in core/helpers.dart; same body in all three apps).
  • Map<String, dynamic> buildLocalDraft({...}) — forwards to each app's _buildLocalDraft (in core/local_course_builders.dart). Eight named params; defaults match the per-app implementations exactly so callers can omit optional args without changing behavior.

Why the hook-based design

Both helpers depend on per-app code (_decodeNoteMetadata lives as a top-level function in app-private code; _buildLocalDraft is an extension method on _AppShellState). The shared mixin can't reach that code directly. Two options were considered:

  1. Lift decodeNoteMetadata and buildLocalDraft into notechondria_shared too. Tempting, but _buildLocalDraft reaches _LocalAppStore.newDraftId() (per-app), and pulling that out has cascading effects. Deferred.
  2. Keep the per-app helpers in place, expose them as abstract hooks. Lower-risk; mechanical port. Chosen for this round.

If a future round consolidates the helpers, the mixin's hook signatures stay stable — only the State's overrides change from "forward to local helper" to "call shared helper".

Three apps ported

Each app's wiring follows the same shape:

  • _AppShellState mixes in AppShellDraftHelpersMixin<AppShell> (slotted between AppShellCourseHelpersMixin and AppShellAuthActionsMixin in the with clause).
  • Two new setters + two new method overrides per app — set localDrafts, set localStats, decodeNoteMetadata, buildLocalDraft. The first two are one-liners; the last two forward to existing local helpers.
  • core/draft_helpers.dart reduced to a shim with a documentation pointer — the file stays so the parts manifest in lib/main.dart doesn't break, but it's effectively empty (just part of notechondria_frontend; plus a comment).
  • All call sites of _storeLocalDraft() / _buildOfflineFallbackDraft() renamed to drop the leading underscore. One call site per app (core/note_crud.dart), three apps total.

Files Changed

  • VERSION — 0.1.79 → 0.1.80.
  • frontend/notechondria_shared/lib/src/app_shell/app_shell_draft_helpers_mixin.dart — new shared mixin (~165 lines, well under the §1.5 cap).
  • frontend/notechondria_shared/lib/notechondria_shared.dart — exports AppShellDraftHelpersMixin.
  • frontend/editor_app/lib/app_shell.dart — mixin slot + 4 override methods.
  • frontend/editor_app/lib/core/draft_helpers.dart — reduced to documentation shim.
  • frontend/editor_app/lib/core/note_crud.dart — call-site rename.
  • frontend/planner_app/lib/app_shell.dart — same wiring shape.
  • frontend/planner_app/lib/core/draft_helpers.dart — shim.
  • frontend/planner_app/lib/core/note_crud.dart — rename.
  • frontend/portal_app/lib/app_shell.dart — same wiring shape.
  • frontend/portal_app/lib/core/draft_helpers.dart — shim.
  • frontend/portal_app/lib/core/note_crud.dart — rename.
  • docs/TODO.md — mixin entry rewritten "3 of 8" → "2 of 8"; AppShellDraftHelpersMixin removed from the pending list.

Notes

  • All four packages (editor / planner / portal / shared) pass flutter analyze (zero errors) and flutter test smoke suites.
  • §1.5 1000-LOC cap respected: largest file in the repo is now portal_app/lib/app_shell.dart at 971 lines — getting close to the cap from accumulating mixin wiring (planner is at 969). Next round adding AppShellSessionMixin will need to be careful: it deletes applyAuthPayload + logout bodies (~140 lines per app, big shrink) but adds wiring for abstract getters/hooks (~30-40 lines per app). Net should still be a shrink. If it isn't, factor out a chunk of the remaining app_shell.dart body into a new extension file.
  • Behavioral surface unchanged: every call to storeLocalDraft() and buildOfflineFallbackDraft() runs the same body it did before. Rename is mechanical; the dedup is in the bodies the mixin now owns.
  • Mixin order: AppShellDraftHelpersMixin sits AFTER AppShellLocalPersistMixin and AppShellCourseHelpersMixin in the with clause. The order doesn't matter semantically (no method-from-mixin overlaps), but keeping a consistent linear order helps readers.