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
This commit is contained in:
Achmad Setyabudi Susilo
2026-06-28 20:11:01 +07:00
parent 8ce0dcc678
commit a0ccf22e67
3 changed files with 670 additions and 2 deletions
@@ -1,15 +1,548 @@
package dev.achmad.ledgerr.ui.screens.home
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.UploadFile
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SmallFloatingActionButton
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost
import com.patrykandpatrick.vico.compose.cartesian.axis.rememberBottom
import com.patrykandpatrick.vico.compose.cartesian.axis.rememberStart
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberColumnCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.rememberCartesianChart
import com.patrykandpatrick.vico.core.cartesian.axis.HorizontalAxis
import com.patrykandpatrick.vico.core.cartesian.axis.VerticalAxis
import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModelProducer
import com.patrykandpatrick.vico.core.cartesian.data.CartesianValueFormatter
import com.patrykandpatrick.vico.core.cartesian.data.columnSeries
import dev.achmad.ledgerr.R
import dev.achmad.ledgerr.domain.category.model.Category
import dev.achmad.ledgerr.domain.expense.model.ExpenseSummary
import dev.achmad.ledgerr.domain.expense.model.ExpenseWithCategory
import dev.achmad.ledgerr.domain.preference.DateRangeOption
import dev.achmad.ledgerr.ui.components.AppBar
import dev.achmad.ledgerr.ui.components.ExportAction
import dev.achmad.ledgerr.ui.components.SingleSelectFilterChipGroup
import dev.achmad.ledgerr.ui.screens.add_edit_expense.AddEditExpenseScreen
import dev.achmad.ledgerr.ui.screens.category.CategoryScreen
import dev.achmad.ledgerr.ui.screens.expenses.ExpenseListScreen
import dev.achmad.ledgerr.ui.screens.import_bank_statement.ImportBankStatementScreen
import dev.achmad.ledgerr.ui.screens.settings.SettingsScreen
import kotlinx.coroutines.launch
import java.time.format.DateTimeFormatter
object HomeScreen: Screen {
object HomeScreen : Screen {
@Suppress("unused")
private fun readResolve(): Any = HomeScreen
@OptIn(ExperimentalMaterial3Api::class)
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
val screenModel = rememberScreenModel { HomeScreenModel() }
val selectedDateRange by screenModel.selectedDateRange.collectAsState()
val expenses by screenModel.expenses.collectAsState()
val summary by screenModel.summary.collectAsState()
val recurringBanner by screenModel.recurringBanner.collectAsState()
val isFabExpanded by screenModel.isFabExpanded.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
val coroutineScope = rememberCoroutineScope()
val exportFailureText = stringResource(R.string.home_export_failure)
Scaffold(
topBar = {
AppBar(
title = stringResource(R.string.home_title),
actions = {
ExportAction(
onExportConfirmed = { range, uri ->
screenModel.exportToCsv(uri, range) { result ->
if (result.isFailure) {
coroutineScope.launch {
snackbarHostState.showSnackbar(exportFailureText)
}
}
}
},
)
IconButton(onClick = { navigator.push(SettingsScreen) }) {
Icon(
imageVector = Icons.Outlined.Settings,
contentDescription = stringResource(R.string.home_settings),
)
}
},
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
floatingActionButton = {
ExpandedFab(
expanded = isFabExpanded,
onToggle = screenModel::toggleFab,
onManual = { navigator.push(AddEditExpenseScreen(expenseId = null)) },
onImport = { navigator.push(ImportBankStatementScreen) },
)
},
) { padding ->
DashboardContent(
paddingValues = padding,
summary = summary,
recent = expenses.take(5),
selectedDateRange = selectedDateRange,
onSelectedDateRangeChange = screenModel::setSelectedDateRange,
recurringBannerCount = recurringBanner?.size,
onDismissBanner = screenModel::dismissRecurringBanner,
onManageCategories = { navigator.push(CategoryScreen) },
onSeeAll = { navigator.push(ExpenseListScreen) },
)
}
}
}
}
@Composable
private fun DashboardContent(
paddingValues: PaddingValues,
summary: ExpenseSummary?,
recent: List<ExpenseWithCategory>,
selectedDateRange: DateRangeOption,
onSelectedDateRangeChange: (DateRangeOption) -> Unit,
recurringBannerCount: Int?,
onDismissBanner: () -> Unit,
onManageCategories: () -> Unit,
onSeeAll: () -> Unit,
) {
val hasData = summary?.takeIf { it.byCategory.isNotEmpty() }
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentPadding = PaddingValues(
top = 8.dp,
bottom = 96.dp,
start = 16.dp,
end = 16.dp,
),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (recurringBannerCount != null) {
item(key = "banner") {
RecurringBanner(
count = recurringBannerCount,
onDismiss = onDismissBanner,
)
}
}
item(key = "total") {
TotalCard(
amount = summary?.totalAmount ?: 0.0,
period = selectedDateRange,
)
}
item(key = "period-filter") {
PeriodFilter(
selected = selectedDateRange,
onChange = onSelectedDateRangeChange,
)
}
hasData?.let { data ->
item(key = "chart") {
CategoryChartCard(summary = data)
}
}
item(key = "actions") {
ActionsRow(
onManageCategories = onManageCategories,
onSeeAll = onSeeAll,
)
}
if (recent.isNotEmpty()) {
item(key = "recent-header") {
SectionHeader(text = stringResource(R.string.home_recent))
}
items(items = recent, key = { it.expense.id }) { item ->
ExpenseRow(item = item)
}
} else {
item(key = "empty") {
Text(
text = stringResource(R.string.home_dashboard_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 16.dp),
)
}
}
}
}
@Composable
private fun RecurringBanner(
count: Int,
onDismiss: () -> Unit,
) {
val text = pluralStringResource(R.plurals.home_recurring_banner, count, count)
Surface(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.secondaryContainer,
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.weight(1f),
)
IconButton(onClick = onDismiss) {
Icon(
imageVector = Icons.Outlined.Close,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
}
}
}
@Composable
private fun TotalCard(
amount: Double,
period: DateRangeOption,
) {
val periodLabel = stringResource(
when (period) {
DateRangeOption.THIS_WEEK -> R.string.home_period_this_week
DateRangeOption.THIS_MONTH -> R.string.home_period_this_month
}
)
Surface(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.primaryContainer,
) {
Column(
modifier = Modifier.padding(16.dp),
) {
Text(
text = stringResource(R.string.home_total, periodLabel),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "%.2f".format(amount),
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
}
@Composable
private fun PeriodFilter(
selected: DateRangeOption,
onChange: (DateRangeOption) -> Unit,
) {
SingleSelectFilterChipGroup(
options = DateRangeOption.entries.map { it to label(it) },
selectedOption = selected to label(selected),
onSelectionChanged = { (option, _) -> onChange(option) },
)
}
@Composable
private fun label(option: DateRangeOption): String = stringResource(
when (option) {
DateRangeOption.THIS_WEEK -> R.string.home_period_this_week
DateRangeOption.THIS_MONTH -> R.string.home_period_this_month
}
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CategoryChartCard(summary: ExpenseSummary) {
val categories = summary.byCategory
val amountFormatter = remember { CartesianValueFormatter.decimal() }
val categoryNames = remember(categories) { categories.map { it.first.name } }
val bottomFormatter = remember(categoryNames) {
CartesianValueFormatter { _, value, _ ->
categoryNames.getOrNull(value.toInt()) ?: ""
}
}
val modelProducer = remember { CartesianChartModelProducer() }
LaunchedEffect(categories) {
if (categories.isEmpty()) return@LaunchedEffect
modelProducer.runTransaction {
columnSeries {
series(categories.map { it.second })
}
}
}
Surface(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surface,
tonalElevation = 1.dp,
) {
Column(modifier = Modifier.padding(16.dp)) {
CartesianChartHost(
chart = rememberCartesianChart(
rememberColumnCartesianLayer(),
startAxis = VerticalAxis.rememberStart(valueFormatter = amountFormatter),
bottomAxis = HorizontalAxis.rememberBottom(valueFormatter = bottomFormatter),
),
modelProducer = modelProducer,
modifier = Modifier
.fillMaxWidth()
.height(200.dp),
)
Spacer(modifier = Modifier.height(12.dp))
CategoryLegend(items = categories)
}
}
}
@Composable
private fun CategoryLegend(items: List<Pair<Category, Double>>) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
items.forEach { (category, amount) ->
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(12.dp)
.clip(CircleShape)
.background(Color(category.color)),
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = category.name,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = "%.2f".format(amount),
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
}
@Composable
private fun ActionsRow(
onManageCategories: () -> Unit,
onSeeAll: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedButton(
onClick = onManageCategories,
modifier = Modifier.weight(1f),
colors = ButtonDefaults.outlinedButtonColors(),
) {
Text(stringResource(R.string.home_manage_categories))
}
OutlinedButton(
onClick = onSeeAll,
modifier = Modifier.weight(1f),
) {
Text(stringResource(R.string.home_see_all))
}
}
}
@Composable
private fun SectionHeader(text: String) {
Text(
text = text,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp),
)
}
@Composable
private fun ExpenseRow(item: ExpenseWithCategory) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surface,
) {
Row(
modifier = Modifier.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(10.dp)
.clip(CircleShape)
.background(Color(item.category.color)),
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = item.category.name,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val note = item.expense.note
if (!note.isNullOrBlank()) {
Text(
text = note,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Text(
text = item.expense.date.format(DateTimeFormatter.ISO_LOCAL_DATE),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
text = "%.2f".format(item.expense.amount),
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
@Composable
private fun ExpandedFab(
expanded: Boolean,
onToggle: () -> Unit,
onManual: () -> Unit,
onImport: () -> Unit,
) {
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
AnimatedVisibility(
visible = expanded,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
MiniFab(
label = stringResource(R.string.home_fab_manual),
icon = Icons.Outlined.Edit,
onClick = {
onToggle()
onManual()
},
)
MiniFab(
label = stringResource(R.string.home_fab_import),
icon = Icons.Outlined.UploadFile,
onClick = {
onToggle()
onImport()
},
)
}
}
FloatingActionButton(onClick = onToggle) {
Icon(
imageVector = if (expanded) Icons.Outlined.Close else Icons.Outlined.Add,
contentDescription = null,
)
}
}
}
@Composable
private fun MiniFab(
label: String,
icon: ImageVector,
onClick: () -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Surface(
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.surface,
shadowElevation = 2.dp,
) {
Text(
text = label,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
style = MaterialTheme.typography.bodyMedium,
)
}
SmallFloatingActionButton(onClick = onClick) {
Icon(imageVector = icon, contentDescription = null)
}
}
}
@@ -0,0 +1,117 @@
package dev.achmad.ledgerr.ui.screens.home
import android.net.Uri
import cafe.adriel.voyager.core.model.ScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import dev.achmad.ledgerr.di.util.inject
import dev.achmad.ledgerr.domain.expense.interactor.GetExpenses
import dev.achmad.ledgerr.domain.expense.model.DateRange
import dev.achmad.ledgerr.domain.expense.model.Expense
import dev.achmad.ledgerr.domain.expense.model.ExpenseSummary
import dev.achmad.ledgerr.domain.expense.model.ExpenseWithCategory
import dev.achmad.ledgerr.domain.export.interactor.ExportExpensesToCsv
import dev.achmad.ledgerr.domain.preference.DateRangeOption
import dev.achmad.ledgerr.domain.preference.ExpensePreference
import dev.achmad.ledgerr.domain.recurring.interactor.ProcessDueRecurringExpenses
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@OptIn(ExperimentalCoroutinesApi::class)
class HomeScreenModel(
private val getExpenses: GetExpenses = inject(),
private val processDueRecurring: ProcessDueRecurringExpenses = inject(),
private val exportExpensesToCsv: ExportExpensesToCsv = inject(),
private val expensePreference: ExpensePreference = inject(),
) : ScreenModel {
private val _selectedDateRange = MutableStateFlow(
expensePreference.defaultDateRange().get()
)
val selectedDateRange: StateFlow<DateRangeOption> = _selectedDateRange.asStateFlow()
private val dateRange: StateFlow<DateRange> = _selectedDateRange
.map { it.toDateRange() }
.stateIn(
scope = screenModelScope,
started = SharingStarted.Eagerly,
initialValue = _selectedDateRange.value.toDateRange(),
)
val expenses: StateFlow<List<ExpenseWithCategory>> = dateRange
.flatMapLatest { getExpenses.subscribeByDateRange(it) }
.flowOn(Dispatchers.IO)
.stateIn(
scope = screenModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = emptyList(),
)
val summary: StateFlow<ExpenseSummary?> = combine(expenses, dateRange) { list, range ->
if (list.isEmpty()) {
null
} else {
ExpenseSummary(
totalAmount = list.sumOf { it.expense.amount },
byCategory = list
.groupBy { it.expense.categoryId }
.map { (_, group) ->
group.first().category to group.sumOf { it.expense.amount }
}
.sortedByDescending { it.second },
period = range,
)
}
}.stateIn(
scope = screenModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = null,
)
private val _recurringBanner = MutableStateFlow<List<Expense>?>(null)
val recurringBanner: StateFlow<List<Expense>?> = _recurringBanner.asStateFlow()
private val _isFabExpanded = MutableStateFlow(false)
val isFabExpanded: StateFlow<Boolean> = _isFabExpanded.asStateFlow()
init {
screenModelScope.launch {
val generated = withContext(Dispatchers.IO) { processDueRecurring.await() }
_recurringBanner.value = generated.takeIf { it.isNotEmpty() }
}
}
fun setSelectedDateRange(option: DateRangeOption) {
_selectedDateRange.value = option
}
fun toggleFab() {
_isFabExpanded.value = !_isFabExpanded.value
}
fun dismissRecurringBanner() {
_recurringBanner.value = null
}
fun exportToCsv(uri: Uri, range: DateRange, onResult: (Result<Unit>) -> Unit) {
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()
}
+18
View File
@@ -102,4 +102,22 @@
<string name="add_edit_recurring_interval_weekly">Weekly</string>
<string name="add_edit_recurring_interval_monthly">Monthly</string>
<string name="add_edit_recurring_interval_yearly">Yearly</string>
<!-- Home (issue #5) -->
<string name="home_title">Ledgerr</string>
<string name="home_total">Total %1$s</string>
<string name="home_period_this_week">this week</string>
<string name="home_period_this_month">this month</string>
<string name="home_manage_categories">Manage Categories</string>
<string name="home_see_all">See all</string>
<string name="home_recent">Recent</string>
<plurals name="home_recurring_banner">
<item quantity="one">%d new recurring expense added</item>
<item quantity="other">%d new recurring expenses added</item>
</plurals>
<string name="home_fab_manual">Manual</string>
<string name="home_fab_import">Import Bank Statement</string>
<string name="home_dashboard_empty">No expenses yet for this period</string>
<string name="home_settings">Settings</string>
<string name="home_export_failure">Export failed</string>
</resources>