Coverage Summary for Class: DashboardCustomizeTestTags (com.georgeci.moneysurfer.feature.dashboard.customize)

Class Class, % Method, % Branch, % Line, % Instruction, %
DashboardCustomizeTestTags 100% (1/1) 100% (8/8) 100% (8/8) 100% (18/18)


 package com.georgeci.moneysurfer.feature.dashboard.customize
 
 import androidx.compose.foundation.clickable
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.PaddingValues
 import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.lazy.LazyColumn
 import androidx.compose.foundation.lazy.items
 import androidx.compose.foundation.lazy.rememberLazyListState
 import androidx.compose.material3.Scaffold
 import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.saveable.rememberSaveable
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.clip
 import androidx.compose.ui.graphics.vector.ImageVector
 import androidx.compose.ui.platform.testTag
 import androidx.compose.ui.tooling.preview.Preview
 import androidx.compose.ui.unit.dp
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import com.georgeci.moneysurfer.domain.dashboard.DashboardLayoutConfig
 import com.georgeci.moneysurfer.domain.dashboard.DashboardLayoutItem
 import com.georgeci.moneysurfer.domain.dashboard.DashboardWidgetType
 import com.georgeci.moneysurfer.uikit.components.SurferSkeletonRow
 import com.georgeci.moneysurfer.uikit.components.base.SurferDragHandle
 import com.georgeci.moneysurfer.uikit.components.base.SurferRoundIconButton
 import com.georgeci.moneysurfer.uikit.components.base.SurferSectionHeader
 import com.georgeci.moneysurfer.uikit.components.base.SurferSectionHeaderHint
 import com.georgeci.moneysurfer.uikit.components.base.SurferToolbar
 import com.georgeci.moneysurfer.uikit.components.base.rememberSurferReorderState
 import com.georgeci.moneysurfer.uikit.components.base.surferReorderHandle
 import com.georgeci.moneysurfer.uikit.components.base.surferReorderableItem
 import com.georgeci.moneysurfer.uikit.components.settings.SurferSettingsRow
 import com.georgeci.moneysurfer.uikit.components.settings.SurferSettingsValuePill
 import com.georgeci.moneysurfer.uikit.icons.SurferIcons
 import com.georgeci.moneysurfer.uikit.modifier.surferContentContainer
 import com.georgeci.moneysurfer.uikit.modifier.surferSafeInsets
 import com.georgeci.moneysurfer.uikit.modifier.surferTestTagAsId
 import com.georgeci.moneysurfer.uikit.preview.SurferComponentPreview
 import com.georgeci.moneysurfer.uikit.theme.AppTheme
 import com.georgeci.moneysurfer.uikit.window.SurferWindowSize
 import com.georgeci.moneysurfer.uikit.window.currentSurferWindowSize
 import com.georgeci.moneysurfer.utils.AsyncState
 import com.georgeci.moneysurfer.utils.HandleSideEffect
 import moneysurfer.feature.dashboard.generated.resources.Res
 import moneysurfer.feature.dashboard.generated.resources.dashboard_customize_add_content_description
 import moneysurfer.feature.dashboard.generated.resources.dashboard_customize_available_empty
 import moneysurfer.feature.dashboard.generated.resources.dashboard_customize_enabled_empty
 import moneysurfer.feature.dashboard.generated.resources.dashboard_customize_footnote
 import moneysurfer.feature.dashboard.generated.resources.dashboard_customize_remove_content_description
 import moneysurfer.feature.dashboard.generated.resources.dashboard_customize_reorder_hint
 import moneysurfer.feature.dashboard.generated.resources.dashboard_customize_section_available
 import moneysurfer.feature.dashboard.generated.resources.dashboard_customize_section_enabled
 import moneysurfer.feature.dashboard.generated.resources.dashboard_customize_title
 import org.jetbrains.compose.resources.stringResource
 import org.koin.compose.viewmodel.koinViewModel
 
 /** Stable selectors for the customize screen — see docs/testing/testing-strategy.md. */
 object DashboardCustomizeTestTags {
     const val Root = "dashboardCustomize:root"
     const val EnabledHeader = "dashboardCustomize:enabledHeader"
     const val AvailableHeader = "dashboardCustomize:availableHeader"
     const val ReorderHint = "dashboardCustomize:reorderHint"
 
     /** Row in the "On dashboard" section — [widget] is the [DashboardWidgetType] name. */
     fun enabledRow(widget: String): String = "dashboardCustomize:on:$widget"
 
     /** Row in the "Available" section. */
     fun availableRow(widget: String): String = "dashboardCustomize:available:$widget"
 
     /**
      * The round +/− button inside a row of either section; a widget appears in exactly one, so the
      * tag says nothing about which way this particular button moves it.
      */
     fun toggle(widget: String): String = "dashboardCustomize:toggle:$widget"
 
     /** Reorder grip. Every row has one — rows drag within a section and across the two. */
     fun dragHandle(widget: String): String = "dashboardCustomize:drag:$widget"
 
     /** The "Hero · Classic" pill on an on-dashboard row; opens the card-style sheet. */
     fun styleAction(widget: String): String = "dashboardCustomize:styleAction:$widget"
 
     /** The card-style sheet, once opened for a widget. */
     fun styleSheet(widget: String): String = "dashboardCustomize:style:$widget"
 
     /** A preview tile in that sheet — [option] is a size name or a variant key. */
     fun styleOption(widget: String, option: String): String = "dashboardCustomize:style:$widget:$option"
 
     /**
      * A grid-width tile in that sheet. Kept out of [styleOption]'s namespace on purpose: variant
      * keys are free-form and widget-defined, so a widget that one day names a treatment "Half" would
      * otherwise put two selectable nodes under one tag and make the selector ambiguous.
      */
     fun spanOption(widget: String, span: String): String = "dashboardCustomize:span:$widget:$span"
 }
 
 @Composable
 fun DashboardCustomizeScreen(
     onNavigateBack: () -> Unit,
     viewModel: DashboardCustomizeViewModel = koinViewModel(),
 ) {
     val state by viewModel.collectAsStateWithLifecycle()
 
     viewModel.HandleSideEffect { effect ->
         when (effect) {
             DashboardCustomizeEffect.NavigateBack -> onNavigateBack()
         }
     }
 
     DashboardCustomizeContent(
         state = state,
         widgetStyleEnabled = viewModel.widgetStyleEnabled,
         onEvent = viewModel::onEvent,
     )
 }
 
 private const val CUSTOMIZE_SKELETON_ROWS = 4
 
 /**
  * Stateless half of the screen — public so `:composeApp` desktop UI tests can mount it with an
  * injected state, the way `SignInContent` is used.
  *
  * [widgetStyleEnabled] is the host's `dashboard_widget_style` build key: off, the rows carry no
  * card-style pill and the picker sheet is unreachable.
  */
 @Composable
 fun DashboardCustomizeContent(
     state: AsyncState<DashboardLayoutConfig>,
     onEvent: (DashboardCustomizeEvent) -> Unit,
     widgetStyleEnabled: Boolean = false,
 ) {
     Scaffold(
         modifier = Modifier
             .surferSafeInsets()
             .surferTestTagAsId()
             .testTag(DashboardCustomizeTestTags.Root),
         topBar = {
             SurferToolbar(
                 title = stringResource(Res.string.dashboard_customize_title),
                 onBack = { onEvent(DashboardCustomizeEvent.OnBackClick) },
             )
         },
     ) { padding ->
         when (state) {
             AsyncState.Loading -> CustomizeSkeleton(padding)
             is AsyncState.Content -> CustomizeList(
                 layout = state.value,
                 padding = padding,
                 widgetStyleEnabled = widgetStyleEnabled,
                 onEvent = onEvent,
             )
         }
     }
 }
 
 @Composable
 private fun CustomizeSkeleton(padding: PaddingValues) {
     Column(
         modifier = Modifier
             .fillMaxSize()
             .surferContentContainer()
             .padding(padding)
             .padding(horizontal = 16.dp),
         verticalArrangement = Arrangement.spacedBy(8.dp),
     ) {
         repeat(CUSTOMIZE_SKELETON_ROWS) { SurferSkeletonRow() }
     }
 }
 
 @Composable
 private fun CustomizeList(
     layout: DashboardLayoutConfig,
     padding: PaddingValues,
     widgetStyleEnabled: Boolean,
     onEvent: (DashboardCustomizeEvent) -> Unit,
 ) {
     val enabled = layout.enabledItems
     val available = layout.disabledItems
     // Grid widths are offered only where a grid exists to apply them: below expanded width the
     // dashboard is one widget per row whatever the spans say.
     val spanEnabled = currentSurferWindowSize() >= SurferWindowSize.Expanded
     // Which widget's style sheet is open, by type name — the type itself is not saveable. Held
     // here rather than in the ViewModel because it is sheet chrome, not part of the layout.
     var styleTarget by rememberSaveable { mutableStateOf<String?>(null) }
     val listState = rememberLazyListState()
     // Both sections are drop targets: a row dragged across the "Available" header changes section,
     // which is the same edit its +/− button makes. A widget is in exactly one section, so its type
     // name is a unique key across the whole list.
     val rows = enabled + available
     val reorderState = rememberSurferReorderState(
         listState = listState,
         keys = rows.map { it.type.name },
         onMove = { from, to ->
             val fromType = rows.firstOrNull { it.type.name == from }?.type
             val toType = rows.firstOrNull { it.type.name == to }?.type
             if (fromType != null && toType != null) {
                 onEvent(DashboardCustomizeEvent.OnWidgetMove(from = fromType, to = toType))
             }
         },
     )
 
     LazyColumn(
         state = listState,
         modifier = Modifier
             .fillMaxSize()
             .surferContentContainer()
             .padding(top = padding.calculateTopPadding()),
         contentPadding = PaddingValues(
             start = 16.dp,
             end = 16.dp,
             bottom = padding.calculateBottomPadding() + 24.dp,
         ),
         verticalArrangement = Arrangement.spacedBy(8.dp),
     ) {
         item(key = "header-enabled") {
             SectionHeader(
                 title = stringResource(Res.string.dashboard_customize_section_enabled),
                 modifier = Modifier.testTag(DashboardCustomizeTestTags.EnabledHeader),
                 // Nothing to reorder with a single widget on the dashboard.
                 hint = stringResource(Res.string.dashboard_customize_reorder_hint)
                     .takeIf { enabled.size > 1 },
             )
         }
         if (enabled.isEmpty()) {
             item(key = "empty-enabled") {
                 SectionNote(text = stringResource(Res.string.dashboard_customize_enabled_empty))
             }
         }
         items(items = enabled, key = { it.type.name }) { item ->
             WidgetRow(
                 item = item,
                 rowTestTag = DashboardCustomizeTestTags.enabledRow(item.type.name),
                 modifier = surferReorderableItem(reorderState, item.type.name),
                 handleModifier = Modifier.surferReorderHandle(reorderState, item.type.name),
                 // Either half of the sheet is reason enough to offer the pill: the card-style
                 // picker ships behind a build key that is off, and the grid widths would be
                 // unreachable in every shipped build if they rode on it.
                 pill = if (widgetStyleEnabled || spanEnabled) {
                     WidgetRowPill(
                         summary = cardStyleSummary(
                             item,
                             showStyle = widgetStyleEnabled,
                             showSpan = spanEnabled,
                         ),
                         onClick = { styleTarget = item.type.name },
                     )
                 } else {
                     null
                 },
                 onEvent = onEvent,
             )
         }
 
         item(key = "header-available") {
             SectionHeader(
                 title = stringResource(Res.string.dashboard_customize_section_available),
                 modifier = Modifier
                     .padding(top = 16.dp)
                     .testTag(DashboardCustomizeTestTags.AvailableHeader),
             )
         }
         if (available.isEmpty()) {
             item(key = "empty-available") {
                 SectionNote(text = stringResource(Res.string.dashboard_customize_available_empty))
             }
         }
         items(items = available, key = { it.type.name }) { item ->
             WidgetRow(
                 item = item,
                 rowTestTag = DashboardCustomizeTestTags.availableRow(item.type.name),
                 modifier = surferReorderableItem(reorderState, item.type.name),
                 handleModifier = Modifier.surferReorderHandle(reorderState, item.type.name),
                 pill = null,
                 onEvent = onEvent,
             )
         }
 
         item(key = "footnote") {
             SectionNote(
                 text = stringResource(Res.string.dashboard_customize_footnote),
                 modifier = Modifier.padding(top = 16.dp),
             )
         }
     }
 
     // Resolved against the live layout, not captured when the sheet opened, so the tiles show the
     // selection the last tap produced. A widget switched off while its sheet is open closes it.
     val styled = styleTarget?.let { name -> enabled.firstOrNull { it.type.name == name } }
     if (styled != null) {
         DashboardCardStyleSheet(
             item = styled,
             onSelect = { onEvent(DashboardCustomizeEvent.OnCardStyleChange(styled.type, it)) },
             onSpanSelect = { onEvent(DashboardCustomizeEvent.OnSpanChange(styled.type, it)) },
             onDismiss = { styleTarget = null },
             styleEnabled = widgetStyleEnabled,
             spanEnabled = spanEnabled,
         )
     }
 }
 
 @Composable
 private fun SectionHeader(
     title: String,
     modifier: Modifier = Modifier,
     hint: String? = null,
 ) {
     SurferSectionHeader(
         title = title,
         modifier = modifier.padding(horizontal = 8.dp, vertical = 4.dp),
         trailing = hint?.let {
             {
                 SurferSectionHeaderHint(
                     text = it,
                     modifier = Modifier.testTag(DashboardCustomizeTestTags.ReorderHint),
                 )
             }
         },
     )
 }
 
 @Composable
 private fun SectionNote(text: String, modifier: Modifier = Modifier) {
     Text(
         text = text,
         style = AppTheme.typography.bodySmall,
         color = AppTheme.materialColors.onSurfaceVariant,
         modifier = modifier.padding(horizontal = 8.dp),
     )
 }
 
 /**
  * The "Hero · Classic" pill under a widget's name: what it says, and the sheet it opens. One value
  * rather than a summary and a callback, because it is one affordance — a row either offers the
  * picker or it does not, and a label with nothing behind it would be a lie either way.
  */
 private data class WidgetRowPill(val summary: String, val onClick: () -> Unit)
 
 /**
  * One widget, in whichever section it currently sits. The two used to be separate composables and
  * had drifted: only the enabled one could be dragged. They differ in exactly two things now — the
  * direction the round button moves the row, and whether [onStyleClick] is offered.
  *
  * Nothing here is clickable at row level: a clickable row merges its subtree semantics, which
  * would swallow the grip and the button as addressable nodes.
  */
 @Composable
 private fun WidgetRow(
     item: DashboardLayoutItem,
     rowTestTag: String,
     modifier: Modifier,
     handleModifier: Modifier,
     pill: WidgetRowPill?,
     onEvent: (DashboardCustomizeEvent) -> Unit,
 ) {
     val widget = item.type.name
     val title = stringResource(item.type.titleResource())
     val colors = AppTheme.materialColors
     SurferSettingsRow(
         title = title,
         modifier = modifier.testTag(rowTestTag),
         icon = item.type.icon(),
         supporting = pill?.let {
             {
                 SurferSettingsValuePill(
                     text = it.summary,
                     modifier = Modifier
                         .clip(AppTheme.shapes.small)
                         .clickable(onClick = it.onClick)
                         .padding(vertical = 4.dp, horizontal = 2.dp)
                         .testTag(DashboardCustomizeTestTags.styleAction(widget)),
                 )
             }
         },
         trailing = {
             Row(
                 verticalAlignment = Alignment.CenterVertically,
                 horizontalArrangement = Arrangement.spacedBy(4.dp),
             ) {
                 SurferRoundIconButton(
                     icon = if (item.enabled) SurferIcons.Remove else SurferIcons.Add,
                     contentDescription = stringResource(
                         if (item.enabled) {
                             Res.string.dashboard_customize_remove_content_description
                         } else {
                             Res.string.dashboard_customize_add_content_description
                         },
                         title,
                     ),
                     onClick = {
                         onEvent(
                             DashboardCustomizeEvent.OnWidgetEnabledChange(item.type, enabled = !item.enabled),
                         )
                     },
                     containerColor = if (item.enabled) {
                         colors.surfaceContainerHighest
                     } else {
                         colors.primaryContainer
                     },
                     contentColor = if (item.enabled) colors.onSurface else colors.onPrimaryContainer,
                     modifier = Modifier.testTag(DashboardCustomizeTestTags.toggle(widget)),
                 )
                 SurferDragHandle(
                     modifier = handleModifier.testTag(DashboardCustomizeTestTags.dragHandle(widget)),
                 )
             }
         },
     )
 }
 
 private fun DashboardWidgetType.icon(): ImageVector = when (this) {
     DashboardWidgetType.Balance -> SurferIcons.Wallet
     DashboardWidgetType.QuickActions -> SurferIcons.Bolt
     DashboardWidgetType.SpentMonth -> SurferIcons.Calendar
     DashboardWidgetType.SafeToSpend -> SurferIcons.Cash
     DashboardWidgetType.BurnRate -> SurferIcons.Clock
     DashboardWidgetType.Budgets -> SurferIcons.Category
     DashboardWidgetType.SpentByCategory -> SurferIcons.Category
     DashboardWidgetType.CategoriesDonut -> SurferIcons.Category
     DashboardWidgetType.Accounts -> SurferIcons.Bank
     DashboardWidgetType.Insights -> SurferIcons.Sparkle
     DashboardWidgetType.Recurring -> SurferIcons.Calendar
     DashboardWidgetType.Goals -> SurferIcons.Savings
     DashboardWidgetType.RecentTransactions -> SurferIcons.Receipt
 }
 
 @Preview
 @Composable
 private fun DashboardCustomizePreview() {
     SurferComponentPreview {
         DashboardCustomizeContent(
             state = AsyncState.Content(
                 DashboardLayoutConfig.DEFAULT.withWidgetEnabled(DashboardWidgetType.Goals, enabled = false),
             ),
             onEvent = {},
             widgetStyleEnabled = true,
         )
     }
 }