feat(compose): debounced periodic draft autosave #199

Merged
JMR-dev merged 2 commits from feat-177-draft-autosave into main 2026-07-03 05:58:35 +00:00
JMR-dev commented 2026-07-03 05:23:10 +00:00 (Migrated from github.com)

Summary

Adds a debounced periodic draft save alongside the existing exit-time save (#177), so an in-progress compose survives a background-kill or crash without going through the explicit back gesture.

  • Prerequisite bug fix — session-stable draft id. draftId was a nullable val; saveOrDeleteDraft()'s id = draftId ?: UUID.randomUUID() minted a new id on every call. Harmless when it ran once at exit, but periodic autosave would insert a fresh duplicate draft row per tick. The persist id is now generated once (persistedDraftId, lazy) and reused for every save this session. A draftPersisted flag tracks whether a row exists, driving delete-on-empty and delete-on-send so an autosaved brand-new draft is never orphaned.
  • Debounced autosave. An init collector observes _state mapped to just the persisted fields (to/cc/bcc/subject/body/bodyHtml/attachments), distinctUntilChanged().drop(1).debounce(1500ms), then calls the shared autosaveDraft(). Coalesces rapid keystrokes into one write. From-account and transient UI flags are intentionally excluded (matches the ticket's field list). Mirrors the existing MailboxViewModel debounce pattern (@OptIn(FlowPreview::class), top-level private const window).
  • Immediate flush on backgrounding. ComposeScreen gains a LifecycleEventEffect(Lifecycle.Event.ON_STOP) { viewModel.flushDraft() }, mirroring the existing ON_RESUME effect, so the last keystrokes within the debounce window aren't lost if the process is killed while backgrounded.
  • Guards. autosaveDraft() mirrors onExit()'s if (!sending) guard and also checks navigated, so a debounce tick that lands after a send can't re-create a draft for the already-sent message. onExit()'s save-or-delete-on-back behavior is unchanged — autosave is additive.

Test plan

New MockK/TestScope VM tests (Turbine-style, using advanceUntilIdle/runCurrent virtual time):

  • rapid successive edits coalesce into a single debounced autosave
  • regression: repeated autosaves of a brand-new draft update a single row (assert saved[0].id == saved[1].id) — the draft-id-reuse fix
  • exiting saves immediately without waiting for the debounce window
  • does not autosave while a send is in flight (gated sendMessage holds sending = true)
  • sending after an autosave deletes the autosaved draft (no orphan)

Full local preflight, JDK 21, --max-workers=8, one at a time:

  • :app:assembleDebug
  • :app:testDebugUnitTest (33/33 ComposeViewModelTest, incl. 5 new; 0 failures)
  • :app:lintDebug
  • :app:ktlintCheck :app:detekt
  • :app:compileDebugAndroidTestKotlin (ComposeScreen changed → confirms ComposeScreenTest still compiles)

No Room change. RichTextEditor.kt / the formatting toolbar were not touched.

Closes #177

🤖 Generated with Claude Code

## Summary Adds a debounced periodic draft save alongside the existing exit-time save (#177), so an in-progress compose survives a background-kill or crash without going through the explicit back gesture. - **Prerequisite bug fix — session-stable draft id.** `draftId` was a nullable `val`; `saveOrDeleteDraft()`'s `id = draftId ?: UUID.randomUUID()` minted a **new** id on every call. Harmless when it ran once at exit, but periodic autosave would insert a fresh duplicate draft row per tick. The persist id is now generated once (`persistedDraftId`, lazy) and reused for every save this session. A `draftPersisted` flag tracks whether a row exists, driving delete-on-empty and delete-on-send so an autosaved brand-new draft is never orphaned. - **Debounced autosave.** An `init` collector observes `_state` mapped to just the persisted fields (`to`/`cc`/`bcc`/`subject`/`body`/`bodyHtml`/`attachments`), `distinctUntilChanged().drop(1).debounce(1500ms)`, then calls the shared `autosaveDraft()`. Coalesces rapid keystrokes into one write. From-account and transient UI flags are intentionally excluded (matches the ticket's field list). Mirrors the existing `MailboxViewModel` debounce pattern (`@OptIn(FlowPreview::class)`, top-level `private const` window). - **Immediate flush on backgrounding.** `ComposeScreen` gains a `LifecycleEventEffect(Lifecycle.Event.ON_STOP) { viewModel.flushDraft() }`, mirroring the existing `ON_RESUME` effect, so the last keystrokes within the debounce window aren't lost if the process is killed while backgrounded. - **Guards.** `autosaveDraft()` mirrors `onExit()`'s `if (!sending)` guard and also checks `navigated`, so a debounce tick that lands after a send can't re-create a draft for the already-sent message. `onExit()`'s save-or-delete-on-back behavior is unchanged — autosave is additive. ## Test plan New MockK/`TestScope` VM tests (Turbine-style, using `advanceUntilIdle`/`runCurrent` virtual time): - [x] rapid successive edits coalesce into a single debounced autosave - [x] **regression:** repeated autosaves of a brand-new draft update a single row (assert `saved[0].id == saved[1].id`) — the draft-id-reuse fix - [x] exiting saves immediately without waiting for the debounce window - [x] does not autosave while a send is in flight (gated `sendMessage` holds `sending = true`) - [x] sending after an autosave deletes the autosaved draft (no orphan) Full local preflight, JDK 21, `--max-workers=8`, one at a time: - [x] `:app:assembleDebug` - [x] `:app:testDebugUnitTest` (33/33 `ComposeViewModelTest`, incl. 5 new; 0 failures) - [x] `:app:lintDebug` - [x] `:app:ktlintCheck :app:detekt` - [x] `:app:compileDebugAndroidTestKotlin` (ComposeScreen changed → confirms `ComposeScreenTest` still compiles) No Room change. `RichTextEditor.kt` / the formatting toolbar were not touched. Closes #177 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.