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
This commit is contained in:
2026-10-21 21:15:02 +02:00
parent 415f9f5efd
commit 1c9856bfba
61 changed files with 1509 additions and 4 deletions
@@ -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
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB