Skip to content

Migrate to Nav3#2160

Open
jocmp wants to merge 24 commits into
mainfrom
jc/nav3
Open

Migrate to Nav3#2160
jocmp wants to merge 24 commits into
mainfrom
jc/nav3

Conversation

@jocmp

@jocmp jocmp commented Jun 15, 2026

Copy link
Copy Markdown
Owner

No description provided.

jocmp added 24 commits June 14, 2026 14:00
Foundation for the Navigation 3 migration: navigation3-runtime/ui 1.1.2 and lifecycle-viewmodel-navigation3 (tracking lifecycle 2.10.0). Route now implements NavKey; adds Route.ArticleList(filter) and Route.ArticleDetail(articleID). Nothing is wired up yet - the existing NavHost still drives the app.
Unifies the whole app under a single NavBackStack/NavDisplay. Account, login, settings, and articles become entry<> destinations; the old popUpTo/launchSingleTop transitions become explicit backstack resets. Adds rememberViewModelStoreNavEntryDecorator so each entry gets its own ViewModelStore (required for per-entry VMs). Login source now flows via Koin parametersOf instead of SavedStateHandle.toRoute. ArticleScreen is unchanged and still drives the list/detail panes internally - that split comes later.

Compile-verified only; nav flows still need a runtime check.
Adopts the AGP 9 toolchain so we can use the official Material adaptive-navigation3 list-detail Scene (requires AGP 9.1+ / compileSdk 37) instead of hand-rolling one.

- AGP 8.11.1 -> 9.1.1, Gradle 8.14.3 -> 9.3.1 (AGP 9.1 minimum)
- compileSdk 36 -> 37 (targetSdk stays 36)
- Built-in Kotlin: drop the org.jetbrains.kotlin.android plugin from :app and root (AGP 9 provides Kotlin compilation). JVM modules keep kotlin.jvm.
- KSP 2.2.20-2.0.4 -> 2.3.9 (new decoupled KSP versioning required by AGP 9)
- Replace internal AGP dependsOn extension with tasks.named API
- Add androidx.compose.material3.adaptive:adaptive-navigation3

Verified: gradlew help, build --dry-run, assembleFreeDebug, and launches on emulator.
ArticleView now takes previousArticleID/nextArticleID instead of the whole LazyPagingItems - the only thing it ever used the list for was resolving neighbors. Neighbor computation moves to the call site (ArticleScreen still works unchanged). This lets the reader live in its own Nav3 detail entry without sharing the list's pager.

Also adds ArticleViewModel (detail VM): takes articleID via Koin parametersOf, resolves the article + full content on init. Wired in ArticlesModule; rendered by the upcoming Route.ArticleDetail entry.
Article selection is now navigation: tapping pushes Route.ArticleDetail(id) to the back stack, opening the reader in its own entry backed by ArticleViewModel. The list (ArticleScreen) and reader (ArticleDetailScreen) are independent screens.

Two-pane on wide screens is handled by the Material adaptive ListDetailSceneStrategy in App.kt (listPane/detailPane metadata) rather than ArticleScreen's old internal ListDetailPaneScaffold; ArticleScaffold is now just the drawer. The list yields back to NavDisplay when a detail is open so back pops the detail rather than exiting.

Removes the detail pane, scaffold navigator, pane expansion, and dead selection/media/back-handler code from ArticleScreen. Verified on emulator (tablet two-pane): list, tap-to-open with full content, and back all work.

Known follow-ups: next/prev reader navigation passes null neighbors for now (Stage 4); custom pane drag-to-resize (ArticlePaneExpansion) and open-in-browser-on-tap are temporarily dropped.
Give the Route.ArticleDetail entry a stable contentKey so next/previous (and tapping a different list article) swaps the article id without remounting the entry. ArticleViewModel now loads reactively via load(id) and keeps the previously shown article until the new one resolves, so the reader chrome (top/bottom bars) stays present and only the content area swaps. Verified on device: switching articles keeps the back stack at [list, detail] and does not recreate the ViewModel (vm_init=0).
Reader prev/next navigation (replace-top) for the All/Unread filter, via a cursor DB query instead of holding a list: articleAfter/articleBefore in articlesByStatus.sq return the adjacent article id by (published_at, id) position, matching the list's order and membership. Anchor-based, so it's O(1) and works from a cold deep link (no list needed) - the property a shared pager can't give.

