Merge pull request 'Implement HomeScreen with Vico dashboard (#5)' (#20) from feat/5-implement-homescreen-with-vico-dashboard into main

Reviewed-on: #20
This commit was merged in pull request #20.
This commit is contained in:
2026-06-28 13:29:41 +00:00
7 changed files with 714 additions and 118 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,15 +1,477 @@
package dev.achmad.ledgerr.ui.screens.home
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.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.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.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.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.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.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
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 snackbarHostState = remember { SnackbarHostState() }
val coroutineScope = rememberCoroutineScope()
var isFabExpanded by remember { mutableStateOf(false) }
val exportFailureText = stringResource(R.string.home_export_failure)
Scaffold(
topBar = {
AppBar(
title = stringResource(R.string.home_title),
actions = {
ExportAction(
onExportConfirmed = { range, uri ->
coroutineScope.launch {
val result = screenModel.exportToCsv(uri, range)
if (result.isFailure) {
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 = { 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(
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,
) {
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, period.labelText()),
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 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 DateRangeOption.labelText(): String = stringResource(labelRes())
@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,
)
}
}
}
@@ -0,0 +1,101 @@
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.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
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.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.flatMapLatest
import kotlinx.coroutines.flow.flow
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 getExpenseSummary: GetExpenseSummary = 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?> = 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()
init {
screenModelScope.launch {
val generated = withContext(Dispatchers.IO) { processDueRecurring.await() }
_recurringBanner.value = generated.takeIf { it.isNotEmpty() }
}
}
fun setSelectedDateRange(option: DateRangeOption) {
_selectedDateRange.value = option
}
fun dismissRecurringBanner() {
_recurringBanner.value = null
}
suspend fun exportToCsv(uri: Uri, range: DateRange): Result<Unit> =
withContext(Dispatchers.IO) { exportExpensesToCsv.await(range, uri) }
}
private fun DateRangeOption.toDateRange(): DateRange = when (this) {
DateRangeOption.THIS_WEEK -> DateRange.thisWeek()
DateRangeOption.THIS_MONTH -> DateRange.thisMonth()
}
+20 -2
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>
@@ -102,4 +100,24 @@
<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>
<!-- 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>
<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_dashboard_empty">No expenses yet for this period</string>
<string name="home_settings">Settings</string>
<string name="home_export_failure">Export failed</string>
</resources>
+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.