Coverage Summary for Class: InsightsScreenKt (com.georgeci.moneysurfer.feature.insights)

Class Method, % Branch, % Line, % Instruction, %
InsightsScreenKt 83.3% (25/30) 53.9% (41/76) 95.4% (187/196) 84.1% (1420/1689)
InsightsScreenKt$categoryBreakdown$$inlined$itemsIndexed$default$1 0% (0/1)
InsightsScreenKt$categoryBreakdown$$inlined$itemsIndexed$default$2 0% (0/1)
InsightsScreenKt$categoryBreakdown$$inlined$itemsIndexed$default$3 0% (0/1)
InsightsScreenKt$merchantList$$inlined$items$default$1 0% (0/1)
InsightsScreenKt$merchantList$$inlined$items$default$2 0% (0/1)
InsightsScreenKt$merchantList$$inlined$items$default$3 0% (0/1)
InsightsScreenKt$merchantList$$inlined$items$default$4 0% (0/1)
Total 67.6% (25/37) 53.9% (41/76) 95.4% (187/196) 84.1% (1420/1689)


 package com.georgeci.moneysurfer.feature.insights
 
 import androidx.compose.foundation.background
 import androidx.compose.foundation.layout.Arrangement
 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.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.size
 import androidx.compose.foundation.lazy.LazyColumn
 import androidx.compose.foundation.lazy.LazyListScope
 import androidx.compose.foundation.lazy.items
 import androidx.compose.foundation.lazy.itemsIndexed
 import androidx.compose.foundation.shape.CircleShape
 import androidx.compose.material3.Scaffold
 import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.getValue
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.alpha
 import androidx.compose.ui.draw.clip
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.platform.testTag
 import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.tooling.preview.Preview
 import androidx.compose.ui.unit.dp
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import com.georgeci.moneysurfer.domain.model.SpendInsights
 import com.georgeci.moneysurfer.uikit.components.SurferCategoryPalette
 import com.georgeci.moneysurfer.uikit.components.SurferEmptyState
 import com.georgeci.moneysurfer.uikit.components.base.SurferSectionHeader
 import com.georgeci.moneysurfer.uikit.components.base.SurferToolbar
 import com.georgeci.moneysurfer.uikit.components.chart.SurferNetTrendCard
 import com.georgeci.moneysurfer.uikit.components.chart.SurferNetTrendColumn
 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.theme.AppTheme
 import com.georgeci.moneysurfer.uikit.widgets.SurferCategoriesDonutWidget
 import com.georgeci.moneysurfer.uikit.widgets.SurferDonutSegment
 import com.georgeci.moneysurfer.uikit.widgets.SurferWidgetSize
 import com.georgeci.moneysurfer.utils.HandleSideEffect
 import moneysurfer.feature.insights.generated.resources.Res
 import moneysurfer.feature.insights.generated.resources.insights_categories_empty_center
 import moneysurfer.feature.insights.generated.resources.insights_categories_empty_legend
 import moneysurfer.feature.insights.generated.resources.insights_categories_title
 import moneysurfer.feature.insights.generated.resources.insights_categories_total
 import moneysurfer.feature.insights.generated.resources.insights_category_share
 import moneysurfer.feature.insights.generated.resources.insights_currency_excluded
 import moneysurfer.feature.insights.generated.resources.insights_currency_filtered_subtitle
 import moneysurfer.feature.insights.generated.resources.insights_currency_filtered_title
 import moneysurfer.feature.insights.generated.resources.insights_empty_subtitle
 import moneysurfer.feature.insights.generated.resources.insights_empty_title
 import moneysurfer.feature.insights.generated.resources.insights_merchant_transactions
 import moneysurfer.feature.insights.generated.resources.insights_merchants_empty
 import moneysurfer.feature.insights.generated.resources.insights_merchants_title
 import moneysurfer.feature.insights.generated.resources.insights_months_short
 import moneysurfer.feature.insights.generated.resources.insights_title
 import moneysurfer.feature.insights.generated.resources.insights_trend_column_a11y
 import moneysurfer.feature.insights.generated.resources.insights_trend_expense
 import moneysurfer.feature.insights.generated.resources.insights_trend_income
 import moneysurfer.feature.insights.generated.resources.insights_trend_months
 import moneysurfer.feature.insights.generated.resources.insights_trend_title
 import moneysurfer.feature.insights.generated.resources.insights_uncategorized
 import org.jetbrains.compose.resources.pluralStringResource
 import org.jetbrains.compose.resources.stringArrayResource
 import org.jetbrains.compose.resources.stringResource
 import org.koin.compose.viewmodel.koinViewModel
 
 /** Stable selectors for the Insights screen — see docs/testing/testing-strategy.md. */
 object InsightsTestTags {
     const val Root = "insights:root"
     const val List = "insights:list"
     const val Donut = "insights:donut"
     const val Trend = "insights:trend"
     const val Empty = "insights:empty"
     const val CurrencyFiltered = "insights:currencyFiltered"
 }
 
 /** How far the figures fade while the next period's rollups are still in flight. */
 private const val InFlightAlpha = 0.4f
 
 /** The "never stored" hue sentinel — anything off the colour wheel falls back to hashing the id. */
 private const val NoStoredHue = -1
 
 @Composable
 fun InsightsScreen(
     onNavigateBack: () -> Unit,
     viewModel: InsightsViewModel = koinViewModel(),
 ) {
     val state by viewModel.collectAsStateWithLifecycle()
 
     viewModel.HandleSideEffect { effect ->
         when (effect) {
             InsightsEffect.NavigateBack -> onNavigateBack()
         }
     }
 
     InsightsContent(state = state, onEvent = viewModel::onEvent)
 }
 
 /**
  * The stateless half of the screen. Public, like `DashboardContent`, because the desktop UI tests in
  * `:composeApp` mount it with an injected state — see docs/testing/testing-strategy.md.
  */
 @Composable
 fun InsightsContent(
     state: InsightsState,
     onEvent: (InsightsEvent) -> Unit,
 ) {
     Scaffold(
         modifier = Modifier
             .surferSafeInsets()
             .testTag(InsightsTestTags.Root)
             .surferTestTagAsId(),
         containerColor = AppTheme.materialColors.surface,
         topBar = {
             SurferToolbar(
                 title = stringResource(Res.string.insights_title),
                 onBack = { onEvent(InsightsEvent.OnBackClick) },
             )
         },
     ) { padding ->
         val content = state as? InsightsState.Content
         if (content == null) {
             Box(modifier = Modifier.fillMaxSize().padding(padding))
             return@Scaffold
         }
 
         LazyColumn(
             modifier = Modifier
                 .fillMaxSize()
                 .surferContentContainer()
                 .padding(top = padding.calculateTopPadding())
                 .testTag(InsightsTestTags.List),
             contentPadding = PaddingValues(
                 start = AppTheme.spacing.default,
                 end = AppTheme.spacing.default,
                 top = AppTheme.spacing.medium,
                 bottom = padding.calculateBottomPadding() + AppTheme.spacing.xxxLarge,
             ),
             verticalArrangement = Arrangement.spacedBy(AppTheme.spacing.medium),
         ) {
             item(key = "period") {
                 InsightsPeriodPager(state = content, onEvent = onEvent)
             }
 
             // Everything below is the answer for the period above, so it fades as one block while
             // the next one is queried rather than each card blinking on its own.
             val bodyModifier = Modifier.alpha(if (content.inFlight) InFlightAlpha else 1f)
 
             // Nothing else is drawn in this case on purpose: charts under a message explaining why
             // there is nothing to chart would read as the message being about something else.
             if (content.hiddenByBaseCurrency) {
                 item(key = "currency-filtered") {
                     CurrencyFilteredState(state = content, modifier = bodyModifier)
                 }
                 return@LazyColumn
             }
 
             if (content.isEmpty) {
                 item(key = "empty") {
                     SurferEmptyState(
                         title = stringResource(Res.string.insights_empty_title),
                         subtitle = stringResource(Res.string.insights_empty_subtitle),
                         icon = SurferIcons.Receipt,
                         modifier = bodyModifier.testTag(InsightsTestTags.Empty),
                     )
                 }
             } else {
                 categoryBreakdown(content, bodyModifier)
             }
 
             // The trend stays: it spans six months, so it has something to say about a period that
             // holds nothing. The merchant list does not — it reads the same window the breakdown
             // does, and its rows are a subset of the breakdown's, so an empty breakdown guarantees
             // an empty merchant list. Drawing its "nobody is named" line under the empty state
             // would answer the same question twice.
             item(key = "trend") {
                 NetTrendCard(state = content, modifier = bodyModifier)
             }
 
             if (!content.isEmpty) merchantList(content, bodyModifier)
         }
     }
 }
 
 /**
  * The donut at full size plus every category behind it.
  *
  * The donut's own legend tops out at five entries by design; a screen whose whole job is the full
  * picture lists the rest under it rather than rounding them into an implicit "other".
  */
 private fun LazyListScope.categoryBreakdown(
     state: InsightsState.Content,
     modifier: Modifier,
 ) {
     item(key = "categories-header") {
         SurferSectionHeader(
             title = stringResource(Res.string.insights_categories_title),
             modifier = modifier,
         )
     }
     item(key = "donut") {
         CategoriesDonut(state = state, modifier = modifier.testTag(InsightsTestTags.Donut))
     }
     // Keyed by position as well as id, because the id is not guaranteed unique: a slice whose
     // stored `categoryId` names no category row — a dangling reference, or a workspace that has not
     // pulled the category yet — arrives here indistinguishable from the genuinely uncategorized
     // bucket, and two rows sharing a key is a `LazyColumn` crash rather than a cosmetic clash.
     itemsIndexed(
         state.categories,
         key = { index, category -> "category:$index:${category.id}" },
     ) { _, category ->
         val name = category.name ?: stringResource(Res.string.insights_uncategorized)
         InsightsAmountRow(
             title = name,
             amount = category.spentFormatted,
             caption = stringResource(Res.string.insights_category_share, category.sharePercent),
             leadingTint = category.tint(),
             modifier = modifier,
         )
     }
     if (state.hiddenCurrencies.isNotEmpty()) {
         item(key = "categories-excluded") {
             SectionCaption(
                 text = stringResource(
                     Res.string.insights_currency_excluded,
                     state.hiddenCurrencies.joinToString(", "),
                 ),
                 modifier = modifier,
             )
         }
     }
 }
 
 /** Who took the most, or the line that says why nobody is named. */
 private fun LazyListScope.merchantList(
     state: InsightsState.Content,
     modifier: Modifier,
 ) {
     item(key = "merchants-header") {
         SurferSectionHeader(
             title = stringResource(Res.string.insights_merchants_title),
             modifier = modifier,
         )
     }
     if (state.merchants.isEmpty()) {
         item(key = "merchants-empty") {
             SectionCaption(
                 text = stringResource(Res.string.insights_merchants_empty),
                 modifier = modifier,
             )
         }
         return
     }
     items(state.merchants, key = { "merchant:${it.merchant}" }) { merchant ->
         InsightsAmountRow(
             title = merchant.merchant,
             amount = merchant.spentFormatted,
             caption = pluralStringResource(
                 Res.plurals.insights_merchant_transactions,
                 merchant.transactionCount,
                 merchant.transactionCount,
             ),
             modifier = modifier,
         )
     }
 }
 
 @Composable
 private fun CategoriesDonut(state: InsightsState.Content, modifier: Modifier = Modifier) {
     val uncategorized = stringResource(Res.string.insights_uncategorized)
     SurferCategoriesDonutWidget(
         segments = state.categories.map { category ->
             SurferDonutSegment(
                 label = category.name ?: uncategorized,
                 percent = category.share,
                 color = category.tint(),
             )
         },
         // The screen is the full-size view of the dashboard's widget, so it asks for the hero
         // layout explicitly rather than inheriting whatever the surrounding composition provides.
         size = SurferWidgetSize.Expanded,
         centerLabel = stringResource(Res.string.insights_categories_total),
         centerValue = state.totalFormatted,
         emptyCenterText = stringResource(Res.string.insights_categories_empty_center),
         emptyLegendText = stringResource(Res.string.insights_categories_empty_legend),
         modifier = modifier.fillMaxWidth().height(DonutHeight),
     )
 }
 
 @Composable
 private fun NetTrendCard(state: InsightsState.Content, modifier: Modifier = Modifier) {
     val shortMonths = stringArrayResource(Res.array.insights_months_short)
     SurferNetTrendCard(
         title = stringResource(Res.string.insights_trend_title),
         trailingLabel = stringResource(Res.string.insights_trend_months, SpendInsights.MONTH_COLUMNS),
         incomeLabel = stringResource(Res.string.insights_trend_income),
         expenseLabel = stringResource(Res.string.insights_trend_expense),
         columns = state.months.map { month ->
             val name = shortMonths[month.monthNumber - 1]
             SurferNetTrendColumn(
                 label = name,
                 income = month.income,
                 expense = month.expense,
                 contentDescription = stringResource(
                     Res.string.insights_trend_column_a11y,
                     name,
                     month.year,
                     month.incomeFormatted,
                     month.expenseFormatted,
                 ),
             )
         },
         modifier = modifier.testTag(InsightsTestTags.Trend),
     )
 }
 
 /**
  * The empty state that names the base-currency filter as the reason.
  *
  * A blank donut in a mixed-currency workspace reads as a bug rather than as the policy it is, so the
  * copy states which currency counts and which ones were left out — the one thing no chart on the
  * screen can express, because the aggregates applied the filter before the figures got here.
  */
 @Composable
 private fun CurrencyFilteredState(state: InsightsState.Content, modifier: Modifier = Modifier) {
     SurferEmptyState(
         title = stringResource(Res.string.insights_currency_filtered_title, state.baseCurrency),
         subtitle = stringResource(
             Res.string.insights_currency_filtered_subtitle,
             state.hiddenCurrencies.joinToString(", "),
         ),
         icon = SurferIcons.Globe,
         modifier = modifier.testTag(InsightsTestTags.CurrencyFiltered),
     )
 }
 
 /** One amount line: an optional tint dot, a name, what it took, and what that is a share of. */
 @Composable
 private fun InsightsAmountRow(
     title: String,
     amount: String,
     caption: String,
     modifier: Modifier = Modifier,
     leadingTint: Color? = null,
 ) {
     Row(
         modifier = modifier
             .fillMaxWidth()
             .padding(vertical = AppTheme.spacing.xSmall),
         verticalAlignment = Alignment.CenterVertically,
         horizontalArrangement = Arrangement.spacedBy(AppTheme.spacing.small),
     ) {
         if (leadingTint != null) {
             Box(
                 modifier = Modifier
                     .size(DotSize)
                     .clip(CircleShape)
                     .background(leadingTint),
             )
         }
         Column(modifier = Modifier.weight(1f)) {
             Text(
                 text = title,
                 style = AppTheme.typography.bodyLarge,
                 color = AppTheme.materialColors.onSurface,
                 maxLines = 1,
                 overflow = TextOverflow.Ellipsis,
             )
             Text(
                 text = caption,
                 style = AppTheme.typography.labelSmall,
                 color = AppTheme.materialColors.onSurfaceVariant,
                 maxLines = 1,
             )
         }
         Text(
             text = amount,
             style = AppTheme.typography.titleSmall,
             color = AppTheme.materialColors.onSurface,
             maxLines = 1,
         )
     }
 }
 
 @Composable
 private fun SectionCaption(text: String, modifier: Modifier = Modifier) {
     Text(
         text = text,
         style = AppTheme.typography.bodyMedium,
         color = AppTheme.materialColors.onSurfaceVariant,
         modifier = modifier.fillMaxWidth(),
     )
 }
 
 /**
  * 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 — so a category keeps one colour across
  * every screen, and the uncategorized bucket keeps its own when the user switches language.
  */
 @Composable
 private fun InsightsCategoryUi.tint(): Color =
     SurferCategoryPalette.tintForHue(hue ?: NoStoredHue) ?: SurferCategoryPalette.tintFor(id)
 
 private val DonutHeight = 200.dp
 private val DotSize = 10.dp
 
 @Preview
 @Composable
 private fun InsightsContentPreview() {
     AppTheme {
         InsightsContent(state = previewInsightsContent(), onEvent = {})
     }
 }
 
 @Preview
 @Composable
 private fun InsightsCurrencyFilteredPreview() {
     AppTheme {
         InsightsContent(state = previewCurrencyFilteredContent(), onEvent = {})
     }
 }