diff --git a/app/src/main/java/dev/achmad/ledgerr/ui/components/EmptyStateIllustration.kt b/app/src/main/java/dev/achmad/ledgerr/ui/components/EmptyStateIllustration.kt new file mode 100644 index 0000000..16c220a --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/ui/components/EmptyStateIllustration.kt @@ -0,0 +1,49 @@ +package dev.achmad.ledgerr.ui.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ReceiptLong +import androidx.compose.material3.MaterialTheme +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.ColorFilter +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +@Composable +fun EmptyStateIllustration( + message: String, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Image( + painter = rememberVectorPainter(image = Icons.AutoMirrored.Outlined.ReceiptLong), + contentDescription = null, + modifier = Modifier.size(120.dp), + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurfaceVariant), + ) + Spacer(Modifier.height(16.dp)) + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } +} diff --git a/app/src/main/java/dev/achmad/ledgerr/ui/screens/expenses/ExpenseListScreen.kt b/app/src/main/java/dev/achmad/ledgerr/ui/screens/expenses/ExpenseListScreen.kt index ad1e964..0e592e6 100644 --- a/app/src/main/java/dev/achmad/ledgerr/ui/screens/expenses/ExpenseListScreen.kt +++ b/app/src/main/java/dev/achmad/ledgerr/ui/screens/expenses/ExpenseListScreen.kt @@ -62,6 +62,7 @@ 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.EmptyStateIllustration import dev.achmad.ledgerr.ui.components.ExpandedFab import dev.achmad.ledgerr.ui.components.ExportAction import dev.achmad.ledgerr.ui.components.MiniFab @@ -240,16 +241,9 @@ private fun ExpensesTabContent( onExpenseLongClick: (Long) -> Unit, ) { if (expenses.isEmpty() && searchQuery.isBlank() && dateRangeFilter == DateRangeFilter.ALL) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - Text( - text = stringResource(R.string.expense_list_empty), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + EmptyStateIllustration( + message = stringResource(R.string.expense_list_empty), + ) return } 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 4533fb6..5eae084 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 @@ -6,6 +6,7 @@ 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.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -21,17 +22,16 @@ 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.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -68,12 +68,12 @@ 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.EmptyStateIllustration 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 @@ -158,7 +158,6 @@ object HomeScreen : Screen { onSelectedDateRangeChange = screenModel::setSelectedDateRange, recurringBannerCount = recurringBanner?.size, onDismissBanner = screenModel::dismissRecurringBanner, - onManageCategories = { navigator.push(CategoryScreen) }, onSeeAll = { navigator.push(ExpenseListScreen) }, ) } @@ -174,7 +173,6 @@ private fun DashboardContent( onSelectedDateRangeChange: (DateRangeOption) -> Unit, recurringBannerCount: Int?, onDismissBanner: () -> Unit, - onManageCategories: () -> Unit, onSeeAll: () -> Unit, ) { val hasData = summary?.takeIf { it.byCategory.isNotEmpty() } @@ -215,26 +213,22 @@ private fun DashboardContent( 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)) + SectionHeader(text = stringResource(R.string.home_recent)) { + TextButton(onClick = onSeeAll) { + Text(stringResource(R.string.home_view_all)) + } + } } 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), + EmptyStateIllustration( + message = stringResource(R.string.home_dashboard_empty), + modifier = Modifier.fillParentMaxSize(), ) } } @@ -394,40 +388,26 @@ private fun CategoryLegend(items: List>) { } @Composable -private fun ActionsRow( - onManageCategories: () -> Unit, - onSeeAll: () -> Unit, +private fun SectionHeader( + text: String, + actions: @Composable RowScope.() -> Unit = {}, ) { Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, ) { - OutlinedButton( - onClick = onManageCategories, + Text( + text = text, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, 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)) - } + ) + actions() } } -@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( diff --git a/app/src/main/java/dev/achmad/ledgerr/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/dev/achmad/ledgerr/ui/screens/settings/SettingsScreen.kt index a78f09c..5054a0e 100644 --- a/app/src/main/java/dev/achmad/ledgerr/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/dev/achmad/ledgerr/ui/screens/settings/SettingsScreen.kt @@ -8,9 +8,12 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.navigator.LocalNavigator @@ -24,6 +27,7 @@ import dev.achmad.ledgerr.ui.components.preference.Preference import dev.achmad.ledgerr.ui.components.preference.PreferenceScreen import cafe.adriel.voyager.core.model.rememberScreenModel import dev.achmad.ledgerr.di.util.inject +import dev.achmad.ledgerr.ui.screens.category.CategoryScreen import kotlinx.coroutines.launch object SettingsScreen : Screen { @@ -38,6 +42,7 @@ object SettingsScreen : Screen { val appPreference = inject() val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() + val categoriesCount by screenModel.categoriesCount.collectAsState() val exportSuccess = stringResource(R.string.settings_export_success) val exportFailure = stringResource(R.string.settings_export_failure) @@ -77,6 +82,20 @@ object SettingsScreen : Screen { ), ), ), + Preference.PreferenceGroup( + title = stringResource(R.string.settings_categories), + preferenceItems = listOf( + Preference.PreferenceItem.TextPreference( + title = stringResource(R.string.settings_edit_categories), + subtitle = pluralStringResource( + R.plurals.settings_edit_categories_subtitle, + categoriesCount, + categoriesCount, + ), + onClick = { navigator.push(CategoryScreen) }, + ), + ), + ), Preference.PreferenceGroup( title = stringResource(R.string.settings_data), preferenceItems = buildList { diff --git a/app/src/main/java/dev/achmad/ledgerr/ui/screens/settings/SettingsScreenModel.kt b/app/src/main/java/dev/achmad/ledgerr/ui/screens/settings/SettingsScreenModel.kt index 1bba75f..2d63ec9 100644 --- a/app/src/main/java/dev/achmad/ledgerr/ui/screens/settings/SettingsScreenModel.kt +++ b/app/src/main/java/dev/achmad/ledgerr/ui/screens/settings/SettingsScreenModel.kt @@ -4,11 +4,16 @@ 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.category.interactor.GetCategories import dev.achmad.ledgerr.domain.category.interactor.SeedDefaultCategories import dev.achmad.ledgerr.domain.data.interactor.ClearAllData import dev.achmad.ledgerr.domain.expense.model.DateRange import dev.achmad.ledgerr.domain.export.interactor.ExportExpensesToCsv import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -16,8 +21,17 @@ class SettingsScreenModel( private val exportExpensesToCsv: ExportExpensesToCsv = inject(), private val clearAllData: ClearAllData = inject(), private val seedDefaultCategories: SeedDefaultCategories = inject(), + private val getCategories: GetCategories = inject(), ) : ScreenModel { + val categoriesCount: StateFlow = getCategories.subscribeAll() + .map { it.size } + .stateIn( + scope = screenModelScope, + started = SharingStarted.Eagerly, + initialValue = 0, + ) + fun exportToCsv(range: DateRange, uri: Uri, onResult: (Result) -> Unit) { screenModelScope.launch { val result = withContext(Dispatchers.IO) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 85d4ed9..e050e40 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -52,6 +52,12 @@ Light Dark System + Categories + Edit Categories + + %d category + %d categories + Data Export CSV Export expenses in a date range to a CSV file @@ -110,8 +116,7 @@ Total %1$s this week this month - Manage Categories - See all + View All Recent %d new recurring expense added