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

Class Class, % Method, % Branch, % Line, % Instruction, %
DashboardCategoriesDonutWidgetKt 100% (1/1) 100% (2/2) 71.4% (10/14) 100% (25/25) 97.4% (225/231)


 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.uikit.components.SurferCategoryPalette
 import com.georgeci.moneysurfer.uikit.widgets.SurferCategoriesDonutWidget
 import com.georgeci.moneysurfer.uikit.widgets.SurferDonutSegment
 import moneysurfer.feature.dashboard.generated.resources.Res
 import moneysurfer.feature.dashboard.generated.resources.dashboard_categories_donut_center_label
 import moneysurfer.feature.dashboard.generated.resources.dashboard_categories_donut_empty_center
 import moneysurfer.feature.dashboard.generated.resources.dashboard_categories_donut_empty_legend
 import moneysurfer.feature.dashboard.generated.resources.dashboard_spent_by_category_uncategorized
 import org.jetbrains.compose.resources.stringResource
 
 /**
  * The selected period's spend as a donut: one arc per category, largest first, with the legend
  * beside it and the period's total in the middle.
  *
  * Reads the same [DashboardState.Content.spentByCategory] rows the spent-by-category card does, so
  * the two can never disagree about what the period holds — including the uncategorized bucket,
  * which arrives as a real slice and is drawn as one rather than being folded away.
  *
  * How many legend rows fit is the widget's own call (Hero 5 / Compact 3, from the size the layout
  * provides), so every segment is still handed over: trimming the list here would shrink the *chart*
  * to what the legend could name, and the arcs would stop adding up to the centre figure.
  */
 @Composable
 internal fun CategoriesDonutWidget(state: DashboardState.Content) {
     val segments = state.spentByCategory.toDonutSegments()
     SurferCategoriesDonutWidget(
         segments = segments,
         centerLabel = stringResource(Res.string.dashboard_categories_donut_center_label),
         centerValue = state.spentByCategoryTotal,
         emptyCenterText = stringResource(Res.string.dashboard_categories_donut_empty_center),
         emptyLegendText = stringResource(Res.string.dashboard_categories_donut_empty_legend),
         modifier = Modifier
             .fillMaxWidth()
             .padding(horizontal = 16.dp)
             .padding(vertical = 8.dp)
             .testTag(DashboardTestTags.CategoriesDonut),
     )
 }
 
 /**
  * The rows as arcs. Colour comes from the stored hue through the one resolver every category bubble
  * goes through, so a category keeps the same colour here, in the spend rows, and on the screens
  * that own it.
  *
  * The uncategorized bucket has no stored appearance, so — exactly as the spend rows do — its tint is
  * hashed from the fixed [DONUT_UNCATEGORIZED_ID] rather than from the label beside it: the label is
  * translated, and a wedge that changed colour with the app language would read as another category.
  */
 @Composable
 private fun List<CategorySpendUi>.toDonutSegments(): List<SurferDonutSegment> {
     val fallbackName = stringResource(Res.string.dashboard_spent_by_category_uncategorized)
     return map { row ->
         val id = row.categoryId ?: DONUT_UNCATEGORIZED_ID
         SurferDonutSegment(
             label = row.name ?: fallbackName,
             percent = row.share,
             color = SurferCategoryPalette.tintForHue(row.hue ?: DONUT_NO_STORED_HUE)
                 ?: SurferCategoryPalette.tintFor(id),
         )
     }
 }
 
 /** Tint seed for the uncategorized bucket, which has no category id to be hashed from. */
 private const val DONUT_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 DONUT_NO_STORED_HUE = -1