Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions composeApp/src/commonMain/kotlin/de/tabmates/composeapp/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import de.tabmates.composeapp.deeplink.navDeepLink
import de.tabmates.composeapp.deeplink.resolveDeepLink
import de.tabmates.composeapp.di.TabMatesKoinApp
import de.tabmates.composeapp.navigation.PlatformBackHandler
import de.tabmates.composeapp.navigation.rememberGroupTwoPaneSceneStrategy
import de.tabmates.composeapp.navigation.rememberScreenTopBarNavEntryDecorator
import de.tabmates.composeapp.promo.AppPromoBannerRoot
import de.tabmates.composeapp.promo.isAndroidBrowser
Expand Down Expand Up @@ -279,12 +280,22 @@ fun App() {
modifier = Modifier.imePadding(),
navigationSuiteType = navigationSuiteType,
navigationItems = {
// The tab the stack is currently under, not just the top key: on wide
// windows the Groups tab keeps a GroupDetail entry stacked on it to fill
// the detail pane, and the rail must stay lit through that.
val activeTab = backStack.lastOrNull { it is TopLevelTab }
topLevelTabs.forEach { tab ->
val selected = currentKey == tab
val selected = activeTab == tab
NavigationSuiteItem(
selected = selected,
onClick = {
backStack.removeAll { it is TopLevelTab }
// Drop the whole current tab section, not just the tab key —
// otherwise its detail entries outlive it and resurface
// full-screen when backing out of the new tab.
val tabIndex = backStack.indexOfLast { it is TopLevelTab }
if (tabIndex >= 0) {
while (backStack.size > tabIndex) backStack.removeLastOrNull()
}
backStack.add(tab)
},
icon = {
Expand Down Expand Up @@ -348,6 +359,7 @@ fun App() {
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryDecorators = rememberEntryDecorators(backStack),
sceneStrategies = listOf(rememberGroupTwoPaneSceneStrategy()),
transitionSpec = { navTransition },
popTransitionSpec = { navTransition },
predictivePopTransitionSpec = { _ -> predictivePopTransition },
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package de.tabmates.composeapp.navigation

import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.scene.Scene
import androidx.navigation3.scene.SceneStrategy
import androidx.navigation3.scene.SceneStrategyScope
import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_MEDIUM_LOWER_BOUND
import de.tabmates.core.presentation.navigation.PaneRole
import de.tabmates.features.tabgroup.presentation.navigation.groupoverview.GroupTwoPane

/**
* Renders the group list and the entry stacked on top of it as two panes of a single scene on wide
* windows, so the back stack alone decides what the detail pane shows: `[Group]` is the list plus an
* empty state, `[Group, GroupDetail]` fills the pane with that group, and `[Group, GroupDetail,
* SettleUp]` swaps the pane to settle-up. Back pops one entry and the pane follows.
*
* Returns null on compact windows and for every other stack shape, which drops NavDisplay back to
* its single-pane scene — so Add Entry, Entry Detail and Group Settings still take the whole window
* even on a tablet.
*/
@Composable
fun rememberGroupTwoPaneSceneStrategy(): SceneStrategy<NavKey> {
val isExpanded =
currentWindowAdaptiveInfoV2().windowSizeClass.isWidthAtLeastBreakpoint(
WIDTH_DP_MEDIUM_LOWER_BOUND,
)
return remember(isExpanded) { GroupTwoPaneSceneStrategy(isExpanded) }
}

private class GroupTwoPaneSceneStrategy(private val isExpanded: Boolean) : SceneStrategy<NavKey> {
override fun SceneStrategyScope<NavKey>.calculateScene(
entries: List<NavEntry<NavKey>>,
): Scene<NavKey>? {
if (!isExpanded) return null
val listIndex = entries.indexOfLast { it.paneRole == PaneRole.LIST }
if (listIndex < 0) return null
val above = entries.subList(listIndex + 1, entries.size)
// Anything that isn't pane content covers both panes instead of splitting them.
if (above.any { it.paneRole != PaneRole.DETAIL }) return null
val listEntry = entries[listIndex]
return GroupTwoPaneScene(
key = above.lastOrNull()?.contentKey ?: listEntry.contentKey,
listEntry = listEntry,
detailEntries = above.toList(),
previousEntries = entries.dropLast(1),
)
}
}

private val NavEntry<NavKey>.paneRole: Any?
get() = metadata[PaneRole.KEY]

private data class GroupTwoPaneScene(
override val key: Any,
val listEntry: NavEntry<NavKey>,
val detailEntries: List<NavEntry<NavKey>>,
override val previousEntries: List<NavEntry<NavKey>>,
) : Scene<NavKey> {
override val entries: List<NavEntry<NavKey>> = listOf(listEntry) + detailEntries

override val content: @Composable () -> Unit = {
GroupTwoPane(
listPane = { listEntry.Content() },
// Every detail entry stays composed, stacked with the newest on top — each entry paints
// an opaque background of its own. Dropping the covered ones instead would throw away
// their saved state, so backing out of settle-up would lose the group's selected tab.
detailPane =
if (detailEntries.isEmpty()) {
null
} else {
{ detailEntries.forEach { entry -> entry.Content() } }
},
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package de.tabmates.core.presentation.navigation

/**
* Marks a nav entry as one half of a list/detail pair so a `SceneStrategy` can render two back-stack
* entries side by side on wide windows instead of stacking them.
*
* Attached declaratively through `entry(metadata = PaneRole.list)`, because a `NavEntry` hides its
* typed key — metadata is the only thing a strategy can read back off an entry.
*/
object PaneRole {
const val KEY: String = "de.tabmates.navigation.paneRole"
const val LIST: String = "list"
const val DETAIL: String = "detail"

/** Metadata for the entry that owns the left pane. */
val list: Map<String, Any> = mapOf(KEY to LIST)

/** Metadata for entries that may fill the right pane above a [list] entry. */
val detail: Map<String, Any> = mapOf(KEY to DETAIL)
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import androidx.compose.material3.SnackbarHostState
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import de.tabmates.core.presentation.navigation.PaneRole
import de.tabmates.core.presentation.navigation.TopLevelTab
import de.tabmates.features.tabgroup.presentation.navigation.activity.ActivityRoot
import de.tabmates.features.tabgroup.presentation.navigation.addentry.AddEntryRoot
Expand Down Expand Up @@ -86,19 +87,24 @@ fun EntryProviderScope<NavKey>.mainGraph(
)
}

entry<Group> {
// The group list and whatever sits on top of it form the two panes on wide windows; see
// GroupTwoPaneSceneStrategy, which reads these roles back off the entries.
entry<Group>(metadata = PaneRole.list) {
// The back stack is the selection: no parallel flag to drift out of sync, and system back
// clears the detail pane on its own.
val selectedGroupId = backStack.filterIsInstance<GroupDetail>().lastOrNull()?.groupId
GroupOverviewRoot(
onGroupOpen = { groupId -> backStack.add(GroupDetail(groupId)) },
onSettingsOpen = { groupId -> backStack.add(GroupSettings(groupId)) },
onAddEntryClick = { groupId -> backStack.add(AddEntry(groupId)) },
onEntryClick = { groupId, entryId ->
backStack.add(EntryDetail(entryId = entryId, groupId = groupId))
selectedGroupId = selectedGroupId,
onGroupOpen = { groupId ->
// Replace rather than stack: picking another group swaps the pane, it does not
// deepen the history.
backStack.removeAll { it is GroupDetail || it is SettleUp }
backStack.add(GroupDetail(groupId))
},
snackbarHostState = snackbarHostState,
)
}

entry<GroupDetail> { route ->
entry<GroupDetail>(metadata = PaneRole.detail) { route ->
val leftMessage = stringResource(Res.string.group_settings_left)
GroupDetailRoot(
groupId = route.groupId,
Expand All @@ -123,7 +129,7 @@ fun EntryProviderScope<NavKey>.mainGraph(
)
}

entry<SettleUp> { route ->
entry<SettleUp>(metadata = PaneRole.detail) { route ->
SettleUpRoot(
groupId = route.groupId,
snackbarHostState = snackbarHostState,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,15 @@ import tabmatesapp.features.tabgroup.presentation.generated.resources.ic_arrow_b
fun GroupDetailRoot(
groupId: String,
snackbarHostState: SnackbarHostState,
onBack: () -> Unit = {},
onSettingsClick: () -> Unit = {},
onAddEntryClick: () -> Unit = {},
onSettleUpClick: () -> Unit = {},
onEntryClick: (String) -> Unit = {},
onSettlementClick: (String) -> Unit = {},
onLeaveGroup: () -> Unit = {},
// No defaults on purpose: a silently defaulted callback is what left settle-up, settlement
// taps and leave-group dead in the tablet two-pane layout. Every call site states its intent.
onBack: () -> Unit,
onSettingsClick: () -> Unit,
onAddEntryClick: () -> Unit,
onSettleUpClick: () -> Unit,
onEntryClick: (String) -> Unit,
onSettlementClick: (String) -> Unit,
onLeaveGroup: () -> Unit,
modifier: Modifier = Modifier,
viewModel: GroupDetailViewModel =
koinViewModel(
Expand Down
Loading