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

Class Method, % Branch, % Line, % Instruction, %
DashboardWidgetPreviewKt 100% (17/17) 61% (47/77) 99.7% (313/314) 98.3% (2643/2690)
DashboardWidgetPreviewKt$WhenMappings
Total 100% (17/17) 61% (47/77) 99.7% (313/314) 98.3% (2643/2690)


 package com.georgeci.moneysurfer.feature.dashboard.customize
 
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.CompositionLocalProvider
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.unit.dp
 import com.georgeci.moneysurfer.domain.dashboard.DashboardCardStyle
 import com.georgeci.moneysurfer.domain.dashboard.DashboardWidgetSize
 import com.georgeci.moneysurfer.domain.dashboard.DashboardWidgetType
 import com.georgeci.moneysurfer.feature.dashboard.spentMonthCardHeight
 import com.georgeci.moneysurfer.uikit.components.SurferCategoryPalette
 import com.georgeci.moneysurfer.uikit.components.budget.SurferBudgetStatus
 import com.georgeci.moneysurfer.uikit.icons.SurferIcons
 import com.georgeci.moneysurfer.uikit.modifier.surferScaleToWidth
 import com.georgeci.moneysurfer.uikit.theme.AppTheme
 import com.georgeci.moneysurfer.uikit.widgets.LocalSurferWidgetSize
 import com.georgeci.moneysurfer.uikit.widgets.SurferAccountItem
 import com.georgeci.moneysurfer.uikit.widgets.SurferAccountsWidget
 import com.georgeci.moneysurfer.uikit.widgets.SurferAddAccountCta
 import com.georgeci.moneysurfer.uikit.widgets.SurferBalanceTrend
 import com.georgeci.moneysurfer.uikit.widgets.SurferBalanceVariant
 import com.georgeci.moneysurfer.uikit.widgets.SurferBalanceWidget
 import com.georgeci.moneysurfer.uikit.widgets.SurferBudgetItem
 import com.georgeci.moneysurfer.uikit.widgets.SurferBudgetsWidget
 import com.georgeci.moneysurfer.uikit.widgets.SurferBurnRateBar
 import com.georgeci.moneysurfer.uikit.widgets.SurferBurnRateData
 import com.georgeci.moneysurfer.uikit.widgets.SurferBurnRatePace
 import com.georgeci.moneysurfer.uikit.widgets.SurferBurnRateWidget
 import com.georgeci.moneysurfer.uikit.widgets.SurferCategoriesDonutWidget
 import com.georgeci.moneysurfer.uikit.widgets.SurferCategorySpendCap
 import com.georgeci.moneysurfer.uikit.widgets.SurferCategorySpendItem
 import com.georgeci.moneysurfer.uikit.widgets.SurferDonutSegment
 import com.georgeci.moneysurfer.uikit.widgets.SurferGoalItem
 import com.georgeci.moneysurfer.uikit.widgets.SurferGoalsWidget
 import com.georgeci.moneysurfer.uikit.widgets.SurferInsightItem
 import com.georgeci.moneysurfer.uikit.widgets.SurferInsightTone
 import com.georgeci.moneysurfer.uikit.widgets.SurferInsightsVariant
 import com.georgeci.moneysurfer.uikit.widgets.SurferInsightsWidget
 import com.georgeci.moneysurfer.uikit.widgets.SurferQuickActionsWidget
 import com.georgeci.moneysurfer.uikit.widgets.SurferRecentTransactionItem
 import com.georgeci.moneysurfer.uikit.widgets.SurferRecentTransactionsWidget
 import com.georgeci.moneysurfer.uikit.widgets.SurferRecurringItem
 import com.georgeci.moneysurfer.uikit.widgets.SurferRecurringWidget
 import com.georgeci.moneysurfer.uikit.widgets.SurferSafeToSpendData
 import com.georgeci.moneysurfer.uikit.widgets.SurferSafeToSpendWidget
 import com.georgeci.moneysurfer.uikit.widgets.SurferSpentByCategoryVariant
 import com.georgeci.moneysurfer.uikit.widgets.SurferSpentByCategoryWidget
 import com.georgeci.moneysurfer.uikit.widgets.SurferSpentMonthWidget
 import com.georgeci.moneysurfer.uikit.widgets.SurferWidgetSize
 import moneysurfer.feature.dashboard.generated.resources.Res
 import moneysurfer.feature.dashboard.generated.resources.dashboard_accounts_manage
 import moneysurfer.feature.dashboard.generated.resources.dashboard_add_account
 import moneysurfer.feature.dashboard.generated.resources.dashboard_add_transaction
 import moneysurfer.feature.dashboard.generated.resources.dashboard_balance_title
 import moneysurfer.feature.dashboard.generated.resources.dashboard_budgets_left
 import moneysurfer.feature.dashboard.generated.resources.dashboard_budgets_over
 import moneysurfer.feature.dashboard.generated.resources.dashboard_budgets_see_all
 import moneysurfer.feature.dashboard.generated.resources.dashboard_budgets_spent_of
 import moneysurfer.feature.dashboard.generated.resources.dashboard_budgets_status_ok
 import moneysurfer.feature.dashboard.generated.resources.dashboard_budgets_status_over
 import moneysurfer.feature.dashboard.generated.resources.dashboard_budgets_status_warn
 import moneysurfer.feature.dashboard.generated.resources.dashboard_budgets_title
 import moneysurfer.feature.dashboard.generated.resources.dashboard_burn_rate_average
 import moneysurfer.feature.dashboard.generated.resources.dashboard_burn_rate_caption
 import moneysurfer.feature.dashboard.generated.resources.dashboard_burn_rate_on_track
 import moneysurfer.feature.dashboard.generated.resources.dashboard_burn_rate_projection
 import moneysurfer.feature.dashboard.generated.resources.dashboard_burn_rate_title
 import moneysurfer.feature.dashboard.generated.resources.dashboard_categories_donut_center_label
 import moneysurfer.feature.dashboard.generated.resources.dashboard_customize_preview_account_cash
 import moneysurfer.feature.dashboard.generated.resources.dashboard_customize_preview_account_everyday
 import moneysurfer.feature.dashboard.generated.resources.dashboard_customize_preview_account_savings
 import moneysurfer.feature.dashboard.generated.resources.dashboard_customize_preview_goal_laptop
 import moneysurfer.feature.dashboard.generated.resources.dashboard_customize_preview_goal_trip
 import moneysurfer.feature.dashboard.generated.resources.dashboard_customize_preview_insight_down_body
 import moneysurfer.feature.dashboard.generated.resources.dashboard_customize_preview_insight_down_title
 import moneysurfer.feature.dashboard.generated.resources.dashboard_customize_preview_insight_subs_body
 import moneysurfer.feature.dashboard.generated.resources.dashboard_customize_preview_insight_subs_title
 import moneysurfer.feature.dashboard.generated.resources.dashboard_customize_preview_insight_up_body
 import moneysurfer.feature.dashboard.generated.resources.dashboard_customize_preview_insight_up_title
 import moneysurfer.feature.dashboard.generated.resources.dashboard_customize_preview_transaction_coffee
 import moneysurfer.feature.dashboard.generated.resources.dashboard_customize_preview_transaction_groceries
 import moneysurfer.feature.dashboard.generated.resources.dashboard_customize_preview_transaction_rent
 import moneysurfer.feature.dashboard.generated.resources.dashboard_customize_preview_transaction_salary
 import moneysurfer.feature.dashboard.generated.resources.dashboard_customize_preview_transaction_transport
 import moneysurfer.feature.dashboard.generated.resources.dashboard_days_left
 import moneysurfer.feature.dashboard.generated.resources.dashboard_goals_see_all
 import moneysurfer.feature.dashboard.generated.resources.dashboard_goals_title
 import moneysurfer.feature.dashboard.generated.resources.dashboard_insights_title
 import moneysurfer.feature.dashboard.generated.resources.dashboard_quick_action_transfer
 import moneysurfer.feature.dashboard.generated.resources.dashboard_recent_see_all
 import moneysurfer.feature.dashboard.generated.resources.dashboard_recent_title
 import moneysurfer.feature.dashboard.generated.resources.dashboard_recurring_due_tomorrow
 import moneysurfer.feature.dashboard.generated.resources.dashboard_recurring_see_all
 import moneysurfer.feature.dashboard.generated.resources.dashboard_recurring_title
 import moneysurfer.feature.dashboard.generated.resources.dashboard_safe_to_spend_caption
 import moneysurfer.feature.dashboard.generated.resources.dashboard_safe_to_spend_per_day
 import moneysurfer.feature.dashboard.generated.resources.dashboard_safe_to_spend_title
 import moneysurfer.feature.dashboard.generated.resources.dashboard_spent_by_category_of_cap
 import moneysurfer.feature.dashboard.generated.resources.dashboard_spent_by_category_share
 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 moneysurfer.feature.dashboard.generated.resources.dashboard_spent_month_delta_down
 import moneysurfer.feature.dashboard.generated.resources.dashboard_spent_month_of_budget
 import moneysurfer.feature.dashboard.generated.resources.dashboard_spent_month_title
 import org.jetbrains.compose.resources.pluralStringResource
 import org.jetbrains.compose.resources.stringResource
 
 /** The picker's half of the size mapping — lifted out so the tile table stays a table. */
 private fun DashboardWidgetSize.toWidgetSize(): SurferWidgetSize = when (this) {
     DashboardWidgetSize.Expanded -> SurferWidgetSize.Expanded
     DashboardWidgetSize.Compact -> SurferWidgetSize.Compact
 }
 
 /**
  * Width the sample widget is laid out at before it is scaled into a picker tile: the dashboard's
  * own content width on a phone (a 392dp artboard minus its 16dp side padding). Measuring at the
  * real width is what makes the thumbnails comparable — see
  * [com.georgeci.moneysurfer.uikit.modifier.surferScaleToWidth].
  */
 private val PREVIEW_CONTENT_WIDTH = 360.dp
 
 /**
  * A widget drawn from sample data, the way [cardStyle] would draw it on the dashboard. The picker
  * shows these side by side, so they render the real widgets rather than a schematic: the whole
  * point of the choice is what the typography and the row density actually look like.
  *
  * The sample list is longer than a compact card shows on purpose — a size the user picks has to be
  * visibly different in the preview, and several widgets differ only in how many rows they keep.
  */
 // The "complexity" is the number of widgets the dashboard offers, not tangled logic — every branch
 // is one tile. Same call as `DashboardWidget`, the render half of the same registry.
 @Suppress("CyclomaticComplexMethod")
 @Composable
 internal fun DashboardWidgetPreview(
     type: DashboardWidgetType,
     cardStyle: DashboardCardStyle,
     modifier: Modifier = Modifier,
 ) {
     CompositionLocalProvider(LocalSurferWidgetSize provides cardStyle.size.toWidgetSize()) {
         val content = Modifier
             .surferScaleToWidth(PREVIEW_CONTENT_WIDTH)
             .fillMaxWidth()
         when (type) {
             DashboardWidgetType.Balance -> BalancePreview(cardStyle.variant, modifier.then(content))
             DashboardWidgetType.QuickActions -> QuickActionsPreview(modifier.then(content))
             DashboardWidgetType.SpentMonth -> SpentMonthPreview(modifier.then(content))
             DashboardWidgetType.SafeToSpend -> SafeToSpendPreview(modifier.then(content))
             DashboardWidgetType.BurnRate -> BurnRatePreview(modifier.then(content))
             DashboardWidgetType.Budgets -> BudgetsPreview(modifier.then(content))
             DashboardWidgetType.SpentByCategory ->
                 SpentByCategoryPreview(cardStyle.variant, modifier.then(content))
             DashboardWidgetType.CategoriesDonut -> CategoriesDonutPreview(modifier.then(content))
             DashboardWidgetType.Accounts -> AccountsPreview(modifier.then(content))
             DashboardWidgetType.Insights -> InsightsPreview(cardStyle.variant, modifier.then(content))
             DashboardWidgetType.Recurring -> RecurringPreview(modifier.then(content))
             DashboardWidgetType.Goals -> GoalsPreview(modifier.then(content))
             DashboardWidgetType.RecentTransactions -> RecentTransactionsPreview(modifier.then(content))
         }
     }
 }
 
 /**
  * Sample curve as well as sample figures: half of what tells the six treatments apart is how much
  * room each gives the trend, and a tile drawn from a workspace with no history would show none of
  * that.
  */
 @Composable
 private fun BalancePreview(variant: String?, modifier: Modifier) {
     SurferBalanceWidget(
         title = stringResource(Res.string.dashboard_balance_title),
         balance = SAMPLE_TOTAL,
         variant = SurferBalanceVariant.fromKey(variant),
         trend = SurferBalanceTrend(text = SAMPLE_TREND, series = SAMPLE_BALANCE_SERIES),
         modifier = modifier,
     )
 }
 
 /**
  * The buttons are inert here — the tile is a thumbnail, not a second place to log a transaction.
  *
  * The two tiles come out nearly the same height, because all this widget's size setting changes is
  * the button height (52dp against 48dp). That is the honest thumbnail: padding it out to make the
  * choice look bigger than it is would promise a row the dashboard then does not draw.
  */
 @Composable
 private fun QuickActionsPreview(modifier: Modifier) {
     SurferQuickActionsWidget(
         primaryLabel = stringResource(Res.string.dashboard_add_transaction),
         primaryIcon = SurferIcons.Add,
         onPrimaryClick = {},
         secondaryLabel = stringResource(Res.string.dashboard_quick_action_transfer),
         secondaryIcon = SurferIcons.SwapHoriz,
         onSecondaryClick = {},
         modifier = modifier,
     )
 }
 
 /**
  * Sample numbers rather than the user's own: the tile is drawn for every widget in the picker,
  * including the ones switched off, so it must not depend on there being a budget to read.
  */
 @Composable
 private fun SafeToSpendPreview(modifier: Modifier) {
     SurferSafeToSpendWidget(
         title = stringResource(Res.string.dashboard_safe_to_spend_title),
         data = SurferSafeToSpendData(
             amount = SAMPLE_SAFE_TO_SPEND,
             caption = stringResource(
                 Res.string.dashboard_safe_to_spend_caption,
                 SAMPLE_BUDGET_LIMIT,
                 stringResource(Res.string.dashboard_customize_preview_account_everyday),
             ),
             perDay = stringResource(Res.string.dashboard_safe_to_spend_per_day, SAMPLE_SAFE_PER_DAY),
             daysLeft = pluralStringResource(
                 Res.plurals.dashboard_days_left,
                 SAMPLE_DAYS_LEFT,
                 SAMPLE_DAYS_LEFT,
             ),
             progress = SAMPLE_SAFE_PROGRESS,
             paceFraction = SAMPLE_SAFE_PACE,
             status = SurferBudgetStatus.Ok,
         ),
         modifier = modifier,
     )
 }
 
 /**
  * Sample numbers again, for the same reason [SafeToSpendPreview] uses them: the tile is drawn for
  * every widget in the picker, including the ones switched off, so it must not depend on the month
  * holding any spend — or on a budget existing to measure it against.
  *
  * The card fills the height it is given, so the tile takes the dashboard's own height for the size
  * being previewed — the picker's whole job is showing what that size looks like, and the two
  * densities really are different heights.
  */
 @Composable
 private fun SpentMonthPreview(modifier: Modifier) {
     SurferSpentMonthWidget(
         title = stringResource(Res.string.dashboard_spent_month_title),
         spent = SAMPLE_SPENT_MONTH,
         progress = SAMPLE_SPENT_MONTH_PROGRESS,
         caption = stringResource(Res.string.dashboard_spent_month_of_budget, SAMPLE_BUDGET_LIMIT),
         trailingLabel = stringResource(Res.string.dashboard_spent_month_delta_down, SAMPLE_SPENT_MONTH_DELTA),
         trailingLabelColor = AppTheme.semanticColors.income,
         modifier = modifier.height(spentMonthCardHeight()),
     )
 }
 
 /**
  * Sample rows, like [SafeToSpendPreview] — the tile is drawn for every widget in the picker,
  * including the ones switched off, so it must not depend on the month holding any spend.
  *
  * One of the three carries a cap, because the near-limit treatment is half of what distinguishes
  * the five variants from each other.
  */
 @Composable
 private fun SpentByCategoryPreview(variant: String?, modifier: Modifier) {
     val tints = SurferCategoryPalette.tints
     SurferSpentByCategoryWidget(
         title = stringResource(Res.string.dashboard_spent_by_category_title),
         items = listOf(
             SurferCategorySpendItem(
                 id = "preview-rent",
                 name = stringResource(Res.string.dashboard_customize_preview_transaction_rent),
                 amount = SAMPLE_EXPENSE_THREE_MAGNITUDE,
                 share = SAMPLE_SHARE_RENT,
                 caption = stringResource(Res.string.dashboard_spent_by_category_share, SAMPLE_PERCENT_RENT),
                 tint = tints[0],
             ),
             SurferCategorySpendItem(
                 id = "preview-groceries",
                 name = stringResource(Res.string.dashboard_customize_preview_transaction_groceries),
                 amount = SAMPLE_CATEGORY_GROCERIES,
                 share = SAMPLE_SHARE_GROCERIES,
                 caption = stringResource(
                     Res.string.dashboard_spent_by_category_of_cap,
                     SAMPLE_CATEGORY_GROCERIES,
                     SAMPLE_CATEGORY_CAP,
                 ),
                 tint = tints[1],
                 cap = SurferCategorySpendCap(
                     progress = SAMPLE_CAP_PROGRESS,
                     status = SurferBudgetStatus.Warn,
                     statusLabel = stringResource(Res.string.dashboard_spent_by_category_status_warn),
                 ),
             ),
             SurferCategorySpendItem(
                 id = "preview-coffee",
                 name = stringResource(Res.string.dashboard_customize_preview_transaction_coffee),
                 amount = SAMPLE_CATEGORY_COFFEE,
                 share = SAMPLE_SHARE_COFFEE,
                 caption = stringResource(Res.string.dashboard_spent_by_category_share, SAMPLE_PERCENT_COFFEE),
                 tint = tints[2],
             ),
         ),
         variant = SurferSpentByCategoryVariant.fromKey(variant),
         modifier = modifier,
     )
 }
 
 /**
  * Sample segments, for the same reason [SpentByCategoryPreview] uses sample rows. Five of them,
  * because the legend keeps five at Hero and three at Compact — a tile with three would look
  * identical at both sizes, and the size is exactly what this picker is asking about.
  *
  * The last one is the uncategorized bucket, drawn under its real label: it is a segment the live
  * widget shows whenever the period holds spend nobody filed, so the preview should not pretend
  * every wedge has a category behind it.
  */
 @Composable
 private fun CategoriesDonutPreview(modifier: Modifier) {
     val tints = SurferCategoryPalette.tints
     val labels = listOf(
         stringResource(Res.string.dashboard_customize_preview_transaction_rent),
         stringResource(Res.string.dashboard_customize_preview_transaction_groceries),
         stringResource(Res.string.dashboard_customize_preview_transaction_coffee),
         stringResource(Res.string.dashboard_customize_preview_transaction_transport),
         stringResource(Res.string.dashboard_spent_by_category_uncategorized),
     )
     SurferCategoriesDonutWidget(
         // Zipped rather than indexed: the shares are a separate list, and a label added to one
         // without the other should shorten the sample, not crash the picker.
         segments = labels.zip(SAMPLE_DONUT_SHARES).mapIndexed { index, (label, share) ->
             SurferDonutSegment(
                 label = label,
                 percent = share,
                 color = tints[index % tints.size],
             )
         },
         centerLabel = stringResource(Res.string.dashboard_categories_donut_center_label),
         centerValue = SAMPLE_DONUT_TOTAL,
         modifier = modifier,
     )
 }
 
 /**
  * Sample payments, for the same reason [SafeToSpendPreview] uses sample numbers: the tile is drawn
  * for every widget in the picker, including the ones switched off, so it must not depend on the
  * workspace keeping any recurring rules.
  *
  * Three rows, because the card keeps three at Hero and two at Compact — and the first is tinted,
  * since the imminent treatment is half of what the two sizes are being compared on.
  */
 @Composable
 private fun RecurringPreview(modifier: Modifier) {
     SurferRecurringWidget(
         items = listOf(
             SurferRecurringItem(
                 id = "preview-recurring-1",
                 name = stringResource(Res.string.dashboard_customize_preview_transaction_rent),
                 dueLabel = stringResource(Res.string.dashboard_recurring_due_tomorrow),
                 amountFormatted = SAMPLE_RECURRING_RENT,
                 isImminent = true,
             ),
             SurferRecurringItem(
                 id = "preview-recurring-2",
                 name = stringResource(Res.string.dashboard_customize_preview_transaction_transport),
                 dueLabel = SAMPLE_RECURRING_SECOND_DUE,
                 amountFormatted = SAMPLE_RECURRING_TRANSPORT,
             ),
             SurferRecurringItem(
                 id = "preview-recurring-3",
                 name = stringResource(Res.string.dashboard_customize_preview_transaction_coffee),
                 dueLabel = SAMPLE_RECURRING_THIRD_DUE,
                 amountFormatted = SAMPLE_RECURRING_COFFEE,
             ),
         ),
         title = stringResource(Res.string.dashboard_recurring_title),
         actionLabel = stringResource(Res.string.dashboard_recurring_see_all),
         onActionClick = {},
         modifier = modifier,
     )
 }
 
 /**
  * Sample numbers again, for the same reason [SafeToSpendPreview] uses them: the tile is drawn for
  * every widget in the picker, including the ones switched off, so it must not depend on there being
  * a week of spend to read.
  */
 @Composable
 private fun BurnRatePreview(modifier: Modifier) {
     SurferBurnRateWidget(
         title = stringResource(Res.string.dashboard_burn_rate_title),
         data = SurferBurnRateData(
             average = stringResource(Res.string.dashboard_burn_rate_average, SAMPLE_BURN_AVERAGE),
             caption = stringResource(Res.string.dashboard_burn_rate_caption),
             bars = SAMPLE_BURN_FRACTIONS.mapIndexed { index, fraction ->
                 SurferBurnRateBar(
                     label = (SAMPLE_BURN_FIRST_DAY + index).toString(),
                     fraction = fraction,
                     isToday = index == SAMPLE_BURN_FRACTIONS.lastIndex,
                 )
             },
             projection = stringResource(Res.string.dashboard_burn_rate_projection, SAMPLE_BURN_PROJECTION),
             pace = SurferBurnRatePace(
                 label = stringResource(Res.string.dashboard_burn_rate_on_track),
                 status = SurferBudgetStatus.Ok,
             ),
         ),
         modifier = modifier,
     )
 }
 
 /**
  * Sample budgets rather than the user's own, for the same reason the safe-to-spend tile uses them:
  * the picker draws every widget, including the ones switched off, so it must not depend on there
  * being a budget to read. The ids are named rather than numbered — nothing reads them back, and
  * repeating "preview-1" across widgets is a duplicated literal for no gain.
  */
 @Composable
 private fun BudgetsPreview(modifier: Modifier) {
     SurferBudgetsWidget(
         items = listOf(
             SurferBudgetItem(
                 id = "preview-budget-groceries",
                 name = stringResource(Res.string.dashboard_customize_preview_transaction_groceries),
                 statusLabel = stringResource(Res.string.dashboard_budgets_status_warn),
                 status = SurferBudgetStatus.Warn,
                 spentOfLimit = stringResource(
                     Res.string.dashboard_budgets_spent_of,
                     SAMPLE_BUDGET_SPENT,
                     SAMPLE_BUDGET_CAP,
                 ),
                 remaining = stringResource(Res.string.dashboard_budgets_left, SAMPLE_BUDGET_LEFT),
                 progress = SAMPLE_BUDGET_PROGRESS,
                 alertFraction = SAMPLE_BUDGET_ALERT,
             ),
             SurferBudgetItem(
                 id = "preview-budget-rent",
                 name = stringResource(Res.string.dashboard_customize_preview_transaction_rent),
                 statusLabel = stringResource(Res.string.dashboard_budgets_status_ok),
                 status = SurferBudgetStatus.Ok,
                 spentOfLimit = stringResource(
                     Res.string.dashboard_budgets_spent_of,
                     SAMPLE_RENT_SPENT,
                     SAMPLE_RENT_CAP,
                 ),
                 remaining = stringResource(Res.string.dashboard_budgets_left, SAMPLE_RENT_LEFT),
                 progress = SAMPLE_RENT_PROGRESS,
                 alertFraction = SAMPLE_BUDGET_ALERT,
             ),
             SurferBudgetItem(
                 id = "preview-budget-coffee",
                 name = stringResource(Res.string.dashboard_customize_preview_transaction_coffee),
                 statusLabel = stringResource(Res.string.dashboard_budgets_status_over),
                 status = SurferBudgetStatus.Over,
                 spentOfLimit = stringResource(
                     Res.string.dashboard_budgets_spent_of,
                     SAMPLE_COFFEE_SPENT,
                     SAMPLE_COFFEE_CAP,
                 ),
                 remaining = stringResource(Res.string.dashboard_budgets_over, SAMPLE_COFFEE_OVER),
                 progress = SAMPLE_COFFEE_PROGRESS,
                 alertFraction = SAMPLE_BUDGET_ALERT,
             ),
         ),
         title = stringResource(Res.string.dashboard_budgets_title),
         seeAllLabel = stringResource(Res.string.dashboard_budgets_see_all),
         onSeeAllClick = {},
         modifier = modifier,
     )
 }
 
 @Composable
 private fun AccountsPreview(modifier: Modifier) {
     SurferAccountsWidget(
         items = listOf(
             SurferAccountItem(
                 id = "preview-1",
                 name = stringResource(Res.string.dashboard_customize_preview_account_everyday),
                 subtitle = SAMPLE_CURRENCY,
                 balance = SAMPLE_ACCOUNT_ONE,
             ),
             SurferAccountItem(
                 id = "preview-2",
                 name = stringResource(Res.string.dashboard_customize_preview_account_savings),
                 subtitle = SAMPLE_CURRENCY,
                 balance = SAMPLE_ACCOUNT_TWO,
             ),
             SurferAccountItem(
                 id = "preview-3",
                 name = stringResource(Res.string.dashboard_customize_preview_account_cash),
                 subtitle = SAMPLE_CURRENCY,
                 balance = SAMPLE_ACCOUNT_THREE,
             ),
         ),
         addCta = SurferAddAccountCta(
             label = stringResource(Res.string.dashboard_add_account),
             onClick = {},
             trailingLabel = stringResource(Res.string.dashboard_accounts_manage),
         ),
         modifier = modifier,
     )
 }
 
 /**
  * Sample sentences rather than the user's own, like every other tile here: the picker is opened
  * to compare two layouts, and a workspace with one insight (or none) would show two blank tiles.
  */
 @Composable
 private fun InsightsPreview(variant: String?, modifier: Modifier) {
     SurferInsightsWidget(
         items = listOf(
             SurferInsightItem(
                 id = "preview-insight-1",
                 tone = SurferInsightTone.Warn,
                 icon = SurferIcons.ArrowUp,
                 title = stringResource(Res.string.dashboard_customize_preview_insight_up_title),
                 body = stringResource(Res.string.dashboard_customize_preview_insight_up_body),
             ),
             SurferInsightItem(
                 id = "preview-insight-2",
                 tone = SurferInsightTone.Good,
                 icon = SurferIcons.ArrowDown,
                 title = stringResource(Res.string.dashboard_customize_preview_insight_down_title),
                 body = stringResource(Res.string.dashboard_customize_preview_insight_down_body),
             ),
             SurferInsightItem(
                 id = "preview-insight-3",
                 tone = SurferInsightTone.Neutral,
                 icon = SurferIcons.Sync,
                 title = stringResource(Res.string.dashboard_customize_preview_insight_subs_title),
                 body = stringResource(Res.string.dashboard_customize_preview_insight_subs_body),
             ),
         ),
         title = stringResource(Res.string.dashboard_insights_title),
         variant = SurferInsightsVariant.fromKey(variant),
         modifier = modifier,
     )
 }
 
 @Composable
 private fun GoalsPreview(modifier: Modifier) {
     SurferGoalsWidget(
         items = listOf(
             SurferGoalItem(
                 id = "preview-1",
                 name = stringResource(Res.string.dashboard_customize_preview_goal_laptop),
                 savedFormatted = SAMPLE_GOAL_SAVED,
                 targetFormatted = SAMPLE_GOAL_TARGET,
                 progress = SAMPLE_GOAL_PROGRESS,
                 captionLine = "",
             ),
             SurferGoalItem(
                 id = "preview-2",
                 name = stringResource(Res.string.dashboard_customize_preview_goal_trip),
                 savedFormatted = SAMPLE_TRIP_SAVED,
                 targetFormatted = SAMPLE_TRIP_TARGET,
                 progress = SAMPLE_TRIP_PROGRESS,
                 captionLine = "",
             ),
         ),
         title = stringResource(Res.string.dashboard_goals_title),
         seeAllLabel = stringResource(Res.string.dashboard_goals_see_all),
         onSeeAllClick = {},
         modifier = modifier,
     )
 }
 
 @Composable
 private fun RecentTransactionsPreview(modifier: Modifier) {
     val bubbleBg = AppTheme.materialColors.primaryContainer
     val bubbleFg = AppTheme.materialColors.onPrimaryContainer
     val samples = listOf(
         stringResource(Res.string.dashboard_customize_preview_transaction_groceries) to SAMPLE_EXPENSE_ONE,
         stringResource(Res.string.dashboard_customize_preview_transaction_salary) to SAMPLE_INCOME,
         stringResource(Res.string.dashboard_customize_preview_transaction_coffee) to SAMPLE_EXPENSE_TWO,
         stringResource(Res.string.dashboard_customize_preview_transaction_rent) to SAMPLE_EXPENSE_THREE,
     )
     SurferRecentTransactionsWidget(
         items = samples.mapIndexed { index, (title, amount) ->
             SurferRecentTransactionItem(
                 id = "preview-$index",
                 title = title,
                 subtitle = "",
                 amount = amount,
                 isExpense = amount != SAMPLE_INCOME,
                 iconBgColor = bubbleBg,
                 iconFgColor = bubbleFg,
                 iconInitial = title.take(1),
                 categoryHueSeed = "preview-$index",
             )
         },
         title = stringResource(Res.string.dashboard_recent_title),
         seeAllLabel = stringResource(Res.string.dashboard_recent_see_all),
         onSeeAllClick = {},
         modifier = modifier,
     )
 }
 
 private const val SAMPLE_CURRENCY = "EUR"
 private const val SAMPLE_TOTAL = "€11,575.32"
 private const val SAMPLE_TREND = "+€412"
 private val SAMPLE_BALANCE_SERIES = listOf(9_800f, 10_240f, 9_950f, 10_610f, 11_160f, 11_575f)
 private const val SAMPLE_ACCOUNT_ONE = "€2,480.32"
 private const val SAMPLE_ACCOUNT_TWO = "€8,915.00"
 private const val SAMPLE_ACCOUNT_THREE = "€180.00"
 private const val SAMPLE_GOAL_SAVED = "€820"
 private const val SAMPLE_GOAL_TARGET = "€1,600"
 private const val SAMPLE_GOAL_PROGRESS = 0.51f
 private const val SAMPLE_TRIP_SAVED = "€1,240"
 private const val SAMPLE_TRIP_TARGET = "€3,000"
 private const val SAMPLE_TRIP_PROGRESS = 0.41f
 private const val SAMPLE_EXPENSE_ONE = "−€48.20"
 private const val SAMPLE_INCOME = "+€3,200.00"
 private const val SAMPLE_EXPENSE_TWO = "−€9.99"
 private const val SAMPLE_EXPENSE_THREE = "−€760.00"
 private const val SAMPLE_SAFE_TO_SPEND = "€642.30"
 private const val SAMPLE_BUDGET_LIMIT = "€1,800"
 private const val SAMPLE_SAFE_PER_DAY = "€53.52"
 private const val SAMPLE_DAYS_LEFT = 12
 private const val SAMPLE_SAFE_PROGRESS = 0.64f
 private const val SAMPLE_SAFE_PACE = 0.6f
 private const val SAMPLE_SPENT_MONTH = "€1,173.69"
 private const val SAMPLE_SPENT_MONTH_PROGRESS = 0.65f
 private const val SAMPLE_SPENT_MONTH_DELTA = 18
 private const val SAMPLE_BURN_AVERAGE = "€42.10"
 private const val SAMPLE_BURN_PROJECTION = "€1,263"
 private const val SAMPLE_BURN_FIRST_DAY = 22
 private val SAMPLE_BURN_FRACTIONS = listOf(0.42f, 0.18f, 1f, 0f, 0.63f, 0.31f, 0.24f)
 private const val SAMPLE_BUDGET_SPENT = "€312.40"
 private const val SAMPLE_BUDGET_CAP = "€400.00"
 private const val SAMPLE_BUDGET_LEFT = "€87.60"
 private const val SAMPLE_BUDGET_PROGRESS = 0.78f
 private const val SAMPLE_BUDGET_ALERT = 0.8f
 private const val SAMPLE_RENT_SPENT = "€760.00"
 private const val SAMPLE_RENT_CAP = "€1,200.00"
 private const val SAMPLE_RENT_LEFT = "€440.00"
 private const val SAMPLE_RENT_PROGRESS = 0.63f
 private const val SAMPLE_COFFEE_SPENT = "€68.40"
 private const val SAMPLE_COFFEE_CAP = "€60.00"
 private const val SAMPLE_COFFEE_OVER = "€8.40"
 private const val SAMPLE_COFFEE_PROGRESS = 1.14f
 private const val SAMPLE_EXPENSE_THREE_MAGNITUDE = "€760.00"
 private const val SAMPLE_CATEGORY_GROCERIES = "€142.10"
 private const val SAMPLE_CATEGORY_COFFEE = "€38.20"
 private const val SAMPLE_CATEGORY_CAP = "€150"
 private const val SAMPLE_SHARE_RENT = 0.62f
 private const val SAMPLE_SHARE_GROCERIES = 0.25f
 private const val SAMPLE_SHARE_COFFEE = 0.13f
 private const val SAMPLE_PERCENT_RENT = 62
 private const val SAMPLE_PERCENT_COFFEE = 13
 private const val SAMPLE_CAP_PROGRESS = 0.95f
 
 /** Adds up to 1 exactly, so the sample donut closes rather than leaving a wedge of track showing. */
 private val SAMPLE_DONUT_SHARES = listOf(0.42f, 0.22f, 0.14f, 0.12f, 0.1f)
 private const val SAMPLE_DONUT_TOTAL = "€1,214.60"
 private const val SAMPLE_RECURRING_RENT = "€980.00"
 private const val SAMPLE_RECURRING_TRANSPORT = "€59.00"
 private const val SAMPLE_RECURRING_COFFEE = "€9.99"
 
 /** Sample due dates, ISO like the live card's — see `RecurringWidget.toWidgetItem`. */
 private const val SAMPLE_RECURRING_SECOND_DUE = "2026-04-30"
 private const val SAMPLE_RECURRING_THIRD_DUE = "2026-05-02"