Skip to content

Rework export feature into lightweight, dynamically-played recaps - #53

Merged
LukeNeedham merged 25 commits into
mainfrom
claude/export-to-recap-rename-bublpv
Oct 4, 2026
Merged

LukeNeedham merged 25 commits into
mainfrom
claude/export-to-recap-rename-bublpv

Conversation

@LukeNeedham

Copy link
Copy Markdown
Owner

Summary

  • Renames the "export" feature to "recap" throughout the app, and reworks it to be lightweight: a recap is now just a date range + name, persisted only when the user explicitly saves it (no video file is created up front).
  • Viewing a recap now plays its constituent daily videos back-to-back via an ExoPlayer queue (VideoQueuePlayer, auto-advance + loop) instead of a pre-stitched merged file. Stitching into a single video (Media3 Transformer, unchanged) only happens on demand when the user taps "Export to single video" to share it.
  • Adds Month/Week/Year recap creation, alongside the existing custom date-range flow:
    • The recap hub lists Month/Week/Year/Custom creation options plus the saved recaps list (with dates shown under each name, unchanged from before).
    • Month/Week/Year pick from a list of whole periods (auto-named, e.g. "February 2026 recap", "Week 8 recap", "2026 recap"), showing only their date range at the list level.
    • Custom recaps require an explicit name and are saved immediately.
    • Whichever way a recap is created, it lands on the recap view screen, which shows thumbnails and lets the user save/un-save it — the period type is a UI convenience only and is never persisted; a saved recap is just id, name, startDate, endDate.
  • Updates the Room schema for saved recaps (bumped version + destructive fallback migration, since the old file-backed rows have no equivalent), calendar side menu, and setup intro copy.

Test plan

  • Reviewed the full diff for compile correctness (renamed/moved ~30 files, added ~10 new ones).
  • ./gradlew assembleDebug / ./gradlew test (CI will run this on the PR).
  • Manual smoke test in a running app: create a custom recap, create a month/week/year recap, play a recap's queue, save/un-save a recap, export a recap to a single video and share it.

🤖 Generated with Claude Code

https://claude.ai/code/session_01LrYZZ5W6x1h2oGmcPAUH5m


Generated by Claude Code

Replace the export feature with recaps: a recap is now just a date
range + name, persisted only when explicitly saved. Viewing a recap
plays its constituent daily videos back-to-back via an ExoPlayer
queue (auto-advance, loop) instead of a pre-stitched file; stitching
into a single video now only happens on demand, when the user taps
"Export to single video" to share it.

Recaps can be created via whole months/weeks/years (auto-named and
listed by date range) in addition to the existing custom date-range
flow, which now requires a name and saves immediately.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LrYZZ5W6x1h2oGmcPAUH5m
@github-actions

github-actions Bot commented Sep 18, 2026 •

Copy link
Copy Markdown

App apk at:
https://github.com/LukeNeedham/VideoDiary/releases/download/claude-export-to-recap-rename-bublpv-187-1/app-debug.apk

  • Triggered by: PR Commit 7a0f37e84b7f0030fb2259f90c731cc75955f752
  • Built at: 04/10/2026 02:12:44

