Implement HomeScreen with Vico dashboard (#5) #20
@@ -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(
|
||||
|
|
||||
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) {
|
||||
|
admin
commented
Blocking: The spec is a pie chart, not a column chart. The issue's acceptance criteria and
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(
|
||||
|
admin
commented
Blocking: The
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(
|
||||
|
admin
commented
Blocking: The issue's constructor example explicitly lists Pick one:
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.
admin
commented
Suggestion: **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 ->
|
||||
|
admin
commented
Suggestion: When the user taps a different period chip, **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) {
|
||||
|
admin
commented
Suggestion: **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()
|
||||
}
|
||||
@@ -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>
|
||||
|
admin
commented
Nit: **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>
|
||||
|
||||
Suggestion: The
DateRangeOption → string-resource-idmapping is duplicated three times in this file:TotalCard(lines 272-277),PeriodFilter(line 307 callinglabel(it)), and the file-levellabel(option: DateRangeOption)helper (lines 314-319). Consolidate to a single source of truth — e.g. aprivate fun DateRangeOption.labelRes(): Intand a@Composable private fun DateRangeOption.labelText(): String = stringResource(labelRes())— and call it from all three sites. Cosmetic, but thewhenis exhaustive on a sealed enum so a future enum value (e.g.LAST_MONTH,CUSTOM) will only need updating in one place.