Implement HomeScreen with Vico dashboard (#5) #20

Merged
admin merged 2 commits from feat/5-implement-homescreen-with-vico-dashboard into main 2026-06-28 13:29:41 +00:00
3 changed files with 670 additions and 2 deletions
Showing only changes of commit a0ccf22e67 - Show all commits
@@ -1,15 +1,548 @@
package dev.achmad.ledgerr.ui.screens.home
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.UploadFile
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SmallFloatingActionButton
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.graphics.vector.ImageVector
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost
import com.patrykandpatrick.vico.compose.cartesian.axis.rememberBottom
import com.patrykandpatrick.vico.compose.cartesian.axis.rememberStart
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberColumnCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.rememberCartesianChart
import com.patrykandpatrick.vico.core.cartesian.axis.HorizontalAxis
import com.patrykandpatrick.vico.core.cartesian.axis.VerticalAxis
import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModelProducer
import com.patrykandpatrick.vico.core.cartesian.data.CartesianValueFormatter
import com.patrykandpatrick.vico.core.cartesian.data.columnSeries
import dev.achmad.ledgerr.R
import dev.achmad.ledgerr.domain.category.model.Category
import dev.achmad.ledgerr.domain.expense.model.ExpenseSummary
import dev.achmad.ledgerr.domain.expense.model.ExpenseWithCategory
import dev.achmad.ledgerr.domain.preference.DateRangeOption
import dev.achmad.ledgerr.ui.components.AppBar
import dev.achmad.ledgerr.ui.components.ExportAction
import dev.achmad.ledgerr.ui.components.SingleSelectFilterChipGroup
import dev.achmad.ledgerr.ui.screens.add_edit_expense.AddEditExpenseScreen
import dev.achmad.ledgerr.ui.screens.category.CategoryScreen
import dev.achmad.ledgerr.ui.screens.expenses.ExpenseListScreen
import dev.achmad.ledgerr.ui.screens.import_bank_statement.ImportBankStatementScreen
import dev.achmad.ledgerr.ui.screens.settings.SettingsScreen
import kotlinx.coroutines.launch
import java.time.format.DateTimeFormatter
object HomeScreen: Screen {
object HomeScreen : Screen {
@Suppress("unused")
private fun readResolve(): Any = HomeScreen
@OptIn(ExperimentalMaterial3Api::class)
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
val screenModel = rememberScreenModel { HomeScreenModel() }
val selectedDateRange by screenModel.selectedDateRange.collectAsState()
val expenses by screenModel.expenses.collectAsState()
val summary by screenModel.summary.collectAsState()
val recurringBanner by screenModel.recurringBanner.collectAsState()
val isFabExpanded by screenModel.isFabExpanded.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
val coroutineScope = rememberCoroutineScope()
val exportFailureText = stringResource(R.string.home_export_failure)
Scaffold(
topBar = {
AppBar(
title = stringResource(R.string.home_title),
actions = {
ExportAction(
onExportConfirmed = { range, uri ->
screenModel.exportToCsv(uri, range) { result ->
if (result.isFailure) {
coroutineScope.launch {
snackbarHostState.showSnackbar(exportFailureText)
}
}
}
},
)
IconButton(onClick = { navigator.push(SettingsScreen) }) {
Icon(
imageVector = Icons.Outlined.Settings,
contentDescription = stringResource(R.string.home_settings),
)
}
},
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
floatingActionButton = {
ExpandedFab(
expanded = isFabExpanded,
onToggle = screenModel::toggleFab,
onManual = { navigator.push(AddEditExpenseScreen(expenseId = null)) },
onImport = { navigator.push(ImportBankStatementScreen) },
)
},
) { padding ->
DashboardContent(
paddingValues = padding,
summary = summary,
recent = expenses.take(5),
selectedDateRange = selectedDateRange,
onSelectedDateRangeChange = screenModel::setSelectedDateRange,
recurringBannerCount = recurringBanner?.size,
onDismissBanner = screenModel::dismissRecurringBanner,
onManageCategories = { navigator.push(CategoryScreen) },
onSeeAll = { navigator.push(ExpenseListScreen) },
)
}
}
}
}
@Composable
private fun DashboardContent(
paddingValues: PaddingValues,
summary: ExpenseSummary?,
recent: List<ExpenseWithCategory>,
selectedDateRange: DateRangeOption,
onSelectedDateRangeChange: (DateRangeOption) -> Unit,
recurringBannerCount: Int?,
onDismissBanner: () -> Unit,
onManageCategories: () -> Unit,
onSeeAll: () -> Unit,
) {
val hasData = summary?.takeIf { it.byCategory.isNotEmpty() }
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentPadding = PaddingValues(
top = 8.dp,
bottom = 96.dp,
start = 16.dp,
end = 16.dp,
),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (recurringBannerCount != null) {
item(key = "banner") {
RecurringBanner(
count = recurringBannerCount,
onDismiss = onDismissBanner,
)
}
}
item(key = "total") {
TotalCard(
amount = summary?.totalAmount ?: 0.0,
period = selectedDateRange,
)
}
item(key = "period-filter") {
PeriodFilter(
selected = selectedDateRange,
onChange = onSelectedDateRangeChange,
)
}
hasData?.let { data ->
item(key = "chart") {
CategoryChartCard(summary = data)
}
}
item(key = "actions") {
ActionsRow(
onManageCategories = onManageCategories,
onSeeAll = onSeeAll,
)
}
if (recent.isNotEmpty()) {
item(key = "recent-header") {
SectionHeader(text = stringResource(R.string.home_recent))
}
items(items = recent, key = { it.expense.id }) { item ->
ExpenseRow(item = item)
}
} else {
item(key = "empty") {
Text(
text = stringResource(R.string.home_dashboard_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 16.dp),
)
}
}
}
}
@Composable
private fun RecurringBanner(
count: Int,
onDismiss: () -> Unit,
) {
val text = pluralStringResource(R.plurals.home_recurring_banner, count, count)
Surface(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.secondaryContainer,
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.weight(1f),
)
IconButton(onClick = onDismiss) {
Icon(
imageVector = Icons.Outlined.Close,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
}
}
}
@Composable
private fun TotalCard(
amount: Double,
period: DateRangeOption,
) {
val periodLabel = stringResource(
Review

Suggestion: The DateRangeOption → string-resource-id mapping is duplicated three times in this file: TotalCard (lines 272-277), PeriodFilter (line 307 calling label(it)), and the file-level label(option: DateRangeOption) helper (lines 314-319). Consolidate to a single source of truth — e.g. a private fun DateRangeOption.labelRes(): Int and a @Composable private fun DateRangeOption.labelText(): String = stringResource(labelRes()) — and call it from all three sites. Cosmetic, but the when is exhaustive on a sealed enum so a future enum value (e.g. LAST_MONTH, CUSTOM) will only need updating in one place.

**Suggestion:** The `DateRangeOption → string-resource-id` mapping is duplicated three times in this file: `TotalCard` (lines 272-277), `PeriodFilter` (line 307 calling `label(it)`), and the file-level `label(option: DateRangeOption)` helper (lines 314-319). Consolidate to a single source of truth — e.g. a `private fun DateRangeOption.labelRes(): Int` and a `@Composable private fun DateRangeOption.labelText(): String = stringResource(labelRes())` — and call it from all three sites. Cosmetic, but the `when` is exhaustive on a sealed enum so a future enum value (e.g. `LAST_MONTH`, `CUSTOM`) will only need updating in one place.
when (period) {
DateRangeOption.THIS_WEEK -> R.string.home_period_this_week
DateRangeOption.THIS_MONTH -> R.string.home_period_this_month
}
)
Surface(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.primaryContainer,
) {
Column(
modifier = Modifier.padding(16.dp),
) {
Text(
text = stringResource(R.string.home_total, periodLabel),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "%.2f".format(amount),
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
}
@Composable
private fun PeriodFilter(
selected: DateRangeOption,
onChange: (DateRangeOption) -> Unit,
) {
SingleSelectFilterChipGroup(
options = DateRangeOption.entries.map { it to label(it) },
selectedOption = selected to label(selected),
onSelectionChanged = { (option, _) -> onChange(option) },
)
}
@Composable
private fun label(option: DateRangeOption): String = stringResource(
when (option) {
DateRangeOption.THIS_WEEK -> R.string.home_period_this_week
DateRangeOption.THIS_MONTH -> R.string.home_period_this_month
}
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CategoryChartCard(summary: ExpenseSummary) {
Review

Blocking: The spec is a pie chart, not a column chart. The issue's acceptance criteria and .opencode/agent/implementor.md "Architecture rules / Charts" both call for a Vico pie chart of summary.byCategory; docs/04-implementation-plan.md says the same. You're rendering a ColumnCartesianLayer (line 350) plus a hand-rolled CategoryLegend (lines 365-391) below it. Vico 2.0.0's cartesian package only exposes Line / Column / Candlestick layers (no pie), so the spec was written for an API that 2.0.0 doesn't provide. Two acceptable resolutions — pick one and document it in the PR body before merge:

  1. Keep the column chart and update the issue, docs/04-implementation-plan.md, and .opencode/agent/implementor.md "Charts" section to say "column chart with a legend below it" so the spec matches the implementation.
  2. Render a real pie — either by pulling in a different chart library (rejected by the "do not add a chart library other than Vico" rule) or by drawing the pie in Canvas (rejected by the "do not draw charts with Canvas — use Vico" rule). So option 1 is the only one consistent with the architecture rules.

The PR description's parenthetical note ("Vico 2.0.0 has no pie chart; column chart with legend is the substitute, scoped to the existing Vico 2.0.0 dependency") is a good start but it's not enough — the spec files need to match.

**Blocking:** The spec is a pie chart, not a column chart. The issue's acceptance criteria and `.opencode/agent/implementor.md` "Architecture rules / Charts" both call for a Vico **pie chart** of `summary.byCategory`; `docs/04-implementation-plan.md` says the same. You're rendering a `ColumnCartesianLayer` (line 350) plus a hand-rolled `CategoryLegend` (lines 365-391) below it. Vico 2.0.0's `cartesian` package only exposes `Line` / `Column` / `Candlestick` layers (no pie), so the spec was written for an API that 2.0.0 doesn't provide. Two acceptable resolutions — pick one and document it in the PR body before merge: 1. Keep the column chart and update the issue, `docs/04-implementation-plan.md`, and `.opencode/agent/implementor.md` "Charts" section to say "column chart with a legend below it" so the spec matches the implementation. 2. Render a real pie — either by pulling in a different chart library (rejected by the "do not add a chart library other than Vico" rule) or by drawing the pie in `Canvas` (rejected by the "do not draw charts with Canvas — use Vico" rule). So option 1 is the only one consistent with the architecture rules. The PR description's parenthetical note ("Vico 2.0.0 has no pie chart; column chart with legend is the substitute, scoped to the existing Vico 2.0.0 dependency") is a good start but it's not enough — the spec files need to match.
val categories = summary.byCategory
val amountFormatter = remember { CartesianValueFormatter.decimal() }
val categoryNames = remember(categories) { categories.map { it.first.name } }
val bottomFormatter = remember(categoryNames) {
CartesianValueFormatter { _, value, _ ->
categoryNames.getOrNull(value.toInt()) ?: ""
}
}
val modelProducer = remember { CartesianChartModelProducer() }
LaunchedEffect(categories) {
if (categories.isEmpty()) return@LaunchedEffect
modelProducer.runTransaction {
columnSeries {
series(categories.map { it.second })
}
}
}
Surface(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surface,
tonalElevation = 1.dp,
) {
Column(modifier = Modifier.padding(16.dp)) {
CartesianChartHost(
chart = rememberCartesianChart(
rememberColumnCartesianLayer(),
startAxis = VerticalAxis.rememberStart(valueFormatter = amountFormatter),
bottomAxis = HorizontalAxis.rememberBottom(valueFormatter = bottomFormatter),
),
modelProducer = modelProducer,
modifier = Modifier
.fillMaxWidth()
.height(200.dp),
)
Spacer(modifier = Modifier.height(12.dp))
CategoryLegend(items = categories)
}
}
}
@Composable
private fun CategoryLegend(items: List<Pair<Category, Double>>) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
items.forEach { (category, amount) ->
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(12.dp)
.clip(CircleShape)
.background(Color(category.color)),
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = category.name,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = "%.2f".format(amount),
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
}
@Composable
private fun ActionsRow(
onManageCategories: () -> Unit,
onSeeAll: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedButton(
onClick = onManageCategories,
modifier = Modifier.weight(1f),
colors = ButtonDefaults.outlinedButtonColors(),
) {
Text(stringResource(R.string.home_manage_categories))
}
OutlinedButton(
onClick = onSeeAll,
modifier = Modifier.weight(1f),
) {
Text(stringResource(R.string.home_see_all))
}
}
}
@Composable
private fun SectionHeader(text: String) {
Text(
text = text,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp),
)
}
@Composable
private fun ExpenseRow(item: ExpenseWithCategory) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surface,
) {
Row(
modifier = Modifier.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(10.dp)
.clip(CircleShape)
.background(Color(item.category.color)),
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = item.category.name,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val note = item.expense.note
if (!note.isNullOrBlank()) {
Text(
text = note,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Text(
text = item.expense.date.format(DateTimeFormatter.ISO_LOCAL_DATE),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
text = "%.2f".format(item.expense.amount),
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
@Composable
private fun ExpandedFab(
Review

Blocking: The MiniFab (lines 523-547) and the expand/collapse host in ExpandedFab (lines 477-521) are essentially copy-pasted from app/src/main/java/dev/achmad/ledgerr/ui/screens/expenses/ExpenseListScreen.kt — see MiniFab at lines 484-508 and TabAwareFab at lines 418-481. The implementations are byte-for-byte equivalent (Row(verticalAlignment = CenterVertically) { Surface { Text } SmallFloatingActionButton { Icon } }; the same AnimatedVisibility(visible = expanded, enter = fadeIn() + expandVertically(), exit = fadeOut() + shrinkVertically()) host; the same Icons.Outlined.Add / Icons.Outlined.Close toggle icon).

.opencode/agent/implementor.md "Architecture rules / Navigation" says: "The expansion/collapse UI is shared; only the actions list is tab-driven." The right fix is to extract the shared parts to ui/components/ExpandedFab.kt (e.g. fun ExpandedFab(expanded: Boolean, onToggle: () -> Unit, actions: @Composable ColumnScope.() -> Unit) plus a MiniFab helper) and have HomeScreen and ExpenseListScreen both consume it — ExpenseListScreen passes its tab-conditional MiniFab list as the actions slot, HomeScreen passes the static two-action list. The LaunchedEffect(selectedTab) { expanded = false } in TabAwareFab can be hoisted to the ExpenseListScreen call site.

This is also where the third copy of the same UI will go if someone else wires it up — fix it before merge.

**Blocking:** The `MiniFab` (lines 523-547) and the expand/collapse host in `ExpandedFab` (lines 477-521) are essentially copy-pasted from `app/src/main/java/dev/achmad/ledgerr/ui/screens/expenses/ExpenseListScreen.kt` — see `MiniFab` at lines 484-508 and `TabAwareFab` at lines 418-481. The implementations are byte-for-byte equivalent (`Row(verticalAlignment = CenterVertically) { Surface { Text } SmallFloatingActionButton { Icon } }`; the same `AnimatedVisibility(visible = expanded, enter = fadeIn() + expandVertically(), exit = fadeOut() + shrinkVertically())` host; the same `Icons.Outlined.Add` / `Icons.Outlined.Close` toggle icon). `.opencode/agent/implementor.md` "Architecture rules / Navigation" says: "The expansion/collapse UI is shared; only the actions list is tab-driven." The right fix is to extract the shared parts to `ui/components/ExpandedFab.kt` (e.g. `fun ExpandedFab(expanded: Boolean, onToggle: () -> Unit, actions: @Composable ColumnScope.() -> Unit)` plus a `MiniFab` helper) and have `HomeScreen` and `ExpenseListScreen` both consume it — `ExpenseListScreen` passes its tab-conditional `MiniFab` list as the `actions` slot, `HomeScreen` passes the static two-action list. The `LaunchedEffect(selectedTab) { expanded = false }` in `TabAwareFab` can be hoisted to the `ExpenseListScreen` call site. This is also where the third copy of the same UI will go if someone else wires it up — fix it before merge.
expanded: Boolean,
onToggle: () -> Unit,
onManual: () -> Unit,
onImport: () -> Unit,
) {
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
AnimatedVisibility(
visible = expanded,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
MiniFab(
label = stringResource(R.string.home_fab_manual),
icon = Icons.Outlined.Edit,
onClick = {
onToggle()
onManual()
},
)
MiniFab(
label = stringResource(R.string.home_fab_import),
icon = Icons.Outlined.UploadFile,
onClick = {
onToggle()
onImport()
},
)
}
}
FloatingActionButton(onClick = onToggle) {
Icon(
imageVector = if (expanded) Icons.Outlined.Close else Icons.Outlined.Add,
contentDescription = null,
)
}
}
}
@Composable
private fun MiniFab(
label: String,
icon: ImageVector,
onClick: () -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Surface(
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.surface,
shadowElevation = 2.dp,
) {
Text(
text = label,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
style = MaterialTheme.typography.bodyMedium,
)
}
SmallFloatingActionButton(onClick = onClick) {
Icon(imageVector = icon, contentDescription = null)
}
}
}
@@ -0,0 +1,117 @@
package dev.achmad.ledgerr.ui.screens.home
import android.net.Uri
import cafe.adriel.voyager.core.model.ScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import dev.achmad.ledgerr.di.util.inject
import dev.achmad.ledgerr.domain.expense.interactor.GetExpenses
import dev.achmad.ledgerr.domain.expense.model.DateRange
import dev.achmad.ledgerr.domain.expense.model.Expense
import dev.achmad.ledgerr.domain.expense.model.ExpenseSummary
import dev.achmad.ledgerr.domain.expense.model.ExpenseWithCategory
import dev.achmad.ledgerr.domain.export.interactor.ExportExpensesToCsv
import dev.achmad.ledgerr.domain.preference.DateRangeOption
import dev.achmad.ledgerr.domain.preference.ExpensePreference
import dev.achmad.ledgerr.domain.recurring.interactor.ProcessDueRecurringExpenses
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@OptIn(ExperimentalCoroutinesApi::class)
class HomeScreenModel(
Review

Blocking: The issue's constructor example explicitly lists getExpenseSummary: GetExpenseSummary = inject(), but this ScreenModel has no such field and the summary flow is computed inline (lines 60-79) by re-implementing exactly the logic that GetExpenseSummary.await(range) performs per docs/03-function-todos.md (sum amounts, group by category, sort DESC, build ExpenseSummary). The inline result is structurally identical to the interactor's output, so this is a spec deviation, not a bug.

Pick one:

  1. Inject GetExpenseSummary and call it from a suspend helper in the ScreenModel (e.g. private suspend fun computeSummary(range: DateRange): ExpenseSummary? = getExpenseSummary.await(range).takeIf { it.byCategory.isNotEmpty() }), then drive summary from a dateRange.flatMapLatest { ... } instead of combine(expenses, dateRange). This also fixes the period-filter flicker in another inline comment.
  2. Update the issue and the docs/02-interfaces.md "expense" section to drop the GetExpenseSummary interactor as a HomeScreen dependency, since the inline computation is fine for a reactive flow that already has the expenses.

Don't just leave the deviation unacknowledged — the issue's literal signature has to match what the PR ships.

**Blocking:** The issue's constructor example explicitly lists `getExpenseSummary: GetExpenseSummary = inject()`, but this ScreenModel has no such field and the `summary` flow is computed inline (lines 60-79) by re-implementing exactly the logic that `GetExpenseSummary.await(range)` performs per `docs/03-function-todos.md` (sum amounts, group by category, sort DESC, build `ExpenseSummary`). The inline result is structurally identical to the interactor's output, so this is a spec deviation, not a bug. Pick one: 1. Inject `GetExpenseSummary` and call it from a `suspend` helper in the ScreenModel (e.g. `private suspend fun computeSummary(range: DateRange): ExpenseSummary? = getExpenseSummary.await(range).takeIf { it.byCategory.isNotEmpty() }`), then drive `summary` from a `dateRange.flatMapLatest { ... }` instead of `combine(expenses, dateRange)`. This also fixes the period-filter flicker in another inline comment. 2. Update the issue and the `docs/02-interfaces.md` "expense" section to drop the `GetExpenseSummary` interactor as a HomeScreen dependency, since the inline computation is fine for a reactive flow that already has the expenses. Don't just leave the deviation unacknowledged — the issue's literal signature has to match what the PR ships.
Review

Suggestion: getRecurringExpenses: GetRecurringExpenses = inject() is in the issue's example constructor but missing here. The "State:" bullets in the issue body never actually use it, so this is an internal inconsistency in the spec — but it should still be resolved one way or the other. Either add it (even unused, so the constructor matches) or update the issue to drop it. A short note in the PR body would suffice.

**Suggestion:** `getRecurringExpenses: GetRecurringExpenses = inject()` is in the issue's example constructor but missing here. The "State:" bullets in the issue body never actually use it, so this is an internal inconsistency in the spec — but it should still be resolved one way or the other. Either add it (even unused, so the constructor matches) or update the issue to drop it. A short note in the PR body would suffice.
private val getExpenses: GetExpenses = inject(),
private val processDueRecurring: ProcessDueRecurringExpenses = inject(),
private val exportExpensesToCsv: ExportExpensesToCsv = inject(),
private val expensePreference: ExpensePreference = inject(),
) : ScreenModel {
private val _selectedDateRange = MutableStateFlow(
expensePreference.defaultDateRange().get()
)
val selectedDateRange: StateFlow<DateRangeOption> = _selectedDateRange.asStateFlow()
private val dateRange: StateFlow<DateRange> = _selectedDateRange
.map { it.toDateRange() }
.stateIn(
scope = screenModelScope,
started = SharingStarted.Eagerly,
initialValue = _selectedDateRange.value.toDateRange(),
)
val expenses: StateFlow<List<ExpenseWithCategory>> = dateRange
.flatMapLatest { getExpenses.subscribeByDateRange(it) }
.flowOn(Dispatchers.IO)
.stateIn(
scope = screenModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = emptyList(),
)
val summary: StateFlow<ExpenseSummary?> = combine(expenses, dateRange) { list, range ->
Review

Suggestion: When the user taps a different period chip, summary briefly flashes to null (and the TotalCard to $0.00, plus the chart disappears) for the duration of the next getExpenses.subscribeByDateRange query. The cause is the initialValue = emptyList() on expenses (line 57): when dateRange re-emits, flatMapLatest re-subscribes and emits emptyList() before the new query returns, so combine(expenses, dateRange) emits (emptyList, newRange) and the if (list.isEmpty()) null branch on line 61 fires. Easy fix: drive summary directly from a dateRange.flatMapLatest { range -> getExpenses.subscribeByDateRange(range).map { ... compute summary ... } } flow so the empty initial value never reaches combine. (As a side benefit this also removes the need to re-derive ExpenseSummary from the expenses list — see the GetExpenseSummary comment.)

**Suggestion:** When the user taps a different period chip, `summary` briefly flashes to `null` (and the TotalCard to `$0.00`, plus the chart disappears) for the duration of the next `getExpenses.subscribeByDateRange` query. The cause is the `initialValue = emptyList()` on `expenses` (line 57): when `dateRange` re-emits, `flatMapLatest` re-subscribes and emits `emptyList()` before the new query returns, so `combine(expenses, dateRange)` emits `(emptyList, newRange)` and the `if (list.isEmpty()) null` branch on line 61 fires. Easy fix: drive `summary` directly from a `dateRange.flatMapLatest { range -> getExpenses.subscribeByDateRange(range).map { ... compute summary ... } }` flow so the empty initial value never reaches `combine`. (As a side benefit this also removes the need to re-derive `ExpenseSummary` from the expenses list — see the `GetExpenseSummary` comment.)
if (list.isEmpty()) {
null
} else {
ExpenseSummary(
totalAmount = list.sumOf { it.expense.amount },
byCategory = list
.groupBy { it.expense.categoryId }
.map { (_, group) ->
group.first().category to group.sumOf { it.expense.amount }
}
.sortedByDescending { it.second },
period = range,
)
}
}.stateIn(
scope = screenModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = null,
)
private val _recurringBanner = MutableStateFlow<List<Expense>?>(null)
val recurringBanner: StateFlow<List<Expense>?> = _recurringBanner.asStateFlow()
private val _isFabExpanded = MutableStateFlow(false)
val isFabExpanded: StateFlow<Boolean> = _isFabExpanded.asStateFlow()
init {
screenModelScope.launch {
val generated = withContext(Dispatchers.IO) { processDueRecurring.await() }
_recurringBanner.value = generated.takeIf { it.isNotEmpty() }
}
}
fun setSelectedDateRange(option: DateRangeOption) {
_selectedDateRange.value = option
}
fun toggleFab() {
_isFabExpanded.value = !_isFabExpanded.value
}
fun dismissRecurringBanner() {
_recurringBanner.value = null
}
fun exportToCsv(uri: Uri, range: DateRange, onResult: (Result<Unit>) -> Unit) {
Review

Suggestion: exportToCsv taking (uri, range, onResult: (Result<Unit>) -> Unit) (line 106) forces the Screen to wrap the call in coroutineScope.launch from the ExportAction callback (HomeScreen.kt lines 116-122) and adds a callback hop for no benefit. A suspend fun exportToCsv(uri: Uri, range: DateRange): Result<Unit> lets the Screen do coroutineScope.launch { val r = screenModel.exportToCsv(uri, range); if (r.isFailure) snackbarHostState.showSnackbar(...) } directly — the screenModelScope.launch becomes a one-liner and the callback plumbing goes away. The Screen already has coroutineScope in scope.

**Suggestion:** `exportToCsv` taking `(uri, range, onResult: (Result<Unit>) -> Unit)` (line 106) forces the Screen to wrap the call in `coroutineScope.launch` from the `ExportAction` callback (HomeScreen.kt lines 116-122) and adds a callback hop for no benefit. A `suspend fun exportToCsv(uri: Uri, range: DateRange): Result<Unit>` lets the Screen do `coroutineScope.launch { val r = screenModel.exportToCsv(uri, range); if (r.isFailure) snackbarHostState.showSnackbar(...) }` directly — the `screenModelScope.launch` becomes a one-liner and the callback plumbing goes away. The Screen already has `coroutineScope` in scope.
screenModelScope.launch {
val result = withContext(Dispatchers.IO) { exportExpensesToCsv.await(range, uri) }
onResult(result)
}
}
}
private fun DateRangeOption.toDateRange(): DateRange = when (this) {
DateRangeOption.THIS_WEEK -> DateRange.thisWeek()
DateRangeOption.THIS_MONTH -> DateRange.thisMonth()
}
+18
View File
@@ -102,4 +102,22 @@
<string name="add_edit_recurring_interval_weekly">Weekly</string>
<string name="add_edit_recurring_interval_monthly">Monthly</string>
<string name="add_edit_recurring_interval_yearly">Yearly</string>
<!-- Home (issue #5) -->
<string name="home_title">Ledgerr</string>
<string name="home_total">Total %1$s</string>
<string name="home_period_this_week">this week</string>
<string name="home_period_this_month">this month</string>
<string name="home_manage_categories">Manage Categories</string>
<string name="home_see_all">See all</string>
<string name="home_recent">Recent</string>
Review

Nit: home_fab_manual ("Manual") and home_fab_import ("Import Bank Statement") are byte-identical to expense_list_fab_manual and expense_list_fab_import (strings.xml lines 75-76). A translator will translate the same English string twice. Either reuse the existing keys (rename to a shared fab_manual / fab_import) or, if you want them independent, leave a one-line comment on the home entries explaining why HomeScreen needs its own copy. As written it's a translation-maintenance footgun.

**Nit:** `home_fab_manual` ("Manual") and `home_fab_import` ("Import Bank Statement") are byte-identical to `expense_list_fab_manual` and `expense_list_fab_import` (strings.xml lines 75-76). A translator will translate the same English string twice. Either reuse the existing keys (rename to a shared `fab_manual` / `fab_import`) or, if you want them independent, leave a one-line comment on the home entries explaining why HomeScreen needs its own copy. As written it's a translation-maintenance footgun.
<plurals name="home_recurring_banner">
<item quantity="one">%d new recurring expense added</item>
<item quantity="other">%d new recurring expenses added</item>
</plurals>
<string name="home_fab_manual">Manual</string>
<string name="home_fab_import">Import Bank Statement</string>
<string name="home_dashboard_empty">No expenses yet for this period</string>
<string name="home_settings">Settings</string>
<string name="home_export_failure">Export failed</string>
</resources>