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

Class Class, % Method, % Branch, % Line, % Instruction, %
BalanceSources 100% (1/1) 100% (1/1) 100% (3/3) 100% (12/12)


 package com.georgeci.moneysurfer.feature.dashboard
 
 import arrow.optics.optics
 import com.georgeci.moneysurfer.domain.config.HostCapabilities
 import com.georgeci.moneysurfer.domain.dashboard.DashboardLayoutConfig
 import com.georgeci.moneysurfer.domain.dashboard.DashboardPeriod
 import com.georgeci.moneysurfer.domain.formatter.MoneyFormatter
 import com.georgeci.moneysurfer.domain.insight.Insight
 import com.georgeci.moneysurfer.domain.insight.InsightTone
 import com.georgeci.moneysurfer.domain.insight.SpendTrend
 import com.georgeci.moneysurfer.domain.model.Account
 import com.georgeci.moneysurfer.domain.model.BudgetProgress
 import com.georgeci.moneysurfer.domain.model.BudgetStatus
 import com.georgeci.moneysurfer.domain.model.BurnRate
 import com.georgeci.moneysurfer.domain.model.BurnRatePace
 import com.georgeci.moneysurfer.domain.model.CategorySpend
 import com.georgeci.moneysurfer.domain.model.ConvertedTotal
 import com.georgeci.moneysurfer.domain.model.DayPart
 import com.georgeci.moneysurfer.domain.model.ExchangeRateSnapshot
 import com.georgeci.moneysurfer.domain.model.MonthlyNetHistory
 import com.georgeci.moneysurfer.domain.model.SafeToSpend
 import com.georgeci.moneysurfer.domain.model.SavingsGoalSummary
 import com.georgeci.moneysurfer.domain.model.SpentByCategory
 import com.georgeci.moneysurfer.domain.model.SpentMonth
 import com.georgeci.moneysurfer.domain.model.TransactionSplitGroup
 import com.georgeci.moneysurfer.domain.model.UpcomingRecurring
 import com.georgeci.moneysurfer.domain.model.buildBalanceTrend
 import com.georgeci.moneysurfer.domain.model.safeToSpend
 import com.georgeci.moneysurfer.domain.preferences.UiPreferences
 import com.georgeci.moneysurfer.domain.primitives.AccountId
 import com.georgeci.moneysurfer.domain.primitives.BudgetId
 import com.georgeci.moneysurfer.domain.primitives.ClockUseCase
 import com.georgeci.moneysurfer.domain.primitives.CurrencyCode
 import com.georgeci.moneysurfer.domain.primitives.GoalId
 import com.georgeci.moneysurfer.domain.primitives.Money
 import com.georgeci.moneysurfer.domain.primitives.RecurringRuleId
 import com.georgeci.moneysurfer.domain.primitives.TransactionId
 import com.georgeci.moneysurfer.domain.primitives.TransactionType
 import com.georgeci.moneysurfer.domain.usecase.ConvertAccountsTotalUseCase
 import com.georgeci.moneysurfer.domain.usecase.GenerateInsightsUseCase
 import com.georgeci.moneysurfer.domain.usecase.GetAccountsUseCase
 import com.georgeci.moneysurfer.domain.usecase.GetActiveBudgetProgressUseCase
 import com.georgeci.moneysurfer.domain.usecase.GetBurnRateUseCase
 import com.georgeci.moneysurfer.domain.usecase.GetCategorySpendUseCase
 import com.georgeci.moneysurfer.domain.usecase.GetCurrentWorkspaceUseCase
 import com.georgeci.moneysurfer.domain.usecase.GetExchangeRatesUseCase
 import com.georgeci.moneysurfer.domain.usecase.GetGoalsUseCase
 import com.georgeci.moneysurfer.domain.usecase.GetMonthlyNetHistoryUseCase
 import com.georgeci.moneysurfer.domain.usecase.GetRecentTransactionsUseCase
 import com.georgeci.moneysurfer.domain.usecase.GetSafeToSpendUseCase
 import com.georgeci.moneysurfer.domain.usecase.GetSpentMonthUseCase
 import com.georgeci.moneysurfer.domain.usecase.GetUpcomingRecurringUseCase
 import com.georgeci.moneysurfer.utils.MviViewModel
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.distinctUntilChanged
 import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.flow.onStart
 import kotlinx.datetime.TimeZone
 import kotlinx.datetime.toLocalDateTime
 import org.koin.core.annotation.KoinViewModel
 import kotlin.math.roundToInt
 import kotlin.time.Instant
 
 /**
  * One reader per widget: the dashboard is the one screen that composes every other feature's
  * numbers, so its dependency list grows by one each time a widget is wired. Grouping them would
  * only move the list somewhere else — see `observeDashboard`, where the flows themselves *are*
  * folded, because there `combine`'s five-flow ceiling forces it.
  */
 @KoinViewModel
 @Suppress("LongParameterList")
 class DashboardViewModel(
     private val getAccounts: GetAccountsUseCase,
     private val getRecentTransactions: GetRecentTransactionsUseCase,
     private val getGoals: GetGoalsUseCase,
     private val getExchangeRates: GetExchangeRatesUseCase,
     private val getSafeToSpend: GetSafeToSpendUseCase,
     private val getCategorySpend: GetCategorySpendUseCase,
     private val getActiveBudgetProgress: GetActiveBudgetProgressUseCase,
     private val getBurnRate: GetBurnRateUseCase,
     private val getSpentMonth: GetSpentMonthUseCase,
     private val generateInsights: GenerateInsightsUseCase,
     private val convertAccountsTotal: ConvertAccountsTotalUseCase,
     private val getMonthlyNetHistory: GetMonthlyNetHistoryUseCase,
     private val getUpcomingRecurring: GetUpcomingRecurringUseCase,
     private val clock: ClockUseCase,
     getCurrentWorkspace: GetCurrentWorkspaceUseCase,
     uiPreferences: UiPreferences,
     hostCapabilities: HostCapabilities,
 ) : MviViewModel<DashboardState, DashboardEvent, DashboardEffect>(
     initialState = DashboardState.Loading,
 ) {
 
     private val isOffline: Boolean = hostCapabilities.isOffline
 
     private val transferEnabled: Boolean = hostCapabilities.transferEnabled
 
     /**
      * Widget order and visibility. Normalized again here so the screen renders a sound layout
      * whatever supplies the pref — the DataStore-backed binding already normalizes on decode, but
      * an in-memory or future remote one need not, and `normalized()` is idempotent.
      */
     private val layout = uiPreferences.dashboardLayout.flow
         .map { it.normalized() }
         .onStart { emit(DashboardLayoutConfig.DEFAULT) }
 
     /**
      * The span the spend-oriented widgets are read at. Device-local and not persisted, per
      * `md/insights.md` decision 6 — the dashboard opens on [DashboardPeriod.DEFAULT] every launch.
      */
     private val period = MutableStateFlow(DashboardPeriod.DEFAULT)
 
     /**
      * What the toolbar calls the active workspace, or null while nothing names one.
      *
      * Narrowed to the name here rather than carried as the whole [Workspace]: the row is rewritten
      * with a fresh `updatedAt` on every sync pull, and a `distinctUntilChanged` over the whole
      * object would let each of those — plus every description or base-currency edit — rebuild the
      * entire dashboard state to produce a value equal to the one already on screen. Trimming before
      * the guard folds `" Home "` into `"Home"` for the same reason.
      *
      * Null rather than blank: null is the screen's "fall back to the app name" signal, and a
      * workspace named nothing but whitespace would otherwise draw an empty toolbar title instead of
      * taking that fallback.
      *
      * `onStart` for the reason [layout] has one: the pointer is DataStore-backed and the row behind
      * it a query, and the balance should not wait on either to draw.
      */
     private val workspaceName = getCurrentWorkspace()
         .map { it?.name?.trim()?.ifBlank { null } }
         .distinctUntilChanged()
         .onStart { emit(null) }
 
     init {
         observeDashboard()
     }
 
     override fun onEvent(event: DashboardEvent) {
         when (event) {
             is DashboardEvent.Navigate -> postSideEffect(event.destination())
             is DashboardEvent.OnPeriodChange -> period.value = event.period
         }
     }
 
     private fun observeDashboard() {
         // The chrome and the four widget readers are folded into one source because `combine`
         // tops out at five typed flows, and these are never read apart.
         //
         // The period reaches safe-to-spend as a flow, not as this combine's value: `getSafeToSpend`
         // re-picks the budget in place that way, instead of restarting the workspace-wide
         // transaction query on every tap of the switch. The cost is one intermediate emission on a
         // change — the new period beside the previous budget's figures — which resolves on the next
         // frame and only differs at all when the workspace runs budgets on both cadences.
         //
         // The sixth source arrived with the budgets card, so what the screen draws *around* the
         // widgets — which layout, which span, whose workspace — rides together in [DashboardChrome]
         // and frees a slot. They are the ones to fold: each carries an opening value of its own, so
         // none of them can be what a widget is waiting on.
         val chrome = combine(layout, period, workspaceName, ::DashboardChrome)
         val widgetSources = combine(
             chrome,
             getSafeToSpend(period.map { it.budgetPeriod }).onStart { emit(null) },
             // The budgets card lists what safe-to-spend picks one of. It reads the progress list
             // itself rather than the headline, and takes no period: a budget owns its own window,
             // so the switch cannot reshape which caps the user is tracking.
             getActiveBudgetProgress().onStart { emit(emptyList()) },
             // Burn rate takes no period: it projects to month-end off a fixed seven-day chart.
             // See `DashboardWidgetType.BurnRate` for why that is a scoping call, not an omission.
             getBurnRate().onStart { emit(null) },
             getCategorySpend(period).onStart { emit(null) },
             ::WidgetSources,
         )
         // The balance widget's two non-account inputs, folded for the same reason the chrome is: the
         // cached FX table is what turns the account list into one figure, and the monthly nets are
         // what shape the curve under it. Neither is read anywhere else, and together they leave the
         // fifth `combine` slot below free.
         val balanceSources = combine(
             getExchangeRates().onStart { emit(null) },
             getMonthlyNetHistory().onStart { emit(null) },
             ::BalanceSources,
         )
         launch {
             combine(
                 getAccounts().onStart { emit(emptyList()) },
                 getRecentTransactions(),
                 getGoals(),
                 balanceSources,
                 widgetSources,
                 // Read by name rather than destructured: at five members the positional form is
                 // one reordered field away from a silent swap, and detekt caps it at three.
             ) { accounts, transactions, goals, balanceInputs, widgets ->
                 val balance = accounts.convertedTotal(balanceInputs.rates)
                     ?.toBalanceUi(balanceInputs.netHistory)
                 DashboardState.Content(
                     accounts = accounts.map { it.toUi() },
                     transactions = transactions
                         .filter { it.primary.type != TransactionType.OPENING_BALANCE }
                         .take(RECENT_TRANSACTIONS_LIMIT)
                         .map { it.toUi() },
                     formattedTotalBalance = balance?.headline,
                     otherCurrencyTotals = balance?.notConverted.orEmpty(),
                     ratesAsOf = balance?.asOf,
                     workspaceName = widgets.chrome.workspaceName,
                     workspaceInitial = widgets.chrome.workspaceInitial,
                     // Read per emission rather than once at subscription, so a dashboard left open
                     // across a boundary is corrected by the next transaction change instead of
                     // greeting the user with the wrong half of the day until they navigate away.
                     greeting = DayPart.at(clock.now(), TimeZone.currentSystemDefault()),
                     formattedTrendDelta = balance?.trend?.text,
                     isTrendDeltaNegative = balance?.trend?.isNegative == true,
                     balanceSeries = balance?.trend?.series.orEmpty(),
                     goals = goals.take(DASHBOARD_GOALS_LIMIT).map { it.toUi() },
                     safeToSpend = widgets.safeToSpend?.toUi(),
                     spentByCategory = widgets.categorySpend?.toUi().orEmpty(),
                     spentByCategoryTotal = widgets.categorySpend
                         ?.let { MoneyFormatter.format(it.total, it.currency) },
                     budgets = widgets.budgetProgress.toBudgetsUi(),
                     burnRate = widgets.burnRate?.toUi(),
                     isOffline = isOffline,
                     transferEnabled = transferEnabled,
                     layout = widgets.chrome.layout,
                     period = widgets.chrome.period,
                 )
             }
                 // Joined here rather than folded into a pair above: the insights are the slowest
                 // input — two aggregates plus the category tree — so keeping them last lets the
                 // rest of the dashboard draw without waiting on them.
                 .combine(generateInsights().onStart { emit(emptyList()) }) { content, insights ->
                     content.copy(insights = insights.map { it.toUi() })
                 }
                 // Appended for the same reason the insights are, not because it is slow: the fold
                 // above is already at `combine`'s five-flow ceiling, and chaining one reader on is
                 // the cheaper edit than repacking six widget sources into a second holder type.
                 // Takes no period — see `DashboardWidgetType.SpentMonth`.
                 .combine(getSpentMonth().onStart { emit(null) }) { content, spentMonth ->
                     content.copy(spentMonth = spentMonth?.toUi())
                 }
                 // Chained on for the same reason, and it costs the upcoming card nothing — it
                 // re-dates itself on a timer, so it is never what the rest is waiting on.
                 .combine(getUpcomingRecurring().onStart { emit(emptyList()) }) { content, upcoming ->
                     content.copy(upcoming = upcoming.map { it.toUi() })
                 }
                 .collect { newContent -> updateState { newContent } }
         }
     }
 
     /**
      * The converted total. `null` until a base currency is known — a workspace is always selected
      * by the time accounts exist, so this only covers the first frame after launch and the
      * signed-out state, both of which render the empty balance anyway.
      */
     private fun List<Account>.convertedTotal(rates: ExchangeRateSnapshot?): ConvertedTotal? =
         rates?.let { convertAccountsTotal(this, it.baseCurrency, it.rates) }
 
     /**
      * Splits the total into what the balance widget renders.
      *
      * When nothing could be priced in the base currency — a workspace whose accounts are all in
      * currencies the cache does not cover — the headline falls back to the largest remaining
      * bucket instead of going empty. `formattedTotalBalance == null` is the screen's "no accounts
      * at all" signal, and letting an unconvertible balance trip it would tell a user who *has*
      * money to add their first account.
      *
      * That fallback headline gets no trend: [history] is quoted in the base currency, and hanging
      * its curve off a total that is one currency's bucket would draw a shape belonging to neither.
      * Neither does a headline that took a rate to assemble — see [coversWholeHeadline].
      */
     private fun ConvertedTotal.toBalanceUi(history: MonthlyNetHistory?): BalanceUi {
         val leftOver = unconverted.map { MoneyFormatter.format(it.amount, it.currencyCode) }
         return when (val converted = total) {
             null -> BalanceUi(
                 headline = leftOver.firstOrNull(),
                 notConverted = leftOver.drop(1),
                 asOf = null,
             )
             else -> BalanceUi(
                 headline = MoneyFormatter.format(converted, baseCurrency),
                 notConverted = leftOver,
                 asOf = asOf?.asIsoDate(),
                 trend = history?.takeIf { coversWholeHeadline }?.toTrendUi(converted, baseCurrency),
             )
         }
     }
 
     /**
      * Whether base-currency history can account for the whole headline, which is what it takes for a
      * delta folded out of that history to be the movement of *this* figure.
      *
      * The aggregate behind the nets filters on the base currency and the app caches only today's
      * rates, so a foreign account's movement is outside the fold no matter how it is anchored. It is
      * inside the headline, though, the moment a rate could price it — and "+$0.00 this month" beside
      * a balance that grew by a third of itself is the class of answer
      * [ConvertedTotal] exists to prevent. So the trend stands down unless every balance in the
      * workspace was already in the base currency: nothing left unconverted ([ConvertedTotal.isComplete])
      * and no quote needed to build the total (a null `asOf`, which is exactly that signal).
      *
      * Costs multi-currency workspaces their trend until the day transactions carry a rate snapshot.
      * A figure that silently omits money is worse than no figure.
      */
     private val ConvertedTotal.coversWholeHeadline: Boolean
         get() = isComplete && asOf == null
 
     /**
      * This month's movement and the curve behind it, or null when there is nothing honest to draw:
      *
      * - the nets are *filtered* to their own workspace's base currency, and the total is converted
      *   into whatever the cached FX table is based on. The two agree except in the window between a
      *   base-currency change and the next rate refresh, and in that window a fold would subtract
      *   euros from dollars;
      * - a window that booked nothing has no curve. The fold would still produce six points — the
      *   present balance repeated — and a flat line across half a year is a claim about months the
      *   aggregate cannot see, since opening balances are outside its monthly nets by construction.
      */
     private fun MonthlyNetHistory.toTrendUi(total: Money, base: CurrencyCode): BalanceTrendUi? {
         if (currency != base || nets.isEmpty()) return null
         val trend = buildBalanceTrend(currentBalance = total, nets = nets, months = months)
         val formatted = MoneyFormatter.format(trend.delta, base)
         return BalanceTrendUi(
             // Locale formatting only signs negatives; a flat month reads as "+" too, since "no
             // change" is not a loss. Same call as the account details chart's delta.
             text = if (trend.delta.isNegative()) formatted else "+$formatted",
             isNegative = trend.delta.isNegative(),
             // Major units: the curve only needs its own shape, and minor units are a Long the
             // chart would have to narrow anyway.
             series = trend.points.map { (it.balance.minor / Money.MINOR_PER_MAJOR).toFloat() },
         )
     }
 
     /** ISO date — the "as of" label has to be unambiguous, and the app ships no date locale rules. */
     private fun Instant.asIsoDate(): String =
         toLocalDateTime(TimeZone.currentSystemDefault()).date.toString()
 
     private fun Account.toUi() = AccountUi(
         id = id,
         name = name,
         formattedBalance = MoneyFormatter.format(balance, currencyCode),
         currency = currencyCode.value,
     )
 
     /**
      * The headline is signed — negative once the budget is overspent — so the widget states the
      * overshoot rather than flooring at zero and pretending the period is merely spent out.
      */
     private fun SafeToSpend.toUi(): SafeToSpendUi = SafeToSpendUi(
         budgetName = budgetName,
         remainingFormatted = MoneyFormatter.format(remaining, currency),
         spentFormatted = MoneyFormatter.format(spent, currency),
         limitFormatted = MoneyFormatter.format(limit, currency),
         perDayFormatted = MoneyFormatter.format(perDay, currency),
         daysLeft = daysLeft,
         progress = spentFraction,
         paceFraction = elapsedFraction,
         status = status,
     )
 
     /**
      * The budgets the widget lists, most pressing first: spend against the limit, descending, with
      * ties broken by budget id so two equally-spent budgets keep a stable order across emissions.
      * A budget the user is closest to blowing is the one worth the row — the rest are a tap away on
      * the budgets screen.
      *
      * Progress carrying no base currency is dropped rather than formatted against a guess: the
      * workspace row can be missing behind its budgets for a moment after a pull, and
      * `MoneyFormatter` is backed by `java.util.Currency`, which rejects anything but an ISO code.
      */
     private fun List<BudgetProgress>.toBudgetsUi(): List<BudgetSummaryUi> =
         sortedWith(compareByDescending<BudgetProgress> { it.spentFraction }.thenBy { it.budget.id.value })
             // Lazily, so a workspace with thirty budgets formats money for the three rows the card
             // draws rather than for all thirty on every transaction change.
             .asSequence()
             .mapNotNull { it.toUiOrNull() }
             .take(DASHBOARD_BUDGETS_LIMIT)
             .toList()
 
     private fun BudgetProgress.toUiOrNull(): BudgetSummaryUi? {
         val currency = currency ?: return null
         return BudgetSummaryUi(
             id = budget.id,
             name = budget.name,
             spentFormatted = MoneyFormatter.format(spent, currency),
             limitFormatted = MoneyFormatter.format(effectiveLimit, currency),
             remainderFormatted = MoneyFormatter.format(remaining.abs(), currency),
             progress = spentFraction,
             alertFraction = budget.alertPercent / PERCENT,
             status = status,
         )
     }
 
     /**
      * The chart's bars carry no money of their own: a currency printed seven times over a 64dp-tall
      * column is unreadable, so the amounts live in the headline, the callout and the chart's
      * screen-reader line, and each bar keeps only its height and its date.
      */
     private fun BurnRate.toUi(): BurnRateUi = BurnRateUi(
         averageFormatted = MoneyFormatter.format(series.average, currency),
         projectedFormatted = MoneyFormatter.format(projectedMonthTotal, currency),
         weekTotalFormatted = MoneyFormatter.format(series.total, currency),
         busiestDayFormatted = MoneyFormatter.format(
             series.days.maxOfOrNull { it.total } ?: Money.zero(),
             currency,
         ),
         days = series.days.zip(series.barFractions) { point, fraction ->
             BurnRateDayUi(
                 dayOfMonth = point.date.day,
                 fraction = fraction,
                 isToday = point.date == series.today,
             )
         },
         pace = pace,
     )
 
     private fun SavingsGoalSummary.toUi() = GoalUi(
         id = goal.id,
         name = goal.title,
         formattedSaved = MoneyFormatter.format(progress.saved, goal.currencyCode),
         formattedTarget = MoneyFormatter.format(progress.target, goal.currencyCode),
         progress = progress.percent.toFloat(),
     )
 
     /**
      * Money is formatted here and the sentence is assembled in the composable: the amounts need a
      * currency the screen does not carry, and the copy needs a locale the ViewModel does not.
      */
     private fun Insight.toUi(): InsightUi = when (this) {
         is Insight.CategoryChange -> InsightUi(
             id = id,
             kind = if (isIncrease) InsightKind.CategoryUp else InsightKind.CategoryDown,
             tone = tone,
             label = categoryName,
             amount = MoneyFormatter.format(current, currency),
             comparison = MoneyFormatter.format(previous, currency),
             percent = changePercent,
         )
         is Insight.PeriodSpend -> InsightUi(
             id = id,
             kind = when (trend) {
                 SpendTrend.Up -> InsightKind.PeriodUp
                 SpendTrend.Down -> InsightKind.PeriodDown
                 SpendTrend.Flat -> InsightKind.PeriodFlat
             },
             tone = tone,
             amount = MoneyFormatter.format(current, currency),
             comparison = MoneyFormatter.format(previous, currency),
             percent = changePercent,
         )
         is Insight.ActiveSubscriptions -> InsightUi(
             id = id,
             kind = InsightKind.Subscriptions,
             tone = tone,
             amount = MoneyFormatter.format(monthlyTotal, currency),
             count = count,
         )
     }
 
     /**
      * One receipt as the widget draws it. A split shows the whole payment and how many categories
      * it covers — its legs are separate rows only where a category is what the screen is about.
      */
     private fun TransactionSplitGroup.toUi() = TransactionUi(
         id = primary.id,
         title = primary.note.ifBlank { "No description" },
         formattedAmount = MoneyFormatter.format(total, primary.currencyCode),
         isExpense = primary.type == TransactionType.EXPENSE,
         categoryHueSeed = primary.categoryId?.value.orEmpty(),
         splitCategoryCount = if (isSplit) categoryCount else 0,
     )
 }
 
 @optics
 sealed interface DashboardState {
     data object Loading : DashboardState
 
     @optics
     data class Content(
         val accounts: List<AccountUi>,
         val transactions: List<TransactionUi>,
         /**
          * Every account balance converted into the workspace base currency, or null when no
          * account could be priced there (no accounts at all, or none in a currency the cached
          * rates cover).
          */
         val formattedTotalBalance: String?,
         /**
          * Per-currency totals for the accounts no cached rate could convert. Shown as a note
          * beside the headline — dropping them was the old bug, and they are never folded into
          * the headline at a rate the app does not have.
          */
         val otherCurrencyTotals: List<String> = emptyList(),
         /**
          * ISO date the converting rates were published, or null when the total needed no rates.
          * Renders as the staleness signal so a total computed offline says how old it is.
          */
         val ratesAsOf: String? = null,
         /**
          * The active workspace's name, or null while nothing names one — nobody signed in, no
          * workspace selected, or a row the device has not pulled yet. The screen draws the app's
          * own name in that case; it never draws a guess at which workspace this is.
          */
         val workspaceName: String?,
         /**
          * The letter for the toolbar's avatar bubble, derived from [workspaceName]. Carried rather
          * than re-derived on the screen so a leading space or a lowercase name cannot produce a
          * different bubble than the title it is drawn beside.
          */
         val workspaceInitial: String?,
         /**
          * Which greeting the toolbar opens with, off the device clock. The part of day, not the
          * sentence — that needs a locale this class does not have. Null only in a state built by
          * hand; the view model always resolves one.
          */
         val greeting: DayPart?,
         /**
          * What the balance moved by this month, signed and formatted, or null when no window of
          * base-currency history backs a figure — see `toTrendUi`.
          */
         val formattedTrendDelta: String?,
         /** Whether [formattedTrendDelta] went down. Picks the arrow the widget draws beside it. */
         val isTrendDeltaNegative: Boolean = false,
         /**
          * The balance at the close of each of the charted months, oldest first, in major units.
          * Empty when there is no curve to draw — the widget needs two points to draw one.
          */
         val balanceSeries: List<Float> = emptyList(),
         val goals: List<GoalUi> = emptyList(),
         /** What is still safe to spend this period, or null while no active budget backs a number. */
         val safeToSpend: SafeToSpendUi? = null,
         /** The active budgets worth a row, most pressing first — empty when none are tracked. */
         val budgets: List<BudgetSummaryUi> = emptyList(),
         /**
          * Where the selected period's money went, largest spender first — empty while it holds no
          * spend, which is the spent-by-category widget's own empty state rather than a missing one.
          */
         val spentByCategory: List<CategorySpendUi> = emptyList(),
         /**
          * Everything [spentByCategory] adds up to, formatted — the donut's centre figure. Kept
          * beside the rows rather than re-summed on the screen: the rows carry formatted strings,
          * not money, so a screen-side sum would have to parse its way back to the amounts. Null
          * while no workspace backs a breakdown, which is not the same as a breakdown of zero.
          */
         val spentByCategoryTotal: String? = null,
         /**
          * The week's spend pace and where the month lands at it, or null while no workspace backs a
          * series. Unlike [safeToSpend] this one does not need a budget — a null pace inside it is
          * the "no cap to miss" state, and the chart is drawn either way.
          */
         val burnRate: BurnRateUi? = null,
         /**
          * What this month has cost, or null while no workspace and base currency back the figure.
          * Like [burnRate] this one does not need a budget — a null cap inside it is the "no budget
          * set" state the card is partly *for*, and the amount is drawn either way.
          */
         val spentMonth: SpentMonthUi? = null,
         /** Generated spending insights, most actionable first. Empty until the engine has run. */
         val insights: List<InsightUi> = emptyList(),
         /**
          * The scheduled payments still to come, soonest first. Empty is the upcoming card's own
          * "nothing scheduled" state — a workspace that keeps no recurring rules is the common case,
          * not a missing read.
          */
         val upcoming: List<UpcomingRecurringUi> = emptyList(),
         val isOffline: Boolean = false,
         /**
          * Whether this build offers multi-account transfers. The quick-actions widget is the only
          * thing that reads it: half of that widget is a Transfer button, and a build with transfers
          * off has no form to send it to.
          */
         val transferEnabled: Boolean = false,
         /** Which widgets the screen renders, in order. */
         val layout: DashboardLayoutConfig = DashboardLayoutConfig.DEFAULT,
         /**
          * The span every spend-oriented widget reads at once. One value for the whole screen, so
          * two widgets can never disagree about which days they are summarising.
          */
         val period: DashboardPeriod = DashboardPeriod.DEFAULT,
     ) : DashboardState {
 
         /**
          * Single source of truth for the "no transactions logged yet" decision so screens
          * never re-derive it — keeps the empty-state vs. content choice off the composable.
          */
         val recentTransactionsEmpty: Boolean
             get() = transactions.isEmpty()
 
         /** Whether the period switch has anything under it to drive — see `hasPeriodScopedWidget`. */
         val periodSwitchVisible: Boolean
             get() = layout.hasPeriodScopedWidget
 
         companion object
     }
 
     companion object
 }
 
 /**
  * The per-widget sources `combine` cannot take as separate arguments — it tops out at five typed
  * flows and the screen already reads four others.
  */
 private data class WidgetSources(
     val chrome: DashboardChrome,
     val safeToSpend: SafeToSpend?,
     /** Every active budget's progress — the rows the budgets card draws. */
     val budgetProgress: List<BudgetProgress>,
     val burnRate: BurnRate?,
     val categorySpend: SpentByCategory?,
 )
 
 /**
  * One scheduled payment as the card draws it. The due *label* is not built here: "Today" and
  * "Tomorrow" are copy, and the fallback is a date — so the screen gets the day count and the ISO
  * date and picks between them where the string resources are.
  *
  * File-level like the spend rows' mapper below, rather than a method: it reads nothing off the view
  * model, and the class is at detekt's function ceiling.
  */
 private fun UpcomingRecurring.toUi() = UpcomingRecurringUi(
     id = ruleId,
     name = title,
     amountFormatted = MoneyFormatter.format(amount, currency),
     dueDateIso = dueDate.toString(),
     daysUntil = daysUntil,
     isImminent = isImminent,
 )
 
 /**
  * The month's spend as the card draws it. A file-level mapper like [SpentByCategory.toUi] below —
  * it reads nothing off the view model, and the class is at detekt's function ceiling.
  *
  * [SpentMonthUi.capFormatted] carries the "is there a budget" answer rather than a second boolean:
  * the caption is either "of <cap>" or the no-budget line, and one nullable string cannot disagree
  * with itself. The bar reads 0 without a cap — there is no limit to fill it against, and inventing
  * one from the spend would draw a full bar on an unbudgeted month.
  */
 private fun SpentMonth.toUi(): SpentMonthUi = SpentMonthUi(
     spentFormatted = MoneyFormatter.format(spent, currency),
     capFormatted = cap?.let { MoneyFormatter.format(it, currency) },
     progress = capFraction ?: 0f,
     delta = delta?.let { SpentMonthDeltaUi(trend = it.trend, percent = it.changePercent) },
 )
 
 /**
  * The breakdown's rows, largest spender first. Money is formatted here against the breakdown's
  * own currency — non-null by construction, so nothing has to invent one.
  */
 private fun SpentByCategory.toUi(): List<CategorySpendUi> = entries.map { it.toUi(currency) }
 
 private fun CategorySpend.toUi(currency: CurrencyCode) = CategorySpendUi(
     categoryId = category?.id?.value,
     name = category?.name,
     hue = category?.hue,
     spentFormatted = MoneyFormatter.format(spent, currency),
     share = share,
     cap = cap?.let {
         CategoryCapUi(
             limitFormatted = MoneyFormatter.format(it.limit, currency),
             status = it.status,
             // Non-null whenever `cap` is — the two come off the same entry.
             progress = capFraction ?: 0f,
         )
     },
 )
 
 /**
  * What the screen draws around the widgets: which cards, in what order, the span they are all read
  * at, and what the toolbar calls the active workspace. Folded into one flow so the five `combine`
  * takes can cover seven sources.
  */
 private data class DashboardChrome(
     val layout: DashboardLayoutConfig,
     val period: DashboardPeriod,
     /** Trimmed, or null while nothing names one — see `DashboardViewModel.workspaceName`. */
     val workspaceName: String?,
 ) {
 
     /**
      * The letter in the toolbar bubble. Taken off the trimmed name, so a workspace called
      * " family " gets an F — the screen's own fallback reads the raw first character and would
      * draw a space.
      */
     val workspaceInitial: String? get() = workspaceName?.initial()
 }
 
 /**
  * The first *character* of a name as a display string — which is not always the first `Char`.
  *
  * A workspace called "🏠 Home" starts with a surrogate pair, and handing half of one to the avatar
  * bubble draws a replacement box where the letter should be. Uppercased through [String.uppercase],
  * which is locale-invariant: the toolbar bubble must not turn a Turkish user's "istanbul" into a
  * dotted İ the title beside it does not have.
  */
 private fun String.initial(): String =
     take(if (first().isHighSurrogate() && length > 1) 2 else 1).uppercase()
 
 /** Everything the balance widget draws, already formatted. */
 private data class BalanceUi(
     val headline: String?,
     val notConverted: List<String>,
     val asOf: String?,
     /** Null when no base-currency history could be anchored on the headline. */
     val trend: BalanceTrendUi? = null,
 )
 
 /** The balance card's movement half: the delta as a signed string, and the curve behind it. */
 private data class BalanceTrendUi(
     val text: String,
     val isNegative: Boolean,
     val series: List<Float>,
 )
 
 /**
  * The two flows behind the headline figure and its curve, folded into one so the screen's
  * five-source `combine` still fits. See `observeDashboard`.
  */
 private data class BalanceSources(
     val rates: ExchangeRateSnapshot?,
     val netHistory: MonthlyNetHistory?,
 )
 
 data class AccountUi(
     val id: AccountId,
     val name: String,
     val formattedBalance: String,
     val currency: String,
 )
 
 data class GoalUi(
     val id: GoalId,
     val name: String,
     val formattedSaved: String,
     val formattedTarget: String,
     val progress: Float,
 )
 
 /**
  * Which sentence an insight draws. One entry per rule outcome rather than per rule, because the
  * direction is what changes the copy: "Dining is up 24%" and "Dining is down 24%" are two
  * sentences, not one with a sign in it.
  */
 enum class InsightKind {
     CategoryUp,
     CategoryDown,
     PeriodUp,
     PeriodDown,
     PeriodFlat,
     Subscriptions,
 }
 
 /**
  * One insight ready for the widget: which sentence to draw ([kind] and [tone]) plus the values
  * that fill its placeholders, already formatted in the workspace base currency.
  *
  * Flat rather than a mirror of the domain's sealed `Insight`: every insight draws the same card,
  * so a per-kind UI type would duplicate the domain hierarchy to feed one `when`. Fields a kind
  * does not use keep their defaults and never reach its copy.
  */
 data class InsightUi(
     val id: String,
     val kind: InsightKind,
     val tone: InsightTone,
     /** Category name for the two category kinds; null for the uncategorized bucket. */
     val label: String? = null,
     /** The headline amount — this period's spend, or the monthly subscription total. */
     val amount: String = "",
     /** The same-stretch figure from the previous period. Unused by [InsightKind.Subscriptions]. */
     val comparison: String = "",
     /** Magnitude of the change in whole percent. Zero for the kinds that compare nothing. */
     val percent: Int = 0,
     /** Active subscription count. Zero for every kind but [InsightKind.Subscriptions]. */
     val count: Int = 0,
 )
 
 /**
  * The safe-to-spend widget's numbers. Money is formatted here; the sentences around it are built
  * on the screen, which is where the string resources are.
  */
 data class SafeToSpendUi(
     val budgetName: String,
     /** Signed — negative once the budget is overspent. */
     val remainingFormatted: String,
     val spentFormatted: String,
     val limitFormatted: String,
     val perDayFormatted: String,
     val daysLeft: Int,
     /** Spend against the limit; can exceed 1, which the bar caps rather than overdraws. */
     val progress: Float,
     /** How much of the period has gone — the tick [progress] is read against. */
     val paceFraction: Float,
     val status: BudgetStatus,
 ) {
     /** Derived rather than stored, so the wording and the colour can never disagree. */
     val isOver: Boolean get() = status == BudgetStatus.OVER
 }
 
 /**
  * One row of the spent-by-category widget. The label for the uncategorized bucket and the sentences
  * around the numbers are built on the screen, which is where the string resources are.
  */
 data class CategorySpendUi(
     /** Null for the uncategorized bucket, which is a real slice with no category behind it. */
     val categoryId: String?,
     /** Null for the uncategorized bucket — the screen owns that label. */
     val name: String?,
     /** The category's stored hue, or null for the bucket that has no stored appearance. */
     val hue: Int?,
     val spentFormatted: String,
     /** Share of the selected period's total spend, `0f..1f`. */
     val share: Float,
     /** The cap on this category, or null while nothing caps it. */
     val cap: CategoryCapUi? = null,
 ) {
 
     /** [share] as whole percent, for the caption of a row that has no cap to state instead. */
     val sharePercent: Int get() = (share * PERCENT).roundToInt()
 }
 
 /** The cap a single-category budget puts on one row, already formatted. */
 data class CategoryCapUi(
     val limitFormatted: String,
     val status: BudgetStatus,
     /** Spend against the cap; can exceed 1, which the meter caps rather than overdrawing. */
     val progress: Float,
 )
 
 /**
  * One budget row of the budgets widget. Money is formatted here; the sentences around it — the
  * status word, "left" against "over" — are built on the screen, which is where the strings are.
  */
 data class BudgetSummaryUi(
     val id: BudgetId,
     val name: String,
     val spentFormatted: String,
     /** The budget amount plus any rollover carry — what the spend is measured against. */
     val limitFormatted: String,
     /** Always positive — the sign lives in [isOver], which picks the wording. */
     val remainderFormatted: String,
     /** Spend against the limit; can exceed 1, which the bar caps rather than overdraws. */
     val progress: Float,
     /** Where this budget's alert threshold sits — the tick [progress] is read against. */
     val alertFraction: Float,
     val status: BudgetStatus,
 ) {
     /** Derived rather than stored, so the wording and the colour can never disagree. */
     val isOver: Boolean get() = status == BudgetStatus.OVER
 }
 
 /**
  * The burn-rate widget's numbers. Money is formatted here; the sentences around it are built on the
  * screen, which is where the string resources are.
  */
 data class BurnRateUi(
     /** Mean spend per day across the charted week. */
     val averageFormatted: String,
     /** Where the month's spend lands if the remaining days each cost the average. */
     val projectedFormatted: String,
     /** What the charted week cost in total — the chart's screen-reader line, not a printed figure. */
     val weekTotalFormatted: String,
     /** The busiest charted day, which is the bar the chart is scaled to. */
     val busiestDayFormatted: String,
     val days: List<BurnRateDayUi>,
     /**
      * The verdict on [projectedFormatted], or null when no monthly budget caps the month. Null is a
      * normal state, not a loading one — see [com.georgeci.moneysurfer.domain.model.BurnRate].
      */
     val pace: BurnRatePace?,
 )
 
 /**
  * The spent-this-month card's numbers. Money is formatted here; the sentences around it are built on
  * the screen, which is where the string resources are.
  */
 data class SpentMonthUi(
     val spentFormatted: String,
     /** The monthly cap, or null when no budget sets one — which is the caption the card shows. */
     val capFormatted: String?,
     /** Spend against the cap, `0f` without one. Can exceed 1, which the bar clamps. */
     val progress: Float,
     /** How the month compares to the last one, or null when no honest comparison is available. */
     val delta: SpentMonthDeltaUi? = null,
 )
 
 /**
  * The month-over-month change. [percent] is a magnitude — [trend] picks which sentence and which
  * colour it is printed in, so a sign is never formatted into the number itself.
  */
 data class SpentMonthDeltaUi(
     val trend: SpendTrend,
     val percent: Int,
 )
 
 /**
  * One bar. [dayOfMonth] rather than a weekday name because the app ships no date locale rules, and
  * a number needs none.
  */
 data class BurnRateDayUi(
     val dayOfMonth: Int,
     /** Height against the busiest charted day, 0..1. */
     val fraction: Float,
     /** The last bar — a day still being spent, which the chart draws solid. */
     val isToday: Boolean,
 )
 
 /**
  * One row of the upcoming card. Money is formatted here; the due label is assembled on the screen,
  * which is where the "Today"/"Tomorrow" strings are.
  */
 data class UpcomingRecurringUi(
     val id: RecurringRuleId,
     val name: String,
     val amountFormatted: String,
     /** ISO — the fallback label, for the reason the balance card's "as of" date is one. */
     val dueDateIso: String,
     /** Whole days from today. `0` is today, `1` tomorrow; never negative. */
     val daysUntil: Int,
     /** Whether the row gets the tint — today and tomorrow. Decided in the domain, not re-derived. */
     val isImminent: Boolean,
 )
 
 data class TransactionUi(
     val id: TransactionId,
     val title: String,
     val formattedAmount: String,
     val isExpense: Boolean,
     val categoryHueSeed: String,
     /**
      * How many categories a collapsed split covers, or `0` for an ordinary transaction. Non-zero
      * makes [formattedAmount] the whole receipt and gives the row its "N categories" meta line;
      * [id] is the leg tapping it opens.
      */
     val splitCategoryCount: Int = 0,
 )
 
 /**
  * The navigation half of the dashboard's contract as a table. A file-level function rather than a
  * method on the view model for the same reason `DashboardNavigation.navigate` is one (issue #404):
  * it is a dozen one-line branches where swapping two lines still compiles, still passes every other
  * test, and lands the user on the wrong screen — so it has to be drivable by a plain spec with no
  * view model to build. Between the two tables, event → effect → destination is covered end to end.
  *
  * Split out of `onEvent` because the dashboard is a hub: all but one of its events do nothing but
  * name a destination, and leaving them inline left the one event that *changes this screen* as the
  * last line of a list that reads as boilerplate.
  */
 // The table's "complexity" is the number of places the dashboard can reach, not tangled logic —
 // every branch is one destination. Same call as `SettingsScreen` and `DashboardNavigation.navigate`.
 @Suppress("CyclomaticComplexMethod")
 internal fun DashboardEvent.Navigate.destination(): DashboardEffect = when (this) {
     is DashboardEvent.OnAccountClick -> DashboardEffect.NavigateToAccountDetails(accountId)
     is DashboardEvent.OnTransactionClick -> DashboardEffect.NavigateToTransactionDetails(transactionId)
     DashboardEvent.OnAddAccountClick -> DashboardEffect.NavigateToAccountCreation
     DashboardEvent.OnSeeAllTransactionsClick -> DashboardEffect.NavigateToTransactionsList
     DashboardEvent.OnAddTransactionClick -> DashboardEffect.NavigateToTransactionCreation(accountId = null)
     DashboardEvent.OnTransferClick -> DashboardEffect.NavigateToTransferCreation
     is DashboardEvent.OnAddTransactionForAccountClick ->
         DashboardEffect.NavigateToTransactionCreation(accountId = accountId)
     DashboardEvent.OnManageAccountsClick -> DashboardEffect.NavigateToAccountsManage
     DashboardEvent.OnSettingsClick -> DashboardEffect.NavigateToSettings
     DashboardEvent.OnCustomizeClick -> DashboardEffect.NavigateToCustomize
     DashboardEvent.OnSeeAllGoalsClick -> DashboardEffect.NavigateToGoals
     is DashboardEvent.OnGoalClick -> DashboardEffect.NavigateToGoalDetails(goalId)
     DashboardEvent.OnSetBudgetClick -> DashboardEffect.NavigateToBudgetCreation
     DashboardEvent.OnSeeAllBudgetsClick -> DashboardEffect.NavigateToBudgets
     is DashboardEvent.OnBudgetClick -> DashboardEffect.NavigateToBudgetDetails(budgetId)
 }
 
 sealed interface DashboardEvent {
 
     /**
      * An event whose whole answer is a destination — the dashboard is a hub, so nearly all of
      * them are. Declaring that as a type rather than as a convention keeps the mapping in
      * `destination()` exhaustive: an event added here without a branch there is a compile error,
      * and one that belongs on this screen instead simply does not extend this.
      */
     sealed interface Navigate : DashboardEvent
 
     data class OnAccountClick(val accountId: AccountId) : Navigate
     data class OnTransactionClick(val transactionId: TransactionId) : Navigate
     data object OnAddAccountClick : Navigate
     data object OnAddTransactionClick : Navigate
     data object OnTransferClick : Navigate
     data object OnSeeAllTransactionsClick : Navigate
     data class OnAddTransactionForAccountClick(val accountId: AccountId) : Navigate
     data object OnManageAccountsClick : Navigate
     data object OnSettingsClick : Navigate
     data object OnCustomizeClick : Navigate
     data object OnSeeAllGoalsClick : Navigate
     data class OnGoalClick(val goalId: GoalId) : Navigate
     data object OnSetBudgetClick : Navigate
     data object OnSeeAllBudgetsClick : Navigate
     data class OnBudgetClick(val budgetId: BudgetId) : Navigate
 
     /** The Week/Month switch. Affects the screen only — nothing about it is written to disk. */
     data class OnPeriodChange(val period: DashboardPeriod) : DashboardEvent
 }
 
 sealed interface DashboardEffect {
     data class NavigateToAccountDetails(val accountId: AccountId) : DashboardEffect
     data class NavigateToTransactionDetails(val transactionId: TransactionId) : DashboardEffect
     data object NavigateToAccountCreation : DashboardEffect
     data object NavigateToAccountsManage : DashboardEffect
     data class NavigateToTransactionCreation(val accountId: AccountId?) : DashboardEffect
 
     /** The same creation screen, opened on its Transfer tab rather than the default expense one. */
     data object NavigateToTransferCreation : DashboardEffect
     data object NavigateToSettings : DashboardEffect
     data object NavigateToCustomize : DashboardEffect
     data object NavigateToTransactionsList : DashboardEffect
     data object NavigateToGoals : DashboardEffect
     data class NavigateToGoalDetails(val goalId: GoalId) : DashboardEffect
 
     /** The budget editor, opened empty — the way out of the safe-to-spend widget's empty state. */
     data object NavigateToBudgetCreation : DashboardEffect
     data object NavigateToBudgets : DashboardEffect
     data class NavigateToBudgetDetails(val budgetId: BudgetId) : DashboardEffect
 }
 
 private const val RECENT_TRANSACTIONS_LIMIT = 5
 
 /** The widget shows two rows at most; reading more of the list would be wasted work. */
 private const val DASHBOARD_GOALS_LIMIT = 2
 
 /** Three rows is what the full-size budgets card draws; the compact one keeps the first. */
 private const val DASHBOARD_BUDGETS_LIMIT = 3
 
 /**
  * Alert thresholds are stored as whole percents and the bar wants a fraction; a share is a fraction
  * and its caption wants a whole percent. One constant serves both directions.
  */
 private const val PERCENT = 100f