ArticleSessionCutoff (Koin single) carries the read/unstar session cutoff, owned by the reading session (stamped when the reader opens its first article, cleared on close) so this-session reads stay pinned in the neighbor set and back-navigation works regardless of whether a list was loaded.

Account.neighbors is suspend + withIOContext so the query never runs on the main thread. Real prev/next ids now flow into ArticleTransition, restoring the directional transition.

Verified on device (All view): open a middle article -> correct prev/next; tap next -> advances and re-anchors (prev points back to where you came from); back stack stays [list, detail].

Follow-ups: neighbor queries for Feeds/Folders/SavedSearches/Today (currently return null -> next/prev disabled there); list writing the cutoff for exact parity when coming from a freshly-loaded list (currently reader-owned, sub-second drift).
Adds articleAfter/articleBefore cursor queries for byFeed and bySavedSearch, and a byToday neighbors path (reuses the byStatus queries with the today cutoff). ArticleRecords.neighbors now dispatches all five filter types, so next/prev works in Feeds, Folders, SavedSearches, and Today views - not just All/Unread.

publishedSince was added back to the byStatus neighbor queries so Today can reuse them. All queries execute within Account.neighbors' withIOContext boundary (off the main thread).

byStatus path runtime-verified earlier; the others use the identical proven cursor pattern with their own scoping (feedIDs+priorities / saved-search join / today cutoff) and are schema-validated by SQLDelight. Smoke-tested: no regression, no SQLite errors.
The list now writes the shared ArticleSessionCutoff (the same value as its own articlesSince) whenever its session snapshot starts, and the reader reads it. So coming from a loaded list, the reader's neighbor pinning matches the list exactly - articles read this session stay navigable via next/prev, even after going back to the list and opening a different article.

The reader only stamps the cutoff itself (start(), idempotent) as a fallback for cold deep links with no list session, and no longer clears it (the list owns its lifecycle and re-stamps on refresh/filter change).
MainActivity parses capy:// VIEW intents into a synthetic Nav3 back stack and seeds it (cold start) or replaces it via onNewIntent (warm start), following URL semantics:

- capy://article/<id>           -> [ArticleList(All), ArticleDetail(id)]
- capy://article/<id>?feedID=<f> -> [ArticleList(feed), ArticleDetail(id)]
- capy://articles                -> [ArticleList(All)]
- capy://articles/unread         -> [ArticleList(Unread)]

The article id is a path segment (percent-encoded by the caller, since ids are URLs; Uri decodes it). The deep link also syncs AppPreferences.filter to its list filter, so the list and the reader's neighbor query agree on siblings. Because rendering and neighbors are list-independent, a cold deep link opens the article AND supports next/prev immediately.

Manifest gets a capy:// VIEW intent-filter on MainActivity (singleTask). The old widget/notification extras path still works in parallel; Stage 6 switches those to capy:// and removes the pendingArticleID trampoline.

Verified on device: cold capy://article/<id>, cold capy://articles/unread, and warm article link all open correctly, no crashes.
The list-detail Scene supports pane resizing natively via rememberListDetailSceneStrategy's paneExpansionDragHandle, so wire a VerticalDragHandle there (resolved through the public ThreePaneScaffoldScope.paneExpansionDraggable extension). With resize back on the handle, the reader's redundant fullscreen-toggle arrow becomes the close 'X' always - ArticleNavigationIcon is now close-only, and the dead isFullscreen/onToggleFullscreen plumbing is removed from ArticleView/ArticleTopBar (it was a no-op stub after the split dropped ArticlePaneExpansion).

Also centers the detail-pane empty-state placeholder (was top-aligned).

Verified on tablet: 'Pane expansion drag handle' renders between panes, the reader shows the X close icon, and the placeholder is centered.
Restores the expand/collapse pane toggle the old hand-rolled scaffold had, now backed by the list-detail Scene's PaneExpansionState. App creates one ArticlePaneExpansion, shares its state with both the Scene's drag handle and (via LocalArticlePaneExpansion) the reader's top bar, so the VerticalDragHandle and the toolbar toggle manipulate the same anchors.

ArticleNavigationIcon keeps its original three-branch behavior: compact -> close 'X'; medium+ split -> OpenInFull (expand); medium+ fullscreen -> CloseFullscreen (collapse). This reverts the always-'X' change while keeping the drag handle and centered placeholder.

