fix(#5): address PR review — share ExpandedFab, inject GetExpenseSummary, column-chart spec

- 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<Unit>; 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)
This commit is contained in:
Achmad Setyabudi Susilo
2026-06-28 20:25:53 +07:00
parent a0ccf22e67
commit 3ddfaa0a22
7 changed files with 187 additions and 259 deletions
+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(
@@ -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)
}
}
}
@@ -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<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>
@@ -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.