feat(recorder/library): clip trash bin with 30-day retention#4546
feat(recorder/library): clip trash bin with 30-day retention#4546rabble wants to merge 7 commits into
Conversation
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
Review summarySolid architecture overall — soft/restore/hard/purge belong on the service, the BLoC orchestrates, the UI stays thin, and the DAO does the right thing with the indexed Two real consistency gaps in the library-tab flow and one nit on the trash UI, none of which prevent a merge but the first one I'd push for before shipping. Disposition: Comment (not request-changes). 1. (Important) Library-tab "Delete" still says "cannot be undone" — and is now a lie
That dialog is shown by This is the kind of mismatch users notice the first time they hit the trash bin and find clips they were told "the video files will be permanently removed" still sitting there. Either:
I'd lean toward the second — the recorder path doesn't confirm, and there's no reason the library path should be more aggressive than the recorder when both are soft-deletes. 2. (Important)
|
Adds `deleted_at` nullable timestamp column on `clips` plus an index, runtime ADD COLUMN IF MISSING for existing databases, and DAO methods (softDeleteClip, restoreClip, getTrashedLibraryClips, watchTrashedLibraryClips, getTrashedClipsOlderThan). Existing read paths (getAllClips, getLibraryClips, getClipsByDraftId, watchLibraryClips, watchClipsByDraftId, getCountByDraftId) now exclude trashed rows. Foundation for clip trash bin: recorder/library deletes will move clips to trash instead of hard-deleting, with 30-day retention. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Splits ClipLibraryService.deleteClip into softDelete, restore, and hardDelete + purgeExpiredTrash + getTrashedClips. softDelete optionally clears draftId so a recorder-trashed clip lands in the library on restore rather than a stale session. ClipsLibraryBloc now routes both single and batch delete events through softDelete, captures the deleted IDs in lastDeletedClipIds for snackbar Undo, and gains four new events: TrashLoadRequested, RestoreClips, HardDeleteClip, EmptyTrash, plus trashedClips on state. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…Undo Recorder's bottom-left button now schedules a soft-delete of the last clip (with draftId cleared) and shows a "Clip moved to trash • Undo" snackbar for 5 seconds. Tapping Undo restores the clip to its original position; ignoring the snackbar lets the clip sit in trash where 30-day retention takes over. ClipManagerNotifier tracks the pending deletion in state so the UI reflects it immediately, and commits-on-addClip / commits-on-reorder / commits-on-clear so a fresh recording never leaks a half-undone state. Icon swapped from arrowCounterClockwise to trash to match the actual destructive semantics. New shared helper clip_delete_snackbar.dart wires both capture and classic recording modes to the same UX. Existing tests updated to use the new schedule/restore API surface. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a Recently Deleted screen (LibraryTrashScreen) accessed from a new trash icon on the clips library toolbar. Lists soft-deleted clips with per-item Restore and Delete-now actions plus a single "Empty trash" action when non-empty. Closing the screen reloads the library so restores show up immediately. Wires purgeExpiredTrash() into the app-startup lifecycle handler so clips past the 30-day retention window are hard-deleted on next launch. Best-effort, idempotent, never blocks startup. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds full translations for videoRecorderClipUndoLabel, libraryTrashTitle, libraryTrashEmptyTitle, libraryTrashEmptySubtitle, libraryTrashRestoreLabel, libraryTrashDeleteNowLabel, libraryTrashEmptyAllLabel, and libraryTrashEntryLabel across am, ar, bg, de, es, fil, fr, id, it, ja, ko, nl, pl, pt, ro, sv, tr. Updates videoRecorderClipDeletedMessage in each locale to match the new "Clip moved to trash" English copy. Drops the corresponding allowlist entries from arb_consistency_test — the gate now enforces full coverage on these keys instead of accepting English fallback. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DB Client CI runs flutter analyze with infos-as-fatal, and [name] bracket references to column getters or other-method parameters fail the comment_references lint when the name isn't visible at the doc- comment scope. Switched those references to backticked code spans so the doc reads the same but the lint no longer flags them. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ntdown
Self-review pass on the clip-trash-bin PR:
1. Drop the library delete confirm dialog. With a 30-day trash bin in
place, the dialog was both lying ("permanently removed from your
device") and double-paying for safety on top of the trash. The
recorder path already skips confirm; library now matches. Removed
the orphaned `libraryDelete*` ARB keys from every locale.
2. Wire the dead `lastDeletedClipIds` field into the library snackbar.
"N clips deleted" now shows an Undo action that dispatches
`ClipsLibraryRestoreClips` on the bloc, surfacing the soft-delete /
restore round-trip the field was already populating.
3. Plumb `clips.deleted_at` from the Drift row through the service
into a new `DivineVideoClip.deletedAt`, then render
"Auto-deletes in N days" (ICU plural, locales today/tomorrow/in N)
in the trash tile instead of the recorded date. The retention
constant lives on `ClipLibraryService.trashRetention` so the
countdown and the purge sweep can't drift apart.
Nits: wrap the startup `purgeExpiredTrash` await in try/catch to honor
the "best-effort" comment; drop the no-op `onTap`/`onLongPress` on the
trash thumbnail (made the callbacks nullable on `VideoClipThumbnailCard`).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
42707c3 to
5f40dd1
Compare
|
Addressed self-review in 5f40dd1. Finding 1 (dialog lie): Dropped the confirm dialog from both library Finding 2 (dead state): Wired the snackbar. The existing Finding 3 (no purge countdown): Added Nits: wrapped the startup l10n: new keys ( Scoped tests pass locally: |
Mobile PR PreviewPreview refreshed for Last refresh:
|
rabble
left a comment
There was a problem hiding this comment.
Re-review after 5f40dd11 ("fix(library): drop confirm dialog, wire undo snackbar, show purge countdown").
Prior findings — resolution
| # | Finding | Status |
|---|---|---|
| 1 | Library delete dialog said "permanently removed" but soft-deleted | Resolved. Confirm dialogs removed from both single (clips_tab.dart _softDeleteClip) and batch (library_screen.dart) paths. Orphan libraryDeleteClip* keys removed from app_en.arb. |
| 2 | lastDeletedClipIds populated but unconsumed |
Resolved. BlocListener in library_screen.dart:424 triggers a snackbar with Undo on lastDeletedCount transitions, dispatching ClipsLibraryRestoreClips(deletedIds). The pre-emit clearDeletedCount: true in both single- and batch-delete handlers correctly drives N → null → M so consecutive deletes of the same size still refire. |
| 3 | Trash UI showed recordedAt, no purge countdown |
Resolved. DivineVideoClip.deletedAt plumbed from clips.deleted_at. _daysUntilPurge derives from deletedAt + ClipLibraryService.trashRetention. Countdown renders via libraryTrashAutoDeletes ICU plural (today / tomorrow / in N days). Retention constant lives on the service — purge sweep and UI countdown share one source of truth. |
| 4a | Best-effort purge not wrapped in try/catch | Resolved. app_lifecycle_handler.dart:68 now wraps purgeExpiredTrash in try/catch with a logged failure path. |
| 4b | No-op onTap/onLongPress on trash thumbnail | Resolved. VideoClipThumbnailCard.onTap/onLongPress made nullable (VoidCallback?); trash tile passes neither. |
| 4c | Invariant test: recoverMissingAssets can't resurrect trashed |
Indirect coverage only. No explicit test, but the guarantee follows structurally: recoverMissingAssets(List<DivineVideoClip>) operates on the parameter; trashed rows never reach it because getLibraryClips / getAllClips / watchLibraryClips / getClipsByDraftId all filter deleted_at IS NULL (verified in clips_dao.dart). One dedicated test would still be cheap insurance — not a blocker. |
New observations on this revision
-
libraryClipsDeletedUndoLabelandlibraryTrashAutoDeletesare English-only + allowlisted. The other 6 library-trash keys +videoRecorderClipUndoLabeland the rewrittenvideoRecorderClipDeletedMessageARE translated across all 17 locales (631ea01d). Mixed approach — fine per the documented allowlist policy, but worth a follow-up to translate these two as well so the snackbar Undo and the countdown match the rest of the surface. -
_daysUntilPurgereturns 0 fordeletedAt == null. The comment frames it as "a degraded row state — surface as 'today' rather than crashing". That's safe but slightly misleading — a clip withdeletedAt == nullshould not be ingetTrashedClips()(DAO filter isdeletedAt IS NOT NULL). Worth either anassert(clip.deletedAt != null)to catch programming errors, or trusting the DAO contract and dropping the null branch. Minor. -
Schema migration safety.
_addColumnIfMissing('clips', 'deleted_at', 'INTEGER')plusCREATE INDEX IF NOT EXISTSis idempotent and tolerates re-runs.schemaVersionstays at 1, so existing installs hit the ADD COLUMN on first launch via the migration step. Reads with the newdeletedAt IS NULLfilter will treat all pre-migration rows as active (NULL = active), which is correct. No data migration needed. -
Cross-device sync is out of scope. Trash is local-only — deleting on Phone A does not propagate to Phone B's library. The PR is explicit about this being a local recovery feature, not a Nostr-level retraction, so this is acknowledged scope, not a gap.
-
Test coverage for the new service surface (softDelete, restore, hardDelete, purgeExpiredTrash) is solid in
clip_library_service_test.dart. Bloc tests cover the new events. No tests for the trash screen itself (library_trash_screen.dart) — adding one widget test for the countdown rendering and Restore/Delete-now button wiring would close that gap.
Verdict
Approve. All four prior findings addressed cleanly. The two open items (translation of the two self-review-pass keys, trash screen widget test) are minor follow-ups rather than blockers. Schema migration is safe, snackbar undo race-condition is handled via the pre-emit null-clear, and the retention constant is correctly centralized.
| "@@locale": "de", | ||
| "appTitle": "Divine", | ||
| "@appTitle": { | ||
| "description": "App title shown in task switcher" | ||
| }, | ||
| "settingsTitle": "Einstellungen", | ||
| "@settingsTitle": { | ||
| "description": "Settings screen app bar title" | ||
| }, | ||
| "settingsSecureAccount": "Konto absichern", | ||
| "settingsSessionExpired": "Sitzung abgelaufen", | ||
| "settingsSessionExpiredSubtitle": "Melde dich erneut an, um wieder vollen Zugriff zu haben", | ||
| "settingsCreatorAnalytics": "Creator-Analytics", | ||
| "settingsSupportCenter": "Support-Center", | ||
| "settingsNotifications": "Benachrichtigungen", | ||
| "settingsContentPreferences": "Inhaltseinstellungen", |
There was a problem hiding this comment.
It seems the formatting in this file changed, which added whitespace everywhere. That’s why the PR shows this feature as adding +60k lines of new code.
| appBar: AppBar( | ||
| backgroundColor: VineTheme.surfaceBackground, | ||
| elevation: 0, | ||
| leading: const _BackButton(), | ||
| title: Text( | ||
| context.l10n.libraryTrashTitle, | ||
| style: VineTheme.titleMediumFont(), | ||
| ), | ||
| actions: const [_EmptyTrashAction()], | ||
| ), |
There was a problem hiding this comment.
Consider using here the DiVineAppBar
| return const Center( | ||
| child: CircularProgressIndicator(color: VineTheme.vineGreen), | ||
| ); |
There was a problem hiding this comment.
Maybe better using the BrandedLoadingIndicator
| @override | ||
| Widget build(BuildContext context) { | ||
| return Padding( | ||
| padding: const EdgeInsets.all(8), |
There was a problem hiding this comment.
Because of this padding, the button became super small in my tests since there wasn’t enough space for it, which caused the button itself to shrink. I think we can completely remove that padding as well.
| SnackBar( | ||
| duration: ClipManagerNotifier.pendingDeletionWindow, | ||
| padding: EdgeInsets.zero, | ||
| backgroundColor: VineTheme.transparent, | ||
| elevation: 0, | ||
| behavior: SnackBarBehavior.floating, | ||
| margin: const EdgeInsets.fromLTRB(16, 0, 16, 68), | ||
| content: DivineSnackbarContainer( | ||
| label: context.l10n.videoRecorderClipDeletedMessage, | ||
| actionLabel: context.l10n.videoRecorderClipUndoLabel, | ||
| onActionPressed: () { | ||
| messenger.hideCurrentSnackBar(); | ||
| ref.read(clipManagerProvider.notifier).undoPendingDeletion(); | ||
| }, |
There was a problem hiding this comment.
Consider using DivineSnackbarContainer.snackBar( instant of just the DivineSnackbarContainer
Closes #4547
Summary
Replaces the recorder's destructive "delete last clip" gesture with a
two-layer safety net:
soft-delete and show a "Clip moved to trash • Undo" snackbar. Undo
restores the clip to its original tray position.
Recently deleted screen (accessible from the clips library), where
they sit for 30 days before auto-purge. Each row offers Restore /
Delete now, plus a single "Empty trash" action.
Library-tab deletes are routed through the same
softDeletepath soboth surfaces share one delete behavior. Icon swapped from
arrowCounterClockwise→trashso the affordance reads asdestructive-but-recoverable instead of undo my last tap.
Motivation: in-app feedback from
723glimmers— accidentally tapped"undo" while assembling a brownie-batter compilation and lost a step.
The undo button's icon promised reversibility but the action was a
single-tap, no-confirm, on-disk hard delete with no recovery.
Architecture
clips.deleted_at(nullable timestamp) + idx_clip_deleted_at.All existing read paths now filter
deleted_at IS NULL.ClipLibraryServicesplit intosoftDelete/restore/hardDelete/getTrashedClips/purgeExpiredTrash.softDeleteoptionally clears
draft_idso a recorder-trashed clip lands in thelibrary on restore rather than a stale session.
ClipManagerNotifiertracks the pending deletion in state, commitsit on
addClip/reorderClip/clearAll/ dispose so a freshrecording never leaks a half-undone state.
ClipsLibraryBlocgains four events:TrashLoadRequested,RestoreClips,HardDeleteClip,EmptyTrash, plustrashedClipsand
lastDeletedClipIdson state.purgeExpiredTrash(retention: 30d)runs on app startup fromapp_lifecycle_handler.dart(best-effort, idempotent).Test plan
flutter test test/services/clip_library_service_test.dart— softDelete / restore / hardDelete / purgeExpiredTrash coverageflutter test test/blocs/clips_library— 64 tests, including state-prop shape after new fieldsflutter test test/providers/clip_manager_provider_test.dart—scheduleDeleteLastClipsmokeflutter test test/widgets/video_recorder— both recording modesflutter test test/widgets/library test/screens/library_screen_test.dart— library entry-point unchanged for existing flowsflutter test test/l10n/arb_consistency_test.dart— new keys allowlistedpackages/db_clientmigration test — schema add safel10n
Eight new keys:
videoRecorderClipUndoLabel+ 7libraryTrash*keys.Allowlisted in
arb_consistency_test.dart; translators will pick themup in the next localization pass.
videoRecorderClipDeletedMessageEnglish copy updated from "Clip deleted" → "Clip moved to trash";
other locales fall back to the prior translation until refreshed.
🤖 Generated with Claude Code