Verified on a foldable (opened, 2076px): split shows the expand arrows, tapping collapses the list to a fullscreen reader and flips the icon, and tapping again restores the saved split.
At 0% the handle sat on the screen edge with half its touch target off-screen and was hard to grab. Animate a 16dp inset into the detail pane while the detail is fullscreen so the full grab area stays reachable to drag the list back out.
Widgets and notifications now launch MainActivity with a capy:// ACTION_VIEW intent instead of stashing article/filter extras that App replayed through a pendingArticleID LaunchedEffect. DeepLink gains articleUri()/articlesUri() builders so the same object both builds and parses the links.

Removes: NotificationHelper.openFromIntent and its UNREAD_ONLY/SHOW_ALL/FEED_ID extras; pendingArticleID + onPendingArticleSelected across MainActivity, App, and ArticleScreen; and the trampoline LaunchedEffect. The list filter is now set from the parsed Route.ArticleList in MainActivity.applyListFilter.

Verified on device: capy://articles, capy://articles/unread, and capy://article/<id>?feedID=<feed> each route to the right list/reader.
On a single pane the list <-> detail navigation is a scene change, which NavDisplay was cross-fading. Restore the pane scaffold's original shared-axis-X motion (10% offset slide + fade) by attaching transitionSpec/popTransitionSpec/predictivePopTransitionSpec to the ArticleDetail entry.

rememberListDetailSceneStrategy defaults shouldHandleSinglePaneLayout=false, so on compact it declines and the single-pane scene forwards this entry metadata; on tablet both panes live in one scene, so the spec never fires there and the cross-fade default still applies to login/settings as before.
Search was folded into the list's article pager, so an active search mutated the very list the reader derives next/previous neighbors from. Split it out:

- The list pager (articles) is now filter-only; a separate searchResults pager carries the query. combine() emits a plain ArticlePagerKey and one flatMapLatest builds the pager, dropping the Flow<Flow<>> flatten.
- Search runs in a full-surface SearchView overlay (its own field + results) shown while active; the list/reader underneath keep the base filter, so neighbors stay correct. Selecting a result closes the overlay and opens the reader.
- ArticleListTopBar drops the inline search field (now just launches the overlay).

Verified on tablet: search filters in the overlay, the list underneath stays on the base filter, and selecting a result opens that article.
Splitting the reader out dropped the old onScrollToArticle bridge, so in two-pane the list no longer followed the reader's selection (e.g. when stepping next/previous). Restore it on the list side, which already receives selectedArticleID: when it changes (and we're not compact) scroll the article into view if it isn't already visible. Single-pane is skipped since selecting there navigates away from the list.
Replace the collapse-only animated offset with a constant 16dp start padding so the handle keeps a graspable gap at every pane position, not just when fullscreen.
After the Nav3 split the ModalNavigationDrawer lived inside the list pane, so its scrim only dimmed the list, not the detail. Move it above NavDisplay in App so the scrim covers the whole window again (matching the pre-split behavior and the usual drawer pattern).

The drawer state and a content slot live in App and are exposed via LocalAppDrawer; the article list entry publishes its FeedList up (keyed on the data so the drawer recomposes only when it changes, with behaviorally-stable callbacks) and drives open/close through the shared state. ArticleScaffold is gone. The drawer content still runs against the list entry's ViewModel since the published lambda captures it.

Verified on tablet: opening the drawer dims both panes, content renders, and selecting a feed filters the list and closes the drawer.
Tapping an image now navigates to Route.MediaViewer, a real back-stack entry rendered by MediaSceneStrategy as an OverlayScene over the live list/detail panes (same window, no Dialog). This restores the full-window viewer the pre-split single scaffold had — previously it rendered inside the detail pane only. Media rides in the route (already @serializable, so it survives process death); MediaScaffold already owns its black surface, swipe-dismiss, and snackbar host. Also pad the pane drag handle on both edges.

Verified on tablet: tapping an article image opens the viewer full-screen over both panes.
The drawerContent only rendered ModalDrawerSheet when content was non-null, so on first composition (before the list entry publishes its content) the drawer had no sheet and initialized open. Always render the sheet, and use a plain remember (not rememberSaveable) so it never restores open across launches.
The OverlayScene shares the window (no Dialog), so it fades in on open and out via onRemove, and claims system back with a BackHandler (otherwise back fell through to the panes beneath and exited the app). Dropped the earlier zoom-from-rect machinery (WebView rect bridge, image hide + cross-entry restore seam, MediaScaffold scale refactor) in favor of this simple fade.
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.

1 participant