diff --git a/app/src/main/java/dev/achmad/ledgerr/ui/screens/home/HomeScreen.kt b/app/src/main/java/dev/achmad/ledgerr/ui/screens/home/HomeScreen.kt index 62aff2f..f30f983 100644 --- a/app/src/main/java/dev/achmad/ledgerr/ui/screens/home/HomeScreen.kt +++ b/app/src/main/java/dev/achmad/ledgerr/ui/screens/home/HomeScreen.kt @@ -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) }, + ) + } } -} \ No newline at end of file +} + +@Composable +private fun DashboardContent( + paddingValues: PaddingValues, + summary: ExpenseSummary?, + recent: List, + 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>) { + 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) + } + } +} diff --git a/app/src/main/java/dev/achmad/ledgerr/ui/screens/home/HomeScreenModel.kt b/app/src/main/java/dev/achmad/ledgerr/ui/screens/home/HomeScreenModel.kt new file mode 100644 index 0000000..28ffe23 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/ui/screens/home/HomeScreenModel.kt @@ -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 = _selectedDateRange.asStateFlow() + + private val dateRange: StateFlow = _selectedDateRange + .map { it.toDateRange() } + .stateIn( + scope = screenModelScope, + started = SharingStarted.Eagerly, + initialValue = _selectedDateRange.value.toDateRange(), + ) + + val expenses: StateFlow> = dateRange + .flatMapLatest { getExpenses.subscribeByDateRange(it) } + .flowOn(Dispatchers.IO) + .stateIn( + scope = screenModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = emptyList(), + ) + + val summary: StateFlow = 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?>(null) + val recurringBanner: StateFlow?> = _recurringBanner.asStateFlow() + + private val _isFabExpanded = MutableStateFlow(false) + val isFabExpanded: StateFlow = _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) { + 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() +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fafc60d..aa374a4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -102,4 +102,22 @@ Weekly Monthly Yearly + + + Ledgerr + Total %1$s + this week + this month + Manage Categories + See all + Recent + + %d new recurring expense added + %d new recurring expenses added + + Manual + Import Bank Statement + No expenses yet for this period + Settings + Export failed