W6 Android Insights on S-11: server trends and forecast (E-34, E-35), online only (D-141 to D-143)
InsightsRepository in :core:model (SpendTrends, SpendForecast and parts); :data DTOs for E-34/E-35 (round-trip the contract examples, fixing ExampleCoverageTest), InsightsRepositoryImpl through KvittApi and OnlineGate with category/account names from Room (deleted rows included); FakeInsightsRepository with scenarios (loaded, empty household, account without history, offline, unreachable, long); the Insights section below S-11's charts: household trend and per-category trends on SpendLineChart with average and moving average, a forecast card per account, "Needs connection" / "Could not reach the server" over the last data of the session. Strings sv/en; 41 goldens. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -72,12 +73,19 @@ fun AnalyticsScreen(
|
||||
onNavigate: (KvittDestination) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: AnalyticsViewModel = hiltViewModel(),
|
||||
insightsViewModel: InsightsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
AnalyticsScreen(state, viewModel, onOpenDrillDown, onNavigate, modifier)
|
||||
val insights by insightsViewModel.state.collectAsStateWithLifecycle()
|
||||
// Entering S-11 and every change of the measure toggle refresh the Insights (D-141).
|
||||
LaunchedEffect(state.filter.measure) { insightsViewModel.show(state.filter.measure) }
|
||||
AnalyticsScreen(state, viewModel, onOpenDrillDown, onNavigate, modifier, insights = insights, insightsEvents = insightsViewModel)
|
||||
}
|
||||
|
||||
/** The stateless S-11 that previews and screenshot tests render. */
|
||||
/**
|
||||
* The stateless S-11 that previews and screenshot tests render. [insights] is the Insights section below the local
|
||||
* analytics (D-141); null renders none.
|
||||
*/
|
||||
@Composable
|
||||
fun AnalyticsScreen(
|
||||
state: AnalyticsUiState,
|
||||
@@ -86,6 +94,8 @@ fun AnalyticsScreen(
|
||||
onNavigate: (KvittDestination) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
scrollState: ScrollState = rememberScrollState(),
|
||||
insights: InsightsUiState? = null,
|
||||
insightsEvents: InsightsEvents = NoInsightsEvents,
|
||||
) {
|
||||
val scrolled by remember(scrollState) { derivedStateOf { scrollState.value > 0 } }
|
||||
KvittScaffold(
|
||||
@@ -108,8 +118,11 @@ fun AnalyticsScreen(
|
||||
when (val content = state.content) {
|
||||
null -> Unit
|
||||
AnalyticsContent.Loading -> LoadingCards()
|
||||
is AnalyticsContent.Ready -> Ready(content, events) { bucket, category, account ->
|
||||
onOpenDrillDown(DrillDownArgs(content.data.filter, bucket, category, account))
|
||||
is AnalyticsContent.Ready -> {
|
||||
Ready(content, events) { bucket, category, account ->
|
||||
onOpenDrillDown(DrillDownArgs(content.data.filter, bucket, category, account))
|
||||
}
|
||||
if (insights != null) InsightsSection(insights, content.accounts, insightsEvents)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -311,3 +324,9 @@ private fun LoadingCards() {
|
||||
|
||||
private const val SKELETON_ROWS = 3
|
||||
|
||||
/** No Insights events: the default of the stateless S-11 when no section is rendered. */
|
||||
private object NoInsightsEvents : InsightsEvents {
|
||||
override fun onRetryInsights() = Unit
|
||||
|
||||
override fun onSelectCategory(id: CategoryId) = Unit
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package se.hagstedt.kvitt.feature.analytics
|
||||
|
||||
import se.hagstedt.kvitt.model.AccountForecast
|
||||
import se.hagstedt.kvitt.model.CategoryId
|
||||
import se.hagstedt.kvitt.model.CategoryTrend
|
||||
import se.hagstedt.kvitt.model.Measure
|
||||
import se.hagstedt.kvitt.model.OnlineResult
|
||||
import se.hagstedt.kvitt.model.SpendForecast
|
||||
import se.hagstedt.kvitt.model.SpendTrends
|
||||
import kotlin.math.abs
|
||||
|
||||
/** One load of S-11's Insights (D-141): the server's trends and forecast for one measure. */
|
||||
data class Insights(val trends: SpendTrends, val forecast: SpendForecast)
|
||||
|
||||
/** Where the Insights section stands; the last loaded [InsightsUiState.data] stays visible under every status. */
|
||||
sealed interface InsightsStatus {
|
||||
/** A request is running, or none has been made yet. */
|
||||
data object Loading : InsightsStatus
|
||||
|
||||
data object Loaded : InsightsStatus
|
||||
|
||||
/** Offline (§9.9): "Kräver anslutning". */
|
||||
data object NeedsConnection : InsightsStatus
|
||||
|
||||
/** The last request failed: `network` is "Kunde inte nå servern", other codes their §14.3.2 text; with "Försök igen". */
|
||||
data class Failed(val code: String) : InsightsStatus
|
||||
}
|
||||
|
||||
/**
|
||||
* S-11's Insights section: the [measure] it shows (S-11's measure toggle), its [status], the last data loaded for that
|
||||
* measure in this session ([data], null before the first success), and the category whose line chart is open.
|
||||
*/
|
||||
data class InsightsUiState(val measure: Measure, val status: InsightsStatus, val data: Insights?, val selectedCategory: CategoryId?)
|
||||
|
||||
/** The Insights section's events (the InsightsViewModel implements them; screenshot tests pass their own). */
|
||||
interface InsightsEvents {
|
||||
fun onRetryInsights()
|
||||
fun onSelectCategory(id: CategoryId)
|
||||
}
|
||||
|
||||
/** The rules of the Insights section (D-141, D-142), pure so the unit tests reach them. */
|
||||
object InsightsRules {
|
||||
/** D-142: the whole months of the trends (E-34 `months`), the server's default and S-11's default range (AC-14.2). */
|
||||
const val MONTHS = 6
|
||||
|
||||
/** D-133: the forecast baseline's window, D-128's moving-average window counted in whole months. */
|
||||
const val BASELINE_MONTHS = AnalyticsRules.MOVING_AVERAGE_WINDOW
|
||||
|
||||
/** Both reads succeed, or the first one that did not: offline before a failure. */
|
||||
fun combine(trends: OnlineResult<SpendTrends>, forecast: OnlineResult<SpendForecast>): OnlineResult<Insights> = when {
|
||||
trends is OnlineResult.Ok && forecast is OnlineResult.Ok -> OnlineResult.Ok(Insights(trends.value, forecast.value))
|
||||
trends == OnlineResult.Offline || forecast == OnlineResult.Offline -> OnlineResult.Offline
|
||||
trends is OnlineResult.Failed -> trends
|
||||
forecast is OnlineResult.Failed -> forecast
|
||||
else -> OnlineResult.Offline
|
||||
}
|
||||
|
||||
/** The section's status: offline always reads "Kräver anslutning"; online, the last attempt for the measure (none yet: loading). */
|
||||
fun status(online: Boolean, attempt: InsightsStatus?): InsightsStatus = if (!online) InsightsStatus.NeedsConnection else attempt ?: InsightsStatus.Loading
|
||||
|
||||
/** The status a finished load leaves. */
|
||||
fun statusOf(result: OnlineResult<Insights>): InsightsStatus = when (result) {
|
||||
is OnlineResult.Ok -> InsightsStatus.Loaded
|
||||
OnlineResult.Offline -> InsightsStatus.NeedsConnection
|
||||
is OnlineResult.Failed -> InsightsStatus.Failed(result.code)
|
||||
}
|
||||
|
||||
/** A load starts when online and the measure has no successful load yet, or on [force] (entering S-11, "Försök igen"). */
|
||||
fun shouldLoad(online: Boolean, attempt: InsightsStatus?, force: Boolean): Boolean =
|
||||
online && attempt != InsightsStatus.Loading && (force || attempt != InsightsStatus.Loaded)
|
||||
|
||||
/** The categories the section lists: those the phone holds a row for (deleted included); unknown ids are left out. */
|
||||
fun shownCategories(trends: SpendTrends): List<CategoryTrend> = trends.categories.filter { it.category != null }
|
||||
|
||||
/** The category whose chart is open: [selected] while it is listed, else the first (the largest total). */
|
||||
fun selectedCategory(trends: SpendTrends, selected: CategoryId?): CategoryTrend? {
|
||||
val shown = shownCategories(trends)
|
||||
return shown.firstOrNull { it.categoryId == selected } ?: shown.firstOrNull()
|
||||
}
|
||||
|
||||
/** No spending in the whole months nor so far this month: the trend cards give way to one line. */
|
||||
fun isEmpty(trends: SpendTrends): Boolean =
|
||||
trends.household.totalOre == 0L && trends.household.currentMonthOre == 0L && shownCategories(trends).isEmpty()
|
||||
|
||||
/** An account with nothing to forecast and nothing spent this month shows one line instead of its months. */
|
||||
fun hasNothing(forecast: AccountForecast): Boolean = forecast.currentMonthOre == 0L && forecast.months.all { it.forecastOre == 0L }
|
||||
|
||||
/** The direction of a last-month change for the S-12 caption words (D-128): 0 % is "på snittet". */
|
||||
fun comparison(lastMonth: String, pct: Int?): AverageComparison? = pct?.let {
|
||||
val direction = when {
|
||||
it > 0 -> AverageComparison.Direction.ABOVE
|
||||
it < 0 -> AverageComparison.Direction.BELOW
|
||||
else -> AverageComparison.Direction.ON
|
||||
}
|
||||
AverageComparison(lastMonth, abs(it).toLong(), direction)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
package se.hagstedt.kvitt.feature.analytics
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.selection.selectableGroup
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import kotlinx.datetime.LocalDate
|
||||
import se.hagstedt.kvitt.design.KvittTheme
|
||||
import se.hagstedt.kvitt.design.chart.ChartCard
|
||||
import se.hagstedt.kvitt.design.chart.LineKind
|
||||
import se.hagstedt.kvitt.design.chart.LineLegend
|
||||
import se.hagstedt.kvitt.design.chart.LineLegendItem
|
||||
import se.hagstedt.kvitt.design.chart.LinePoint
|
||||
import se.hagstedt.kvitt.design.chart.SpendLineChart
|
||||
import se.hagstedt.kvitt.design.component.AccountTile
|
||||
import se.hagstedt.kvitt.design.component.AmountStyle
|
||||
import se.hagstedt.kvitt.design.component.AmountTone
|
||||
import se.hagstedt.kvitt.design.component.ArchivedBadge
|
||||
import se.hagstedt.kvitt.design.component.Banner
|
||||
import se.hagstedt.kvitt.design.component.BannerKind
|
||||
import se.hagstedt.kvitt.design.component.ButtonKind
|
||||
import se.hagstedt.kvitt.design.component.CategoryTile
|
||||
import se.hagstedt.kvitt.design.component.KvittButton
|
||||
import se.hagstedt.kvitt.design.component.KvittCard
|
||||
import se.hagstedt.kvitt.design.component.MoneyText
|
||||
import se.hagstedt.kvitt.design.component.NeedsConnectionBanner
|
||||
import se.hagstedt.kvitt.design.component.SectionHeader
|
||||
import se.hagstedt.kvitt.design.component.Skeleton
|
||||
import se.hagstedt.kvitt.design.component.categoryName
|
||||
import se.hagstedt.kvitt.design.component.kvittLanguage
|
||||
import se.hagstedt.kvitt.design.component.spokenMoney
|
||||
import se.hagstedt.kvitt.design.format.DateFormatter
|
||||
import se.hagstedt.kvitt.design.format.MoneyFormatter
|
||||
import se.hagstedt.kvitt.design.format.commonFailureText
|
||||
import se.hagstedt.kvitt.design.icon.KvittIcon
|
||||
import se.hagstedt.kvitt.design.theme.KvittDimens
|
||||
import se.hagstedt.kvitt.model.Account
|
||||
import se.hagstedt.kvitt.model.AccountForecast
|
||||
import se.hagstedt.kvitt.model.CategoryId
|
||||
import se.hagstedt.kvitt.model.CategoryTrend
|
||||
import se.hagstedt.kvitt.model.ForecastMonth
|
||||
import se.hagstedt.kvitt.model.SpendTrend
|
||||
import se.hagstedt.kvitt.model.SpendTrends
|
||||
import se.hagstedt.kvitt.core.design.R as DesignR
|
||||
|
||||
/**
|
||||
* S-11's Insights section (D-133, D-141), below "Per konto" (or the empty state): heading "Insikter" with the months
|
||||
* and measure; a "Kräver anslutning" banner offline, or "Kunde inte nå servern" with "Försök igen" after a failure,
|
||||
* above the last data loaded this session; skeleton cards before the first load. The data: "Utgifter per månad" (the
|
||||
* household's or my share's line chart with the average and the moving average from the server), "Kategorier per
|
||||
* månad" (the chosen category's line chart over the list of categories), and "Prognos" with a card per account.
|
||||
* Emits its parts into the caller's column.
|
||||
*/
|
||||
@Composable
|
||||
internal fun InsightsSection(state: InsightsUiState, accounts: List<Account>, events: InsightsEvents) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
SectionHeader(stringResource(R.string.analytics_insights_title))
|
||||
Text(
|
||||
pluralStringResource(R.plurals.analytics_insights_caption, InsightsRules.MONTHS, InsightsRules.MONTHS, stringResource(measureLabel(state.measure))),
|
||||
Modifier.padding(horizontal = KvittTheme.spacing.s2),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
when (val status = state.status) {
|
||||
InsightsStatus.NeedsConnection -> NeedsConnectionBanner(stringResource(R.string.analytics_insights_offline_body))
|
||||
is InsightsStatus.Failed -> Banner(
|
||||
BannerKind.Error,
|
||||
KvittIcon.CloudOff,
|
||||
stringResource(commonFailureText(status.code) ?: DesignR.string.error_server),
|
||||
) { content ->
|
||||
KvittButton(stringResource(DesignR.string.common_retry), events::onRetryInsights, kind = ButtonKind.Text, contentColor = content, icon = KvittIcon.Refresh)
|
||||
}
|
||||
InsightsStatus.Loading, InsightsStatus.Loaded -> Unit
|
||||
}
|
||||
val data = state.data
|
||||
when {
|
||||
data != null -> InsightsContent(data, state.selectedCategory, accounts, events)
|
||||
state.status == InsightsStatus.Loading -> InsightsSkeleton()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InsightsContent(data: Insights, selected: CategoryId?, accounts: List<Account>, events: InsightsEvents) {
|
||||
val trends = data.trends
|
||||
if (InsightsRules.isEmpty(trends)) {
|
||||
KvittCard(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
pluralStringResource(R.plurals.analytics_insights_no_spending, trends.months, trends.months),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
ChartCard(stringResource(R.string.analytics_insights_trend)) {
|
||||
TrendChart(stringResource(measureLabel(trends.measure)), trends.household, MaterialTheme.colorScheme.primary, trends)
|
||||
}
|
||||
CategoryTrends(trends, selected, events)
|
||||
}
|
||||
ForecastHeader()
|
||||
data.forecast.accounts.forEach { forecast ->
|
||||
val index = accounts.indexOfFirst { it.id == forecast.accountId }
|
||||
val account = forecast.account ?: accounts.getOrNull(index) ?: return@forEach
|
||||
AccountForecastCard(account, if (index >= 0) index else accounts.size, forecast, data.forecast.currentMonth)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One trend as D-128's line chart: the months, the server's average as the dashed line and its moving average (3
|
||||
* months); then the legend, the average per month and the last whole month against it, and the current month so far.
|
||||
*/
|
||||
@Composable
|
||||
private fun TrendChart(name: String, trend: SpendTrend, color: Color, trends: SpendTrends) {
|
||||
val points = trend.points
|
||||
val overlays = points.size > 1 && trend.averageOre > 0L
|
||||
val window = AnalyticsRules.MOVING_AVERAGE_WINDOW
|
||||
SpendLineChart(
|
||||
points = points.map { LinePoint(it.month, it.amountOre) },
|
||||
color = color,
|
||||
summary = trendSummary(name, trend),
|
||||
averageOre = trend.averageOre.takeIf { overlays },
|
||||
movingAverage = if (overlays) points.map { it.movingAverageOre } else emptyList(),
|
||||
)
|
||||
LineLegend(
|
||||
listOfNotNull(
|
||||
LineLegendItem(name, LineKind.Series),
|
||||
LineLegendItem(stringResource(R.string.analytics_average), LineKind.Average).takeIf { overlays },
|
||||
LineLegendItem(stringResource(R.string.analytics_moving_average, window), LineKind.MovingAverage).takeIf { overlays },
|
||||
),
|
||||
color,
|
||||
)
|
||||
val language = kvittLanguage()
|
||||
val separator = stringResource(DesignR.string.common_separator_dot)
|
||||
val average = stringResource(R.string.analytics_insights_average_per_month, MoneyFormatter.format(trend.averageOre, language))
|
||||
val comparison = points.lastOrNull()?.let { InsightsRules.comparison(it.month, trend.lastMonthVsAveragePct) }?.let { comparisonWords(it) }
|
||||
Column {
|
||||
Text(listOfNotNull(average, comparison).joinToString(separator), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(
|
||||
stringResource(R.string.analytics_insights_so_far, bucketName(trends.currentMonth, capitalized = false), MoneyFormatter.format(trend.currentMonthOre, language)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** "Mat & hushåll över tid, apr–sep 2026: från … till …, totalt …, snitt …." (D-128's sentence, `analytics_summary_line`). */
|
||||
@Composable
|
||||
private fun trendSummary(name: String, trend: SpendTrend): String {
|
||||
val first = trend.points.firstOrNull()
|
||||
val last = trend.points.lastOrNull()
|
||||
if (first == null || last == null) return name
|
||||
val range = DateFormatter.monthRange(monthStart(first.month), monthStart(last.month), kvittLanguage())
|
||||
val total = spokenMoney(trend.totalOre)
|
||||
return if (trend.points.size == 1) {
|
||||
stringResource(R.string.analytics_summary_line_single, name, range, total)
|
||||
} else {
|
||||
stringResource(
|
||||
R.string.analytics_summary_line,
|
||||
name,
|
||||
range,
|
||||
spokenMoney(first.amountOre),
|
||||
bucketName(first.month, capitalized = false),
|
||||
spokenMoney(last.amountOre),
|
||||
bucketName(last.month, capitalized = false),
|
||||
total,
|
||||
spokenMoney(trend.averageOre),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun monthStart(month: String): LocalDate = LocalDate.parse("$month-01")
|
||||
|
||||
/** "September 2026: 53 % över snittet" (D-128's caption words). */
|
||||
@Composable
|
||||
private fun comparisonWords(comparison: AverageComparison): String {
|
||||
val month = bucketName(comparison.key, capitalized = true)
|
||||
val percent = stringResource(DesignR.string.common_percent, comparison.percent.toString())
|
||||
return when (comparison.direction) {
|
||||
AverageComparison.Direction.ABOVE -> stringResource(R.string.analytics_vs_average_above, month, percent)
|
||||
AverageComparison.Direction.BELOW -> stringResource(R.string.analytics_vs_average_below, month, percent)
|
||||
AverageComparison.Direction.ON -> stringResource(R.string.analytics_vs_average_on, month)
|
||||
}
|
||||
}
|
||||
|
||||
/** "Kategorier per månad": the chosen category's chart, then every category as a radio row (tile, name, average, change). */
|
||||
@Composable
|
||||
private fun CategoryTrends(trends: SpendTrends, selected: CategoryId?, events: InsightsEvents) {
|
||||
val current = InsightsRules.selectedCategory(trends, selected) ?: return
|
||||
val category = current.category ?: return
|
||||
ChartCard(stringResource(R.string.analytics_insights_categories)) {
|
||||
TrendChart(categoryName(category), current.trend, KvittTheme.colors.category(category.color), trends)
|
||||
Column(Modifier.fillMaxWidth().selectableGroup(), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s1)) {
|
||||
InsightsRules.shownCategories(trends).forEach { row ->
|
||||
CategoryTrendRow(row, row.categoryId == current.categoryId) { events.onSelectCategory(row.categoryId) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CategoryTrendRow(row: CategoryTrend, selected: Boolean, onClick: () -> Unit) {
|
||||
val category = row.category ?: return
|
||||
val language = kvittLanguage()
|
||||
val pct = row.trend.lastMonthVsAveragePct
|
||||
val change = when {
|
||||
pct == null -> null
|
||||
pct > 0 -> stringResource(R.string.analytics_insights_change_up, stringResource(DesignR.string.common_percent, pct.toString()))
|
||||
pct < 0 -> stringResource(R.string.analytics_insights_change_down, stringResource(DesignR.string.common_percent, (-pct).toString()))
|
||||
else -> stringResource(DesignR.string.common_percent, "0")
|
||||
}
|
||||
val fill = if (selected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = KvittTheme.spacing.touchTarget)
|
||||
.clip(KvittTheme.shapes.field)
|
||||
.background(fill)
|
||||
.selectable(selected = selected, role = Role.RadioButton, onClick = onClick)
|
||||
.padding(horizontal = KvittTheme.spacing.s2, vertical = KvittTheme.spacing.s2),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3),
|
||||
) {
|
||||
CategoryTile(category, KvittDimens.tileBreakdown)
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(categoryName(category), style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
stringResource(R.string.analytics_insights_average_per_month, MoneyFormatter.format(row.trend.averageOre, language)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (change != null) Text(change, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
/** "Prognos" and how it is made (D-133). */
|
||||
@Composable
|
||||
private fun ForecastHeader() {
|
||||
Column(Modifier.fillMaxWidth().padding(horizontal = KvittTheme.spacing.s2).semantics(mergeDescendants = true) { }) {
|
||||
Text(stringResource(R.string.analytics_insights_forecast), style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
pluralStringResource(R.plurals.analytics_insights_forecast_caption, InsightsRules.BASELINE_MONTHS, InsightsRules.BASELINE_MONTHS),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** One account's forecast: tile, name and the current month so far, then the two months with their parts. */
|
||||
@Composable
|
||||
private fun AccountForecastCard(account: Account, index: Int, forecast: AccountForecast, currentMonth: String) {
|
||||
val language = kvittLanguage()
|
||||
KvittCard(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3)) {
|
||||
Row(
|
||||
Modifier.semantics(mergeDescendants = true) { },
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3),
|
||||
) {
|
||||
AccountTile(account.name, index, size = KvittDimens.tileBreakdown, muted = account.archived)
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(account.name, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
stringResource(R.string.analytics_insights_so_far, bucketName(currentMonth, capitalized = false), MoneyFormatter.format(forecast.currentMonthOre, language)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (account.archived) ArchivedBadge()
|
||||
}
|
||||
if (InsightsRules.hasNothing(forecast)) {
|
||||
Text(stringResource(R.string.analytics_insights_forecast_none), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
} else {
|
||||
forecast.months.forEach { ForecastMonthRow(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ForecastMonthRow(month: ForecastMonth) {
|
||||
val language = kvittLanguage()
|
||||
val name = bucketName(month.month, capitalized = true)
|
||||
val spoken = stringResource(
|
||||
R.string.analytics_insights_forecast_month_spoken,
|
||||
name,
|
||||
spokenMoney(month.forecastOre),
|
||||
spokenMoney(month.recurringOre),
|
||||
spokenMoney(month.baselineOre),
|
||||
)
|
||||
Row(Modifier.fillMaxWidth().clearAndSetSemantics { contentDescription = spoken }, verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(name, style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
stringResource(R.string.analytics_insights_forecast_parts, MoneyFormatter.format(month.recurringOre, language), MoneyFormatter.format(month.baselineOre, language)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
MoneyText(month.forecastOre, AmountStyle.Medium, AmountTone.Neutral)
|
||||
}
|
||||
}
|
||||
|
||||
/** Before the first load: a chart card and an account card in skeleton form, read as "Hämtar insikter". */
|
||||
@Composable
|
||||
private fun InsightsSkeleton() {
|
||||
val loading = stringResource(R.string.analytics_insights_loading)
|
||||
Column(
|
||||
Modifier.fillMaxWidth().clearAndSetSemantics { contentDescription = loading },
|
||||
verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.detailGap),
|
||||
) {
|
||||
ChartCard(stringResource(R.string.analytics_insights_trend)) {
|
||||
Skeleton(KvittDimens.chartPlot, KvittDimens.chartPlot, Modifier.fillMaxWidth())
|
||||
}
|
||||
KvittCard(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3)) {
|
||||
Skeleton(KvittDimens.menuMinWidth / 2, KvittTheme.spacing.s4)
|
||||
repeat(SKELETON_MONTHS) { Skeleton(KvittDimens.chartPlot, KvittTheme.spacing.s8, Modifier.fillMaxWidth()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The forecast's two months (E-35). */
|
||||
private const val SKELETON_MONTHS = 2
|
||||
@@ -0,0 +1,75 @@
|
||||
package se.hagstedt.kvitt.feature.analytics
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import se.hagstedt.kvitt.model.CategoryId
|
||||
import se.hagstedt.kvitt.model.ConnectivityRepository
|
||||
import se.hagstedt.kvitt.model.InsightsRepository
|
||||
import se.hagstedt.kvitt.model.Measure
|
||||
import se.hagstedt.kvitt.model.OnlineResult
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* S-11's Insights (D-133, D-141): the server's trends and forecast, online only (§9.9). The data of each measure is
|
||||
* kept in memory for the life of S-11's back stack entry and stays on screen while offline or after a failure; nothing
|
||||
* is stored. A load runs when S-11 shows ([show]), when the measure changes to one without data, when the phone comes
|
||||
* online, and on "Försök igen".
|
||||
*/
|
||||
@HiltViewModel
|
||||
class InsightsViewModel @Inject constructor(
|
||||
private val repository: InsightsRepository,
|
||||
connectivity: ConnectivityRepository,
|
||||
) : ViewModel(), InsightsEvents {
|
||||
private val measure = MutableStateFlow(Measure.HOUSEHOLD)
|
||||
private val online = MutableStateFlow<Boolean?>(null)
|
||||
private val cache = MutableStateFlow<Map<Measure, Insights>>(emptyMap())
|
||||
private val attempts = MutableStateFlow<Map<Measure, InsightsStatus>>(emptyMap())
|
||||
private val selected = MutableStateFlow<CategoryId?>(null)
|
||||
|
||||
val state: StateFlow<InsightsUiState> = combine(measure, online, cache, attempts, selected) { m, o, c, a, s ->
|
||||
InsightsUiState(m, if (o == null) InsightsStatus.Loading else InsightsRules.status(o, a[m]), c[m], s)
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, InsightsUiState(measure.value, InsightsStatus.Loading, null, null))
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
connectivity.online.distinctUntilChanged().onEach { online.value = it }.collect { load(measure.value, force = true) }
|
||||
}
|
||||
}
|
||||
|
||||
/** S-11 is on screen with [measure] (its measure toggle): refresh it. */
|
||||
fun show(measure: Measure) {
|
||||
val changed = this.measure.value != measure
|
||||
this.measure.value = measure
|
||||
load(measure, force = !changed || attempts.value[measure] != InsightsStatus.Loaded)
|
||||
}
|
||||
|
||||
override fun onRetryInsights() = load(measure.value, force = true)
|
||||
|
||||
override fun onSelectCategory(id: CategoryId) = selected.update { id }
|
||||
|
||||
private fun load(m: Measure, force: Boolean) {
|
||||
if (!InsightsRules.shouldLoad(online.value == true, attempts.value[m], force)) return
|
||||
attempts.update { it + (m to InsightsStatus.Loading) }
|
||||
viewModelScope.launch {
|
||||
val result = coroutineScope {
|
||||
val trends = async { repository.trends(InsightsRules.MONTHS, m) }
|
||||
val forecast = async { repository.forecast(m) }
|
||||
InsightsRules.combine(trends.await(), forecast.await())
|
||||
}
|
||||
if (result is OnlineResult.Ok) cache.update { it + (m to result.value) }
|
||||
attempts.update { it + (m to InsightsRules.statusOf(result)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,4 +50,30 @@
|
||||
<string name="analytics_vs_average_on">%1$s: på snittet</string>
|
||||
<string name="analytics_summary_line">%1$s över tid, %2$s: från %3$s i %4$s till %5$s i %6$s, totalt %7$s, snitt %8$s.</string>
|
||||
<string name="analytics_summary_line_single">%1$s över tid, %2$s: totalt %3$s.</string>
|
||||
<!-- Insikter (D-133, D-141): serverns trender och prognos under S-11:s diagram, bara med anslutning (§9.9). -->
|
||||
<string name="analytics_insights_title">Insikter</string>
|
||||
<plurals name="analytics_insights_caption">
|
||||
<item quantity="one">Senaste %1$d hela månaden · %2$s</item>
|
||||
<item quantity="other">Senaste %1$d hela månaderna · %2$s</item>
|
||||
</plurals>
|
||||
<string name="analytics_insights_offline_body">Insikterna räknas fram på servern. Anslut till internet för att se dem.</string>
|
||||
<string name="analytics_insights_loading">Hämtar insikter</string>
|
||||
<string name="analytics_insights_trend">Utgifter per månad</string>
|
||||
<string name="analytics_insights_categories">Kategorier per månad</string>
|
||||
<string name="analytics_insights_average_per_month">Snitt %1$s i månaden</string>
|
||||
<string name="analytics_insights_so_far">Hittills i %1$s: %2$s</string>
|
||||
<string name="analytics_insights_change_up">+%1$s</string>
|
||||
<string name="analytics_insights_change_down">−%1$s</string>
|
||||
<plurals name="analytics_insights_no_spending">
|
||||
<item quantity="one">Inga utgifter den senaste %1$d hela månaden</item>
|
||||
<item quantity="other">Inga utgifter de senaste %1$d hela månaderna</item>
|
||||
</plurals>
|
||||
<string name="analytics_insights_forecast">Prognos</string>
|
||||
<plurals name="analytics_insights_forecast_caption">
|
||||
<item quantity="one">Återkommande utgifter som förfaller, plus snittet av övriga utgifter den senaste %1$d månaden</item>
|
||||
<item quantity="other">Återkommande utgifter som förfaller, plus snittet av övriga utgifter de senaste %1$d månaderna</item>
|
||||
</plurals>
|
||||
<string name="analytics_insights_forecast_parts">Återkommande %1$s + snitt %2$s</string>
|
||||
<string name="analytics_insights_forecast_none">Inga återkommande utgifter och inga utgifter på sistone</string>
|
||||
<string name="analytics_insights_forecast_month_spoken">%1$s: %2$s, återkommande %3$s, snitt %4$s</string>
|
||||
</resources>
|
||||
|
||||
@@ -50,4 +50,30 @@
|
||||
<string name="analytics_vs_average_on">%1$s: on average</string>
|
||||
<string name="analytics_summary_line">%1$s over time, %2$s: from %3$s in %4$s to %5$s in %6$s, %7$s in total, average %8$s.</string>
|
||||
<string name="analytics_summary_line_single">%1$s over time, %2$s: %3$s in total.</string>
|
||||
<!-- Insights (D-133, D-141): the server's trends and forecast below S-11's charts, online only (§9.9). -->
|
||||
<string name="analytics_insights_title">Insights</string>
|
||||
<plurals name="analytics_insights_caption">
|
||||
<item quantity="one">Last %1$d whole month · %2$s</item>
|
||||
<item quantity="other">Last %1$d whole months · %2$s</item>
|
||||
</plurals>
|
||||
<string name="analytics_insights_offline_body">Insights are calculated on the server. Connect to the internet to see them.</string>
|
||||
<string name="analytics_insights_loading">Loading insights</string>
|
||||
<string name="analytics_insights_trend">Spending per month</string>
|
||||
<string name="analytics_insights_categories">Categories per month</string>
|
||||
<string name="analytics_insights_average_per_month">Average %1$s a month</string>
|
||||
<string name="analytics_insights_so_far">So far in %1$s: %2$s</string>
|
||||
<string name="analytics_insights_change_up">+%1$s</string>
|
||||
<string name="analytics_insights_change_down">−%1$s</string>
|
||||
<plurals name="analytics_insights_no_spending">
|
||||
<item quantity="one">No spending in the last %1$d whole month</item>
|
||||
<item quantity="other">No spending in the last %1$d whole months</item>
|
||||
</plurals>
|
||||
<string name="analytics_insights_forecast">Forecast</string>
|
||||
<plurals name="analytics_insights_forecast_caption">
|
||||
<item quantity="one">Recurring expenses due, plus the average of other spending in the last %1$d month</item>
|
||||
<item quantity="other">Recurring expenses due, plus the average of other spending in the last %1$d months</item>
|
||||
</plurals>
|
||||
<string name="analytics_insights_forecast_parts">Recurring %1$s + average %2$s</string>
|
||||
<string name="analytics_insights_forecast_none">No recurring expenses and no recent spending</string>
|
||||
<string name="analytics_insights_forecast_month_spoken">%1$s: %2$s, recurring %3$s, average %4$s</string>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package se.hagstedt.kvitt.feature.analytics
|
||||
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import se.hagstedt.kvitt.design.testing.MainDispatcherRule
|
||||
import se.hagstedt.kvitt.fakes.FakeRepositories
|
||||
import se.hagstedt.kvitt.fakes.FakeScenario
|
||||
import se.hagstedt.kvitt.fakes.analytics.AnalyticsScenarios
|
||||
import se.hagstedt.kvitt.fakes.analytics.InsightsScenarios
|
||||
import se.hagstedt.kvitt.model.Measure
|
||||
import se.hagstedt.kvitt.model.OnlineResult
|
||||
|
||||
/** S-11's Insights state holder (D-141): online only (§9.9), the last data kept for the session. */
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class InsightsViewModelTest {
|
||||
@get:Rule
|
||||
val main = MainDispatcherRule()
|
||||
|
||||
private fun setup(scenario: FakeScenario): Pair<FakeRepositories, InsightsViewModel> {
|
||||
val fakes = FakeRepositories(scenario)
|
||||
return fakes to InsightsViewModel(fakes.insights, fakes.connectivity)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onlineLoadsTrendsAndForecastForTheHousehold() = runTest {
|
||||
val (_, vm) = setup(InsightsScenarios.loaded)
|
||||
val state = vm.state.first { it.status == InsightsStatus.Loaded }
|
||||
val data = requireNotNull(state.data)
|
||||
assertEquals(Measure.HOUSEHOLD, data.trends.measure)
|
||||
assertEquals(InsightsRules.MONTHS, data.trends.months)
|
||||
assertEquals(5, data.forecast.accounts.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun offlineShowsNeedsConnectionWithoutData() = runTest {
|
||||
val (_, vm) = setup(InsightsScenarios.offline)
|
||||
vm.show(Measure.HOUSEHOLD)
|
||||
val state = vm.state.first { it.status == InsightsStatus.NeedsConnection }
|
||||
assertNull(state.data)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun goingOfflineKeepsTheLastDataUnderTheHint_andComingBackLoadsAgain() = runTest {
|
||||
val (fakes, vm) = setup(InsightsScenarios.loaded)
|
||||
val loaded = vm.state.first { it.status == InsightsStatus.Loaded }.data
|
||||
fakes.store.edit { it.copy(online = false) }
|
||||
val offline = vm.state.first { it.status == InsightsStatus.NeedsConnection }
|
||||
assertEquals(loaded, offline.data)
|
||||
fakes.store.edit { it.copy(online = true) }
|
||||
assertEquals(loaded, vm.state.first { it.status == InsightsStatus.Loaded }.data)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aNetworkFailureKeepsTheLastData_andRetryRecovers() = runTest {
|
||||
val (fakes, vm) = setup(InsightsScenarios.loaded)
|
||||
val loaded = vm.state.first { it.status == InsightsStatus.Loaded }.data
|
||||
fakes.store.edit { it.copy(insightsFailure = "network") }
|
||||
vm.show(Measure.HOUSEHOLD)
|
||||
val failed = vm.state.first { it.status is InsightsStatus.Failed }
|
||||
assertEquals(InsightsStatus.Failed("network"), failed.status)
|
||||
assertEquals(loaded, failed.data)
|
||||
fakes.store.edit { it.copy(insightsFailure = null) }
|
||||
vm.onRetryInsights()
|
||||
assertEquals(InsightsStatus.Loaded, vm.state.first { it.status == InsightsStatus.Loaded }.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theMeasureToggleLoadsMyShare_andTheHouseholdDataIsKept() = runTest {
|
||||
val (_, vm) = setup(InsightsScenarios.loaded)
|
||||
val household = vm.state.first { it.status == InsightsStatus.Loaded }.data
|
||||
vm.show(Measure.MY_SHARE)
|
||||
val share = vm.state.first { it.measure == Measure.MY_SHARE && it.status == InsightsStatus.Loaded }
|
||||
assertEquals(Measure.MY_SHARE, share.data?.trends?.measure)
|
||||
vm.show(Measure.HOUSEHOLD)
|
||||
val back = vm.state.first { it.measure == Measure.HOUSEHOLD }
|
||||
assertEquals(household, back.data)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun selectingACategoryOpensItsChart() = runTest {
|
||||
val (_, vm) = setup(InsightsScenarios.loaded)
|
||||
val data = requireNotNull(vm.state.first { it.status == InsightsStatus.Loaded }.data)
|
||||
assertEquals(AnalyticsScenarios.housing.id, InsightsRules.selectedCategory(data.trends, null)?.categoryId)
|
||||
vm.onSelectCategory(AnalyticsScenarios.travel.id)
|
||||
val selected = vm.state.first { it.selectedCategory != null }.selectedCategory
|
||||
assertEquals(AnalyticsScenarios.travel.id, InsightsRules.selectedCategory(data.trends, selected)?.categoryId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rulesCombineStatusesAndLoads() {
|
||||
assertEquals(OnlineResult.Offline, InsightsRules.combine(OnlineResult.Offline, OnlineResult.Failed("network", null)))
|
||||
assertEquals(OnlineResult.Failed("internal", null), InsightsRules.combine(OnlineResult.Failed("internal", null), OnlineResult.Failed("network", null)))
|
||||
assertEquals(InsightsStatus.NeedsConnection, InsightsRules.status(online = false, attempt = InsightsStatus.Loaded))
|
||||
assertEquals(InsightsStatus.Loading, InsightsRules.status(online = true, attempt = null))
|
||||
assertFalse(InsightsRules.shouldLoad(online = false, attempt = null, force = true))
|
||||
assertFalse(InsightsRules.shouldLoad(online = true, attempt = InsightsStatus.Loading, force = true))
|
||||
assertFalse(InsightsRules.shouldLoad(online = true, attempt = InsightsStatus.Loaded, force = false))
|
||||
assertTrue(InsightsRules.shouldLoad(online = true, attempt = InsightsStatus.Loaded, force = true))
|
||||
assertTrue(InsightsRules.shouldLoad(online = true, attempt = InsightsStatus.Failed("network"), force = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun comparisonWordsFollowTheSignOfThePercent() {
|
||||
assertEquals(AverageComparison("2026-09", 53, AverageComparison.Direction.ABOVE), InsightsRules.comparison("2026-09", 53))
|
||||
assertEquals(AverageComparison("2026-09", 25, AverageComparison.Direction.BELOW), InsightsRules.comparison("2026-09", -25))
|
||||
assertEquals(AverageComparison.Direction.ON, InsightsRules.comparison("2026-09", 0)?.direction)
|
||||
assertNull(InsightsRules.comparison("2026-09", null))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package se.hagstedt.kvitt.feature.analytics.screenshot
|
||||
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.ParameterizedRobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
import se.hagstedt.kvitt.design.KvittTheme
|
||||
import se.hagstedt.kvitt.design.testing.ScreenSnapshots
|
||||
import se.hagstedt.kvitt.fakes.FakeRepositories
|
||||
import se.hagstedt.kvitt.fakes.FakeScenario
|
||||
import se.hagstedt.kvitt.fakes.analytics.AnalyticsScenarios
|
||||
import se.hagstedt.kvitt.fakes.analytics.InsightsScenarios
|
||||
import se.hagstedt.kvitt.feature.analytics.AnalyticsContent
|
||||
import se.hagstedt.kvitt.feature.analytics.AnalyticsRules
|
||||
import se.hagstedt.kvitt.feature.analytics.AnalyticsScreen
|
||||
import se.hagstedt.kvitt.feature.analytics.AnalyticsUiState
|
||||
import se.hagstedt.kvitt.feature.analytics.Insights
|
||||
import se.hagstedt.kvitt.feature.analytics.InsightsEvents
|
||||
import se.hagstedt.kvitt.feature.analytics.InsightsRules
|
||||
import se.hagstedt.kvitt.feature.analytics.InsightsSection
|
||||
import se.hagstedt.kvitt.feature.analytics.InsightsStatus
|
||||
import se.hagstedt.kvitt.feature.analytics.InsightsUiState
|
||||
import se.hagstedt.kvitt.feature.analytics.PeriodDialog
|
||||
import se.hagstedt.kvitt.model.Account
|
||||
import se.hagstedt.kvitt.model.CategoryId
|
||||
import se.hagstedt.kvitt.model.Measure
|
||||
import se.hagstedt.kvitt.model.OnlineResult
|
||||
|
||||
/**
|
||||
* S-11's Insights section (D-141) in each state, × light/dark × sv/en, plus 200 % for long content (D-098). The section
|
||||
* renders in S-11's column (gutter, gap 10); `_scrolled` frames show its end (the forecast cards), `s11_insights_screen`
|
||||
* the end of the whole S-11 with the section under "Per konto". Re-record with
|
||||
* `tools/gradle-serial.sh :feature:analytics:recordRoborazziDebug` and open every changed PNG.
|
||||
*/
|
||||
@RunWith(ParameterizedRobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [36], qualifiers = ScreenSnapshots.QUALIFIERS)
|
||||
class InsightsScreenshotTest(private val name: String) {
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun screen() {
|
||||
AnalyticsSnapshots.snap(compose, name, largeFont = name == "s11_insights_long", content = states.getValue(name))
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** The Insights of [scenario] as the server would answer them, or null when the fake reads fail. */
|
||||
private fun insights(scenario: FakeScenario, measure: Measure = Measure.HOUSEHOLD): Insights? = runBlocking {
|
||||
val fakes = FakeRepositories(scenario.copy(online = true, insightsFailure = null))
|
||||
(InsightsRules.combine(fakes.insights.trends(InsightsRules.MONTHS, measure), fakes.insights.forecast(measure)) as? OnlineResult.Ok)?.value
|
||||
}
|
||||
|
||||
private fun accounts(scenario: FakeScenario): List<Account> = runBlocking { FakeRepositories(scenario).accounts.all().first() }
|
||||
|
||||
private fun section(scenario: FakeScenario, state: InsightsUiState, scrolled: Boolean = false): @Composable () -> Unit {
|
||||
val accounts = accounts(scenario)
|
||||
return { SectionFrame(state, accounts, scrolled) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionFrame(state: InsightsUiState, accounts: List<Account>, scrolled: Boolean) {
|
||||
val scroll = remember { ScrollState(if (scrolled) Int.MAX_VALUE else 0) }
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scroll)
|
||||
.padding(horizontal = KvittTheme.spacing.gutter, vertical = KvittTheme.spacing.s2),
|
||||
verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.detailGap),
|
||||
) {
|
||||
InsightsSection(state, accounts, NoInsightsEvents)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ui(status: InsightsStatus, data: Insights?, selected: CategoryId? = null, measure: Measure = Measure.HOUSEHOLD) =
|
||||
InsightsUiState(measure, status, data, selected)
|
||||
|
||||
private val loaded = insights(InsightsScenarios.loaded)
|
||||
|
||||
/** The whole S-11 (default filter) scrolled to its end, with the section loaded. */
|
||||
private val screenContent: AnalyticsContent = runBlocking {
|
||||
val fakes = FakeRepositories(InsightsScenarios.loaded)
|
||||
AnalyticsContent.Ready(fakes.analytics.analytics(AnalyticsRules.defaultFilter).first(), fakes.categories.all().first(), fakes.accounts.all().first())
|
||||
}
|
||||
|
||||
private val screen: @Composable () -> Unit = {
|
||||
val content = screenContent
|
||||
val scroll = remember { ScrollState(Int.MAX_VALUE) }
|
||||
AnalyticsScreen(
|
||||
AnalyticsUiState(AnalyticsRules.defaultFilter, PeriodDialog.None, content),
|
||||
NoEvents,
|
||||
{},
|
||||
{},
|
||||
scrollState = scroll,
|
||||
insights = ui(InsightsStatus.Loaded, loaded),
|
||||
)
|
||||
}
|
||||
|
||||
val states: Map<String, @Composable () -> Unit> = buildMap {
|
||||
put("s11_insights_loaded", section(InsightsScenarios.loaded, ui(InsightsStatus.Loaded, loaded)))
|
||||
put("s11_insights_loaded_scrolled", section(InsightsScenarios.loaded, ui(InsightsStatus.Loaded, loaded), scrolled = true))
|
||||
put("s11_insights_category", section(InsightsScenarios.loaded, ui(InsightsStatus.Loaded, loaded, selected = AnalyticsScenarios.travel.id), scrolled = false))
|
||||
put(
|
||||
"s11_insights_my_share",
|
||||
section(InsightsScenarios.loaded, ui(InsightsStatus.Loaded, insights(InsightsScenarios.loaded, Measure.MY_SHARE), measure = Measure.MY_SHARE)),
|
||||
)
|
||||
put("s11_insights_loading", section(InsightsScenarios.loaded, ui(InsightsStatus.Loading, null)))
|
||||
put("s11_insights_offline", section(InsightsScenarios.offline, ui(InsightsStatus.NeedsConnection, null)))
|
||||
put("s11_insights_offline_kept", section(InsightsScenarios.offline, ui(InsightsStatus.NeedsConnection, loaded)))
|
||||
put("s11_insights_unreachable", section(InsightsScenarios.unreachable, ui(InsightsStatus.Failed("network"), loaded)))
|
||||
put("s11_insights_unreachable_empty", section(InsightsScenarios.unreachable, ui(InsightsStatus.Failed("network"), null)))
|
||||
put("s11_insights_empty", section(InsightsScenarios.emptyHousehold, ui(InsightsStatus.Loaded, insights(InsightsScenarios.emptyHousehold))))
|
||||
put("s11_insights_long", section(InsightsScenarios.long, ui(InsightsStatus.Loaded, insights(InsightsScenarios.long))))
|
||||
put("s11_insights_long_scrolled", section(InsightsScenarios.long, ui(InsightsStatus.Loaded, insights(InsightsScenarios.long)), scrolled = true))
|
||||
put("s11_insights_screen", screen)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@ParameterizedRobolectricTestRunner.Parameters(name = "{0}")
|
||||
fun names(): List<Array<Any>> = states.keys.map { arrayOf<Any>(it) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Insights events that do nothing: screenshots render a fixed state. */
|
||||
internal object NoInsightsEvents : InsightsEvents {
|
||||
override fun onRetryInsights() = Unit
|
||||
override fun onSelectCategory(id: CategoryId) = Unit
|
||||
}
|
||||
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 47 KiB |