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:
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user