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 ### Charts
- **Vico** (`com.patrykandpatrick.vico:compose`, `compose-m3`, `core`) is the only charting library used. - **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. - Apache 2.0 license, Compose-native, no AndroidView wrapper.
--- ---
@@ -231,7 +231,7 @@ class AppPreference(private val store: PreferenceStore) {
| Koin | 4.2.2 | DI | | Koin | 4.2.2 | DI |
| Room | 2.7.1 | Local DB | | Room | 2.7.1 | Local DB |
| PDFBox-Android | 2.0.27.0 | PDF text extraction | | 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 | | 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 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.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues 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.lazy.items
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons 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.Edit
import androidx.compose.material.icons.outlined.Repeat import androidx.compose.material.icons.outlined.Repeat
import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.UploadFile import androidx.compose.material.icons.outlined.UploadFile
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SmallFloatingActionButton
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Tab import androidx.compose.material3.Tab
import androidx.compose.material3.PrimaryTabRow import androidx.compose.material3.PrimaryTabRow
@@ -60,7 +49,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow 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.RecurringExpenseWithCategory
import dev.achmad.ledgerr.domain.recurring.model.RecurringInterval import dev.achmad.ledgerr.domain.recurring.model.RecurringInterval
import dev.achmad.ledgerr.ui.components.AppBar 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.ExportAction
import dev.achmad.ledgerr.ui.components.MiniFab
import dev.achmad.ledgerr.ui.components.SingleSelectFilterChipGroup 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_expense.AddEditExpenseScreen
import dev.achmad.ledgerr.ui.screens.add_edit_recurring.AddEditRecurringScreen import dev.achmad.ledgerr.ui.screens.add_edit_recurring.AddEditRecurringScreen
@@ -105,6 +95,11 @@ object ExpenseListScreen : Screen {
val pagerState = rememberPagerState(pageCount = { 2 }) val pagerState = rememberPagerState(pageCount = { 2 })
val selectedTab = pagerState.currentPage val selectedTab = pagerState.currentPage
var isFabExpanded by remember { mutableStateOf(false) }
LaunchedEffect(selectedTab) {
isFabExpanded = false
}
Scaffold( Scaffold(
topBar = { topBar = {
AppBar( AppBar(
@@ -129,12 +124,38 @@ object ExpenseListScreen : Screen {
}, },
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
floatingActionButton = { floatingActionButton = {
TabAwareFab( ExpandedFab(
selectedTab = selectedTab, expanded = isFabExpanded,
onAddExpense = { navigator.push(AddEditExpenseScreen(expenseId = null)) }, onToggle = { isFabExpanded = !isFabExpanded },
onImportBankStatement = { navigator.push(ImportBankStatementScreen) }, ) {
onAddRecurring = { navigator.push(AddEditRecurringScreen(recurringId = null)) }, 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 -> ) { padding ->
Column( 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) { private fun DateRangeFilter.labelRes(): Int = when (this) {
DateRangeFilter.ALL -> R.string.expense_list_filter_all DateRangeFilter.ALL -> R.string.expense_list_filter_all
DateRangeFilter.THIS_WEEK -> R.string.expense_list_filter_this_week DateRangeFilter.THIS_WEEK -> R.string.expense_list_filter_this_week
@@ -1,10 +1,5 @@
package dev.achmad.ledgerr.ui.screens.home 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.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.lazy.items
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons 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.Close
import androidx.compose.material.icons.outlined.Edit import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.UploadFile import androidx.compose.material.icons.outlined.UploadFile
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SmallFloatingActionButton
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
@@ -44,13 +36,14 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow 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.expense.model.ExpenseWithCategory
import dev.achmad.ledgerr.domain.preference.DateRangeOption import dev.achmad.ledgerr.domain.preference.DateRangeOption
import dev.achmad.ledgerr.ui.components.AppBar 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.ExportAction
import dev.achmad.ledgerr.ui.components.MiniFab
import dev.achmad.ledgerr.ui.components.SingleSelectFilterChipGroup 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_expense.AddEditExpenseScreen
import dev.achmad.ledgerr.ui.screens.category.CategoryScreen import dev.achmad.ledgerr.ui.screens.category.CategoryScreen
@@ -99,10 +94,10 @@ object HomeScreen : Screen {
val expenses by screenModel.expenses.collectAsState() val expenses by screenModel.expenses.collectAsState()
val summary by screenModel.summary.collectAsState() val summary by screenModel.summary.collectAsState()
val recurringBanner by screenModel.recurringBanner.collectAsState() val recurringBanner by screenModel.recurringBanner.collectAsState()
val isFabExpanded by screenModel.isFabExpanded.collectAsState()
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
var isFabExpanded by remember { mutableStateOf(false) }
val exportFailureText = stringResource(R.string.home_export_failure) val exportFailureText = stringResource(R.string.home_export_failure)
@@ -113,11 +108,10 @@ object HomeScreen : Screen {
actions = { actions = {
ExportAction( ExportAction(
onExportConfirmed = { range, uri -> onExportConfirmed = { range, uri ->
screenModel.exportToCsv(uri, range) { result -> coroutineScope.launch {
val result = screenModel.exportToCsv(uri, range)
if (result.isFailure) { if (result.isFailure) {
coroutineScope.launch { snackbarHostState.showSnackbar(exportFailureText)
snackbarHostState.showSnackbar(exportFailureText)
}
} }
} }
}, },
@@ -135,10 +129,25 @@ object HomeScreen : Screen {
floatingActionButton = { floatingActionButton = {
ExpandedFab( ExpandedFab(
expanded = isFabExpanded, expanded = isFabExpanded,
onToggle = screenModel::toggleFab, onToggle = { isFabExpanded = !isFabExpanded },
onManual = { navigator.push(AddEditExpenseScreen(expenseId = null)) }, ) {
onImport = { navigator.push(ImportBankStatementScreen) }, 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 -> ) { padding ->
DashboardContent( DashboardContent(
@@ -269,12 +278,6 @@ private fun TotalCard(
amount: Double, amount: Double,
period: DateRangeOption, 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( Surface(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium, shape = MaterialTheme.shapes.medium,
@@ -284,7 +287,7 @@ private fun TotalCard(
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(16.dp),
) { ) {
Text( Text(
text = stringResource(R.string.home_total, periodLabel), text = stringResource(R.string.home_total, period.labelText()),
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer, color = MaterialTheme.colorScheme.onPrimaryContainer,
) )
@@ -304,19 +307,19 @@ private fun PeriodFilter(
onChange: (DateRangeOption) -> Unit, onChange: (DateRangeOption) -> Unit,
) { ) {
SingleSelectFilterChipGroup( SingleSelectFilterChipGroup(
options = DateRangeOption.entries.map { it to label(it) }, options = DateRangeOption.entries.map { it to it.labelText() },
selectedOption = selected to label(selected), selectedOption = selected to selected.labelText(),
onSelectionChanged = { (option, _) -> onChange(option) }, 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 @Composable
private fun label(option: DateRangeOption): String = stringResource( private fun DateRangeOption.labelText(): String = stringResource(labelRes())
when (option) {
DateRangeOption.THIS_WEEK -> R.string.home_period_this_week
DateRangeOption.THIS_MONTH -> R.string.home_period_this_month
}
)
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @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.ScreenModel
import cafe.adriel.voyager.core.model.screenModelScope import cafe.adriel.voyager.core.model.screenModelScope
import dev.achmad.ledgerr.di.util.inject 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.interactor.GetExpenses
import dev.achmad.ledgerr.domain.expense.model.DateRange import dev.achmad.ledgerr.domain.expense.model.DateRange
import dev.achmad.ledgerr.domain.expense.model.Expense 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 dev.achmad.ledgerr.domain.recurring.interactor.ProcessDueRecurringExpenses
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
@@ -30,6 +32,7 @@ import kotlinx.coroutines.withContext
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
class HomeScreenModel( class HomeScreenModel(
private val getExpenses: GetExpenses = inject(), private val getExpenses: GetExpenses = inject(),
private val getExpenseSummary: GetExpenseSummary = inject(),
private val processDueRecurring: ProcessDueRecurringExpenses = inject(), private val processDueRecurring: ProcessDueRecurringExpenses = inject(),
private val exportExpensesToCsv: ExportExpensesToCsv = inject(), private val exportExpensesToCsv: ExportExpensesToCsv = inject(),
private val expensePreference: ExpensePreference = inject(), private val expensePreference: ExpensePreference = inject(),
@@ -57,33 +60,22 @@ class HomeScreenModel(
initialValue = emptyList(), initialValue = emptyList(),
) )
val summary: StateFlow<ExpenseSummary?> = combine(expenses, dateRange) { list, range -> val summary: StateFlow<ExpenseSummary?> = dateRange
if (list.isEmpty()) { .flatMapLatest { range -> summaryFlow(range) }
null .stateIn(
} else { scope = screenModelScope,
ExpenseSummary( started = SharingStarted.WhileSubscribed(5_000L),
totalAmount = list.sumOf { it.expense.amount }, initialValue = null,
byCategory = list )
.groupBy { it.expense.categoryId }
.map { (_, group) -> private fun summaryFlow(range: DateRange): Flow<ExpenseSummary?> = flow {
group.first().category to group.sumOf { it.expense.amount } val s = withContext(Dispatchers.IO) { getExpenseSummary.await(range) }
} emit(s.takeIf { it.byCategory.isNotEmpty() })
.sortedByDescending { it.second }, }
period = range,
)
}
}.stateIn(
scope = screenModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = null,
)
private val _recurringBanner = MutableStateFlow<List<Expense>?>(null) private val _recurringBanner = MutableStateFlow<List<Expense>?>(null)
val recurringBanner: StateFlow<List<Expense>?> = _recurringBanner.asStateFlow() val recurringBanner: StateFlow<List<Expense>?> = _recurringBanner.asStateFlow()
private val _isFabExpanded = MutableStateFlow(false)
val isFabExpanded: StateFlow<Boolean> = _isFabExpanded.asStateFlow()
init { init {
screenModelScope.launch { screenModelScope.launch {
val generated = withContext(Dispatchers.IO) { processDueRecurring.await() } val generated = withContext(Dispatchers.IO) { processDueRecurring.await() }
@@ -95,20 +87,12 @@ class HomeScreenModel(
_selectedDateRange.value = option _selectedDateRange.value = option
} }
fun toggleFab() {
_isFabExpanded.value = !_isFabExpanded.value
}
fun dismissRecurringBanner() { fun dismissRecurringBanner() {
_recurringBanner.value = null _recurringBanner.value = null
} }
fun exportToCsv(uri: Uri, range: DateRange, onResult: (Result<Unit>) -> Unit) { suspend fun exportToCsv(uri: Uri, range: DateRange): Result<Unit> =
screenModelScope.launch { withContext(Dispatchers.IO) { exportExpensesToCsv.await(range, uri) }
val result = withContext(Dispatchers.IO) { exportExpensesToCsv.await(range, uri) }
onResult(result)
}
}
} }
private fun DateRangeOption.toDateRange(): DateRange = when (this) { 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_all">All</string>
<string name="expense_list_filter_this_week">This week</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_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_fab_add_recurring">Add Recurring</string>
<string name="expense_list_delete_title">Delete expense?</string> <string name="expense_list_delete_title">Delete expense?</string>
<string name="expense_list_delete_message">This will permanently delete the 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_monthly">Monthly</string>
<string name="add_edit_recurring_interval_yearly">Yearly</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) --> <!-- Home (issue #5) -->
<string name="home_title">Ledgerr</string> <string name="home_title">Ledgerr</string>
<string name="home_total">Total %1$s</string> <string name="home_total">Total %1$s</string>
@@ -115,8 +117,6 @@
<item quantity="one">%d new recurring expense added</item> <item quantity="one">%d new recurring expense added</item>
<item quantity="other">%d new recurring expenses added</item> <item quantity="other">%d new recurring expenses added</item>
</plurals> </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_dashboard_empty">No expenses yet for this period</string>
<string name="home_settings">Settings</string> <string name="home_settings">Settings</string>
<string name="home_export_failure">Export failed</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) 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. **No manifest permissions** — SAF handles file access.