W6 Android Insights on S-11: server trends and forecast (E-34, E-35), online only (D-141 to D-143)
InsightsRepository in :core:model (SpendTrends, SpendForecast and parts); :data DTOs for E-34/E-35 (round-trip the contract examples, fixing ExampleCoverageTest), InsightsRepositoryImpl through KvittApi and OnlineGate with category/account names from Room (deleted rows included); FakeInsightsRepository with scenarios (loaded, empty household, account without history, offline, unreachable, long); the Insights section below S-11's charts: household trend and per-category trends on SpendLineChart with average and moving average, a forecast card per account, "Needs connection" / "Could not reach the server" over the last data of the session. Strings sv/en; 41 goldens. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
@@ -0,0 +1,118 @@
|
||||
package se.hagstedt.kvitt.fakes
|
||||
|
||||
import se.hagstedt.kvitt.model.AccountForecast
|
||||
import se.hagstedt.kvitt.model.CategoryTrend
|
||||
import se.hagstedt.kvitt.model.ForecastMonth
|
||||
import se.hagstedt.kvitt.model.InsightsRepository
|
||||
import se.hagstedt.kvitt.model.Measure
|
||||
import se.hagstedt.kvitt.model.OnlineResult
|
||||
import se.hagstedt.kvitt.model.RecurringTemplate
|
||||
import se.hagstedt.kvitt.model.SpendForecast
|
||||
import se.hagstedt.kvitt.model.SpendTrend
|
||||
import se.hagstedt.kvitt.model.SpendTrends
|
||||
import se.hagstedt.kvitt.model.TrendPoint
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.sign
|
||||
|
||||
/**
|
||||
* The server's E-34 and E-35 (D-133, D-141) over the scenario, so Insights screens render without a server. The rules
|
||||
* are the endpoints' as `contracts/openapi.yaml` describes them (whole months before the current one, empty months
|
||||
* count, half-up to öre, trailing 3-month moving average; forecast = templates due + the 3-month average of spend not
|
||||
* generated from a template). Offline is [OnlineResult.Offline]; [FakeScenario.insightsFailure] fails both reads.
|
||||
*/
|
||||
class FakeInsightsRepository(private val store: FakeStore) : InsightsRepository {
|
||||
override suspend fun trends(months: Int, measure: Measure): OnlineResult<SpendTrends> = store.online { s ->
|
||||
when {
|
||||
months !in 1..MAX_MONTHS -> OnlineResult.Failed("invalid_request", "months")
|
||||
else -> s.insightsFailure?.let { OnlineResult.Failed(it, null) } ?: OnlineResult.Ok(s.trends(months, measure))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun forecast(measure: Measure): OnlineResult<SpendForecast> = store.online { s ->
|
||||
s.insightsFailure?.let { OnlineResult.Failed(it, null) } ?: OnlineResult.Ok(s.forecast(measure))
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MAX_MONTHS = 24
|
||||
}
|
||||
}
|
||||
|
||||
/** D-128's window, counted in whole months here (§15.8). */
|
||||
private const val WINDOW = 3
|
||||
private const val PERCENT = 100L
|
||||
private const val MONTHS_PER_YEAR = 12
|
||||
|
||||
/** A month as a count of months since year 0, so neighbours are ±1. */
|
||||
private fun monthIndex(period: String): Int = period.substring(0, 4).toInt() * MONTHS_PER_YEAR + period.substring(5, 7).toInt() - 1
|
||||
|
||||
private fun period(index: Int): String = "${index / MONTHS_PER_YEAR}-${(index % MONTHS_PER_YEAR + 1).toString().padStart(2, '0')}"
|
||||
|
||||
private fun divHalfUp(a: Long, b: Long): Long {
|
||||
val q = a / b
|
||||
val r = a % b
|
||||
return if (2 * abs(r) >= abs(b)) q + a.sign * b.sign else q
|
||||
}
|
||||
|
||||
private fun FakeScenario.measured(e: FakeExpense, measure: Measure): Long =
|
||||
if (measure == Measure.HOUSEHOLD) e.amountOre else e.shares.firstOrNull { it.userId == me.id }?.shareOre ?: 0L
|
||||
|
||||
private fun FakeScenario.trends(months: Int, measure: Measure): SpendTrends {
|
||||
val current = monthIndex(today)
|
||||
val whole = (current - months until current).map(::period)
|
||||
val live = expenses.filter { !it.deleted && it.date <= today }
|
||||
fun trend(of: List<FakeExpense>): SpendTrend {
|
||||
val byMonth = of.groupBy { it.date.substring(0, 7) }.mapValues { (_, list) -> list.sumOf { measured(it, measure) } }
|
||||
val amounts = whole.map { byMonth[it] ?: 0L }
|
||||
val total = amounts.sum()
|
||||
val average = divHalfUp(total, months.toLong())
|
||||
val last = amounts.last()
|
||||
val points = whole.mapIndexed { i, month ->
|
||||
val slice = amounts.subList((i - WINDOW + 1).coerceAtLeast(0), i + 1)
|
||||
TrendPoint(month, amounts[i], divHalfUp(slice.sum(), slice.size.toLong()))
|
||||
}
|
||||
return SpendTrend(
|
||||
totalOre = total,
|
||||
averageOre = average,
|
||||
lastMonthOre = last,
|
||||
lastMonthVsAveragePct = if (average == 0L) null else divHalfUp((last - average) * PERCENT, average).toInt(),
|
||||
currentMonthOre = byMonth[period(current)] ?: 0L,
|
||||
points = points,
|
||||
)
|
||||
}
|
||||
val inScope = live.filter { monthIndex(it.date) in current - months..current }
|
||||
val categoryTrends = inScope.groupBy { it.categoryId }
|
||||
.map { (id, list) -> CategoryTrend(id, category(id), trend(list)) }
|
||||
.filter { it.trend.totalOre != 0L || it.trend.currentMonthOre != 0L }
|
||||
.sortedWith(compareByDescending<CategoryTrend> { it.trend.totalOre }.thenBy { it.categoryId.value })
|
||||
return SpendTrends(measure, today, period(current), months, trend(live), categoryTrends)
|
||||
}
|
||||
|
||||
private fun RecurringTemplate.dueIn(month: String): Boolean = !paused && startPeriod <= month && endPeriod.let { it == null || month <= it }
|
||||
|
||||
private fun FakeScenario.recurringAmount(t: RecurringTemplate, measure: Measure): Long {
|
||||
if (measure == Measure.HOUSEHOLD) return t.draft.amountOre ?: 0L
|
||||
val shares = (split(t.draft) as? SplitOutcome.Ok)?.shares.orEmpty()
|
||||
return shares.firstOrNull { it.userId == me.id }?.shareOre ?: 0L
|
||||
}
|
||||
|
||||
private fun FakeScenario.forecast(measure: Measure): SpendForecast {
|
||||
val current = monthIndex(today)
|
||||
val live = expenses.filter { !it.deleted && it.date <= today }
|
||||
val forecasts = accountsInOrder().map { account ->
|
||||
val own = live.filter { it.accountId == account.id }
|
||||
val window = own.filter { it.recurringTemplateId == null && monthIndex(it.date) in current - WINDOW until current }
|
||||
val baseline = divHalfUp(window.sumOf { measured(it, measure) }, WINDOW.toLong())
|
||||
val templates = templates.filter { it.draft.accountId == account.id }
|
||||
AccountForecast(
|
||||
accountId = account.id,
|
||||
account = account,
|
||||
currentMonthOre = own.filter { monthIndex(it.date) == current }.sumOf { measured(it, measure) },
|
||||
months = (1..2).map { ahead ->
|
||||
val month = period(current + ahead)
|
||||
val recurring = templates.filter { it.dueIn(month) }.sumOf { recurringAmount(it, measure) }
|
||||
ForecastMonth(month, recurring, baseline, recurring + baseline)
|
||||
},
|
||||
)
|
||||
}
|
||||
return SpendForecast(measure, today, period(current), forecasts)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ class FakeRepositories(scenario: FakeScenario = FakeScenarios.empty) {
|
||||
val expenses = FakeExpenseRepository(store)
|
||||
val settlements = FakeSettlementRepository(store)
|
||||
val analytics = FakeAnalyticsRepository(store)
|
||||
val insights = FakeInsightsRepository(store)
|
||||
val profile = FakeProfileRepository(store)
|
||||
val categories = FakeCategoryRepository(store)
|
||||
val recurring = FakeRecurringRepository(store)
|
||||
|
||||
@@ -66,6 +66,8 @@ data class FakeSettlement(
|
||||
* per account on this phone.
|
||||
* @property lastAttemptFailed the last sync cycle failed (`SyncSummary.lastAttemptFailed`, false while one runs).
|
||||
* @property pairingInviteKind the invite kind `pair` reports; null: `partner` for a member, `admin_setup` for the admin.
|
||||
* @property insightsFailure the code the Insights reads fail with while online (`network`: the server could not be
|
||||
* reached); null: they succeed (D-141).
|
||||
*/
|
||||
data class FakeScenario(
|
||||
val me: User,
|
||||
@@ -92,6 +94,7 @@ data class FakeScenario(
|
||||
val lastCategoryFor: Map<AccountId, CategoryId>,
|
||||
val lastAttemptFailed: Boolean = false,
|
||||
val pairingInviteKind: InviteKind? = null,
|
||||
val insightsFailure: String? = null,
|
||||
) {
|
||||
val users: List<User> get() = listOfNotNull(me, partner)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package se.hagstedt.kvitt.fakes.analytics
|
||||
|
||||
import se.hagstedt.kvitt.fakes.FakeScenario
|
||||
import se.hagstedt.kvitt.fakes.FakeScenarios
|
||||
import se.hagstedt.kvitt.model.Account
|
||||
import se.hagstedt.kvitt.model.AccountId
|
||||
import se.hagstedt.kvitt.model.Category
|
||||
import se.hagstedt.kvitt.model.ExpenseDraft
|
||||
import se.hagstedt.kvitt.model.RecurringTemplate
|
||||
import se.hagstedt.kvitt.model.SplitMode
|
||||
import se.hagstedt.kvitt.model.TemplateId
|
||||
|
||||
/**
|
||||
* Scenarios of S-11's Insights section (D-133, D-141), on [AnalyticsScenarios]' household (today 2026-10-17, so the
|
||||
* whole months are April to September 2026 and the forecast is November and December).
|
||||
*
|
||||
* - [loaded]: [AnalyticsScenarios.loaded] with three templates (rent and home insurance on Huset, the insurance ending
|
||||
* in November so December differs; a paused SL card on Vardag, never forecast), the rent and insurance expenses
|
||||
* generated from them (so the baseline leaves them out), and a new account "Bilen" without history.
|
||||
* - [emptyHousehold]: two accounts and no expenses or templates: zero trends, no categories, a zero forecast.
|
||||
* - [offline]: [loaded] while offline: the reads report Offline (§9.9).
|
||||
* - [unreachable]: [loaded] online, but the server cannot be reached (`Failed("network")`).
|
||||
* - [long]: [AnalyticsScenarios.long] with a template on its long-named account.
|
||||
*/
|
||||
object InsightsScenarios {
|
||||
private val me = FakeScenarios.owner.id
|
||||
|
||||
private val rent = template(0x01, "Hyra", AnalyticsScenarios.huset, AnalyticsScenarios.housing, 1_033_300, 3, endPeriod = null)
|
||||
private val insurance = template(0x02, "Hemförsäkring", AnalyticsScenarios.huset, AnalyticsScenarios.insurance, 32_900, 5, endPeriod = "2026-11")
|
||||
private val travelCard = template(0x03, "SL-kort", AnalyticsScenarios.vardag, AnalyticsScenarios.transport, 98_000, 14, endPeriod = null, paused = true)
|
||||
|
||||
/** A new account with no expenses: its forecast is zero (no recurring, no history). */
|
||||
val bilen = Account(AccountId("01920000-0000-7000-8000-000000000a07"), "Bilen", mapOf(me to 5000, FakeScenarios.partner.id to 5000), "2026-10-01T09:00:00.000Z", archived = false)
|
||||
|
||||
val loaded: FakeScenario = AnalyticsScenarios.loaded.let { base ->
|
||||
base.copy(
|
||||
accounts = base.accounts + bilen,
|
||||
templates = listOf(rent, insurance, travelCard),
|
||||
expenses = base.expenses.map { e ->
|
||||
when (e.description) {
|
||||
"Hyra" -> e.copy(recurringTemplateId = rent.id)
|
||||
"Hemförsäkring" -> e.copy(recurringTemplateId = insurance.id)
|
||||
else -> e
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val emptyHousehold: FakeScenario = FakeScenarios.empty.copy(accounts = listOf(AnalyticsScenarios.vardag, bilen))
|
||||
|
||||
val offline: FakeScenario = loaded.copy(online = false)
|
||||
|
||||
val unreachable: FakeScenario = loaded.copy(insightsFailure = "network")
|
||||
|
||||
val long: FakeScenario = AnalyticsScenarios.long.let { base ->
|
||||
val account = base.accounts[1]
|
||||
base.copy(templates = listOf(template(0x04, "Arrende för sommarstugan", account, base.categories.first(), 1_250_000, 28, endPeriod = null)))
|
||||
}
|
||||
|
||||
private fun template(n: Int, description: String, account: Account, category: Category, ore: Long, day: Int, endPeriod: String?, paused: Boolean = false) =
|
||||
RecurringTemplate(
|
||||
id = TemplateId("01920000-0000-7000-8000-0000000007" + n.toString(16).padStart(2, '0')),
|
||||
draft = ExpenseDraft(
|
||||
id = null,
|
||||
accountId = account.id,
|
||||
categoryId = category.id,
|
||||
amountOre = ore,
|
||||
description = description,
|
||||
notes = "",
|
||||
date = "2026-10-17",
|
||||
payer = me,
|
||||
mode = SplitMode.DEFAULT,
|
||||
ratioBp = null,
|
||||
exactOre = null,
|
||||
fullOn = null,
|
||||
),
|
||||
dayOfMonth = day,
|
||||
startPeriod = "2025-01",
|
||||
endPeriod = endPeriod,
|
||||
paused = paused,
|
||||
nextOccurrence = null,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package se.hagstedt.kvitt.fakes
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import se.hagstedt.kvitt.fakes.analytics.AnalyticsScenarios
|
||||
import se.hagstedt.kvitt.fakes.analytics.InsightsScenarios
|
||||
import se.hagstedt.kvitt.model.Measure
|
||||
import se.hagstedt.kvitt.model.OnlineResult
|
||||
import se.hagstedt.kvitt.model.SpendForecast
|
||||
import se.hagstedt.kvitt.model.SpendTrends
|
||||
|
||||
/** The Insights fake (D-141) follows E-34's and E-35's rules over the scenarios (today 2026-10-17). */
|
||||
class FakeInsightsRepositoryTest {
|
||||
private suspend fun trends(scenario: FakeScenario, measure: Measure = Measure.HOUSEHOLD): SpendTrends =
|
||||
(FakeRepositories(scenario).insights.trends(6, measure) as OnlineResult.Ok).value
|
||||
|
||||
private suspend fun forecast(scenario: FakeScenario, measure: Measure = Measure.HOUSEHOLD): SpendForecast =
|
||||
(FakeRepositories(scenario).insights.forecast(measure) as OnlineResult.Ok).value
|
||||
|
||||
@Test
|
||||
fun trendsCoverTheWholeMonthsBeforeTheCurrentOneWithEmptyMonthsAsZero() = runTest {
|
||||
val t = trends(InsightsScenarios.loaded)
|
||||
assertEquals("2026-10", t.currentMonth)
|
||||
assertEquals(listOf("2026-04", "2026-05", "2026-06", "2026-07", "2026-08", "2026-09"), t.household.points.map { it.month })
|
||||
assertEquals(0L, t.household.points.first().amountOre)
|
||||
assertEquals(t.household.points.sumOf { it.amountOre }, t.household.totalOre)
|
||||
assertEquals((t.household.totalOre * 2 + 6) / 12, t.household.averageOre)
|
||||
assertEquals(t.household.points.last().amountOre, t.household.lastMonthOre)
|
||||
assertTrue(t.household.currentMonthOre > 0)
|
||||
// Sorted by total, rent first; categories spent only this month are listed too.
|
||||
assertEquals(AnalyticsScenarios.housing.id, t.categories.first().categoryId)
|
||||
assertEquals(t.categories.sortedByDescending { it.trend.totalOre }.map { it.trend.totalOre }, t.categories.map { it.trend.totalOre })
|
||||
assertTrue(t.categories.all { it.category != null })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun movingAverageIsTrailingOverThreeMonths() = runTest {
|
||||
val p = trends(InsightsScenarios.loaded).household.points
|
||||
assertEquals(p[0].amountOre, p[0].movingAverageOre)
|
||||
val last = p.takeLast(3).sumOf { it.amountOre }
|
||||
assertEquals((last * 2 + 3) / 6, p.last().movingAverageOre)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun forecastIsTemplatesDuePlusTheThreeMonthBaselineOfOtherSpend() = runTest {
|
||||
val f = forecast(InsightsScenarios.loaded)
|
||||
assertEquals(listOf("2026-11", "2026-12"), f.accounts.first().months.map { it.month })
|
||||
val huset = f.accounts.single { it.accountId == AnalyticsScenarios.huset.id }
|
||||
// Rent and insurance in November, rent only in December (the insurance ends in November).
|
||||
assertEquals(listOf(1_066_200L, 1_033_300L), huset.months.map { it.recurringOre })
|
||||
// Electricity July to September (127 050 + 131 200 + 135 350) ÷ 3; the generated rent and insurance are left out.
|
||||
assertEquals(listOf(131_200L, 131_200L), huset.months.map { it.baselineOre })
|
||||
assertTrue(huset.months.all { it.forecastOre == it.recurringOre + it.baselineOre })
|
||||
// The paused SL card is never forecast; the new account has nothing at all.
|
||||
assertTrue(f.accounts.single { it.accountId == AnalyticsScenarios.vardag.id }.months.all { it.recurringOre == 0L })
|
||||
val bilen = f.accounts.single { it.accountId == InsightsScenarios.bilen.id }
|
||||
assertEquals(0L, bilen.currentMonthOre)
|
||||
assertTrue(bilen.months.all { it.forecastOre == 0L })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun myShareUsesTheSharesAGenerationWouldWrite() = runTest {
|
||||
val huset = forecast(InsightsScenarios.loaded, Measure.MY_SHARE).accounts.single { it.accountId == AnalyticsScenarios.huset.id }
|
||||
assertEquals(516_650L + 16_450L, huset.months.first().recurringOre)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anEmptyHouseholdHasZeroTrendsAndNoCategories() = runTest {
|
||||
val t = trends(InsightsScenarios.emptyHousehold)
|
||||
assertEquals(0L, t.household.totalOre)
|
||||
assertNull(t.household.lastMonthVsAveragePct)
|
||||
assertTrue(t.categories.isEmpty())
|
||||
assertEquals(2, forecast(InsightsScenarios.emptyHousehold).accounts.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun offlineAndUnreachableFailLikeTheServerWould() = runTest {
|
||||
assertEquals(OnlineResult.Offline, FakeRepositories(InsightsScenarios.offline).insights.trends(6, Measure.HOUSEHOLD))
|
||||
assertEquals(OnlineResult.Failed("network", null), FakeRepositories(InsightsScenarios.unreachable).insights.forecast(Measure.HOUSEHOLD))
|
||||
assertEquals(OnlineResult.Failed("invalid_request", "months"), FakeRepositories(InsightsScenarios.loaded).insights.trends(25, Measure.HOUSEHOLD))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package se.hagstedt.kvitt.model
|
||||
|
||||
// Insights (S-11's Insights section, D-133, D-141), SPEC §14.2.4: the server's advanced analytics, E-34
|
||||
// `GET /analytics/trends` and E-35 `GET /analytics/forecast`, online only (§9.9). Nothing is stored on the phone.
|
||||
// Months are `YYYY-MM` (Europe/Stockholm); amounts are öre for the requested measure.
|
||||
|
||||
/** One whole month of a trend: its spend and the trailing moving average over 3 months (the first months average what exists, D-128). */
|
||||
data class TrendPoint(val month: String, val amountOre: Long, val movingAverageOre: Long)
|
||||
|
||||
/**
|
||||
* The E-34 `Trend` of the household or of one category over the whole months before the current one: [averageOre] is
|
||||
* [totalOre] ÷ months half-up (empty months count); [lastMonthVsAveragePct] the last whole month against the average in
|
||||
* whole percent of the average (null when the average is 0); [currentMonthOre] the current month so far (not a point).
|
||||
*/
|
||||
data class SpendTrend(
|
||||
val totalOre: Long,
|
||||
val averageOre: Long,
|
||||
val lastMonthOre: Long,
|
||||
val lastMonthVsAveragePct: Int?,
|
||||
val currentMonthOre: Long,
|
||||
val points: List<TrendPoint>,
|
||||
)
|
||||
|
||||
/** [category] from the phone's copy, archived and deleted categories included; null when the phone does not hold the row. */
|
||||
data class CategoryTrend(val categoryId: CategoryId, val category: Category?, val trend: SpendTrend)
|
||||
|
||||
/** E-34: [months] whole months before [currentMonth]; [categories] by total descending, then id (the server's order). */
|
||||
data class SpendTrends(
|
||||
val measure: Measure,
|
||||
val today: String,
|
||||
val currentMonth: String,
|
||||
val months: Int,
|
||||
val household: SpendTrend,
|
||||
val categories: List<CategoryTrend>,
|
||||
)
|
||||
|
||||
/** One of the two calendar months after the current one: recurring templates due + the 3-month baseline (D-133). */
|
||||
data class ForecastMonth(val month: String, val recurringOre: Long, val baselineOre: Long, val forecastOre: Long)
|
||||
|
||||
/** [account] from the phone's copy (null when the phone does not hold the row); [months] the next two months in order. */
|
||||
data class AccountForecast(val accountId: AccountId, val account: Account?, val currentMonthOre: Long, val months: List<ForecastMonth>)
|
||||
|
||||
/** E-35: every non-deleted account, archived included, by `created_at`, then id (the server's order). */
|
||||
data class SpendForecast(val measure: Measure, val today: String, val currentMonth: String, val accounts: List<AccountForecast>)
|
||||
|
||||
/**
|
||||
* The Insights reads (D-141). Online only: [OnlineResult.Offline] without a request when offline (§9.9),
|
||||
* `Failed("network")` when the server could not be reached, else the §14.3.2 code. [trends] takes 1 to 24 months.
|
||||
*/
|
||||
interface InsightsRepository {
|
||||
suspend fun trends(months: Int, measure: Measure): OnlineResult<SpendTrends>
|
||||
suspend fun forecast(measure: Measure): OnlineResult<SpendForecast>
|
||||
}
|
||||
@@ -74,4 +74,22 @@ class ContractTest {
|
||||
)
|
||||
.forEach { assertEquals(it, CategoryIcons.ALL.single { icon -> icon == it }) }
|
||||
}
|
||||
|
||||
/** D-141: the Insights types carry E-34's `Trend` and E-35's `AccountForecast` fields by name; the percent is nullable. */
|
||||
@Test
|
||||
fun insightsTypesCarryTheE34AndE35Fields() {
|
||||
val trend = SpendTrend(
|
||||
totalOre = 0,
|
||||
averageOre = 0,
|
||||
lastMonthOre = 0,
|
||||
lastMonthVsAveragePct = null,
|
||||
currentMonthOre = 0,
|
||||
points = listOf(TrendPoint(month = "2026-09", amountOre = 0, movingAverageOre = 0)),
|
||||
)
|
||||
val trends = SpendTrends(Measure.MY_SHARE, today = "2026-10-17", currentMonth = "2026-10", months = 1, household = trend, categories = emptyList())
|
||||
val month = ForecastMonth(month = "2026-11", recurringOre = 4_900, baselineOre = 40_000, forecastOre = 44_900)
|
||||
val forecast = SpendForecast(Measure.HOUSEHOLD, "2026-10-17", "2026-10", listOf(AccountForecast(AccountId("a"), null, currentMonthOre = 0, months = listOf(month))))
|
||||
assertEquals(null, trends.household.lastMonthVsAveragePct)
|
||||
assertEquals(month.recurringOre + month.baselineOre, forecast.accounts.single().months.single().forecastOre)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package se.hagstedt.kvitt.data.api
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
// Response bodies of the advanced analytics, E-34 and E-35 (D-133, `contracts/openapi.yaml` TrendsResponse, ForecastResponse).
|
||||
|
||||
/** E-34 `TrendPoint`: `month` is `YYYY-MM`. */
|
||||
@Serializable
|
||||
data class TrendPointDto(
|
||||
val month: String,
|
||||
@SerialName("amount_ore") val amountOre: Long,
|
||||
@SerialName("moving_average_ore") val movingAverageOre: Long,
|
||||
)
|
||||
|
||||
/** E-34 `Trend`; `last_month_vs_average_pct` is always present, null when the average is 0. */
|
||||
@Serializable
|
||||
data class TrendDto(
|
||||
@SerialName("total_ore") val totalOre: Long,
|
||||
@SerialName("average_ore") val averageOre: Long,
|
||||
@SerialName("last_month_ore") val lastMonthOre: Long,
|
||||
@SerialName("last_month_vs_average_pct") val lastMonthVsAveragePct: Int?,
|
||||
@SerialName("current_month_ore") val currentMonthOre: Long,
|
||||
val points: List<TrendPointDto>,
|
||||
)
|
||||
|
||||
/** E-34 `CategoryTrend`. */
|
||||
@Serializable
|
||||
data class CategoryTrendDto(
|
||||
@SerialName("category_id") val categoryId: String,
|
||||
val trend: TrendDto,
|
||||
)
|
||||
|
||||
/** E-34 response. */
|
||||
@Serializable
|
||||
data class TrendsResponse(
|
||||
val measure: String,
|
||||
val today: String,
|
||||
@SerialName("current_month") val currentMonth: String,
|
||||
val months: Int,
|
||||
val household: TrendDto,
|
||||
val categories: List<CategoryTrendDto>,
|
||||
)
|
||||
|
||||
/** E-35 `ForecastMonth`. */
|
||||
@Serializable
|
||||
data class ForecastMonthDto(
|
||||
val month: String,
|
||||
@SerialName("recurring_ore") val recurringOre: Long,
|
||||
@SerialName("baseline_ore") val baselineOre: Long,
|
||||
@SerialName("forecast_ore") val forecastOre: Long,
|
||||
)
|
||||
|
||||
/** E-35 `AccountForecast`. */
|
||||
@Serializable
|
||||
data class AccountForecastDto(
|
||||
@SerialName("account_id") val accountId: String,
|
||||
@SerialName("current_month_ore") val currentMonthOre: Long,
|
||||
val months: List<ForecastMonthDto>,
|
||||
)
|
||||
|
||||
/** E-35 response. */
|
||||
@Serializable
|
||||
data class ForecastResponse(
|
||||
val measure: String,
|
||||
val today: String,
|
||||
@SerialName("current_month") val currentMonth: String,
|
||||
val accounts: List<AccountForecastDto>,
|
||||
)
|
||||
@@ -135,6 +135,15 @@ class KvittApi(
|
||||
suspend fun proposal(request: ProposalRequest): ApiResult<ProposalResponse> =
|
||||
authed(HttpMethod.Post, "/settlements/proposal") { jsonBody(request) }
|
||||
|
||||
// E-34, E-35 (D-133): `measure` is the wire value `household` or `my_share`.
|
||||
suspend fun analyticsTrends(months: Int, measure: String): ApiResult<TrendsResponse> = authed(HttpMethod.Get, "/analytics/trends") {
|
||||
parameter("months", months)
|
||||
parameter("measure", measure)
|
||||
}
|
||||
suspend fun analyticsForecast(measure: String): ApiResult<ForecastResponse> = authed(HttpMethod.Get, "/analytics/forecast") {
|
||||
parameter("measure", measure)
|
||||
}
|
||||
|
||||
private suspend inline fun <reified T> authed(
|
||||
method: HttpMethod,
|
||||
path: String,
|
||||
|
||||
@@ -46,6 +46,7 @@ import se.hagstedt.kvitt.data.repo.CategoryRepositoryImpl
|
||||
import se.hagstedt.kvitt.data.repo.DeviceRepositoryImpl
|
||||
import se.hagstedt.kvitt.data.repo.ExpenseRepositoryImpl
|
||||
import se.hagstedt.kvitt.data.repo.HomeRepositoryImpl
|
||||
import se.hagstedt.kvitt.data.repo.InsightsRepositoryImpl
|
||||
import se.hagstedt.kvitt.data.repo.InviteRepositoryImpl
|
||||
import se.hagstedt.kvitt.data.repo.LocalData
|
||||
import se.hagstedt.kvitt.data.repo.PhonePrefs
|
||||
@@ -75,6 +76,7 @@ import se.hagstedt.kvitt.model.ConnectivityRepository
|
||||
import se.hagstedt.kvitt.model.DeviceRepository
|
||||
import se.hagstedt.kvitt.model.ExpenseRepository
|
||||
import se.hagstedt.kvitt.model.HomeRepository
|
||||
import se.hagstedt.kvitt.model.InsightsRepository
|
||||
import se.hagstedt.kvitt.model.InviteRepository
|
||||
import se.hagstedt.kvitt.model.ProfileRepository
|
||||
import se.hagstedt.kvitt.model.RecurringRepository
|
||||
@@ -266,6 +268,9 @@ object DataModule {
|
||||
@Provides @Singleton
|
||||
fun analytics(local: LocalData, clock: Clock): AnalyticsRepositoryImpl = AnalyticsRepositoryImpl(local, clock)
|
||||
|
||||
@Provides @Singleton
|
||||
fun insights(local: LocalData, api: KvittApi, gate: OnlineGate): InsightsRepositoryImpl = InsightsRepositoryImpl(local, api, gate)
|
||||
|
||||
@Provides @Singleton
|
||||
fun profile(local: LocalData, api: KvittApi, gate: OnlineGate, db: KvittDatabase, clock: Clock): ProfileRepositoryImpl =
|
||||
ProfileRepositoryImpl(local, api, gate, db, clock)
|
||||
@@ -303,6 +308,7 @@ abstract class DataBindingsModule {
|
||||
@Binds abstract fun expenses(impl: ExpenseRepositoryImpl): ExpenseRepository
|
||||
@Binds abstract fun settlements(impl: SettlementRepositoryImpl): SettlementRepository
|
||||
@Binds abstract fun analytics(impl: AnalyticsRepositoryImpl): AnalyticsRepository
|
||||
@Binds abstract fun insights(impl: InsightsRepositoryImpl): InsightsRepository
|
||||
@Binds abstract fun profile(impl: ProfileRepositoryImpl): ProfileRepository
|
||||
@Binds abstract fun categories(impl: CategoryRepositoryImpl): CategoryRepository
|
||||
@Binds abstract fun recurring(impl: RecurringRepositoryImpl): RecurringRepository
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package se.hagstedt.kvitt.data.repo
|
||||
|
||||
import kotlinx.coroutines.flow.first
|
||||
import se.hagstedt.kvitt.data.api.AccountForecastDto
|
||||
import se.hagstedt.kvitt.data.api.ErrorCodes
|
||||
import se.hagstedt.kvitt.data.api.ForecastResponse
|
||||
import se.hagstedt.kvitt.data.api.KvittApi
|
||||
import se.hagstedt.kvitt.data.api.TrendDto
|
||||
import se.hagstedt.kvitt.data.api.TrendsResponse
|
||||
import se.hagstedt.kvitt.data.connectivity.OnlineGate
|
||||
import se.hagstedt.kvitt.model.AccountForecast
|
||||
import se.hagstedt.kvitt.model.AccountId
|
||||
import se.hagstedt.kvitt.model.CategoryId
|
||||
import se.hagstedt.kvitt.model.CategoryTrend
|
||||
import se.hagstedt.kvitt.model.ForecastMonth
|
||||
import se.hagstedt.kvitt.model.InsightsRepository
|
||||
import se.hagstedt.kvitt.model.Measure
|
||||
import se.hagstedt.kvitt.model.OnlineResult
|
||||
import se.hagstedt.kvitt.model.SpendForecast
|
||||
import se.hagstedt.kvitt.model.SpendTrend
|
||||
import se.hagstedt.kvitt.model.SpendTrends
|
||||
import se.hagstedt.kvitt.model.TrendPoint
|
||||
|
||||
/**
|
||||
* S-11's Insights (D-133, D-141): E-34 and E-35 through [OnlineGate] (§9.9: offline without a request; a network
|
||||
* failure is `Failed("network")`). Nothing is stored. Category and account ids are resolved from the phone's copy,
|
||||
* deleted rows included, so history still has names; an id the phone does not hold maps to null.
|
||||
*/
|
||||
class InsightsRepositoryImpl(private val local: LocalData, private val api: KvittApi, private val gate: OnlineGate) : InsightsRepository {
|
||||
override suspend fun trends(months: Int, measure: Measure): OnlineResult<SpendTrends> {
|
||||
if (months !in MONTHS) return OnlineResult.Failed(ErrorCodes.INVALID_REQUEST, "months")
|
||||
return gate.run({ api.analyticsTrends(months, measure.wire()) }) { it.toModel(local.snapshot.first(), measure) }
|
||||
}
|
||||
|
||||
override suspend fun forecast(measure: Measure): OnlineResult<SpendForecast> =
|
||||
gate.run({ api.analyticsForecast(measure.wire()) }) { it.toModel(local.snapshot.first(), measure) }
|
||||
|
||||
companion object {
|
||||
/** E-34 `months`: 1 to 24 (`invalid_request` otherwise). */
|
||||
val MONTHS = 1..24
|
||||
}
|
||||
}
|
||||
|
||||
private fun Measure.wire(): String = name.lowercase()
|
||||
|
||||
/** The response's measure; an unknown wire value from a newer server reads as the one requested. */
|
||||
private fun measureOf(wire: String, requested: Measure): Measure = Measure.entries.firstOrNull { it.name.lowercase() == wire } ?: requested
|
||||
|
||||
internal fun TrendsResponse.toModel(s: Snapshot, requested: Measure): SpendTrends = SpendTrends(
|
||||
measure = measureOf(measure, requested),
|
||||
today = today,
|
||||
currentMonth = currentMonth,
|
||||
months = months,
|
||||
household = household.toModel(),
|
||||
categories = categories.map { CategoryTrend(CategoryId(it.categoryId), s.category(it.categoryId), it.trend.toModel()) },
|
||||
)
|
||||
|
||||
private fun TrendDto.toModel(): SpendTrend = SpendTrend(
|
||||
totalOre = totalOre,
|
||||
averageOre = averageOre,
|
||||
lastMonthOre = lastMonthOre,
|
||||
lastMonthVsAveragePct = lastMonthVsAveragePct,
|
||||
currentMonthOre = currentMonthOre,
|
||||
points = points.map { TrendPoint(it.month, it.amountOre, it.movingAverageOre) },
|
||||
)
|
||||
|
||||
internal fun ForecastResponse.toModel(s: Snapshot, requested: Measure): SpendForecast = SpendForecast(
|
||||
measure = measureOf(measure, requested),
|
||||
today = today,
|
||||
currentMonth = currentMonth,
|
||||
accounts = accounts.map { it.toModel(s) },
|
||||
)
|
||||
|
||||
private fun AccountForecastDto.toModel(s: Snapshot): AccountForecast = AccountForecast(
|
||||
accountId = AccountId(accountId),
|
||||
account = s.account(accountId),
|
||||
currentMonthOre = currentMonthOre,
|
||||
months = months.map { ForecastMonth(it.month, it.recurringOre, it.baselineOre, it.forecastOre) },
|
||||
)
|
||||
@@ -88,6 +88,8 @@ internal fun serializerFor(path: String): KSerializer<*>? {
|
||||
31 -> PullPage.serializer()
|
||||
32 -> if (request) PushRequest.serializer() else PushResponse.serializer()
|
||||
33 -> if (request) ProposalRequest.serializer() else ProposalResponse.serializer()
|
||||
34 -> TrendsResponse.serializer()
|
||||
35 -> ForecastResponse.serializer()
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package se.hagstedt.kvitt.data.repo
|
||||
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.http.HttpMethod
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import se.hagstedt.kvitt.data.Harness
|
||||
import se.hagstedt.kvitt.data.Harness.Companion.ACCOUNT
|
||||
import se.hagstedt.kvitt.data.Harness.Companion.GROCERIES
|
||||
import se.hagstedt.kvitt.data.api.ErrorCodes
|
||||
import se.hagstedt.kvitt.data.api.ErrorDto
|
||||
import se.hagstedt.kvitt.data.api.KvittApi
|
||||
import se.hagstedt.kvitt.data.api.KvittJson
|
||||
import se.hagstedt.kvitt.data.api.examplesDir
|
||||
import se.hagstedt.kvitt.data.runSync
|
||||
import se.hagstedt.kvitt.model.AccountForecast
|
||||
import se.hagstedt.kvitt.model.AccountId
|
||||
import se.hagstedt.kvitt.model.CategoryId
|
||||
import se.hagstedt.kvitt.model.DefaultCategoryKey
|
||||
import se.hagstedt.kvitt.model.ForecastMonth
|
||||
import se.hagstedt.kvitt.model.Measure
|
||||
import se.hagstedt.kvitt.model.OnlineResult
|
||||
import se.hagstedt.kvitt.model.SpendForecast
|
||||
import se.hagstedt.kvitt.model.SpendTrend
|
||||
import se.hagstedt.kvitt.model.SpendTrends
|
||||
import se.hagstedt.kvitt.model.TrendPoint
|
||||
import java.io.File
|
||||
|
||||
/** D-141: E-34 and E-35 through `InsightsRepositoryImpl`, mapped from the committed examples (§14.3.5), online only (§9.9). */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [36])
|
||||
class InsightsRepositoryTest {
|
||||
private lateinit var h: Harness
|
||||
private lateinit var repo: InsightsRepositoryImpl
|
||||
|
||||
private fun example(dir: String) = File(examplesDir(), "$dir/response.json").readText()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
h = Harness().ready()
|
||||
repo = InsightsRepositoryImpl(h.local, h.api, h.gate)
|
||||
h.server.restHandler = { _, path ->
|
||||
when (path) {
|
||||
"/analytics/trends" -> HttpStatusCode.OK to example("E-34-analytics-trends")
|
||||
"/analytics/forecast" -> HttpStatusCode.OK to example("E-35-analytics-forecast")
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() = h.close()
|
||||
|
||||
@Test
|
||||
fun trendsMapTheE34ExampleAndResolveCategoriesFromThePhone() = runSync {
|
||||
val result = repo.trends(3, Measure.HOUSEHOLD)
|
||||
val trends = (result as OnlineResult.Ok).value
|
||||
val request = h.requests.single()
|
||||
assertEquals(HttpMethod.Get, request.method)
|
||||
assertEquals("/analytics/trends", request.url.encodedPath)
|
||||
assertEquals("3", request.url.parameters["months"])
|
||||
assertEquals("household", request.url.parameters["measure"])
|
||||
assertEquals("Bearer ${Harness.TOKEN}", request.headers[HttpHeaders.Authorization])
|
||||
assertEquals("1", request.headers[KvittApi.PROTOCOL_HEADER])
|
||||
assertEquals(Harness.APP_KEY, request.headers[KvittApi.APP_KEY_HEADER])
|
||||
|
||||
val household = SpendTrend(
|
||||
totalOre = 153_000, averageOre = 51_000, lastMonthOre = 78_000, lastMonthVsAveragePct = 53, currentMonthOre = 12_000,
|
||||
points = listOf(TrendPoint("2026-07", 40_000, 40_000), TrendPoint("2026-08", 35_000, 37_500), TrendPoint("2026-09", 78_000, 51_000)),
|
||||
)
|
||||
val expected = SpendTrends(Measure.HOUSEHOLD, "2026-10-17", "2026-10", 3, household, trends.categories)
|
||||
assertEquals(expected, trends)
|
||||
assertEquals(listOf(CategoryId(GROCERIES), CategoryId("01920000-0000-7000-8000-000000000003")), trends.categories.map { it.categoryId })
|
||||
assertEquals(DefaultCategoryKey.GROCERIES, trends.categories[0].category?.defaultKey)
|
||||
assertNull("a category the phone does not hold has no row", trends.categories[1].category)
|
||||
assertEquals(103, trends.categories[0].trend.lastMonthVsAveragePct)
|
||||
assertEquals(-25, trends.categories[1].trend.lastMonthVsAveragePct)
|
||||
assertEquals(listOf(30_000L, 0L, 63_000L), trends.categories[0].trend.points.map { it.amountOre })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun forecastMapsTheE35ExampleWithTheRequestedMeasure() = runSync {
|
||||
val forecast = (repo.forecast(Measure.MY_SHARE) as OnlineResult.Ok).value
|
||||
val request = h.requests.single()
|
||||
assertEquals("/analytics/forecast", request.url.encodedPath)
|
||||
assertEquals("my_share", request.url.parameters["measure"])
|
||||
assertNull("E-35 takes no months", request.url.parameters["months"])
|
||||
// The example says household; the response's measure wins over the requested one.
|
||||
assertEquals(Measure.HOUSEHOLD, forecast.measure)
|
||||
val first = AccountForecast(
|
||||
AccountId(ACCOUNT),
|
||||
forecast.accounts[0].account,
|
||||
12_000,
|
||||
listOf(ForecastMonth("2026-11", 4_900, 40_000, 44_900), ForecastMonth("2026-12", 0, 40_000, 40_000)),
|
||||
)
|
||||
assertEquals(SpendForecast(Measure.HOUSEHOLD, "2026-10-17", "2026-10", forecast.accounts), forecast)
|
||||
assertEquals(first, forecast.accounts[0])
|
||||
assertEquals("Vardag", forecast.accounts[0].account?.name)
|
||||
assertEquals(listOf(null, null), forecast.accounts.drop(1).map { it.account })
|
||||
assertEquals(listOf(16_000L, 16_000L), forecast.accounts[1].months.map { it.forecastOre })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun offlineIsOfflineWithoutARequest() = runSync {
|
||||
h.monitor.state.value = false
|
||||
assertEquals(OnlineResult.Offline, repo.trends(6, Measure.HOUSEHOLD))
|
||||
assertEquals(OnlineResult.Offline, repo.forecast(Measure.HOUSEHOLD))
|
||||
assertTrue(h.requests.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aNetworkFailureIsFailedNetwork() = runSync {
|
||||
h.server.failNetwork = true
|
||||
assertEquals(OnlineResult.Failed(ErrorCodes.NETWORK, null), repo.trends(6, Measure.HOUSEHOLD))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun serverErrorsKeepTheirCodeAndField() = runSync {
|
||||
h.server.restHandler = { _, _ -> HttpStatusCode.BadRequest to KvittJson.encodeToString(ErrorDto.serializer(), ErrorDto("invalid_request", "bad", "measure")) }
|
||||
assertEquals(OnlineResult.Failed("invalid_request", "measure"), repo.forecast(Measure.HOUSEHOLD))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun monthsOutsideOneToTwentyFourAreRefusedWithoutARequest() = runSync {
|
||||
assertEquals(OnlineResult.Failed(ErrorCodes.INVALID_REQUEST, "months"), repo.trends(0, Measure.HOUSEHOLD))
|
||||
assertEquals(OnlineResult.Failed(ErrorCodes.INVALID_REQUEST, "months"), repo.trends(25, Measure.HOUSEHOLD))
|
||||
assertTrue(h.requests.isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -72,12 +73,19 @@ fun AnalyticsScreen(
|
||||
onNavigate: (KvittDestination) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: AnalyticsViewModel = hiltViewModel(),
|
||||
insightsViewModel: InsightsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
AnalyticsScreen(state, viewModel, onOpenDrillDown, onNavigate, modifier)
|
||||
val insights by insightsViewModel.state.collectAsStateWithLifecycle()
|
||||
// Entering S-11 and every change of the measure toggle refresh the Insights (D-141).
|
||||
LaunchedEffect(state.filter.measure) { insightsViewModel.show(state.filter.measure) }
|
||||
AnalyticsScreen(state, viewModel, onOpenDrillDown, onNavigate, modifier, insights = insights, insightsEvents = insightsViewModel)
|
||||
}
|
||||
|
||||
/** The stateless S-11 that previews and screenshot tests render. */
|
||||
/**
|
||||
* The stateless S-11 that previews and screenshot tests render. [insights] is the Insights section below the local
|
||||
* analytics (D-141); null renders none.
|
||||
*/
|
||||
@Composable
|
||||
fun AnalyticsScreen(
|
||||
state: AnalyticsUiState,
|
||||
@@ -86,6 +94,8 @@ fun AnalyticsScreen(
|
||||
onNavigate: (KvittDestination) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
scrollState: ScrollState = rememberScrollState(),
|
||||
insights: InsightsUiState? = null,
|
||||
insightsEvents: InsightsEvents = NoInsightsEvents,
|
||||
) {
|
||||
val scrolled by remember(scrollState) { derivedStateOf { scrollState.value > 0 } }
|
||||
KvittScaffold(
|
||||
@@ -108,8 +118,11 @@ fun AnalyticsScreen(
|
||||
when (val content = state.content) {
|
||||
null -> Unit
|
||||
AnalyticsContent.Loading -> LoadingCards()
|
||||
is AnalyticsContent.Ready -> Ready(content, events) { bucket, category, account ->
|
||||
onOpenDrillDown(DrillDownArgs(content.data.filter, bucket, category, account))
|
||||
is AnalyticsContent.Ready -> {
|
||||
Ready(content, events) { bucket, category, account ->
|
||||
onOpenDrillDown(DrillDownArgs(content.data.filter, bucket, category, account))
|
||||
}
|
||||
if (insights != null) InsightsSection(insights, content.accounts, insightsEvents)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -311,3 +324,9 @@ private fun LoadingCards() {
|
||||
|
||||
private const val SKELETON_ROWS = 3
|
||||
|
||||
/** No Insights events: the default of the stateless S-11 when no section is rendered. */
|
||||
private object NoInsightsEvents : InsightsEvents {
|
||||
override fun onRetryInsights() = Unit
|
||||
|
||||
override fun onSelectCategory(id: CategoryId) = Unit
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package se.hagstedt.kvitt.feature.analytics
|
||||
|
||||
import se.hagstedt.kvitt.model.AccountForecast
|
||||
import se.hagstedt.kvitt.model.CategoryId
|
||||
import se.hagstedt.kvitt.model.CategoryTrend
|
||||
import se.hagstedt.kvitt.model.Measure
|
||||
import se.hagstedt.kvitt.model.OnlineResult
|
||||
import se.hagstedt.kvitt.model.SpendForecast
|
||||
import se.hagstedt.kvitt.model.SpendTrends
|
||||
import kotlin.math.abs
|
||||
|
||||
/** One load of S-11's Insights (D-141): the server's trends and forecast for one measure. */
|
||||
data class Insights(val trends: SpendTrends, val forecast: SpendForecast)
|
||||
|
||||
/** Where the Insights section stands; the last loaded [InsightsUiState.data] stays visible under every status. */
|
||||
sealed interface InsightsStatus {
|
||||
/** A request is running, or none has been made yet. */
|
||||
data object Loading : InsightsStatus
|
||||
|
||||
data object Loaded : InsightsStatus
|
||||
|
||||
/** Offline (§9.9): "Kräver anslutning". */
|
||||
data object NeedsConnection : InsightsStatus
|
||||
|
||||
/** The last request failed: `network` is "Kunde inte nå servern", other codes their §14.3.2 text; with "Försök igen". */
|
||||
data class Failed(val code: String) : InsightsStatus
|
||||
}
|
||||
|
||||
/**
|
||||
* S-11's Insights section: the [measure] it shows (S-11's measure toggle), its [status], the last data loaded for that
|
||||
* measure in this session ([data], null before the first success), and the category whose line chart is open.
|
||||
*/
|
||||
data class InsightsUiState(val measure: Measure, val status: InsightsStatus, val data: Insights?, val selectedCategory: CategoryId?)
|
||||
|
||||
/** The Insights section's events (the InsightsViewModel implements them; screenshot tests pass their own). */
|
||||
interface InsightsEvents {
|
||||
fun onRetryInsights()
|
||||
fun onSelectCategory(id: CategoryId)
|
||||
}
|
||||
|
||||
/** The rules of the Insights section (D-141, D-142), pure so the unit tests reach them. */
|
||||
object InsightsRules {
|
||||
/** D-142: the whole months of the trends (E-34 `months`), the server's default and S-11's default range (AC-14.2). */
|
||||
const val MONTHS = 6
|
||||
|
||||
/** D-133: the forecast baseline's window, D-128's moving-average window counted in whole months. */
|
||||
const val BASELINE_MONTHS = AnalyticsRules.MOVING_AVERAGE_WINDOW
|
||||
|
||||
/** Both reads succeed, or the first one that did not: offline before a failure. */
|
||||
fun combine(trends: OnlineResult<SpendTrends>, forecast: OnlineResult<SpendForecast>): OnlineResult<Insights> = when {
|
||||
trends is OnlineResult.Ok && forecast is OnlineResult.Ok -> OnlineResult.Ok(Insights(trends.value, forecast.value))
|
||||
trends == OnlineResult.Offline || forecast == OnlineResult.Offline -> OnlineResult.Offline
|
||||
trends is OnlineResult.Failed -> trends
|
||||
forecast is OnlineResult.Failed -> forecast
|
||||
else -> OnlineResult.Offline
|
||||
}
|
||||
|
||||
/** The section's status: offline always reads "Kräver anslutning"; online, the last attempt for the measure (none yet: loading). */
|
||||
fun status(online: Boolean, attempt: InsightsStatus?): InsightsStatus = if (!online) InsightsStatus.NeedsConnection else attempt ?: InsightsStatus.Loading
|
||||
|
||||
/** The status a finished load leaves. */
|
||||
fun statusOf(result: OnlineResult<Insights>): InsightsStatus = when (result) {
|
||||
is OnlineResult.Ok -> InsightsStatus.Loaded
|
||||
OnlineResult.Offline -> InsightsStatus.NeedsConnection
|
||||
is OnlineResult.Failed -> InsightsStatus.Failed(result.code)
|
||||
}
|
||||
|
||||
/** A load starts when online and the measure has no successful load yet, or on [force] (entering S-11, "Försök igen"). */
|
||||
fun shouldLoad(online: Boolean, attempt: InsightsStatus?, force: Boolean): Boolean =
|
||||
online && attempt != InsightsStatus.Loading && (force || attempt != InsightsStatus.Loaded)
|
||||
|
||||
/** The categories the section lists: those the phone holds a row for (deleted included); unknown ids are left out. */
|
||||
fun shownCategories(trends: SpendTrends): List<CategoryTrend> = trends.categories.filter { it.category != null }
|
||||
|
||||
/** The category whose chart is open: [selected] while it is listed, else the first (the largest total). */
|
||||
fun selectedCategory(trends: SpendTrends, selected: CategoryId?): CategoryTrend? {
|
||||
val shown = shownCategories(trends)
|
||||
return shown.firstOrNull { it.categoryId == selected } ?: shown.firstOrNull()
|
||||
}
|
||||
|
||||
/** No spending in the whole months nor so far this month: the trend cards give way to one line. */
|
||||
fun isEmpty(trends: SpendTrends): Boolean =
|
||||
trends.household.totalOre == 0L && trends.household.currentMonthOre == 0L && shownCategories(trends).isEmpty()
|
||||
|
||||
/** An account with nothing to forecast and nothing spent this month shows one line instead of its months. */
|
||||
fun hasNothing(forecast: AccountForecast): Boolean = forecast.currentMonthOre == 0L && forecast.months.all { it.forecastOre == 0L }
|
||||
|
||||
/** The direction of a last-month change for the S-12 caption words (D-128): 0 % is "på snittet". */
|
||||
fun comparison(lastMonth: String, pct: Int?): AverageComparison? = pct?.let {
|
||||
val direction = when {
|
||||
it > 0 -> AverageComparison.Direction.ABOVE
|
||||
it < 0 -> AverageComparison.Direction.BELOW
|
||||
else -> AverageComparison.Direction.ON
|
||||
}
|
||||
AverageComparison(lastMonth, abs(it).toLong(), direction)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
package se.hagstedt.kvitt.feature.analytics
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.selection.selectableGroup
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import kotlinx.datetime.LocalDate
|
||||
import se.hagstedt.kvitt.design.KvittTheme
|
||||
import se.hagstedt.kvitt.design.chart.ChartCard
|
||||
import se.hagstedt.kvitt.design.chart.LineKind
|
||||
import se.hagstedt.kvitt.design.chart.LineLegend
|
||||
import se.hagstedt.kvitt.design.chart.LineLegendItem
|
||||
import se.hagstedt.kvitt.design.chart.LinePoint
|
||||
import se.hagstedt.kvitt.design.chart.SpendLineChart
|
||||
import se.hagstedt.kvitt.design.component.AccountTile
|
||||
import se.hagstedt.kvitt.design.component.AmountStyle
|
||||
import se.hagstedt.kvitt.design.component.AmountTone
|
||||
import se.hagstedt.kvitt.design.component.ArchivedBadge
|
||||
import se.hagstedt.kvitt.design.component.Banner
|
||||
import se.hagstedt.kvitt.design.component.BannerKind
|
||||
import se.hagstedt.kvitt.design.component.ButtonKind
|
||||
import se.hagstedt.kvitt.design.component.CategoryTile
|
||||
import se.hagstedt.kvitt.design.component.KvittButton
|
||||
import se.hagstedt.kvitt.design.component.KvittCard
|
||||
import se.hagstedt.kvitt.design.component.MoneyText
|
||||
import se.hagstedt.kvitt.design.component.NeedsConnectionBanner
|
||||
import se.hagstedt.kvitt.design.component.SectionHeader
|
||||
import se.hagstedt.kvitt.design.component.Skeleton
|
||||
import se.hagstedt.kvitt.design.component.categoryName
|
||||
import se.hagstedt.kvitt.design.component.kvittLanguage
|
||||
import se.hagstedt.kvitt.design.component.spokenMoney
|
||||
import se.hagstedt.kvitt.design.format.DateFormatter
|
||||
import se.hagstedt.kvitt.design.format.MoneyFormatter
|
||||
import se.hagstedt.kvitt.design.format.commonFailureText
|
||||
import se.hagstedt.kvitt.design.icon.KvittIcon
|
||||
import se.hagstedt.kvitt.design.theme.KvittDimens
|
||||
import se.hagstedt.kvitt.model.Account
|
||||
import se.hagstedt.kvitt.model.AccountForecast
|
||||
import se.hagstedt.kvitt.model.CategoryId
|
||||
import se.hagstedt.kvitt.model.CategoryTrend
|
||||
import se.hagstedt.kvitt.model.ForecastMonth
|
||||
import se.hagstedt.kvitt.model.SpendTrend
|
||||
import se.hagstedt.kvitt.model.SpendTrends
|
||||
import se.hagstedt.kvitt.core.design.R as DesignR
|
||||
|
||||
/**
|
||||
* S-11's Insights section (D-133, D-141), below "Per konto" (or the empty state): heading "Insikter" with the months
|
||||
* and measure; a "Kräver anslutning" banner offline, or "Kunde inte nå servern" with "Försök igen" after a failure,
|
||||
* above the last data loaded this session; skeleton cards before the first load. The data: "Utgifter per månad" (the
|
||||
* household's or my share's line chart with the average and the moving average from the server), "Kategorier per
|
||||
* månad" (the chosen category's line chart over the list of categories), and "Prognos" with a card per account.
|
||||
* Emits its parts into the caller's column.
|
||||
*/
|
||||
@Composable
|
||||
internal fun InsightsSection(state: InsightsUiState, accounts: List<Account>, events: InsightsEvents) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
SectionHeader(stringResource(R.string.analytics_insights_title))
|
||||
Text(
|
||||
pluralStringResource(R.plurals.analytics_insights_caption, InsightsRules.MONTHS, InsightsRules.MONTHS, stringResource(measureLabel(state.measure))),
|
||||
Modifier.padding(horizontal = KvittTheme.spacing.s2),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
when (val status = state.status) {
|
||||
InsightsStatus.NeedsConnection -> NeedsConnectionBanner(stringResource(R.string.analytics_insights_offline_body))
|
||||
is InsightsStatus.Failed -> Banner(
|
||||
BannerKind.Error,
|
||||
KvittIcon.CloudOff,
|
||||
stringResource(commonFailureText(status.code) ?: DesignR.string.error_server),
|
||||
) { content ->
|
||||
KvittButton(stringResource(DesignR.string.common_retry), events::onRetryInsights, kind = ButtonKind.Text, contentColor = content, icon = KvittIcon.Refresh)
|
||||
}
|
||||
InsightsStatus.Loading, InsightsStatus.Loaded -> Unit
|
||||
}
|
||||
val data = state.data
|
||||
when {
|
||||
data != null -> InsightsContent(data, state.selectedCategory, accounts, events)
|
||||
state.status == InsightsStatus.Loading -> InsightsSkeleton()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InsightsContent(data: Insights, selected: CategoryId?, accounts: List<Account>, events: InsightsEvents) {
|
||||
val trends = data.trends
|
||||
if (InsightsRules.isEmpty(trends)) {
|
||||
KvittCard(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
pluralStringResource(R.plurals.analytics_insights_no_spending, trends.months, trends.months),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
ChartCard(stringResource(R.string.analytics_insights_trend)) {
|
||||
TrendChart(stringResource(measureLabel(trends.measure)), trends.household, MaterialTheme.colorScheme.primary, trends)
|
||||
}
|
||||
CategoryTrends(trends, selected, events)
|
||||
}
|
||||
ForecastHeader()
|
||||
data.forecast.accounts.forEach { forecast ->
|
||||
val index = accounts.indexOfFirst { it.id == forecast.accountId }
|
||||
val account = forecast.account ?: accounts.getOrNull(index) ?: return@forEach
|
||||
AccountForecastCard(account, if (index >= 0) index else accounts.size, forecast, data.forecast.currentMonth)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One trend as D-128's line chart: the months, the server's average as the dashed line and its moving average (3
|
||||
* months); then the legend, the average per month and the last whole month against it, and the current month so far.
|
||||
*/
|
||||
@Composable
|
||||
private fun TrendChart(name: String, trend: SpendTrend, color: Color, trends: SpendTrends) {
|
||||
val points = trend.points
|
||||
val overlays = points.size > 1 && trend.averageOre > 0L
|
||||
val window = AnalyticsRules.MOVING_AVERAGE_WINDOW
|
||||
SpendLineChart(
|
||||
points = points.map { LinePoint(it.month, it.amountOre) },
|
||||
color = color,
|
||||
summary = trendSummary(name, trend),
|
||||
averageOre = trend.averageOre.takeIf { overlays },
|
||||
movingAverage = if (overlays) points.map { it.movingAverageOre } else emptyList(),
|
||||
)
|
||||
LineLegend(
|
||||
listOfNotNull(
|
||||
LineLegendItem(name, LineKind.Series),
|
||||
LineLegendItem(stringResource(R.string.analytics_average), LineKind.Average).takeIf { overlays },
|
||||
LineLegendItem(stringResource(R.string.analytics_moving_average, window), LineKind.MovingAverage).takeIf { overlays },
|
||||
),
|
||||
color,
|
||||
)
|
||||
val language = kvittLanguage()
|
||||
val separator = stringResource(DesignR.string.common_separator_dot)
|
||||
val average = stringResource(R.string.analytics_insights_average_per_month, MoneyFormatter.format(trend.averageOre, language))
|
||||
val comparison = points.lastOrNull()?.let { InsightsRules.comparison(it.month, trend.lastMonthVsAveragePct) }?.let { comparisonWords(it) }
|
||||
Column {
|
||||
Text(listOfNotNull(average, comparison).joinToString(separator), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(
|
||||
stringResource(R.string.analytics_insights_so_far, bucketName(trends.currentMonth, capitalized = false), MoneyFormatter.format(trend.currentMonthOre, language)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** "Mat & hushåll över tid, apr–sep 2026: från … till …, totalt …, snitt …." (D-128's sentence, `analytics_summary_line`). */
|
||||
@Composable
|
||||
private fun trendSummary(name: String, trend: SpendTrend): String {
|
||||
val first = trend.points.firstOrNull()
|
||||
val last = trend.points.lastOrNull()
|
||||
if (first == null || last == null) return name
|
||||
val range = DateFormatter.monthRange(monthStart(first.month), monthStart(last.month), kvittLanguage())
|
||||
val total = spokenMoney(trend.totalOre)
|
||||
return if (trend.points.size == 1) {
|
||||
stringResource(R.string.analytics_summary_line_single, name, range, total)
|
||||
} else {
|
||||
stringResource(
|
||||
R.string.analytics_summary_line,
|
||||
name,
|
||||
range,
|
||||
spokenMoney(first.amountOre),
|
||||
bucketName(first.month, capitalized = false),
|
||||
spokenMoney(last.amountOre),
|
||||
bucketName(last.month, capitalized = false),
|
||||
total,
|
||||
spokenMoney(trend.averageOre),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun monthStart(month: String): LocalDate = LocalDate.parse("$month-01")
|
||||
|
||||
/** "September 2026: 53 % över snittet" (D-128's caption words). */
|
||||
@Composable
|
||||
private fun comparisonWords(comparison: AverageComparison): String {
|
||||
val month = bucketName(comparison.key, capitalized = true)
|
||||
val percent = stringResource(DesignR.string.common_percent, comparison.percent.toString())
|
||||
return when (comparison.direction) {
|
||||
AverageComparison.Direction.ABOVE -> stringResource(R.string.analytics_vs_average_above, month, percent)
|
||||
AverageComparison.Direction.BELOW -> stringResource(R.string.analytics_vs_average_below, month, percent)
|
||||
AverageComparison.Direction.ON -> stringResource(R.string.analytics_vs_average_on, month)
|
||||
}
|
||||
}
|
||||
|
||||
/** "Kategorier per månad": the chosen category's chart, then every category as a radio row (tile, name, average, change). */
|
||||
@Composable
|
||||
private fun CategoryTrends(trends: SpendTrends, selected: CategoryId?, events: InsightsEvents) {
|
||||
val current = InsightsRules.selectedCategory(trends, selected) ?: return
|
||||
val category = current.category ?: return
|
||||
ChartCard(stringResource(R.string.analytics_insights_categories)) {
|
||||
TrendChart(categoryName(category), current.trend, KvittTheme.colors.category(category.color), trends)
|
||||
Column(Modifier.fillMaxWidth().selectableGroup(), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s1)) {
|
||||
InsightsRules.shownCategories(trends).forEach { row ->
|
||||
CategoryTrendRow(row, row.categoryId == current.categoryId) { events.onSelectCategory(row.categoryId) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CategoryTrendRow(row: CategoryTrend, selected: Boolean, onClick: () -> Unit) {
|
||||
val category = row.category ?: return
|
||||
val language = kvittLanguage()
|
||||
val pct = row.trend.lastMonthVsAveragePct
|
||||
val change = when {
|
||||
pct == null -> null
|
||||
pct > 0 -> stringResource(R.string.analytics_insights_change_up, stringResource(DesignR.string.common_percent, pct.toString()))
|
||||
pct < 0 -> stringResource(R.string.analytics_insights_change_down, stringResource(DesignR.string.common_percent, (-pct).toString()))
|
||||
else -> stringResource(DesignR.string.common_percent, "0")
|
||||
}
|
||||
val fill = if (selected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = KvittTheme.spacing.touchTarget)
|
||||
.clip(KvittTheme.shapes.field)
|
||||
.background(fill)
|
||||
.selectable(selected = selected, role = Role.RadioButton, onClick = onClick)
|
||||
.padding(horizontal = KvittTheme.spacing.s2, vertical = KvittTheme.spacing.s2),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3),
|
||||
) {
|
||||
CategoryTile(category, KvittDimens.tileBreakdown)
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(categoryName(category), style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
stringResource(R.string.analytics_insights_average_per_month, MoneyFormatter.format(row.trend.averageOre, language)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (change != null) Text(change, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
/** "Prognos" and how it is made (D-133). */
|
||||
@Composable
|
||||
private fun ForecastHeader() {
|
||||
Column(Modifier.fillMaxWidth().padding(horizontal = KvittTheme.spacing.s2).semantics(mergeDescendants = true) { }) {
|
||||
Text(stringResource(R.string.analytics_insights_forecast), style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
pluralStringResource(R.plurals.analytics_insights_forecast_caption, InsightsRules.BASELINE_MONTHS, InsightsRules.BASELINE_MONTHS),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** One account's forecast: tile, name and the current month so far, then the two months with their parts. */
|
||||
@Composable
|
||||
private fun AccountForecastCard(account: Account, index: Int, forecast: AccountForecast, currentMonth: String) {
|
||||
val language = kvittLanguage()
|
||||
KvittCard(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3)) {
|
||||
Row(
|
||||
Modifier.semantics(mergeDescendants = true) { },
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3),
|
||||
) {
|
||||
AccountTile(account.name, index, size = KvittDimens.tileBreakdown, muted = account.archived)
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(account.name, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
stringResource(R.string.analytics_insights_so_far, bucketName(currentMonth, capitalized = false), MoneyFormatter.format(forecast.currentMonthOre, language)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (account.archived) ArchivedBadge()
|
||||
}
|
||||
if (InsightsRules.hasNothing(forecast)) {
|
||||
Text(stringResource(R.string.analytics_insights_forecast_none), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
} else {
|
||||
forecast.months.forEach { ForecastMonthRow(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ForecastMonthRow(month: ForecastMonth) {
|
||||
val language = kvittLanguage()
|
||||
val name = bucketName(month.month, capitalized = true)
|
||||
val spoken = stringResource(
|
||||
R.string.analytics_insights_forecast_month_spoken,
|
||||
name,
|
||||
spokenMoney(month.forecastOre),
|
||||
spokenMoney(month.recurringOre),
|
||||
spokenMoney(month.baselineOre),
|
||||
)
|
||||
Row(Modifier.fillMaxWidth().clearAndSetSemantics { contentDescription = spoken }, verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(name, style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
stringResource(R.string.analytics_insights_forecast_parts, MoneyFormatter.format(month.recurringOre, language), MoneyFormatter.format(month.baselineOre, language)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
MoneyText(month.forecastOre, AmountStyle.Medium, AmountTone.Neutral)
|
||||
}
|
||||
}
|
||||
|
||||
/** Before the first load: a chart card and an account card in skeleton form, read as "Hämtar insikter". */
|
||||
@Composable
|
||||
private fun InsightsSkeleton() {
|
||||
val loading = stringResource(R.string.analytics_insights_loading)
|
||||
Column(
|
||||
Modifier.fillMaxWidth().clearAndSetSemantics { contentDescription = loading },
|
||||
verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.detailGap),
|
||||
) {
|
||||
ChartCard(stringResource(R.string.analytics_insights_trend)) {
|
||||
Skeleton(KvittDimens.chartPlot, KvittDimens.chartPlot, Modifier.fillMaxWidth())
|
||||
}
|
||||
KvittCard(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3)) {
|
||||
Skeleton(KvittDimens.menuMinWidth / 2, KvittTheme.spacing.s4)
|
||||
repeat(SKELETON_MONTHS) { Skeleton(KvittDimens.chartPlot, KvittTheme.spacing.s8, Modifier.fillMaxWidth()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The forecast's two months (E-35). */
|
||||
private const val SKELETON_MONTHS = 2
|
||||
@@ -0,0 +1,75 @@
|
||||
package se.hagstedt.kvitt.feature.analytics
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import se.hagstedt.kvitt.model.CategoryId
|
||||
import se.hagstedt.kvitt.model.ConnectivityRepository
|
||||
import se.hagstedt.kvitt.model.InsightsRepository
|
||||
import se.hagstedt.kvitt.model.Measure
|
||||
import se.hagstedt.kvitt.model.OnlineResult
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* S-11's Insights (D-133, D-141): the server's trends and forecast, online only (§9.9). The data of each measure is
|
||||
* kept in memory for the life of S-11's back stack entry and stays on screen while offline or after a failure; nothing
|
||||
* is stored. A load runs when S-11 shows ([show]), when the measure changes to one without data, when the phone comes
|
||||
* online, and on "Försök igen".
|
||||
*/
|
||||
@HiltViewModel
|
||||
class InsightsViewModel @Inject constructor(
|
||||
private val repository: InsightsRepository,
|
||||
connectivity: ConnectivityRepository,
|
||||
) : ViewModel(), InsightsEvents {
|
||||
private val measure = MutableStateFlow(Measure.HOUSEHOLD)
|
||||
private val online = MutableStateFlow<Boolean?>(null)
|
||||
private val cache = MutableStateFlow<Map<Measure, Insights>>(emptyMap())
|
||||
private val attempts = MutableStateFlow<Map<Measure, InsightsStatus>>(emptyMap())
|
||||
private val selected = MutableStateFlow<CategoryId?>(null)
|
||||
|
||||
val state: StateFlow<InsightsUiState> = combine(measure, online, cache, attempts, selected) { m, o, c, a, s ->
|
||||
InsightsUiState(m, if (o == null) InsightsStatus.Loading else InsightsRules.status(o, a[m]), c[m], s)
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, InsightsUiState(measure.value, InsightsStatus.Loading, null, null))
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
connectivity.online.distinctUntilChanged().onEach { online.value = it }.collect { load(measure.value, force = true) }
|
||||
}
|
||||
}
|
||||
|
||||
/** S-11 is on screen with [measure] (its measure toggle): refresh it. */
|
||||
fun show(measure: Measure) {
|
||||
val changed = this.measure.value != measure
|
||||
this.measure.value = measure
|
||||
load(measure, force = !changed || attempts.value[measure] != InsightsStatus.Loaded)
|
||||
}
|
||||
|
||||
override fun onRetryInsights() = load(measure.value, force = true)
|
||||
|
||||
override fun onSelectCategory(id: CategoryId) = selected.update { id }
|
||||
|
||||
private fun load(m: Measure, force: Boolean) {
|
||||
if (!InsightsRules.shouldLoad(online.value == true, attempts.value[m], force)) return
|
||||
attempts.update { it + (m to InsightsStatus.Loading) }
|
||||
viewModelScope.launch {
|
||||
val result = coroutineScope {
|
||||
val trends = async { repository.trends(InsightsRules.MONTHS, m) }
|
||||
val forecast = async { repository.forecast(m) }
|
||||
InsightsRules.combine(trends.await(), forecast.await())
|
||||
}
|
||||
if (result is OnlineResult.Ok) cache.update { it + (m to result.value) }
|
||||
attempts.update { it + (m to InsightsRules.statusOf(result)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,4 +50,30 @@
|
||||
<string name="analytics_vs_average_on">%1$s: på snittet</string>
|
||||
<string name="analytics_summary_line">%1$s över tid, %2$s: från %3$s i %4$s till %5$s i %6$s, totalt %7$s, snitt %8$s.</string>
|
||||
<string name="analytics_summary_line_single">%1$s över tid, %2$s: totalt %3$s.</string>
|
||||
<!-- Insikter (D-133, D-141): serverns trender och prognos under S-11:s diagram, bara med anslutning (§9.9). -->
|
||||
<string name="analytics_insights_title">Insikter</string>
|
||||
<plurals name="analytics_insights_caption">
|
||||
<item quantity="one">Senaste %1$d hela månaden · %2$s</item>
|
||||
<item quantity="other">Senaste %1$d hela månaderna · %2$s</item>
|
||||
</plurals>
|
||||
<string name="analytics_insights_offline_body">Insikterna räknas fram på servern. Anslut till internet för att se dem.</string>
|
||||
<string name="analytics_insights_loading">Hämtar insikter</string>
|
||||
<string name="analytics_insights_trend">Utgifter per månad</string>
|
||||
<string name="analytics_insights_categories">Kategorier per månad</string>
|
||||
<string name="analytics_insights_average_per_month">Snitt %1$s i månaden</string>
|
||||
<string name="analytics_insights_so_far">Hittills i %1$s: %2$s</string>
|
||||
<string name="analytics_insights_change_up">+%1$s</string>
|
||||
<string name="analytics_insights_change_down">−%1$s</string>
|
||||
<plurals name="analytics_insights_no_spending">
|
||||
<item quantity="one">Inga utgifter den senaste %1$d hela månaden</item>
|
||||
<item quantity="other">Inga utgifter de senaste %1$d hela månaderna</item>
|
||||
</plurals>
|
||||
<string name="analytics_insights_forecast">Prognos</string>
|
||||
<plurals name="analytics_insights_forecast_caption">
|
||||
<item quantity="one">Återkommande utgifter som förfaller, plus snittet av övriga utgifter den senaste %1$d månaden</item>
|
||||
<item quantity="other">Återkommande utgifter som förfaller, plus snittet av övriga utgifter de senaste %1$d månaderna</item>
|
||||
</plurals>
|
||||
<string name="analytics_insights_forecast_parts">Återkommande %1$s + snitt %2$s</string>
|
||||
<string name="analytics_insights_forecast_none">Inga återkommande utgifter och inga utgifter på sistone</string>
|
||||
<string name="analytics_insights_forecast_month_spoken">%1$s: %2$s, återkommande %3$s, snitt %4$s</string>
|
||||
</resources>
|
||||
|
||||
@@ -50,4 +50,30 @@
|
||||
<string name="analytics_vs_average_on">%1$s: on average</string>
|
||||
<string name="analytics_summary_line">%1$s over time, %2$s: from %3$s in %4$s to %5$s in %6$s, %7$s in total, average %8$s.</string>
|
||||
<string name="analytics_summary_line_single">%1$s over time, %2$s: %3$s in total.</string>
|
||||
<!-- Insights (D-133, D-141): the server's trends and forecast below S-11's charts, online only (§9.9). -->
|
||||
<string name="analytics_insights_title">Insights</string>
|
||||
<plurals name="analytics_insights_caption">
|
||||
<item quantity="one">Last %1$d whole month · %2$s</item>
|
||||
<item quantity="other">Last %1$d whole months · %2$s</item>
|
||||
</plurals>
|
||||
<string name="analytics_insights_offline_body">Insights are calculated on the server. Connect to the internet to see them.</string>
|
||||
<string name="analytics_insights_loading">Loading insights</string>
|
||||
<string name="analytics_insights_trend">Spending per month</string>
|
||||
<string name="analytics_insights_categories">Categories per month</string>
|
||||
<string name="analytics_insights_average_per_month">Average %1$s a month</string>
|
||||
<string name="analytics_insights_so_far">So far in %1$s: %2$s</string>
|
||||
<string name="analytics_insights_change_up">+%1$s</string>
|
||||
<string name="analytics_insights_change_down">−%1$s</string>
|
||||
<plurals name="analytics_insights_no_spending">
|
||||
<item quantity="one">No spending in the last %1$d whole month</item>
|
||||
<item quantity="other">No spending in the last %1$d whole months</item>
|
||||
</plurals>
|
||||
<string name="analytics_insights_forecast">Forecast</string>
|
||||
<plurals name="analytics_insights_forecast_caption">
|
||||
<item quantity="one">Recurring expenses due, plus the average of other spending in the last %1$d month</item>
|
||||
<item quantity="other">Recurring expenses due, plus the average of other spending in the last %1$d months</item>
|
||||
</plurals>
|
||||
<string name="analytics_insights_forecast_parts">Recurring %1$s + average %2$s</string>
|
||||
<string name="analytics_insights_forecast_none">No recurring expenses and no recent spending</string>
|
||||
<string name="analytics_insights_forecast_month_spoken">%1$s: %2$s, recurring %3$s, average %4$s</string>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package se.hagstedt.kvitt.feature.analytics
|
||||
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import se.hagstedt.kvitt.design.testing.MainDispatcherRule
|
||||
import se.hagstedt.kvitt.fakes.FakeRepositories
|
||||
import se.hagstedt.kvitt.fakes.FakeScenario
|
||||
import se.hagstedt.kvitt.fakes.analytics.AnalyticsScenarios
|
||||
import se.hagstedt.kvitt.fakes.analytics.InsightsScenarios
|
||||
import se.hagstedt.kvitt.model.Measure
|
||||
import se.hagstedt.kvitt.model.OnlineResult
|
||||
|
||||
/** S-11's Insights state holder (D-141): online only (§9.9), the last data kept for the session. */
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class InsightsViewModelTest {
|
||||
@get:Rule
|
||||
val main = MainDispatcherRule()
|
||||
|
||||
private fun setup(scenario: FakeScenario): Pair<FakeRepositories, InsightsViewModel> {
|
||||
val fakes = FakeRepositories(scenario)
|
||||
return fakes to InsightsViewModel(fakes.insights, fakes.connectivity)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onlineLoadsTrendsAndForecastForTheHousehold() = runTest {
|
||||
val (_, vm) = setup(InsightsScenarios.loaded)
|
||||
val state = vm.state.first { it.status == InsightsStatus.Loaded }
|
||||
val data = requireNotNull(state.data)
|
||||
assertEquals(Measure.HOUSEHOLD, data.trends.measure)
|
||||
assertEquals(InsightsRules.MONTHS, data.trends.months)
|
||||
assertEquals(5, data.forecast.accounts.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun offlineShowsNeedsConnectionWithoutData() = runTest {
|
||||
val (_, vm) = setup(InsightsScenarios.offline)
|
||||
vm.show(Measure.HOUSEHOLD)
|
||||
val state = vm.state.first { it.status == InsightsStatus.NeedsConnection }
|
||||
assertNull(state.data)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun goingOfflineKeepsTheLastDataUnderTheHint_andComingBackLoadsAgain() = runTest {
|
||||
val (fakes, vm) = setup(InsightsScenarios.loaded)
|
||||
val loaded = vm.state.first { it.status == InsightsStatus.Loaded }.data
|
||||
fakes.store.edit { it.copy(online = false) }
|
||||
val offline = vm.state.first { it.status == InsightsStatus.NeedsConnection }
|
||||
assertEquals(loaded, offline.data)
|
||||
fakes.store.edit { it.copy(online = true) }
|
||||
assertEquals(loaded, vm.state.first { it.status == InsightsStatus.Loaded }.data)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aNetworkFailureKeepsTheLastData_andRetryRecovers() = runTest {
|
||||
val (fakes, vm) = setup(InsightsScenarios.loaded)
|
||||
val loaded = vm.state.first { it.status == InsightsStatus.Loaded }.data
|
||||
fakes.store.edit { it.copy(insightsFailure = "network") }
|
||||
vm.show(Measure.HOUSEHOLD)
|
||||
val failed = vm.state.first { it.status is InsightsStatus.Failed }
|
||||
assertEquals(InsightsStatus.Failed("network"), failed.status)
|
||||
assertEquals(loaded, failed.data)
|
||||
fakes.store.edit { it.copy(insightsFailure = null) }
|
||||
vm.onRetryInsights()
|
||||
assertEquals(InsightsStatus.Loaded, vm.state.first { it.status == InsightsStatus.Loaded }.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theMeasureToggleLoadsMyShare_andTheHouseholdDataIsKept() = runTest {
|
||||
val (_, vm) = setup(InsightsScenarios.loaded)
|
||||
val household = vm.state.first { it.status == InsightsStatus.Loaded }.data
|
||||
vm.show(Measure.MY_SHARE)
|
||||
val share = vm.state.first { it.measure == Measure.MY_SHARE && it.status == InsightsStatus.Loaded }
|
||||
assertEquals(Measure.MY_SHARE, share.data?.trends?.measure)
|
||||
vm.show(Measure.HOUSEHOLD)
|
||||
val back = vm.state.first { it.measure == Measure.HOUSEHOLD }
|
||||
assertEquals(household, back.data)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun selectingACategoryOpensItsChart() = runTest {
|
||||
val (_, vm) = setup(InsightsScenarios.loaded)
|
||||
val data = requireNotNull(vm.state.first { it.status == InsightsStatus.Loaded }.data)
|
||||
assertEquals(AnalyticsScenarios.housing.id, InsightsRules.selectedCategory(data.trends, null)?.categoryId)
|
||||
vm.onSelectCategory(AnalyticsScenarios.travel.id)
|
||||
val selected = vm.state.first { it.selectedCategory != null }.selectedCategory
|
||||
assertEquals(AnalyticsScenarios.travel.id, InsightsRules.selectedCategory(data.trends, selected)?.categoryId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rulesCombineStatusesAndLoads() {
|
||||
assertEquals(OnlineResult.Offline, InsightsRules.combine(OnlineResult.Offline, OnlineResult.Failed("network", null)))
|
||||
assertEquals(OnlineResult.Failed("internal", null), InsightsRules.combine(OnlineResult.Failed("internal", null), OnlineResult.Failed("network", null)))
|
||||
assertEquals(InsightsStatus.NeedsConnection, InsightsRules.status(online = false, attempt = InsightsStatus.Loaded))
|
||||
assertEquals(InsightsStatus.Loading, InsightsRules.status(online = true, attempt = null))
|
||||
assertFalse(InsightsRules.shouldLoad(online = false, attempt = null, force = true))
|
||||
assertFalse(InsightsRules.shouldLoad(online = true, attempt = InsightsStatus.Loading, force = true))
|
||||
assertFalse(InsightsRules.shouldLoad(online = true, attempt = InsightsStatus.Loaded, force = false))
|
||||
assertTrue(InsightsRules.shouldLoad(online = true, attempt = InsightsStatus.Loaded, force = true))
|
||||
assertTrue(InsightsRules.shouldLoad(online = true, attempt = InsightsStatus.Failed("network"), force = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun comparisonWordsFollowTheSignOfThePercent() {
|
||||
assertEquals(AverageComparison("2026-09", 53, AverageComparison.Direction.ABOVE), InsightsRules.comparison("2026-09", 53))
|
||||
assertEquals(AverageComparison("2026-09", 25, AverageComparison.Direction.BELOW), InsightsRules.comparison("2026-09", -25))
|
||||
assertEquals(AverageComparison.Direction.ON, InsightsRules.comparison("2026-09", 0)?.direction)
|
||||
assertNull(InsightsRules.comparison("2026-09", null))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package se.hagstedt.kvitt.feature.analytics.screenshot
|
||||
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.ParameterizedRobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
import se.hagstedt.kvitt.design.KvittTheme
|
||||
import se.hagstedt.kvitt.design.testing.ScreenSnapshots
|
||||
import se.hagstedt.kvitt.fakes.FakeRepositories
|
||||
import se.hagstedt.kvitt.fakes.FakeScenario
|
||||
import se.hagstedt.kvitt.fakes.analytics.AnalyticsScenarios
|
||||
import se.hagstedt.kvitt.fakes.analytics.InsightsScenarios
|
||||
import se.hagstedt.kvitt.feature.analytics.AnalyticsContent
|
||||
import se.hagstedt.kvitt.feature.analytics.AnalyticsRules
|
||||
import se.hagstedt.kvitt.feature.analytics.AnalyticsScreen
|
||||
import se.hagstedt.kvitt.feature.analytics.AnalyticsUiState
|
||||
import se.hagstedt.kvitt.feature.analytics.Insights
|
||||
import se.hagstedt.kvitt.feature.analytics.InsightsEvents
|
||||
import se.hagstedt.kvitt.feature.analytics.InsightsRules
|
||||
import se.hagstedt.kvitt.feature.analytics.InsightsSection
|
||||
import se.hagstedt.kvitt.feature.analytics.InsightsStatus
|
||||
import se.hagstedt.kvitt.feature.analytics.InsightsUiState
|
||||
import se.hagstedt.kvitt.feature.analytics.PeriodDialog
|
||||
import se.hagstedt.kvitt.model.Account
|
||||
import se.hagstedt.kvitt.model.CategoryId
|
||||
import se.hagstedt.kvitt.model.Measure
|
||||
import se.hagstedt.kvitt.model.OnlineResult
|
||||
|
||||
/**
|
||||
* S-11's Insights section (D-141) in each state, × light/dark × sv/en, plus 200 % for long content (D-098). The section
|
||||
* renders in S-11's column (gutter, gap 10); `_scrolled` frames show its end (the forecast cards), `s11_insights_screen`
|
||||
* the end of the whole S-11 with the section under "Per konto". Re-record with
|
||||
* `tools/gradle-serial.sh :feature:analytics:recordRoborazziDebug` and open every changed PNG.
|
||||
*/
|
||||
@RunWith(ParameterizedRobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [36], qualifiers = ScreenSnapshots.QUALIFIERS)
|
||||
class InsightsScreenshotTest(private val name: String) {
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun screen() {
|
||||
AnalyticsSnapshots.snap(compose, name, largeFont = name == "s11_insights_long", content = states.getValue(name))
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** The Insights of [scenario] as the server would answer them, or null when the fake reads fail. */
|
||||
private fun insights(scenario: FakeScenario, measure: Measure = Measure.HOUSEHOLD): Insights? = runBlocking {
|
||||
val fakes = FakeRepositories(scenario.copy(online = true, insightsFailure = null))
|
||||
(InsightsRules.combine(fakes.insights.trends(InsightsRules.MONTHS, measure), fakes.insights.forecast(measure)) as? OnlineResult.Ok)?.value
|
||||
}
|
||||
|
||||
private fun accounts(scenario: FakeScenario): List<Account> = runBlocking { FakeRepositories(scenario).accounts.all().first() }
|
||||
|
||||
private fun section(scenario: FakeScenario, state: InsightsUiState, scrolled: Boolean = false): @Composable () -> Unit {
|
||||
val accounts = accounts(scenario)
|
||||
return { SectionFrame(state, accounts, scrolled) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionFrame(state: InsightsUiState, accounts: List<Account>, scrolled: Boolean) {
|
||||
val scroll = remember { ScrollState(if (scrolled) Int.MAX_VALUE else 0) }
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scroll)
|
||||
.padding(horizontal = KvittTheme.spacing.gutter, vertical = KvittTheme.spacing.s2),
|
||||
verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.detailGap),
|
||||
) {
|
||||
InsightsSection(state, accounts, NoInsightsEvents)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ui(status: InsightsStatus, data: Insights?, selected: CategoryId? = null, measure: Measure = Measure.HOUSEHOLD) =
|
||||
InsightsUiState(measure, status, data, selected)
|
||||
|
||||
private val loaded = insights(InsightsScenarios.loaded)
|
||||
|
||||
/** The whole S-11 (default filter) scrolled to its end, with the section loaded. */
|
||||
private val screenContent: AnalyticsContent = runBlocking {
|
||||
val fakes = FakeRepositories(InsightsScenarios.loaded)
|
||||
AnalyticsContent.Ready(fakes.analytics.analytics(AnalyticsRules.defaultFilter).first(), fakes.categories.all().first(), fakes.accounts.all().first())
|
||||
}
|
||||
|
||||
private val screen: @Composable () -> Unit = {
|
||||
val content = screenContent
|
||||
val scroll = remember { ScrollState(Int.MAX_VALUE) }
|
||||
AnalyticsScreen(
|
||||
AnalyticsUiState(AnalyticsRules.defaultFilter, PeriodDialog.None, content),
|
||||
NoEvents,
|
||||
{},
|
||||
{},
|
||||
scrollState = scroll,
|
||||
insights = ui(InsightsStatus.Loaded, loaded),
|
||||
)
|
||||
}
|
||||
|
||||
val states: Map<String, @Composable () -> Unit> = buildMap {
|
||||
put("s11_insights_loaded", section(InsightsScenarios.loaded, ui(InsightsStatus.Loaded, loaded)))
|
||||
put("s11_insights_loaded_scrolled", section(InsightsScenarios.loaded, ui(InsightsStatus.Loaded, loaded), scrolled = true))
|
||||
put("s11_insights_category", section(InsightsScenarios.loaded, ui(InsightsStatus.Loaded, loaded, selected = AnalyticsScenarios.travel.id), scrolled = false))
|
||||
put(
|
||||
"s11_insights_my_share",
|
||||
section(InsightsScenarios.loaded, ui(InsightsStatus.Loaded, insights(InsightsScenarios.loaded, Measure.MY_SHARE), measure = Measure.MY_SHARE)),
|
||||
)
|
||||
put("s11_insights_loading", section(InsightsScenarios.loaded, ui(InsightsStatus.Loading, null)))
|
||||
put("s11_insights_offline", section(InsightsScenarios.offline, ui(InsightsStatus.NeedsConnection, null)))
|
||||
put("s11_insights_offline_kept", section(InsightsScenarios.offline, ui(InsightsStatus.NeedsConnection, loaded)))
|
||||
put("s11_insights_unreachable", section(InsightsScenarios.unreachable, ui(InsightsStatus.Failed("network"), loaded)))
|
||||
put("s11_insights_unreachable_empty", section(InsightsScenarios.unreachable, ui(InsightsStatus.Failed("network"), null)))
|
||||
put("s11_insights_empty", section(InsightsScenarios.emptyHousehold, ui(InsightsStatus.Loaded, insights(InsightsScenarios.emptyHousehold))))
|
||||
put("s11_insights_long", section(InsightsScenarios.long, ui(InsightsStatus.Loaded, insights(InsightsScenarios.long))))
|
||||
put("s11_insights_long_scrolled", section(InsightsScenarios.long, ui(InsightsStatus.Loaded, insights(InsightsScenarios.long)), scrolled = true))
|
||||
put("s11_insights_screen", screen)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@ParameterizedRobolectricTestRunner.Parameters(name = "{0}")
|
||||
fun names(): List<Array<Any>> = states.keys.map { arrayOf<Any>(it) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Insights events that do nothing: screenshots render a fixed state. */
|
||||
internal object NoInsightsEvents : InsightsEvents {
|
||||
override fun onRetryInsights() = Unit
|
||||
override fun onSelectCategory(id: CategoryId) = Unit
|
||||
}
|
||||
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 47 KiB |