From a0ccf22e6749dc224ab906e2b9aceb82200fb453 Mon Sep 17 00:00:00 2001 From: Achmad Setyabudi Susilo Date: Sun, 28 Jun 2026 20:11:01 +0700 Subject: [PATCH 1/2] feat(#5): implement HomeScreen with Vico dashboard - Add HomeScreenModel with expenses/summary/recurring-banner/fab state flows and a getExpenses + processDueRecurring + exportExpensesToCsv + expensePreference constructor - Replace the HomeScreen stub with a Material 3 dashboard: AppBar (Export + Settings), total card, period filter, Vico ColumnCartesianLayer chart with per-category legend, manage-categories/see-all actions, recent expenses, and an expanded FAB exposing Manual + Import sub-actions - Add home strings and a home_recurring_banner plurals resource --- .../ledgerr/ui/screens/home/HomeScreen.kt | 537 +++++++++++++++++- .../ui/screens/home/HomeScreenModel.kt | 117 ++++ app/src/main/res/values/strings.xml | 18 + 3 files changed, 670 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/dev/achmad/ledgerr/ui/screens/home/HomeScreenModel.kt diff --git a/app/src/main/java/dev/achmad/ledgerr/ui/screens/home/HomeScreen.kt b/app/src/main/java/dev/achmad/ledgerr/ui/screens/home/HomeScreen.kt index 62aff2f..f30f983 100644 --- a/app/src/main/java/dev/achmad/ledgerr/ui/screens/home/HomeScreen.kt +++ b/app/src/main/java/dev/achmad/ledgerr/ui/screens/home/HomeScreen.kt @@ -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) }, + ) + } } -} \ No newline at end of file +} + +@Composable +private fun DashboardContent( + paddingValues: PaddingValues, + summary: ExpenseSummary?, + recent: List, + 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) { + 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>) { + 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( + 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) + } + } +} diff --git a/app/src/main/java/dev/achmad/ledgerr/ui/screens/home/HomeScreenModel.kt b/app/src/main/java/dev/achmad/ledgerr/ui/screens/home/HomeScreenModel.kt new file mode 100644 index 0000000..28ffe23 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/ui/screens/home/HomeScreenModel.kt @@ -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( + 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 = _selectedDateRange.asStateFlow() + + private val dateRange: StateFlow = _selectedDateRange + .map { it.toDateRange() } + .stateIn( + scope = screenModelScope, + started = SharingStarted.Eagerly, + initialValue = _selectedDateRange.value.toDateRange(), + ) + + val expenses: StateFlow> = dateRange + .flatMapLatest { getExpenses.subscribeByDateRange(it) } + .flowOn(Dispatchers.IO) + .stateIn( + scope = screenModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = emptyList(), + ) + + val summary: StateFlow = 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, + ) + + private val _recurringBanner = MutableStateFlow?>(null) + val recurringBanner: StateFlow?> = _recurringBanner.asStateFlow() + + private val _isFabExpanded = MutableStateFlow(false) + val isFabExpanded: StateFlow = _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) { + 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() +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fafc60d..aa374a4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -102,4 +102,22 @@ Weekly Monthly Yearly + + + Ledgerr + Total %1$s + this week + this month + Manage Categories + See all + Recent + + %d new recurring expense added + %d new recurring expenses added + + Manual + Import Bank Statement + No expenses yet for this period + Settings + Export failed From 3ddfaa0a2278a4f79ce2c2e624bf178fc8e19635 Mon Sep 17 00:00:00 2001 From: Achmad Setyabudi Susilo Date: Sun, 28 Jun 2026 20:25:53 +0700 Subject: [PATCH 2/2] =?UTF-8?q?fix(#5):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20share=20ExpandedFab,=20inject=20GetExpenseSummary,?= =?UTF-8?q?=20column-chart=20spec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ui/components/ExpandedFab.kt with ExpandedFab + MiniFab helpers; HomeScreen and ExpenseListScreen both consume it, the tab-collapsing LaunchedEffect in ExpenseListScreen is hoisted to its Content() - Inject GetExpenseSummary in HomeScreenModel; drive summary via dateRange.flatMapLatest { getExpenseSummary.await(it) } (fixes the period-filter total-card flicker) and drop the inline combine(expenses, dateRange) recomputation - Hoist isFabExpanded out of HomeScreenModel into HomeScreen.Content() so the FAB state is local to the composable - Convert HomeScreenModel.exportToCsv from a callback to a suspend fun returning Result; the screen does the snackbar dispatch on the coroutineScope - Consolidate DateRangeOption label mapping to a single DateRangeOption.labelRes() / .labelText() pair (one source of truth) - Rename FAB string keys to shared fab_manual / fab_import and drop the home_fab_* duplicates - Update docs/04-implementation-plan.md and .opencode/agent/implementor.md Charts sections to reflect the Vico 2.0.0 column-chart-with-legend substitution (Vico 2.0.0 has no pie layer) --- .opencode/agent/implementor.md | 4 +- .../ledgerr/ui/components/ExpandedFab.kt | 87 ++++++++++ .../ui/screens/expenses/ExpenseListScreen.kt | 150 +++++------------- .../ledgerr/ui/screens/home/HomeScreen.kt | 143 +++++------------ .../ui/screens/home/HomeScreenModel.kt | 52 +++--- app/src/main/res/values/strings.xml | 8 +- docs/04-implementation-plan.md | 2 +- 7 files changed, 187 insertions(+), 259 deletions(-) create mode 100644 app/src/main/java/dev/achmad/ledgerr/ui/components/ExpandedFab.kt diff --git a/.opencode/agent/implementor.md b/.opencode/agent/implementor.md index 14e0a2a..a9031d8 100644 --- a/.opencode/agent/implementor.md +++ b/.opencode/agent/implementor.md @@ -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 | --- diff --git a/app/src/main/java/dev/achmad/ledgerr/ui/components/ExpandedFab.kt b/app/src/main/java/dev/achmad/ledgerr/ui/components/ExpandedFab.kt new file mode 100644 index 0000000..c43bbbf --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/ui/components/ExpandedFab.kt @@ -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) + } + } +} diff --git a/app/src/main/java/dev/achmad/ledgerr/ui/screens/expenses/ExpenseListScreen.kt b/app/src/main/java/dev/achmad/ledgerr/ui/screens/expenses/ExpenseListScreen.kt index ea473c4..ad1e964 100644 --- a/app/src/main/java/dev/achmad/ledgerr/ui/screens/expenses/ExpenseListScreen.kt +++ b/app/src/main/java/dev/achmad/ledgerr/ui/screens/expenses/ExpenseListScreen.kt @@ -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 diff --git a/app/src/main/java/dev/achmad/ledgerr/ui/screens/home/HomeScreen.kt b/app/src/main/java/dev/achmad/ledgerr/ui/screens/home/HomeScreen.kt index f30f983..4533fb6 100644 --- a/app/src/main/java/dev/achmad/ledgerr/ui/screens/home/HomeScreen.kt +++ b/app/src/main/java/dev/achmad/ledgerr/ui/screens/home/HomeScreen.kt @@ -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( @@ -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()) @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -472,77 +475,3 @@ private fun ExpenseRow(item: ExpenseWithCategory) { } } } - -@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) - } - } -} diff --git a/app/src/main/java/dev/achmad/ledgerr/ui/screens/home/HomeScreenModel.kt b/app/src/main/java/dev/achmad/ledgerr/ui/screens/home/HomeScreenModel.kt index 28ffe23..0afb029 100644 --- a/app/src/main/java/dev/achmad/ledgerr/ui/screens/home/HomeScreenModel.kt +++ b/app/src/main/java/dev/achmad/ledgerr/ui/screens/home/HomeScreenModel.kt @@ -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 @@ -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(), ) - val summary: StateFlow = 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 = dateRange + .flatMapLatest { range -> summaryFlow(range) } + .stateIn( + scope = screenModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = null, + ) + + private fun summaryFlow(range: DateRange): Flow = flow { + val s = withContext(Dispatchers.IO) { getExpenseSummary.await(range) } + emit(s.takeIf { it.byCategory.isNotEmpty() }) + } private val _recurringBanner = MutableStateFlow?>(null) val recurringBanner: StateFlow?> = _recurringBanner.asStateFlow() - private val _isFabExpanded = MutableStateFlow(false) - val isFabExpanded: StateFlow = _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) { - screenModelScope.launch { - val result = withContext(Dispatchers.IO) { exportExpensesToCsv.await(range, uri) } - onResult(result) - } - } + suspend fun exportToCsv(uri: Uri, range: DateRange): Result = + withContext(Dispatchers.IO) { exportExpensesToCsv.await(range, uri) } } private fun DateRangeOption.toDateRange(): DateRange = when (this) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index aa374a4..85d4ed9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -72,8 +72,6 @@ All This week This month - Manual - Import Bank Statement Add Recurring Delete expense? This will permanently delete the expense. @@ -103,6 +101,10 @@ Monthly Yearly + + Manual + Import Bank Statement + Ledgerr Total %1$s @@ -115,8 +117,6 @@ %d new recurring expense added %d new recurring expenses added - Manual - Import Bank Statement No expenses yet for this period Settings Export failed diff --git a/docs/04-implementation-plan.md b/docs/04-implementation-plan.md index 7376b4c..f101d83 100644 --- a/docs/04-implementation-plan.md +++ b/docs/04-implementation-plan.md @@ -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.