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
7 changed files with 187 additions and 259 deletions
Showing only changes of commit 3ddfaa0a22 - Show all commits
+2 -2
View File
@@ -217,7 +217,7 @@ class AppPreference(private val store: PreferenceStore) {
### Charts
- **Vico** (`com.patrykandpatrick.vico:compose`, `compose-m3`, `core`) is the only charting library used.
- `HomeScreen` dashboard renders a pie chart from `ExpenseSummary.byCategory` via Vico's `Chart` composable. No Canvas drawing for charts.
- `HomeScreen` dashboard renders a Vico `ColumnCartesianLayer` (one bar per category) from `ExpenseSummary.byCategory`, with a small `Row { colored swatch; category name; amount }` legend below the chart that carries the per-category color. Vico 2.0.0's `cartesian` package only exposes `Line` / `Column` / `Candlestick` layers (no pie), so the dashboard uses a column chart with a legend; per-category colors live in the legend, not in the chart. No Canvas drawing for charts.
- Apache 2.0 license, Compose-native, no AndroidView wrapper.
---
@@ -231,7 +231,7 @@ class AppPreference(private val store: PreferenceStore) {
| Koin | 4.2.2 | DI |
| Room | 2.7.1 | Local DB |
| PDFBox-Android | 2.0.27.0 | PDF text extraction |
| Vico (compose / compose-m3 / core) | 2.x | Charts (pie / bar) |
| Vico (compose / compose-m3 / core) | 2.x | Charts (column with legend) |
| Okio | (transitive) | CSV write |
---
@@ -0,0 +1,87 @@
package dev.achmad.ledgerr.ui.components
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.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SmallFloatingActionButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
@Composable
fun ExpandedFab(
expanded: Boolean,
onToggle: () -> Unit,
modifier: Modifier = Modifier,
actions: @Composable ColumnScope.() -> Unit,
) {
Column(
modifier = modifier,
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),
content = actions,
)
}
FloatingActionButton(onClick = onToggle) {
Icon(
imageVector = if (expanded) Icons.Outlined.Close else Icons.Outlined.Add,
contentDescription = null,
)
}
}
}
@Composable
fun MiniFab(
label: String,
icon: ImageVector,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier,
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)
}
}
}
@@ -1,14 +1,8 @@
package dev.achmad.ledgerr.ui.screens.expenses
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.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@@ -23,24 +17,19 @@ 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.Repeat
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.UploadFile
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
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.Switch
import androidx.compose.material3.Tab
import androidx.compose.material3.PrimaryTabRow
@@ -60,7 +49,6 @@ 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.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
@@ -74,7 +62,9 @@ import dev.achmad.ledgerr.domain.expense.model.ExpenseWithCategory
import dev.achmad.ledgerr.domain.recurring.model.RecurringExpenseWithCategory
import dev.achmad.ledgerr.domain.recurring.model.RecurringInterval
import dev.achmad.ledgerr.ui.components.AppBar
import dev.achmad.ledgerr.ui.components.ExpandedFab
import dev.achmad.ledgerr.ui.components.ExportAction
import dev.achmad.ledgerr.ui.components.MiniFab
import dev.achmad.ledgerr.ui.components.SingleSelectFilterChipGroup
import dev.achmad.ledgerr.ui.screens.add_edit_expense.AddEditExpenseScreen
import dev.achmad.ledgerr.ui.screens.add_edit_recurring.AddEditRecurringScreen
@@ -105,6 +95,11 @@ object ExpenseListScreen : Screen {
val pagerState = rememberPagerState(pageCount = { 2 })
val selectedTab = pagerState.currentPage
var isFabExpanded by remember { mutableStateOf(false) }
LaunchedEffect(selectedTab) {
isFabExpanded = false
}
Scaffold(
topBar = {
AppBar(
@@ -129,12 +124,38 @@ object ExpenseListScreen : Screen {
},
snackbarHost = { SnackbarHost(snackbarHostState) },
floatingActionButton = {
TabAwareFab(
selectedTab = selectedTab,
onAddExpense = { navigator.push(AddEditExpenseScreen(expenseId = null)) },
onImportBankStatement = { navigator.push(ImportBankStatementScreen) },
onAddRecurring = { navigator.push(AddEditRecurringScreen(recurringId = null)) },
)
ExpandedFab(
expanded = isFabExpanded,
onToggle = { isFabExpanded = !isFabExpanded },
) {
if (selectedTab == 0) {
MiniFab(
label = stringResource(R.string.fab_manual),
icon = Icons.Outlined.Edit,
onClick = {
isFabExpanded = false
navigator.push(AddEditExpenseScreen(expenseId = null))
},
)
MiniFab(
label = stringResource(R.string.fab_import),
icon = Icons.Outlined.UploadFile,
onClick = {
isFabExpanded = false
navigator.push(ImportBankStatementScreen)
},
)
} else {
MiniFab(
label = stringResource(R.string.expense_list_fab_add_recurring),
icon = Icons.Outlined.Repeat,
onClick = {
isFabExpanded = false
navigator.push(AddEditRecurringScreen(recurringId = null))
},
)
}
}
},
) { padding ->
Column(
@@ -414,99 +435,6 @@ private fun RecurringRow(
}
}
@Composable
private fun TabAwareFab(
selectedTab: Int,
onAddExpense: () -> Unit,
onImportBankStatement: () -> Unit,
onAddRecurring: () -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
LaunchedEffect(selectedTab) {
expanded = false
}
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),
) {
if (selectedTab == 0) {
MiniFab(
label = stringResource(R.string.expense_list_fab_manual),
icon = Icons.Outlined.Edit,
onClick = {
expanded = false
onAddExpense()
},
)
MiniFab(
label = stringResource(R.string.expense_list_fab_import),
icon = Icons.Outlined.UploadFile,
onClick = {
expanded = false
onImportBankStatement()
},
)
} else {
MiniFab(
label = stringResource(R.string.expense_list_fab_add_recurring),
icon = Icons.Outlined.Repeat,
onClick = {
expanded = false
onAddRecurring()
},
)
}
}
}
FloatingActionButton(
onClick = { expanded = !expanded },
) {
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)
}
}
}
private fun DateRangeFilter.labelRes(): Int = when (this) {
DateRangeFilter.ALL -> R.string.expense_list_filter_all
DateRangeFilter.THIS_WEEK -> R.string.expense_list_filter_this_week
@@ -1,10 +1,5 @@
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
@@ -22,20 +17,17 @@ 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
@@ -44,13 +36,14 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
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
@@ -75,7 +68,9 @@ 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.ExpandedFab
import dev.achmad.ledgerr.ui.components.ExportAction
import dev.achmad.ledgerr.ui.components.MiniFab
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
@@ -99,10 +94,10 @@ object HomeScreen : Screen {
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()
var isFabExpanded by remember { mutableStateOf(false) }
val exportFailureText = stringResource(R.string.home_export_failure)
@@ -113,11 +108,10 @@ object HomeScreen : Screen {
actions = {
ExportAction(
onExportConfirmed = { range, uri ->
screenModel.exportToCsv(uri, range) { result ->
coroutineScope.launch {
val result = screenModel.exportToCsv(uri, range)
if (result.isFailure) {
coroutineScope.launch {
snackbarHostState.showSnackbar(exportFailureText)
}
snackbarHostState.showSnackbar(exportFailureText)
}
}
},
@@ -135,10 +129,25 @@ object HomeScreen : Screen {
floatingActionButton = {
ExpandedFab(
expanded = isFabExpanded,
onToggle = screenModel::toggleFab,
onManual = { navigator.push(AddEditExpenseScreen(expenseId = null)) },
onImport = { navigator.push(ImportBankStatementScreen) },
)
onToggle = { isFabExpanded = !isFabExpanded },
) {
MiniFab(
label = stringResource(R.string.fab_manual),
icon = Icons.Outlined.Edit,
onClick = {
isFabExpanded = false
navigator.push(AddEditExpenseScreen(expenseId = null))
},
)
MiniFab(
label = stringResource(R.string.fab_import),
icon = Icons.Outlined.UploadFile,
onClick = {
isFabExpanded = false
navigator.push(ImportBankStatementScreen)
},
)
}
},
) { padding ->
DashboardContent(
1
@@ -269,12 +278,6 @@ private fun TotalCard(
amount: Double,
period: DateRangeOption,
) {
val periodLabel = stringResource(
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,
@@ -284,7 +287,7 @@ private fun TotalCard(
modifier = Modifier.padding(16.dp),
) {
Text(
text = stringResource(R.string.home_total, periodLabel),
text = stringResource(R.string.home_total, period.labelText()),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
@@ -304,19 +307,19 @@ private fun PeriodFilter(
onChange: (DateRangeOption) -> Unit,
) {
SingleSelectFilterChipGroup(
options = DateRangeOption.entries.map { it to label(it) },
selectedOption = selected to label(selected),
options = DateRangeOption.entries.map { it to it.labelText() },
selectedOption = selected to selected.labelText(),
onSelectionChanged = { (option, _) -> onChange(option) },
)
}
private fun DateRangeOption.labelRes(): Int = when (this) {
DateRangeOption.THIS_WEEK -> R.string.home_period_this_week
DateRangeOption.THIS_MONTH -> R.string.home_period_this_month
}
@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
}
)
private fun DateRangeOption.labelText(): String = stringResource(labelRes())
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.
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -472,77 +475,3 @@ private fun ExpenseRow(item: ExpenseWithCategory) {
}
}
}
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.
@Composable
private fun ExpandedFab(
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)
}
}
}
3
@@ -4,6 +4,7 @@ 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.GetExpenseSummary
import dev.achmad.ledgerr.domain.expense.interactor.GetExpenses
import dev.achmad.ledgerr.domain.expense.model.DateRange
import dev.achmad.ledgerr.domain.expense.model.Expense
@@ -15,12 +16,13 @@ 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.Flow
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.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
2
@@ -30,6 +32,7 @@ import kotlinx.coroutines.withContext
@OptIn(ExperimentalCoroutinesApi::class)
class HomeScreenModel(
private val getExpenses: GetExpenses = inject(),
private val getExpenseSummary: GetExpenseSummary = inject(),
private val processDueRecurring: ProcessDueRecurringExpenses = inject(),
private val exportExpensesToCsv: ExportExpensesToCsv = inject(),
private val expensePreference: ExpensePreference = inject(),
@@ -57,33 +60,22 @@ class HomeScreenModel(
initialValue = emptyList(),
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.)
)
val summary: StateFlow<ExpenseSummary?> = combine(expenses, dateRange) { list, range ->
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,
)
val summary: StateFlow<ExpenseSummary?> = dateRange
.flatMapLatest { range -> summaryFlow(range) }
.stateIn(
scope = screenModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = null,
)
private fun summaryFlow(range: DateRange): Flow<ExpenseSummary?> = flow {
val s = withContext(Dispatchers.IO) { getExpenseSummary.await(range) }
emit(s.takeIf { it.byCategory.isNotEmpty() })
}
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() }
@@ -95,20 +87,12 @@ class HomeScreenModel(
_selectedDateRange.value = option
}
fun toggleFab() {
_isFabExpanded.value = !_isFabExpanded.value
}
fun dismissRecurringBanner() {
_recurringBanner.value = null
}
fun exportToCsv(uri: Uri, range: DateRange, onResult: (Result<Unit>) -> Unit) {
screenModelScope.launch {
val result = withContext(Dispatchers.IO) { exportExpensesToCsv.await(range, uri) }
onResult(result)
}
}
suspend fun exportToCsv(uri: Uri, range: DateRange): Result<Unit> =
withContext(Dispatchers.IO) { exportExpensesToCsv.await(range, uri) }
}
private fun DateRangeOption.toDateRange(): DateRange = when (this) {
+4 -4
View File
@@ -72,8 +72,6 @@
<string name="expense_list_filter_all">All</string>
<string name="expense_list_filter_this_week">This week</string>
<string name="expense_list_filter_this_month">This month</string>
<string name="expense_list_fab_manual">Manual</string>
<string name="expense_list_fab_import">Import Bank Statement</string>
<string name="expense_list_fab_add_recurring">Add Recurring</string>
<string name="expense_list_delete_title">Delete expense?</string>
<string name="expense_list_delete_message">This will permanently delete the expense.</string>
@@ -103,6 +101,10 @@
<string name="add_edit_recurring_interval_monthly">Monthly</string>
<string name="add_edit_recurring_interval_yearly">Yearly</string>
<!-- Shared FAB (issue #5) -->
<string name="fab_manual">Manual</string>
<string name="fab_import">Import Bank Statement</string>
<!-- Home (issue #5) -->
<string name="home_title">Ledgerr</string>
<string name="home_total">Total %1$s</string>
1
@@ -115,8 +117,6 @@
<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>
+1 -1
View File
@@ -34,7 +34,7 @@ implementation(libs.vico.compose.m3)
implementation(libs.vico.core)
```
**Charts**: Vico 2.x Compose-native library. `HomeScreen` dashboard renders a pie chart from `ExpenseSummary.byCategory`. No custom Canvas drawing.
**Charts**: Vico 2.x Compose-native library. `HomeScreen` dashboard renders a Vico `ColumnCartesianLayer` from `ExpenseSummary.byCategory`, with a small category legend (`Row { colored swatch; category name; amount }`) below the chart that carries the per-category color. Vico 2.0.0's `cartesian` package only exposes `Line` / `Column` / `Candlestick` layers (no pie), so the dashboard uses a column chart with the legend. No custom Canvas drawing.
**No manifest permissions** — SAF handles file access.