A month/week/year is now only offered once it has fully elapsed (e.g.
September isn't listed until 1st October), and a fully-elapsed period
with no recorded videos still shows up, greyed out and non-clickable,
with a note that it has no videos.

Also collapses the four separate create-recap cards on the hub into a
single card that opens a new page for picking which kind of recap to
create.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LrYZZ5W6x1h2oGmcPAUH5m
The greyed-out state for empty periods was compounding alpha across
background and text, making it nearly invisible; now a single alpha
applied before the background fades the whole card as one unit, and
those rows use smaller padding/type since they're not interactive.

Date ranges for month/week/year options now drop whatever's implied
by the period itself (e.g. "10 - 30 September" instead of repeating
the month and year on both ends), and month/week labels only show
the year when it differs from the current year.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LrYZZ5W6x1h2oGmcPAUH5m
Cards were full-width but content-light, so a 2-column grid uses the
space better. Options stay ordered latest-first, now paired with
reverseLayout so the page opens scrolled to the latest one at the
bottom, with older ones revealed by scrolling up (oldest at the top).

Also replaces the vague "no videos yet" empty state with a period-
aware message explaining why nothing's showing yet and when the first
one will appear (e.g. "after the end of this calendar month").

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LrYZZ5W6x1h2oGmcPAUH5m
Within-row order came out newest-to-oldest left-to-right, since
reverseLayout only flips row order, not the order within a row, and
the list itself is latest-first. Each pair of items is now reversed
before handing it to the grid, so left-to-right reads oldest-to-newest
as expected while rows stay bottom-to-top newest-to-oldest.

Cards also varied in height depending on font size, padding, and
whether the "No videos" line was present. They now share the same
title size and padding, and the subtitle always reserves two lines
(via minLines/maxLines, not a fixed dp height) so every card matches
regardless of content or font scaling.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LrYZZ5W6x1h2oGmcPAUH5m
The reverseLayout + reversed-pairs trick correctly ordered rows but
produced a confusing layout for odd counts (a lone oldest card stuck
at the top, paired items packed at the bottom). Replaced it with the
much simpler oldest-first list laid out normally, with the grid's
initial scroll position set to the last item - same "opens scrolled
to latest, scroll up for older" behavior, without the row gymnastics
or odd-count surprises.

Also extends the "No videos" case into an ordinary video-count line
shown on every card (e.g. "12 videos", "1 video", "0 videos"), so
having no videos is no longer a special case in the display logic.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LrYZZ5W6x1h2oGmcPAUH5m
The queue player never paused the shared player on dispose, so audio
kept playing after navigating away from a recap. It now pauses (and
still resets repeat mode) when the screen is torn down, tying playback
to the page's lifecycle.

Also tightens the gap between a period card's date range and video
count lines, and reorders the fixed recap types to Week, Month, Year
(ascending duration) before Custom.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LrYZZ5W6x1h2oGmcPAUH5m
VideoToolbarLayout sized the video purely from its aspect ratio and
width, so a portrait video could claim nearly the whole screen height,
squeezing the bottom bar down to a sliver - and since that bar wasn't
scrollable, its controls (save/export buttons, checkbox) were simply
clipped off and unreachable.

VideoToolbarLayout now takes an optional maxVideoHeightFraction
(default 1f, so existing callers are unaffected) capping the video's
height so it pillarboxes instead of crowding out the bottom bar; the
recap view screen uses 0.6f, since its bar is unusually tall. Its
bottom bar is also now scrollable as a fallback, so nothing is ever
permanently unreachable regardless of aspect ratio or screen size.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LrYZZ5W6x1h2oGmcPAUH5m
Revert VideoToolbarLayout to its original behaviour (video sized
purely by aspect ratio, no height cap, no scrolling bottom bar) - the
earlier fix traded one problem for a worse one. Instead, the recap
view screen's bottom bar is cut down to match the height every other
video screen already uses: a close button, the recap's name, and a
save button, in a single VideoControlsRow. Thumbnails, playback
controls, the date-stamp checkbox and the export button are all
dropped from this screen for now, pending a rethink of where that UX
belongs; the recap just plays on loop behind the bar.

RecapViewViewModel/Page are trimmed to match (aspect ratio, video
files, name, save/unsave). The export-to-single-video flow (progress
page, view model, request model) is left in place, just unreferenced
from this screen for now.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LrYZZ5W6x1h2oGmcPAUH5m
Replace the plain header/Save row with a bottom sheet matching the
approved mockup: a chevron toggle expands the sheet to reveal export
options (currently just the date-stamp checkbox) below a fixed header
row, without ever overlaying the video. The header centers the recap
name (wrapping onto multiple lines if needed) between a borderless
back button and save/share icon buttons.

Tapping share now wires back up the dormant RecapExportProgress flow:
the ViewModel derives the recap's RecapDay list and builds a
RecapExportRequest honoring the sheet's date-stamp toggle.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LrYZZ5W6x1h2oGmcPAUH5m
The bottom sheet's export options never made sense in place: sharing
a recap requires stitching it into a file first, which takes time, so
picking export options and watching that happen can't both fit in a
row docked over the video.

RecapView's bottom bar goes back to being static - same look, but no
chevron, no expand/collapse, no options tucked inside it. Tapping
share now opens a new RecapSharePage (replacing the dormant
RecapExportProgress flow) that walks through the whole job: pick
whether to include the date stamp, kick off the export, watch its
progress, then hit Share to send the finished file to the system
share sheet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LrYZZ5W6x1h2oGmcPAUH5m
Previously every export overwrote the same fixed export.mp4, so
returning to share a recap always re-ran the (slow) video stitching
step even if nothing had changed. VideoExportDao now writes each
export to a path keyed by the recap's date range and its date-stamp
choice, so the two options for the same recap are cached
independently, and a failed export cleans up its own partial output
so it's never mistaken for a valid cached file.

RecapShareViewModel checks for a matching cached export whenever the
page loads or the date-stamp toggle changes, jumping straight to the
Ready/share state instead of the options screen when one already
exists.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LrYZZ5W6x1h2oGmcPAUH5m
Replace the bare includeDateStamp boolean threaded through
VideoExportDao/VideoExporter with a RecapExportOptions value holding
all export choices and deriving the cache key from them. Adding a
future export option (e.g. a filter or resolution choice) now just
means adding a field to RecapExportOptions and folding it into its
cacheKey, rather than threading a new parameter through every method
along the export path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LrYZZ5W6x1h2oGmcPAUH5m
The bar still had the rounded-top-corners/AppSurfaceVariant look left
over from the earlier bottom-sheet design, making it look like a
sheet header rather than a plain video toolbar. It now sits directly
on VideoToolbarLayout's own black background with no surface color or
shape of its own, matching the calendar day view's bottom bar.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LrYZZ5W6x1h2oGmcPAUH5m
Every other video toolbar (calendar day, record video) fills the
whole area below the video and centers its content within it, rather
than docking to the bottom edge. RecapView's bottom bar was aligned
to BottomCenter instead of Center, so it broke that convention.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LrYZZ5W6x1h2oGmcPAUH5m
Opening the share page previously jumped straight to "ready to share"
whenever a cached export already matched the default options, skipping
the options screen entirely. The user should always see the options
screen first to choose what they want, and only then - once they
confirm - does a cache hit skip the export and go straight to ready.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LrYZZ5W6x1h2oGmcPAUH5m
fallbackToDestructiveMigration() wipes the whole database on any
unhandled version bump, not just this one - dangerous for every future
schema change, not just the saved-export -> saved-recap rework. Add
MIGRATION_1_2, which only drops the old, now-unused saved_exports
table and creates the new saved_recaps table; every other table (none
yet, but future ones) is left untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LrYZZ5W6x1h2oGmcPAUH5m
Replace the standalone RecapSharePage with RecapShareSheet, a modal
bottom sheet docked over the recap video. It carries the whole flow
(pick options, create the export, share it) through the same sheet
rather than navigating to a new page - dismissing it just hides it,
it doesn't cancel an in-progress export or discard a finished one, so
reopening it resumes wherever it left off.

RecapShareViewModel now gets its date range/name directly (no more
Parcelable RecapShareRequest nav arg) and has its `days` list pushed
in from RecapViewViewModel via updateDays, since it's no longer tied
to navigation timing. It also now emits the share request
automatically the moment an export finishes (or a cached one is
reused), rather than waiting for an explicit tap - the Share button
on the ready screen just re-opens the system share sheet on demand.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LrYZZ5W6x1h2oGmcPAUH5m
Replace the hand-rolled scrim + AnimatedVisibility overlay with
Compose Material's own ModalBottomSheetLayout. This gets scrim-tap,
swipe-down and back-press dismissal for free, correctly handled by
the framework rather than a half-reimplemented version of it.

ModalBottomSheetState is hoisted up to RecapViewPageContent (which
wraps the whole page content via the layout's `content` slot) so the
share icon in the bottom bar, a sibling of the sheet's own content,
can call `.show()` on the same state the sheet reads from.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LrYZZ5W6x1h2oGmcPAUH5m
Root.kt pads the whole app away from the system bars before
NormalRouter is ever composed, so ModalBottomSheetLayout's "hidden"
offset is computed against that already-reduced height - the sheet's
full content was still being measured/composed while hidden, which
could leave a sliver visible right at the boundary with the
navigation bar. The sheet body now only composes once it's visible or
about to become visible (targetValue != Hidden), so there's nothing
sized enough to peek out while truly closed - only a 1-line drag
handle, which replaces the close button (the whole sheet is already
swipeable-to-dismiss, so the handle is just a visual cue now).

Also add a "New export" button next to Share on the ready screen,
going back to the options screen to pick different settings and
potentially produce a different export - reusing the same
backToOptions() transition the Failed screen's "Try again" already
used (renamed from retryAfterFailure).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LrYZZ5W6x1h2oGmcPAUH5m
- Remove the auto-opened system share sheet on export completion -
  sharing now only ever happens when the user taps the Share button.
- Give the sheet's content area a fixed height (sized for its tallest
  state) instead of letting it resize to fit whichever state is
  showing, so switching between options/progress/ready/failed no
  longer changes the sheet's own size.
- The drag handle was still being composed unconditionally even while
  the sheet was meant to be fully hidden, which (combined with the
  content Column not filling the sheet's width) left a thin,
  left-aligned sliver visible between the app's own bottom bar and the
  system navigation bar. The handle now shares the same
  visible-or-becoming-visible guard as the rest of the sheet body, and
  the content Column fills the full sheet width so it's genuinely
  centered.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LrYZZ5W6x1h2oGmcPAUH5m
The sheet's content is only unmounted once sheetState.currentValue
becomes Hidden, which is correct - it needs to stay mounted through
the whole close animation so there's something to animate away. But
the default spring animationSpec has a long, visually-flat settle
tail: the sheet looks like it's stopped moving well before
currentValue actually flips, so the (still fully mounted, and - since
Root pads the whole app away from the system bars - sitting
unclipped in the navigation bar's area) sheet content stayed visible
for roughly a second after the animation appeared done.

A tween settles deterministically and matches the visible motion, so
currentValue (and the content-unmount it triggers) now follows the
animation closely instead of trailing behind it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LrYZZ5W6x1h2oGmcPAUH5m
The content-unmount-while-hidden approach from the last fix caused a
worse problem: since the sheet's content had to stay mounted for the
whole close animation (it's only safe to remove once
sheetState.currentValue actually settles on Hidden), the gating
instead raced the animation - the frame capture shows real content
snapping away well before the slide finishes, leaving a bare,
still-sliding drag-handle-only remnant for the rest of the animation.

Root cause: Root.kt pads the whole app away from the system bars
before any screen is composed, so ModalBottomSheetLayout only ever
sees that reduced height as its available space. Its "hidden" anchor
is defined as that height, which places the sheet's top edge right at
the boundary with the (transparent, edge-to-edge) navigation bar -
not below the true screen edge - so the sheet's own content, drawn
unclipped past that boundary, was genuinely visible in the nav bar's
area for as long as it stayed mounted.

Fixed at the source instead: a `fullWindowHeight` layout modifier
measures ModalBottomSheetLayout against the real window height
(`LocalView.current.height`) rather than the reduced incoming
constraint, so its hidden anchor now reaches the true screen edge.
The sheet's own `navigationBarsPadding` already grows its content by
the same amount this adds back to the available height, so the
*visible* position is unaffected - only "hidden" moves to where it
should've been all along. Content is always mounted now, so the
animation plays through cleanly in one continuous motion.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LrYZZ5W6x1h2oGmcPAUH5m
… bar

Forcing ModalBottomSheetLayout to measure against the true window
height (rather than the height Root.kt's systemBarsPadding leaves
available) was meant to only extend the sheet's hidden resting
position further down, past the navigation bar. In practice it also
affected the layout of `content` (the actual page, video included),
pushing it up under the status bar - a worse, constantly-visible
regression traded for a narrower edge case (the sheet barely peeking
near the nav bar only while hidden).

Decompiling reimagined-navigation's NavHost confirmed it (and Root's
own Box) both use plain Alignment.TopStart, ruling out the most
obvious "something recenters the oversized content" explanation, so
the precise mechanism remains unclear - reverting this now rather
than risk further unverified layout changes without a device to test
on. The sheet's own animation/sizing fixes from the previous commit
(always-mounted content, fixed content height, tween animation) are
unaffected and stay in place.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LrYZZ5W6x1h2oGmcPAUH5m
Root previously applied systemBarsPadding() globally, pre-consuming
both the status bar AND navigation bar space from every screen before
it composed - including RecapShareSheet's ModalBottomSheetLayout,
whose hidden-position math is based on the height it's given. That
mismatch was the root cause of the bottom sheet resting visibly above
the nav bar when fully hidden.

Root now only applies statusBarsPadding(), globally, since every
screen wants that. Each screen that has content flush against the
bottom edge now applies its own navigationBarsPadding() instead:
RecapHub, RecapCreateType, RecapCreatePeriodList, RecapCreateCustom,
Debug, CrashLog and RequestPermissionsPageContent's root Column, plus
SetupRouter's root Column (which owns the Next/Skip button shared by
every setup step page, so the individual step pages need no changes).
Screens that already self-handled bottom insets (VideoToolbarLayout,
CheckVideoPageContent, RecapShareSheet, Calendar) are unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LrYZZ5W6x1h2oGmcPAUH5m
@LukeNeedham
LukeNeedham merged commit 159e6a5 into main Oct 4, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants