Coverage Summary for Class: BudgetUi (com.georgeci.moneysurfer.feature.budget)
| Class |
Class, %
|
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| BudgetUi |
100%
(1/1)
|
100%
(1/1)
|
|
100%
(24/24)
|
100%
(117/117)
|
package com.georgeci.moneysurfer.feature.budget
import com.georgeci.moneysurfer.domain.formatter.MoneyFormatter
import com.georgeci.moneysurfer.domain.model.BudgetPeriod
import com.georgeci.moneysurfer.domain.model.BudgetProgress
import com.georgeci.moneysurfer.domain.model.BudgetStatus
import com.georgeci.moneysurfer.domain.model.Category
import com.georgeci.moneysurfer.domain.primitives.BudgetId
import com.georgeci.moneysurfer.domain.primitives.CurrencyCode
import com.georgeci.moneysurfer.domain.primitives.Money
import com.georgeci.moneysurfer.uikit.components.budget.SurferBudgetStatus
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.daysUntil
import kotlinx.datetime.minus
/** A category as a budget screen needs it: enough to draw a bubble and name it. */
data class BudgetCategoryUi(
val id: String,
val name: String,
val iconKey: String,
val hue: Int,
val systemKind: String?,
)
/** One budget, pre-formatted for the list and the details header. */
data class BudgetUi(
val id: BudgetId,
val name: String,
val status: BudgetStatus,
val spentFormatted: String,
val limitFormatted: String,
/** Always positive — the sign lives in [isOver], which picks the wording. */
val remainderFormatted: String,
val isOver: Boolean,
val progress: Float,
val alertFraction: Float,
val alertPercent: Int,
val percent: Int,
val period: BudgetPeriod,
val windowLabel: String,
val daysLeft: Int,
val elapsedDays: Int,
val categories: List<BudgetCategoryUi>,
val isActive: Boolean,
val hasMixedCurrency: Boolean,
val rolloverCarryFormatted: String?,
val dailyAverageFormatted: String,
val projectedTotalFormatted: String,
val perDayRemainingFormatted: String,
val overspendFormatted: String,
)
fun BudgetStatus.toUi(): SurferBudgetStatus = when (this) {
BudgetStatus.OK -> SurferBudgetStatus.Ok
BudgetStatus.WARN -> SurferBudgetStatus.Warn
BudgetStatus.OVER -> SurferBudgetStatus.Over
}
private const val PERCENT = 100f
/**
* Turns domain progress into the strings the screens render.
*
* [categoriesById] is the whole workspace map; a budget naming a category that has since been
* deleted simply drops that bubble instead of failing to render.
*/
fun BudgetProgress.toUi(categoriesById: Map<String, Category>): BudgetUi {
val currency = currency ?: CurrencyCode("")
val limit = effectiveLimit
return BudgetUi(
id = budget.id,
name = budget.name,
status = status,
spentFormatted = MoneyFormatter.format(spent, currency),
limitFormatted = MoneyFormatter.format(limit, currency),
remainderFormatted = MoneyFormatter.format(remaining.abs(), currency),
isOver = status == BudgetStatus.OVER,
progress = if (limit.minor <= 0L) 0f else spent.minor.toFloat() / limit.minor.toFloat(),
alertFraction = budget.alertPercent / PERCENT,
alertPercent = budget.alertPercent,
percent = percent,
period = budget.period,
windowLabel = formatWindow(window.startInclusive, window.endExclusive),
daysLeft = daysLeft,
elapsedDays = elapsedDaysOf(this),
categories = budget.categoryIds.mapNotNull { categoriesById[it.value]?.toUi() },
isActive = budget.isActive,
hasMixedCurrency = hasMixedCurrency,
rolloverCarryFormatted = rolloverCarry
.takeIf { budget.rollover && it.isPositive() }
?.let { MoneyFormatter.format(it, currency) },
dailyAverageFormatted = MoneyFormatter.format(dailyAverage, currency),
projectedTotalFormatted = MoneyFormatter.format(projectedTotal, currency),
perDayRemainingFormatted = MoneyFormatter.format(perDayRemaining, currency),
overspendFormatted = MoneyFormatter.format(
if (remaining.isNegative()) remaining.abs() else Money.zero(),
currency,
),
)
}
private fun Category.toUi() = BudgetCategoryUi(
id = id.value,
name = name,
iconKey = iconKey,
hue = hue,
systemKind = systemKind?.name,
)
/** Days of the window already behind us, counting today — the denominator of the daily average. */
private fun elapsedDaysOf(progress: BudgetProgress): Int {
val windowDays = progress.window.startInclusive.daysUntil(progress.window.endExclusive)
return (windowDays - progress.daysLeft + 1).coerceIn(1, windowDays)
}
private const val MONTH_ABBREVIATION_LENGTH = 3
/**
* "1 Apr – 30 Apr". The end is exclusive in the model but inclusive to a reader, so the label
* shows the last day that still counts.
*/
fun formatWindow(startInclusive: LocalDate, endExclusive: LocalDate): String {
val lastDay = endExclusive.minus(1, DateTimeUnit.DAY)
return "${formatShortDate(startInclusive)} – ${formatShortDate(lastDay)}"
}
fun formatShortDate(date: LocalDate): String {
val month = date.month.name.lowercase()
.replaceFirstChar { it.uppercase() }
.take(MONTH_ABBREVIATION_LENGTH)
return "${date.day} $month"
}