diff --git a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeInsightsRepository.kt b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeInsightsRepository.kt new file mode 100644 index 0000000..2920255 --- /dev/null +++ b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeInsightsRepository.kt @@ -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 = 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 = 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): 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 { 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) +} diff --git a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeRepositories.kt b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeRepositories.kt index 78b19ae..6f1436e 100644 --- a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeRepositories.kt +++ b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeRepositories.kt @@ -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) diff --git a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeScenario.kt b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeScenario.kt index 85caaf3..73fbd35 100644 --- a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeScenario.kt +++ b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeScenario.kt @@ -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, val lastAttemptFailed: Boolean = false, val pairingInviteKind: InviteKind? = null, + val insightsFailure: String? = null, ) { val users: List get() = listOfNotNull(me, partner) } diff --git a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/analytics/InsightsScenarios.kt b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/analytics/InsightsScenarios.kt new file mode 100644 index 0000000..0815672 --- /dev/null +++ b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/analytics/InsightsScenarios.kt @@ -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, + ) +} diff --git a/android/core/fakes/src/test/kotlin/se/hagstedt/kvitt/fakes/FakeInsightsRepositoryTest.kt b/android/core/fakes/src/test/kotlin/se/hagstedt/kvitt/fakes/FakeInsightsRepositoryTest.kt new file mode 100644 index 0000000..5daaba8 --- /dev/null +++ b/android/core/fakes/src/test/kotlin/se/hagstedt/kvitt/fakes/FakeInsightsRepositoryTest.kt @@ -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)) + } +} diff --git a/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Insights.kt b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Insights.kt new file mode 100644 index 0000000..5c7c279 --- /dev/null +++ b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Insights.kt @@ -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, +) + +/** [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, +) + +/** 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) + +/** 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) + +/** + * 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 + suspend fun forecast(measure: Measure): OnlineResult +} diff --git a/android/core/model/src/test/kotlin/se/hagstedt/kvitt/model/ContractTest.kt b/android/core/model/src/test/kotlin/se/hagstedt/kvitt/model/ContractTest.kt index ff70db9..e1928a4 100644 --- a/android/core/model/src/test/kotlin/se/hagstedt/kvitt/model/ContractTest.kt +++ b/android/core/model/src/test/kotlin/se/hagstedt/kvitt/model/ContractTest.kt @@ -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) + } } diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/api/AnalyticsDtos.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/api/AnalyticsDtos.kt new file mode 100644 index 0000000..843c54c --- /dev/null +++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/api/AnalyticsDtos.kt @@ -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, +) + +/** 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, +) + +/** 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, +) + +/** E-35 response. */ +@Serializable +data class ForecastResponse( + val measure: String, + val today: String, + @SerialName("current_month") val currentMonth: String, + val accounts: List, +) diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/api/KvittApi.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/api/KvittApi.kt index 5aff74b..ca5f418 100644 --- a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/api/KvittApi.kt +++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/api/KvittApi.kt @@ -135,6 +135,15 @@ class KvittApi( suspend fun proposal(request: ProposalRequest): ApiResult = 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 = authed(HttpMethod.Get, "/analytics/trends") { + parameter("months", months) + parameter("measure", measure) + } + suspend fun analyticsForecast(measure: String): ApiResult = authed(HttpMethod.Get, "/analytics/forecast") { + parameter("measure", measure) + } + private suspend inline fun authed( method: HttpMethod, path: String, diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/di/DataModule.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/di/DataModule.kt index ac6d058..088f963 100644 --- a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/di/DataModule.kt +++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/di/DataModule.kt @@ -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 diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/InsightsRepositoryImpl.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/InsightsRepositoryImpl.kt new file mode 100644 index 0000000..f120cac --- /dev/null +++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/InsightsRepositoryImpl.kt @@ -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 { + 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 = + 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) }, +) diff --git a/android/data/src/test/kotlin/se/hagstedt/kvitt/data/api/ExampleRoundTripTest.kt b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/api/ExampleRoundTripTest.kt index 1f52aed..2821679 100644 --- a/android/data/src/test/kotlin/se/hagstedt/kvitt/data/api/ExampleRoundTripTest.kt +++ b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/api/ExampleRoundTripTest.kt @@ -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 } } diff --git a/android/data/src/test/kotlin/se/hagstedt/kvitt/data/repo/InsightsRepositoryTest.kt b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/repo/InsightsRepositoryTest.kt new file mode 100644 index 0000000..5c5847f --- /dev/null +++ b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/repo/InsightsRepositoryTest.kt @@ -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()) + } +} diff --git a/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsScreen.kt b/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsScreen.kt index 6b6993f..f726285 100644 --- a/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsScreen.kt +++ b/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsScreen.kt @@ -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 +} diff --git a/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/InsightsModel.kt b/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/InsightsModel.kt new file mode 100644 index 0000000..23280fd --- /dev/null +++ b/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/InsightsModel.kt @@ -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, forecast: OnlineResult): OnlineResult = 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): 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 = 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) + } +} diff --git a/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/InsightsSection.kt b/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/InsightsSection.kt new file mode 100644 index 0000000..dcd9310 --- /dev/null +++ b/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/InsightsSection.kt @@ -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, 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, 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 diff --git a/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/InsightsViewModel.kt b/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/InsightsViewModel.kt new file mode 100644 index 0000000..1182682 --- /dev/null +++ b/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/InsightsViewModel.kt @@ -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(null) + private val cache = MutableStateFlow>(emptyMap()) + private val attempts = MutableStateFlow>(emptyMap()) + private val selected = MutableStateFlow(null) + + val state: StateFlow = 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)) } + } + } +} diff --git a/android/feature/analytics/src/main/res/values-sv/strings.xml b/android/feature/analytics/src/main/res/values-sv/strings.xml index a012196..6ded13c 100644 --- a/android/feature/analytics/src/main/res/values-sv/strings.xml +++ b/android/feature/analytics/src/main/res/values-sv/strings.xml @@ -50,4 +50,30 @@ %1$s: på snittet %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. %1$s över tid, %2$s: totalt %3$s. + + Insikter + + Senaste %1$d hela månaden · %2$s + Senaste %1$d hela månaderna · %2$s + + Insikterna räknas fram på servern. Anslut till internet för att se dem. + Hämtar insikter + Utgifter per månad + Kategorier per månad + Snitt %1$s i månaden + Hittills i %1$s: %2$s + +%1$s + −%1$s + + Inga utgifter den senaste %1$d hela månaden + Inga utgifter de senaste %1$d hela månaderna + + Prognos + + Återkommande utgifter som förfaller, plus snittet av övriga utgifter den senaste %1$d månaden + Återkommande utgifter som förfaller, plus snittet av övriga utgifter de senaste %1$d månaderna + + Återkommande %1$s + snitt %2$s + Inga återkommande utgifter och inga utgifter på sistone + %1$s: %2$s, återkommande %3$s, snitt %4$s diff --git a/android/feature/analytics/src/main/res/values/strings.xml b/android/feature/analytics/src/main/res/values/strings.xml index 302071a..8be0390 100644 --- a/android/feature/analytics/src/main/res/values/strings.xml +++ b/android/feature/analytics/src/main/res/values/strings.xml @@ -50,4 +50,30 @@ %1$s: on average %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. %1$s over time, %2$s: %3$s in total. + + Insights + + Last %1$d whole month · %2$s + Last %1$d whole months · %2$s + + Insights are calculated on the server. Connect to the internet to see them. + Loading insights + Spending per month + Categories per month + Average %1$s a month + So far in %1$s: %2$s + +%1$s + −%1$s + + No spending in the last %1$d whole month + No spending in the last %1$d whole months + + Forecast + + Recurring expenses due, plus the average of other spending in the last %1$d month + Recurring expenses due, plus the average of other spending in the last %1$d months + + Recurring %1$s + average %2$s + No recurring expenses and no recent spending + %1$s: %2$s, recurring %3$s, average %4$s diff --git a/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/InsightsViewModelTest.kt b/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/InsightsViewModelTest.kt new file mode 100644 index 0000000..bb2e8dc --- /dev/null +++ b/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/InsightsViewModelTest.kt @@ -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 { + 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)) + } +} diff --git a/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/screenshot/InsightsScreenshotTest.kt b/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/screenshot/InsightsScreenshotTest.kt new file mode 100644 index 0000000..aa87a0e --- /dev/null +++ b/android/feature/analytics/src/test/kotlin/se/hagstedt/kvitt/feature/analytics/screenshot/InsightsScreenshotTest.kt @@ -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 = 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, 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 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> = states.keys.map { arrayOf(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 +} diff --git a/android/feature/analytics/src/test/screenshots/s11_insights_category_en_light.png b/android/feature/analytics/src/test/screenshots/s11_insights_category_en_light.png new file mode 100644 index 0000000..ecbb94e Binary files /dev/null and b/android/feature/analytics/src/test/screenshots/s11_insights_category_en_light.png differ diff --git a/android/feature/analytics/src/test/screenshots/s11_insights_category_sv_dark.png b/android/feature/analytics/src/test/screenshots/s11_insights_category_sv_dark.png new file mode 100644 index 0000000..2542b93 Binary files /dev/null and b/android/feature/analytics/src/test/screenshots/s11_insights_category_sv_dark.png differ diff --git a/android/feature/analytics/src/test/screenshots/s11_insights_category_sv_light.png b/android/feature/analytics/src/test/screenshots/s11_insights_category_sv_light.png new file mode 100644 index 0000000..de45702 Binary files /dev/null and b/android/feature/analytics/src/test/screenshots/s11_insights_category_sv_light.png differ diff --git a/android/feature/analytics/src/test/screenshots/s11_insights_empty_en_light.png b/android/feature/analytics/src/test/screenshots/s11_insights_empty_en_light.png new file mode 100644 index 0000000..bd6e5f0 Binary files /dev/null and b/android/feature/analytics/src/test/screenshots/s11_insights_empty_en_light.png differ diff --git a/android/feature/analytics/src/test/screenshots/s11_insights_empty_sv_dark.png b/android/feature/analytics/src/test/screenshots/s11_insights_empty_sv_dark.png new file mode 100644 index 0000000..2690d46 Binary files /dev/null and b/android/feature/analytics/src/test/screenshots/s11_insights_empty_sv_dark.png differ diff --git a/android/feature/analytics/src/test/screenshots/s11_insights_empty_sv_light.png b/android/feature/analytics/src/test/screenshots/s11_insights_empty_sv_light.png new file mode 100644 index 0000000..55c7882 Binary files /dev/null and b/android/feature/analytics/src/test/screenshots/s11_insights_empty_sv_light.png differ diff --git a/android/feature/analytics/src/test/screenshots/s11_insights_loaded_en_light.png b/android/feature/analytics/src/test/screenshots/s11_insights_loaded_en_light.png new file mode 100644 index 0000000..12bddea Binary files /dev/null and b/android/feature/analytics/src/test/screenshots/s11_insights_loaded_en_light.png differ diff --git a/android/feature/analytics/src/test/screenshots/s11_insights_loaded_scrolled_en_light.png b/android/feature/analytics/src/test/screenshots/s11_insights_loaded_scrolled_en_light.png new file mode 100644 index 0000000..27be217 Binary files /dev/null and b/android/feature/analytics/src/test/screenshots/s11_insights_loaded_scrolled_en_light.png differ diff --git a/android/feature/analytics/src/test/screenshots/s11_insights_loaded_scrolled_sv_dark.png b/android/feature/analytics/src/test/screenshots/s11_insights_loaded_scrolled_sv_dark.png new file mode 100644 index 0000000..674ffd9 Binary files /dev/null and b/android/feature/analytics/src/test/screenshots/s11_insights_loaded_scrolled_sv_dark.png differ diff --git a/android/feature/analytics/src/test/screenshots/s11_insights_loaded_scrolled_sv_light.png b/android/feature/analytics/src/test/screenshots/s11_insights_loaded_scrolled_sv_light.png new file mode 100644 index 0000000..359b16b Binary files /dev/null and b/android/feature/analytics/src/test/screenshots/s11_insights_loaded_scrolled_sv_light.png differ diff --git a/android/feature/analytics/src/test/screenshots/s11_insights_loaded_sv_dark.png b/android/feature/analytics/src/test/screenshots/s11_insights_loaded_sv_dark.png new file mode 100644 index 0000000..0204e34 Binary files /dev/null and b/android/feature/analytics/src/test/screenshots/s11_insights_loaded_sv_dark.png differ diff --git a/android/feature/analytics/src/test/screenshots/s11_insights_loaded_sv_light.png b/android/feature/analytics/src/test/screenshots/s11_insights_loaded_sv_light.png new file mode 100644 index 0000000..abd7b74 Binary files /dev/null and b/android/feature/analytics/src/test/screenshots/s11_insights_loaded_sv_light.png differ diff --git a/android/feature/analytics/src/test/screenshots/s11_insights_loading_en_light.png b/android/feature/analytics/src/test/screenshots/s11_insights_loading_en_light.png new file mode 100644 index 0000000..5e2e9ec Binary files /dev/null and b/android/feature/analytics/src/test/screenshots/s11_insights_loading_en_light.png differ diff --git a/android/feature/analytics/src/test/screenshots/s11_insights_loading_sv_dark.png b/android/feature/analytics/src/test/screenshots/s11_insights_loading_sv_dark.png new file mode 100644 index 0000000..59310a7 Binary files /dev/null and b/android/feature/analytics/src/test/screenshots/s11_insights_loading_sv_dark.png differ diff --git a/android/feature/analytics/src/test/screenshots/s11_insights_loading_sv_light.png b/android/feature/analytics/src/test/screenshots/s11_insights_loading_sv_light.png new file mode 100644 index 0000000..d7e4f2a Binary files /dev/null and b/android/feature/analytics/src/test/screenshots/s11_insights_loading_sv_light.png differ diff --git a/android/feature/analytics/src/test/screenshots/s11_insights_long_en_light.png b/android/feature/analytics/src/test/screenshots/s11_insights_long_en_light.png new file mode 100644 index 0000000..a5af1b0 Binary files /dev/null and b/android/feature/analytics/src/test/screenshots/s11_insights_long_en_light.png differ diff --git a/android/feature/analytics/src/test/screenshots/s11_insights_long_scrolled_en_light.png b/android/feature/analytics/src/test/screenshots/s11_insights_long_scrolled_en_light.png new file mode 100644 index 0000000..6cfeefc Binary files /dev/null and b/android/feature/analytics/src/test/screenshots/s11_insights_long_scrolled_en_light.png differ diff --git a/android/feature/analytics/src/test/screenshots/s11_insights_long_scrolled_sv_dark.png b/android/feature/analytics/src/test/screenshots/s11_insights_long_scrolled_sv_dark.png new file mode 100644 index 0000000..6fa136e Binary files /dev/null and b/android/feature/analytics/src/test/screenshots/s11_insights_long_scrolled_sv_dark.png differ diff --git a/android/feature/analytics/src/test/screenshots/s11_insights_long_scrolled_sv_light.png b/android/feature/analytics/src/test/screenshots/s11_insights_long_scrolled_sv_light.png new file mode 100644 index 0000000..ca2ad93 Binary files /dev/null and b/android/feature/analytics/src/test/screenshots/s11_insights_long_scrolled_sv_light.png differ diff --git a/android/feature/analytics/src/test/screenshots/s11_insights_long_sv_dark.png b/android/feature/analytics/src/test/screenshots/s11_insights_long_sv_dark.png new file mode 100644 index 0000000..1c6809d Binary files /dev/null and b/android/feature/analytics/src/test/screenshots/s11_insights_long_sv_dark.png differ diff --git a/android/feature/analytics/src/test/screenshots/s11_insights_long_sv_light.png b/android/feature/analytics/src/test/screenshots/s11_insights_long_sv_light.png new file mode 100644 index 0000000..9541170 Binary files /dev/null and b/android/feature/analytics/src/test/screenshots/s11_insights_long_sv_light.png differ diff --git a/android/feature/analytics/src/test/screenshots/s11_insights_long_sv_light_200.png b/android/feature/analytics/src/test/screenshots/s11_insights_long_sv_light_200.png new file mode 100644 index 0000000..2c30d26 Binary files /dev/null and b/android/feature/analytics/src/test/screenshots/s11_insights_long_sv_light_200.png differ diff --git a/android/feature/analytics/src/test/screenshots/s11_insights_my_share_en_light.png b/android/feature/analytics/src/test/screenshots/s11_insights_my_share_en_light.png new file mode 100644 index 0000000..2e8038e Binary files /dev/null and b/android/feature/analytics/src/test/screenshots/s11_insights_my_share_en_light.png differ diff --git a/android/feature/analytics/src/test/screenshots/s11_insights_my_share_sv_dark.png b/android/feature/analytics/src/test/screenshots/s11_insights_my_share_sv_dark.png new file mode 100644 index 0000000..6473168 Binary files /dev/null and b/android/feature/analytics/src/test/screenshots/s11_insights_my_share_sv_dark.png differ diff --git a/android/feature/analytics/src/test/screenshots/s11_insights_my_share_sv_light.png b/android/feature/analytics/src/test/screenshots/s11_insights_my_share_sv_light.png new file mode 100644 index 0000000..a4ed230 Binary files /dev/null and b/android/feature/analytics/src/test/screenshots/s11_insights_my_share_sv_light.png differ diff --git a/android/feature/analytics/src/test/screenshots/s11_insights_offline_en_light.png b/android/feature/analytics/src/test/screenshots/s11_insights_offline_en_light.png new file mode 100644 index 0000000..50c6585 Binary files /dev/null and b/android/feature/analytics/src/test/screenshots/s11_insights_offline_en_light.png differ diff --git a/android/feature/analytics/src/test/screenshots/s11_insights_offline_kept_en_light.png b/android/feature/analytics/src/test/screenshots/s11_insights_offline_kept_en_light.png new file mode 100644 index 0000000..1c90d0d Binary files /dev/null and b/android/feature/analytics/src/test/screenshots/s11_insights_offline_kept_en_light.png differ diff --git a/android/feature/analytics/src/test/screenshots/s11_insights_offline_kept_sv_dark.png b/android/feature/analytics/src/test/screenshots/s11_insights_offline_kept_sv_dark.png new file mode 100644 index 0000000..0220eda Binary files /dev/null and b/android/feature/analytics/src/test/screenshots/s11_insights_offline_kept_sv_dark.png differ diff --git a/android/feature/analytics/src/test/screenshots/s11_insights_offline_kept_sv_light.png b/android/feature/analytics/src/test/screenshots/s11_insights_offline_kept_sv_light.png new file mode 100644 index 0000000..53b7de2 Binary files /dev/null and b/android/feature/analytics/src/test/screenshots/s11_insights_offline_kept_sv_light.png differ diff --git a/android/feature/analytics/src/test/screenshots/s11_insights_offline_sv_dark.png b/android/feature/analytics/src/test/screenshots/s11_insights_offline_sv_dark.png new file mode 100644 index 0000000..14e9290 Binary files /dev/null and b/android/feature/analytics/src/test/screenshots/s11_insights_offline_sv_dark.png differ diff --git a/android/feature/analytics/src/test/screenshots/s11_insights_offline_sv_light.png b/android/feature/analytics/src/test/screenshots/s11_insights_offline_sv_light.png new file mode 100644 index 0000000..00378a5 Binary files /dev/null and b/android/feature/analytics/src/test/screenshots/s11_insights_offline_sv_light.png differ diff --git a/android/feature/analytics/src/test/screenshots/s11_insights_screen_en_light.png b/android/feature/analytics/src/test/screenshots/s11_insights_screen_en_light.png new file mode 100644 index 0000000..745c18f Binary files /dev/null and b/android/feature/analytics/src/test/screenshots/s11_insights_screen_en_light.png differ diff --git a/android/feature/analytics/src/test/screenshots/s11_insights_screen_sv_dark.png b/android/feature/analytics/src/test/screenshots/s11_insights_screen_sv_dark.png new file mode 100644 index 0000000..9b5e666 Binary files /dev/null and b/android/feature/analytics/src/test/screenshots/s11_insights_screen_sv_dark.png differ diff --git a/android/feature/analytics/src/test/screenshots/s11_insights_screen_sv_light.png b/android/feature/analytics/src/test/screenshots/s11_insights_screen_sv_light.png new file mode 100644 index 0000000..1825c73 Binary files /dev/null and b/android/feature/analytics/src/test/screenshots/s11_insights_screen_sv_light.png differ diff --git a/android/feature/analytics/src/test/screenshots/s11_insights_unreachable_empty_en_light.png b/android/feature/analytics/src/test/screenshots/s11_insights_unreachable_empty_en_light.png new file mode 100644 index 0000000..4b8ee7c Binary files /dev/null and b/android/feature/analytics/src/test/screenshots/s11_insights_unreachable_empty_en_light.png differ diff --git a/android/feature/analytics/src/test/screenshots/s11_insights_unreachable_empty_sv_dark.png b/android/feature/analytics/src/test/screenshots/s11_insights_unreachable_empty_sv_dark.png new file mode 100644 index 0000000..5dca5ec Binary files /dev/null and b/android/feature/analytics/src/test/screenshots/s11_insights_unreachable_empty_sv_dark.png differ diff --git a/android/feature/analytics/src/test/screenshots/s11_insights_unreachable_empty_sv_light.png b/android/feature/analytics/src/test/screenshots/s11_insights_unreachable_empty_sv_light.png new file mode 100644 index 0000000..0d271ea Binary files /dev/null and b/android/feature/analytics/src/test/screenshots/s11_insights_unreachable_empty_sv_light.png differ diff --git a/android/feature/analytics/src/test/screenshots/s11_insights_unreachable_en_light.png b/android/feature/analytics/src/test/screenshots/s11_insights_unreachable_en_light.png new file mode 100644 index 0000000..e0742aa Binary files /dev/null and b/android/feature/analytics/src/test/screenshots/s11_insights_unreachable_en_light.png differ diff --git a/android/feature/analytics/src/test/screenshots/s11_insights_unreachable_sv_dark.png b/android/feature/analytics/src/test/screenshots/s11_insights_unreachable_sv_dark.png new file mode 100644 index 0000000..ea4f49a Binary files /dev/null and b/android/feature/analytics/src/test/screenshots/s11_insights_unreachable_sv_dark.png differ diff --git a/android/feature/analytics/src/test/screenshots/s11_insights_unreachable_sv_light.png b/android/feature/analytics/src/test/screenshots/s11_insights_unreachable_sv_light.png new file mode 100644 index 0000000..d25747a Binary files /dev/null and b/android/feature/analytics/src/test/screenshots/s11_insights_unreachable_sv_light.png differ