Coverage Summary for Class: NavigationPresentation (com.georgeci.moneysurfer.navigation)

Class Class, % Method, % Branch, % Line, % Instruction, %
NavigationPresentation 100% (1/1) 100% (1/1) 100% (3/3) 100% (24/24)


 package com.georgeci.moneysurfer.navigation
 
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.WindowInsets
 import androidx.compose.foundation.layout.WindowInsetsSides
 import androidx.compose.foundation.layout.consumeWindowInsets
 import androidx.compose.foundation.layout.fillMaxHeight
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.only
 import androidx.compose.foundation.layout.safeDrawing
 import androidx.compose.material3.Icon
 import androidx.compose.material3.Text
 import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
 import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
 import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.remember
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.clipToBounds
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import com.georgeci.moneysurfer.uikit.components.base.SurferDrawerItem
 import com.georgeci.moneysurfer.uikit.components.base.SurferDrawerSection
 import com.georgeci.moneysurfer.uikit.components.base.SurferNavigationDrawer
 import com.georgeci.moneysurfer.uikit.theme.AppTheme
 import com.georgeci.moneysurfer.uikit.window.SurferWindowSize
 import com.georgeci.moneysurfer.uikit.window.currentSurferWindowSize
 import moneysurfer.navigation.generated.resources.Res
 import moneysurfer.navigation.generated.resources.nav_user_guest
 import org.jetbrains.compose.resources.stringResource
 import org.koin.compose.viewmodel.koinViewModel
 
 /**
  * How the top-level destinations are presented at a given window width.
  *
  * Compact is [None] on purpose. §G6 of `md/tablet-desktop-responsive.md` asked whether the missing
  * bottom bar was a deliberate product decision; issue #389 settles it as one, so it is spelled out
  * here as a branch rather than left to fall out of a width comparison. Three things back it: the
  * product split the research board states (phone captures data, wide windows analyse it), the
  * seven-destination taxonomy the drawer needs — a `NavigationBar` holds at most five — and the
  * fact that every phone screen already ends in a FAB and its own bottom insets, which a bar would
  * have to be threaded through across nine feature modules.
  */
 internal enum class NavigationPresentation {
     /** Compact — no navigation suite; the dashboard's own affordances carry phone navigation. */
     None,
 
     /** Medium — the `NavigationSuiteScaffold` rail, which needs no chrome the suite cannot model. */
     Rail,
 
     /** Expanded and Large — the custom permanent drawer (issue #389, gap G5). */
     Drawer,
 }
 
 internal fun SurferWindowSize.navigationPresentation(): NavigationPresentation = when (this) {
     SurferWindowSize.Compact -> NavigationPresentation.None
     SurferWindowSize.Medium -> NavigationPresentation.Rail
     SurferWindowSize.Expanded, SurferWindowSize.Large -> NavigationPresentation.Drawer
 }
 
 /**
  * Hosts [content] next to whichever presentation of [TopLevelDestination] the window width calls
  * for. One destination list, three presentations — see [NavigationPresentation].
  *
  * @param onOpenWorkspaceSelector invoked by the drawer's user footer; the rail has nowhere to put
  *   it, so at Medium the workspace switcher stays where it already lives, in Settings.
  */
 @Composable
 internal fun AppNavigationShell(
     currentTopLevel: Route.TopLevel,
     onSelect: (Route) -> Unit,
     onOpenWorkspaceSelector: () -> Unit,
     content: @Composable () -> Unit,
 ) {
     when (currentSurferWindowSize().navigationPresentation()) {
         NavigationPresentation.Drawer -> AppNavigationDrawer(
             currentTopLevel = currentTopLevel,
             onSelect = onSelect,
             onOpenWorkspaceSelector = onOpenWorkspaceSelector,
             content = content,
         )
         NavigationPresentation.Rail -> AppNavigationSuite(
             layoutType = NavigationSuiteType.NavigationRail,
             currentTopLevel = currentTopLevel,
             onSelect = onSelect,
             content = content,
         )
         NavigationPresentation.None -> AppNavigationSuite(
             layoutType = NavigationSuiteType.None,
             currentTopLevel = currentTopLevel,
             onSelect = onSelect,
             content = content,
         )
     }
 }
 
 /**
  * The drawer presentation: [SurferNavigationDrawer] beside the content, with the destinations
  * grouped by [NavigationSection].
  *
  * Insets are split the way `NavigationSuiteScaffold` splits them for the rail, not the way
  * [SurferPaneSceneStrategy] splits them for two panes. The drawer is chrome, so its surface has to
  * reach the window edge and pad its own content — padding the whole row instead would leave a
  * strip of the scaffold background beside a start-edge cutout, and leave the brand header under
  * the status bar. What the host owes the content pane is only the *consumption*: the start inset
  * is the drawer's, so the pane inside must not apply it again on an interior edge. The end inset
  * survives for `SurferPaneScene` and the screens to apply.
  */
 @Composable
 private fun AppNavigationDrawer(
     currentTopLevel: Route.TopLevel,
     onSelect: (Route) -> Unit,
     onOpenWorkspaceSelector: () -> Unit,
     content: @Composable () -> Unit,
 ) {
     val viewModel: AppShellViewModel = koinViewModel()
     val identity by viewModel.identity.collectAsStateWithLifecycle()
     Row(modifier = Modifier.fillMaxSize()) {
         SurferNavigationDrawer(
             sections = drawerSections(currentTopLevel = currentTopLevel, onSelect = onSelect),
             userName = identity.userName ?: stringResource(Res.string.nav_user_guest),
             workspaceName = identity.workspaceName,
             onUserClick = onOpenWorkspaceSelector,
         )
         Box(
             // `weight` only sizes the horizontal axis; without `fillMaxHeight` the content column
             // would wrap its children's height instead of filling the window beside the drawer.
             modifier = Modifier
                 .weight(1f)
                 .fillMaxHeight()
                 // `NavDisplay` slides a leaving screen out by its own full width, and drawing is
                 // not bounded by layout: without this the outgoing screen paints across the
                 // drawer for the length of the transition, so switching destinations appears to
                 // repaint the destination list itself. The drawer is chrome and stays put.
                 .clipToBounds()
                 .consumeWindowInsets(WindowInsets.safeDrawing.only(WindowInsetsSides.Start)),
         ) {
             content()
         }
     }
 }
 
 /**
  * [TopLevelDestination.entries] folded into the drawer's groups, keeping declaration order inside
  * each one. `groupBy` preserves insertion order for both the keys and the values, so a section
  * that ever became non-contiguous in the enum would still render as one block — the enum's own
  * doc is what keeps the two orders honest.
  *
  * Remembered because the `onClick` lambdas are built inside a plain `map`, which the Compose
  * compiler does not memoize: without this every recomposition would hand the drawer seven items
  * with new lambda identities, and `@Immutable` would buy nothing. The resolved label lists are
  * keys rather than a locale token, so a configuration change still rebuilds them.
  */
 @Composable
 private fun drawerSections(
     currentTopLevel: Route.TopLevel,
     onSelect: (Route) -> Unit,
 ): List<SurferDrawerSection> {
     val labels = TopLevelDestination.entries.map { stringResource(it.label) }
     val sectionLabels = NavigationSection.entries.map { section ->
         section.label?.let { stringResource(it) }
     }
     return remember(currentTopLevel, onSelect, labels, sectionLabels) {
         TopLevelDestination.entries.groupBy { it.section }.map { (section, destinations) ->
             SurferDrawerSection(
                 label = sectionLabels[section.ordinal],
                 items = destinations.map { destination ->
                     SurferDrawerItem(
                         label = labels[destination.ordinal],
                         icon = destination.icon,
                         selected = destination.matches(currentTopLevel),
                         onClick = { onSelect(destination.route) },
                     )
                 },
             )
         }
     }
 }
 
 /** The rail (and the empty Compact) presentation — the flat list `NavigationSuiteScaffold` takes. */
 @OptIn(ExperimentalMaterial3AdaptiveApi::class)
 @Composable
 private fun AppNavigationSuite(
     layoutType: NavigationSuiteType,
     currentTopLevel: Route.TopLevel,
     onSelect: (Route) -> Unit,
     content: @Composable () -> Unit,
 ) {
     val labels = TopLevelDestination.entries.associateWith { stringResource(it.label) }
     NavigationSuiteScaffold(
         layoutType = layoutType,
         navigationSuiteItems = {
             TopLevelDestination.entries.forEach { destination ->
                 val label = labels.getValue(destination)
                 item(
                     selected = destination.matches(currentTopLevel),
                     onClick = { onSelect(destination.route) },
                     icon = { Icon(imageVector = destination.icon, contentDescription = label) },
                     label = { Text(label) },
                 )
             }
         },
         containerColor = AppTheme.materialColors.background,
         content = content,
     )
 }