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

Class Method, % Branch, % Line, % Instruction, %
DashboardSpentByCategoryWidgetKt 100% (5/5) 78.3% (18/23) 95.1% (39/41) 96.4% (487/505)
DashboardSpentByCategoryWidgetKt$WhenMappings
Total 100% (5/5) 78.3% (18/23) 95.1% (39/41) 96.4% (487/505)


 package com.georgeci.moneysurfer.feature.dashboard
 
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.padding
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.platform.testTag
 import androidx.compose.ui.unit.dp
 import com.georgeci.moneysurfer.domain.model.BudgetStatus
 import com.georgeci.moneysurfer.uikit.components.SurferCategoryPalette
 import com.georgeci.moneysurfer.uikit.widgets.SurferCategorySpendCap
 import com.georgeci.moneysurfer.uikit.widgets.SurferCategorySpendItem
 import com.georgeci.moneysurfer.uikit.widgets.SurferSpentByCategoryEmpty
 import com.georgeci.moneysurfer.uikit.widgets.SurferSpentByCategoryVariant
 import com.georgeci.moneysurfer.uikit.widgets.SurferSpentByCategoryWidget
 import moneysurfer.feature.dashboard.generated.resources.Res
 import moneysurfer.feature.dashboard.generated.resources.dashboard_spent_by_category_empty_subtitle
 import moneysurfer.feature.dashboard.generated.resources.dashboard_spent_by_category_empty_title
 import moneysurfer.feature.dashboard.generated.resources.dashboard_spent_by_category_of_cap
 import moneysurfer.feature.dashboard.generated.resources.dashboard_spent_by_category_over_cap
 import moneysurfer.feature.dashboard.generated.resources.dashboard_spent_by_category_share
 import moneysurfer.feature.dashboard.generated.resources.dashboard_spent_by_category_status_over
 import moneysurfer.feature.dashboard.generated.resources.dashboard_spent_by_category_status_warn
 import moneysurfer.feature.dashboard.generated.resources.dashboard_spent_by_category_title
 import moneysurfer.feature.dashboard.generated.resources.dashboard_spent_by_category_uncategorized
 import org.jetbrains.compose.resources.stringResource
 
 /**
  * Where the selected period's money went, category by category, in whichever of the five
  * treatments the card style names.
  *
  * Like safe-to-spend this one keeps drawing with nothing behind it: a period with no expenses yet
  * is a state the widget is *for*, not a reason to leave a gap in the column.
  */
 @Composable
 internal fun SpentByCategoryWidget(state: DashboardState.Content, variant: String?) {
     SurferSpentByCategoryWidget(
         title = stringResource(Res.string.dashboard_spent_by_category_title),
         items = state.spentByCategory.map { it.toWidgetItem() },
         variant = SurferSpentByCategoryVariant.fromKey(variant),
         empty = SurferSpentByCategoryEmpty(
             title = stringResource(Res.string.dashboard_spent_by_category_empty_title),
             subtitle = stringResource(Res.string.dashboard_spent_by_category_empty_subtitle),
         ),
         modifier = Modifier
             .fillMaxWidth()
             .padding(horizontal = 16.dp)
             .padding(vertical = 8.dp)
             .testTag(DashboardTestTags.SpentByCategory),
     )
 }
 
 /**
  * The row as the card draws it. The tint comes from the category's stored hue through the one
  * resolver every category bubble goes through, so a category keeps the same colour here as on the
  * screens that own it.
  *
  * The uncategorized bucket has no stored appearance, so its tint is hashed from the fixed
  * [UNCATEGORIZED_ID] rather than from the label beside it — the label is translated, and a bucket
  * that changed colour when the user switched language would look like a different category.
  */
 @Composable
 private fun CategorySpendUi.toWidgetItem(): SurferCategorySpendItem {
     val fallbackName = stringResource(Res.string.dashboard_spent_by_category_uncategorized)
     val id = categoryId ?: UNCATEGORIZED_ID
     return SurferCategorySpendItem(
         id = id,
         name = name ?: fallbackName,
         amount = spentFormatted,
         share = share,
         caption = caption(),
         // The same resolver every category bubble goes through: the stored hue snapped to the
         // palette, falling back to hashing the id for a row that stored none.
         tint = SurferCategoryPalette.tintForHue(hue ?: NO_STORED_HUE) ?: SurferCategoryPalette.tintFor(id),
         cap = cap?.toWidgetCap(),
     )
 }
 
 /**
  * The line that names what the row's meter measures. A capped category is measured against its cap,
  * so the caption states the cap; an uncapped one is measured against the period's spend, so it
  * states the share. The two must never be told apart by the shape alone.
  */
 @Composable
 private fun CategorySpendUi.caption(): String {
     val limit = cap ?: return stringResource(Res.string.dashboard_spent_by_category_share, sharePercent)
     return if (limit.status == BudgetStatus.OVER) {
         stringResource(Res.string.dashboard_spent_by_category_over_cap, limit.limitFormatted)
     } else {
         stringResource(Res.string.dashboard_spent_by_category_of_cap, spentFormatted, limit.limitFormatted)
     }
 }
 
 @Composable
 private fun CategoryCapUi.toWidgetCap(): SurferCategorySpendCap = SurferCategorySpendCap(
     progress = progress,
     status = status.toWidgetStatus(),
     statusLabel = status.statusLabel(),
 )
 
 /** Only the two states worth interrupting a row for carry a word; OK is the quiet default. */
 @Composable
 private fun BudgetStatus.statusLabel(): String? = when (this) {
     BudgetStatus.OK -> null
     BudgetStatus.WARN -> stringResource(Res.string.dashboard_spent_by_category_status_warn)
     BudgetStatus.OVER -> stringResource(Res.string.dashboard_spent_by_category_status_over)
 }
 
 /** List key for the uncategorized bucket, which has no category id to be keyed by. */
 private const val UNCATEGORIZED_ID = "uncategorized"
 
 /**
  * The "never stored" hue sentinel — `SurferCategoryPalette.tintForHue` reads anything off the
  * colour wheel as absent and falls back to hashing the id.
  */
 private const val NO_STORED_HUE = -1