From 5453b81c2c8ea6d126c94cec444e57c22b69262e Mon Sep 17 00:00:00 2001 From: DennisBauer <37552885+DennisBauer@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:13:37 +0200 Subject: [PATCH] fix(tabgroup): reach settle up in the tablet two-pane The two-pane layout inlined GroupDetailRoot and passed only part of its callbacks, so settle-up, settlement taps and leave-group hit the silent `= {}` defaults and did nothing. Drive the panes from the back stack instead: a Nav3 SceneStrategy renders the Group list entry together with the GroupDetail/SettleUp entries stacked on it, marked via PaneRole metadata. Selection leaves GroupOverviewViewModel and back pops the detail pane on its own. Detail entries stay composed so a covered group keeps its selected tab. Compact widths fall through to the single-pane scene, unchanged. Drop the `= {}` defaults on GroupDetailRoot so no call site can skip a callback silently again. In the shell, light the rail from the last TopLevelTab in the stack and truncate the whole tab section on switch, so detail entries neither darken their tab nor outlive it. --- .../kotlin/de/tabmates/composeapp/App.kt | 16 +- .../navigation/GroupTwoPaneSceneStrategy.kt | 79 +++++++++ .../core/presentation/navigation/PaneRole.kt | 20 +++ .../presentation/navigation/MainGraph.kt | 24 ++- .../navigation/groupdetail/GroupDetailRoot.kt | 16 +- .../groupoverview/GroupOverviewScreen.kt | 160 ++---------------- .../groupoverview/GroupOverviewState.kt | 6 +- .../groupoverview/GroupOverviewViewModel.kt | 9 +- .../navigation/groupoverview/GroupTwoPane.kt | 85 ++++++++++ .../GroupOverviewViewModelTest.kt | 39 ----- 10 files changed, 235 insertions(+), 219 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/de/tabmates/composeapp/navigation/GroupTwoPaneSceneStrategy.kt create mode 100644 core/presentation/src/commonMain/kotlin/de/tabmates/core/presentation/navigation/PaneRole.kt create mode 100644 features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/GroupTwoPane.kt diff --git a/composeApp/src/commonMain/kotlin/de/tabmates/composeapp/App.kt b/composeApp/src/commonMain/kotlin/de/tabmates/composeapp/App.kt index 2381aed5..073d795a 100644 --- a/composeApp/src/commonMain/kotlin/de/tabmates/composeapp/App.kt +++ b/composeApp/src/commonMain/kotlin/de/tabmates/composeapp/App.kt @@ -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 @@ -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 = { @@ -348,6 +359,7 @@ fun App() { backStack = backStack, onBack = { backStack.removeLastOrNull() }, entryDecorators = rememberEntryDecorators(backStack), + sceneStrategies = listOf(rememberGroupTwoPaneSceneStrategy()), transitionSpec = { navTransition }, popTransitionSpec = { navTransition }, predictivePopTransitionSpec = { _ -> predictivePopTransition }, diff --git a/composeApp/src/commonMain/kotlin/de/tabmates/composeapp/navigation/GroupTwoPaneSceneStrategy.kt b/composeApp/src/commonMain/kotlin/de/tabmates/composeapp/navigation/GroupTwoPaneSceneStrategy.kt new file mode 100644 index 00000000..6e66555a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/de/tabmates/composeapp/navigation/GroupTwoPaneSceneStrategy.kt @@ -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 { + val isExpanded = + currentWindowAdaptiveInfoV2().windowSizeClass.isWidthAtLeastBreakpoint( + WIDTH_DP_MEDIUM_LOWER_BOUND, + ) + return remember(isExpanded) { GroupTwoPaneSceneStrategy(isExpanded) } +} + +private class GroupTwoPaneSceneStrategy(private val isExpanded: Boolean) : SceneStrategy { + override fun SceneStrategyScope.calculateScene( + entries: List>, + ): Scene? { + 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.paneRole: Any? + get() = metadata[PaneRole.KEY] + +private data class GroupTwoPaneScene( + override val key: Any, + val listEntry: NavEntry, + val detailEntries: List>, + override val previousEntries: List>, +) : Scene { + override val entries: List> = 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() } } + }, + ) + } +} diff --git a/core/presentation/src/commonMain/kotlin/de/tabmates/core/presentation/navigation/PaneRole.kt b/core/presentation/src/commonMain/kotlin/de/tabmates/core/presentation/navigation/PaneRole.kt new file mode 100644 index 00000000..8f770249 --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/de/tabmates/core/presentation/navigation/PaneRole.kt @@ -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 = mapOf(KEY to LIST) + + /** Metadata for entries that may fill the right pane above a [list] entry. */ + val detail: Map = mapOf(KEY to DETAIL) +} diff --git a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/MainGraph.kt b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/MainGraph.kt index d4b1a1db..3419dcc0 100644 --- a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/MainGraph.kt +++ b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/MainGraph.kt @@ -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 @@ -86,19 +87,24 @@ fun EntryProviderScope.mainGraph( ) } - entry { + // 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(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().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 { route -> + entry(metadata = PaneRole.detail) { route -> val leftMessage = stringResource(Res.string.group_settings_left) GroupDetailRoot( groupId = route.groupId, @@ -123,7 +129,7 @@ fun EntryProviderScope.mainGraph( ) } - entry { route -> + entry(metadata = PaneRole.detail) { route -> SettleUpRoot( groupId = route.groupId, snackbarHostState = snackbarHostState, diff --git a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupdetail/GroupDetailRoot.kt b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupdetail/GroupDetailRoot.kt index 07d4457a..7eb74676 100644 --- a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupdetail/GroupDetailRoot.kt +++ b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupdetail/GroupDetailRoot.kt @@ -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( diff --git a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/GroupOverviewScreen.kt b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/GroupOverviewScreen.kt index 718a9a93..f7d41a32 100644 --- a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/GroupOverviewScreen.kt +++ b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/GroupOverviewScreen.kt @@ -7,12 +7,10 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape @@ -26,13 +24,10 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedCard -import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults -import androidx.compose.material3.VerticalDivider -import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2 import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -55,7 +50,6 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_MEDIUM_LOWER_BOUND import de.tabmates.core.designsystem.preview.PreviewScreenSizes import de.tabmates.core.designsystem.preview.PreviewThemes import de.tabmates.core.designsystem.spacer.HorizontalSpacer @@ -66,13 +60,10 @@ import de.tabmates.core.presentation.format.AmountSign import de.tabmates.features.tabgroup.domain.models.GroupBalance import de.tabmates.features.tabgroup.presentation.components.GroupAvatar import de.tabmates.features.tabgroup.presentation.components.SyncStatusChip -import de.tabmates.features.tabgroup.presentation.navigation.groupdetail.GroupDetailRoot import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.vectorResource import org.koin.compose.viewmodel.koinViewModel import tabmatesapp.features.tabgroup.presentation.generated.resources.Res -import tabmatesapp.features.tabgroup.presentation.generated.resources.groups_detail_placeholder_caption -import tabmatesapp.features.tabgroup.presentation.generated.resources.groups_detail_placeholder_title import tabmatesapp.features.tabgroup.presentation.generated.resources.groups_empty_caption import tabmatesapp.features.tabgroup.presentation.generated.resources.groups_empty_title import tabmatesapp.features.tabgroup.presentation.generated.resources.groups_expense_count @@ -92,68 +83,30 @@ import tabmatesapp.features.tabgroup.presentation.generated.resources.ic_close import tabmatesapp.features.tabgroup.presentation.generated.resources.ic_search import kotlin.time.Clock +/** + * The group list. On wide windows this is the left pane of [GroupTwoPane] and the right pane is a + * separate back-stack entry, so the screen renders the same list either way and only the highlight + * differs — [selectedGroupId] comes from the back stack, not from local state. + */ @Composable fun GroupOverviewRoot( onGroupOpen: (String) -> Unit, - onSettingsOpen: (String) -> Unit, - snackbarHostState: SnackbarHostState, - onAddEntryClick: (String) -> Unit = {}, - onEntryClick: (groupId: String, entryId: String) -> Unit = { _, _ -> }, + selectedGroupId: String? = null, viewModel: GroupOverviewViewModel = koinViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() GroupOverviewScreen( state = state, + selectedGroupId = selectedGroupId, onFilterSelected = viewModel::onFilterSelected, - onGroupSelected = viewModel::onGroupSelected, onGroupOpen = onGroupOpen, - onSettingsOpen = onSettingsOpen, - onAddEntryClick = onAddEntryClick, - onEntryClick = onEntryClick, - snackbarHostState = snackbarHostState, ) } @Composable private fun GroupOverviewScreen( state: GroupOverviewState, - onFilterSelected: (GroupFilter) -> Unit, - onGroupSelected: (String) -> Unit, - onGroupOpen: (String) -> Unit, - onSettingsOpen: (String) -> Unit, - onAddEntryClick: (String) -> Unit, - onEntryClick: (groupId: String, entryId: String) -> Unit, - snackbarHostState: SnackbarHostState, - modifier: Modifier = Modifier, -) { - val isExpanded = - currentWindowAdaptiveInfoV2().windowSizeClass.isWidthAtLeastBreakpoint( - WIDTH_DP_MEDIUM_LOWER_BOUND, - ) - if (isExpanded) { - ExpandedLayout( - state = state, - onFilterSelected = onFilterSelected, - onGroupSelected = onGroupSelected, - onSettingsOpen = onSettingsOpen, - onAddEntryClick = onAddEntryClick, - onEntryClick = onEntryClick, - snackbarHostState = snackbarHostState, - modifier = modifier, - ) - } else { - CompactLayout( - state = state, - onFilterSelected = onFilterSelected, - onGroupOpen = onGroupOpen, - modifier = modifier, - ) - } -} - -@Composable -private fun CompactLayout( - state: GroupOverviewState, + selectedGroupId: String?, onFilterSelected: (GroupFilter) -> Unit, onGroupOpen: (String) -> Unit, modifier: Modifier = Modifier, @@ -173,7 +126,7 @@ private fun CompactLayout( VerticalSpacer(8.dp) GroupList( items = state.displayedItems, - selectedGroupId = null, + selectedGroupId = selectedGroupId, onGroupSelected = onGroupOpen, contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), modifier = Modifier.fillMaxSize(), @@ -181,66 +134,6 @@ private fun CompactLayout( } } -@Composable -private fun ExpandedLayout( - state: GroupOverviewState, - onFilterSelected: (GroupFilter) -> Unit, - onGroupSelected: (String) -> Unit, - onSettingsOpen: (String) -> Unit, - onAddEntryClick: (String) -> Unit, - onEntryClick: (groupId: String, entryId: String) -> Unit, - snackbarHostState: SnackbarHostState, - modifier: Modifier = Modifier, -) { - Row(modifier = modifier.fillMaxSize()) { - Column( - modifier = - Modifier - .widthIn(min = 280.dp, max = 360.dp) - .fillMaxHeight(), - ) { - GroupsHeader( - searchQueryState = state.searchQueryState, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), - ) - FilterChipsRow( - filter = state.filter, - onFilterSelected = onFilterSelected, - modifier = Modifier.padding(horizontal = 16.dp), - ) - VerticalSpacer(8.dp) - GroupList( - items = state.displayedItems, - selectedGroupId = state.selectedGroupId, - onGroupSelected = onGroupSelected, - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), - modifier = Modifier.fillMaxSize(), - ) - } - VerticalDivider( - modifier = Modifier.fillMaxHeight(), - color = MaterialTheme.colorScheme.outlineVariant, - ) - Box(modifier = Modifier.weight(1f).fillMaxHeight()) { - val selectedId = state.selectedGroupId - if (selectedId == null) { - DetailPlaceholder(modifier = Modifier.fillMaxSize()) - } else { - key(selectedId) { - GroupDetailRoot( - groupId = selectedId, - snackbarHostState = snackbarHostState, - onSettingsClick = { onSettingsOpen(selectedId) }, - onAddEntryClick = { onAddEntryClick(selectedId) }, - onEntryClick = { entryId -> onEntryClick(selectedId, entryId) }, - modifier = Modifier.fillMaxSize(), - ) - } - } - } - } -} - @Composable private fun GroupsHeader( searchQueryState: TextFieldState, @@ -529,28 +422,6 @@ private fun EmptyState(modifier: Modifier = Modifier) { } } -@Composable -private fun DetailPlaceholder(modifier: Modifier = Modifier) { - Column( - modifier = modifier.padding(32.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Text( - text = stringResource(Res.string.groups_detail_placeholder_title), - style = MaterialTheme.typography.titleLarge, - textAlign = TextAlign.Center, - ) - VerticalSpacer(8.dp) - Text( - text = stringResource(Res.string.groups_detail_placeholder_caption), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - ) - } -} - @PreviewThemes @Composable private fun GroupOverviewPreviewThemes() { @@ -558,13 +429,9 @@ private fun GroupOverviewPreviewThemes() { Surface { GroupOverviewScreen( state = previewState(), + selectedGroupId = "1", onFilterSelected = {}, - onGroupSelected = {}, onGroupOpen = {}, - onSettingsOpen = {}, - onAddEntryClick = {}, - onEntryClick = { _, _ -> }, - snackbarHostState = remember { SnackbarHostState() }, ) } } @@ -577,13 +444,9 @@ private fun GroupOverviewPreviewSizes() { Surface { GroupOverviewScreen( state = previewState(), + selectedGroupId = "1", onFilterSelected = {}, - onGroupSelected = {}, onGroupOpen = {}, - onSettingsOpen = {}, - onAddEntryClick = {}, - onEntryClick = { _, _ -> }, - snackbarHostState = remember { SnackbarHostState() }, ) } } @@ -670,6 +533,5 @@ private fun previewState(): GroupOverviewState { displayedItems = items, filter = GroupFilter.ALL, searchQueryState = TextFieldState(), - selectedGroupId = "1", ) } diff --git a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/GroupOverviewState.kt b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/GroupOverviewState.kt index f383b0da..7d45bf91 100644 --- a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/GroupOverviewState.kt +++ b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/GroupOverviewState.kt @@ -10,11 +10,7 @@ data class GroupOverviewState( val displayedItems: List = emptyList(), val filter: GroupFilter = GroupFilter.ALL, val searchQueryState: TextFieldState = TextFieldState(), - val selectedGroupId: String? = null, -) { - val selectedItem: GroupOverviewItem? - get() = allItems.firstOrNull { it.id == selectedGroupId } -} +) enum class GroupFilter { ALL, ACTIVE, SETTLED } diff --git a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/GroupOverviewViewModel.kt b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/GroupOverviewViewModel.kt index 8df48cc9..bd8933f3 100644 --- a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/GroupOverviewViewModel.kt +++ b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/GroupOverviewViewModel.kt @@ -43,7 +43,6 @@ class GroupOverviewViewModel( private val searchQueryFlow = snapshotFlow { searchQueryState.text.toString() } private val filterFlow = MutableStateFlow(GroupFilter.ALL) - private val selectedGroupIdFlow = MutableStateFlow(null) private val groupItemsFlow: Flow> = combine( @@ -92,8 +91,7 @@ class GroupOverviewViewModel( groupItemsFlow, filterFlow, searchQueryFlow, - selectedGroupIdFlow, - ) { items, filter, query, selected -> + ) { items, filter, query -> val displayed = items .filter { filter.matches(it.balance) } @@ -104,7 +102,6 @@ class GroupOverviewViewModel( displayedItems = displayed, filter = filter, searchQueryState = searchQueryState, - selectedGroupId = selected ?: items.firstOrNull()?.id, ) }.stateIn( scope = viewModelScope, @@ -115,10 +112,6 @@ class GroupOverviewViewModel( fun onFilterSelected(filter: GroupFilter) { filterFlow.value = filter } - - fun onGroupSelected(groupId: String) { - selectedGroupIdFlow.value = groupId - } } private fun GroupFilter.matches(balance: GroupBalance): Boolean = diff --git a/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/GroupTwoPane.kt b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/GroupTwoPane.kt new file mode 100644 index 00000000..8807a98b --- /dev/null +++ b/features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/GroupTwoPane.kt @@ -0,0 +1,85 @@ +package de.tabmates.features.tabgroup.presentation.navigation.groupoverview + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import de.tabmates.core.designsystem.spacer.VerticalSpacer +import org.jetbrains.compose.resources.stringResource +import tabmatesapp.features.tabgroup.presentation.generated.resources.Res +import tabmatesapp.features.tabgroup.presentation.generated.resources.groups_detail_placeholder_caption +import tabmatesapp.features.tabgroup.presentation.generated.resources.groups_detail_placeholder_title + +/** + * Wide-window layout for the Groups tab: the group list on the left, whatever the back stack has + * pushed on top of it on the right. + * + * The panes are supplied as slots because their content is nav-entry content owned by the + * `SceneStrategy` in `:composeApp` — this module keeps the geometry and the empty state, the + * strategy keeps the back-stack reading. + */ +@Composable +fun GroupTwoPane( + listPane: @Composable () -> Unit, + detailPane: (@Composable () -> Unit)?, + modifier: Modifier = Modifier, +) { + Row(modifier = modifier.fillMaxSize()) { + Box( + modifier = + Modifier + .widthIn(min = ListPaneMinWidth, max = ListPaneMaxWidth) + .fillMaxHeight(), + ) { + listPane() + } + VerticalDivider( + modifier = Modifier.fillMaxHeight(), + color = MaterialTheme.colorScheme.outlineVariant, + ) + Box(modifier = Modifier.weight(1f).fillMaxHeight()) { + if (detailPane == null) { + DetailPlaceholder(modifier = Modifier.fillMaxSize()) + } else { + detailPane() + } + } + } +} + +@Composable +private fun DetailPlaceholder(modifier: Modifier = Modifier) { + Column( + modifier = modifier.padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = stringResource(Res.string.groups_detail_placeholder_title), + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + ) + VerticalSpacer(8.dp) + Text( + text = stringResource(Res.string.groups_detail_placeholder_caption), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } +} + +private val ListPaneMinWidth = 280.dp +private val ListPaneMaxWidth = 360.dp diff --git a/features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/GroupOverviewViewModelTest.kt b/features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/GroupOverviewViewModelTest.kt index e1e6a636..4ee14756 100644 --- a/features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/GroupOverviewViewModelTest.kt +++ b/features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/GroupOverviewViewModelTest.kt @@ -175,24 +175,6 @@ class GroupOverviewViewModelTest { assertEquals("Weekend in Lisbon", displayed.single().title) } - @Test - fun selectedGroupIdDefaultsToFirstItem() = - runTest(testDispatcher) { - val groupRepo = - FakeGroupRepository( - initialGroups = - listOf( - Fixtures.group(id = "g1", title = "First", activityEpochMs = 2), - Fixtures.group(id = "g2", title = "Second", activityEpochMs = 1), - ), - ) - val viewModel = createViewModel(groupRepository = groupRepo) - activateState(viewModel) - advanceUntilIdle() - - assertEquals("g1", viewModel.state.value.selectedGroupId) - } - @Test fun groupWithPendingExpenseExposesHasPendingSync() = runTest(testDispatcher) { @@ -311,27 +293,6 @@ class GroupOverviewViewModelTest { ) } - @Test - fun onGroupSelectedUpdatesSelectedGroupId() = - runTest(testDispatcher) { - val groupRepo = - FakeGroupRepository( - initialGroups = - listOf( - Fixtures.group(id = "g1", title = "First"), - Fixtures.group(id = "g2", title = "Second"), - ), - ) - val viewModel = createViewModel(groupRepository = groupRepo) - activateState(viewModel) - advanceUntilIdle() - - viewModel.onGroupSelected("g2") - advanceUntilIdle() - - assertEquals("g2", viewModel.state.value.selectedGroupId) - } - private fun TestScope.activateState(viewModel: GroupOverviewViewModel) { backgroundScope.launch { viewModel.state.collect {} } advanceUntilIdle()