From 86bea46c307e06b7016de57945081520857acd76 Mon Sep 17 00:00:00 2001 From: Achmad Setyabudi Susilo Date: Sun, 28 Jun 2026 21:09:33 +0700 Subject: [PATCH 1/3] fix(#31): vertical-align lock icon with trash icon in category rows --- .../ledgerr/ui/screens/category/CategoryScreen.kt | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/dev/achmad/ledgerr/ui/screens/category/CategoryScreen.kt b/app/src/main/java/dev/achmad/ledgerr/ui/screens/category/CategoryScreen.kt index d0230b9..7b9db46 100644 --- a/app/src/main/java/dev/achmad/ledgerr/ui/screens/category/CategoryScreen.kt +++ b/app/src/main/java/dev/achmad/ledgerr/ui/screens/category/CategoryScreen.kt @@ -201,11 +201,12 @@ private fun CategoryRow( modifier = Modifier.weight(1f), ) if (category.isDefault) { - Icon( - imageVector = Icons.Outlined.Lock, - contentDescription = stringResource(R.string.category_default_lock), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + IconButton(onClick = {}, enabled = false) { + Icon( + imageVector = Icons.Outlined.Lock, + contentDescription = stringResource(R.string.category_default_lock), + ) + } } else { IconButton(onClick = onDelete) { Icon( From b39ca61cfb0d77cbdc908c767e487143ee58cf05 Mon Sep 17 00:00:00 2001 From: Achmad Setyabudi Susilo Date: Sun, 28 Jun 2026 21:17:21 +0700 Subject: [PATCH 2/3] feat(#30): replace category swatch grid with HSV color picker --- app/build.gradle.kts | 1 + .../ui/screens/category/CategoryScreen.kt | 67 +++++-------------- gradle/libs.versions.toml | 2 + 3 files changed, 20 insertions(+), 50 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 37edf89..fbe9c45 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -94,4 +94,5 @@ dependencies { implementation(libs.vico.compose) implementation(libs.vico.compose.m3) implementation(libs.vico.core) + implementation(libs.colorpicker.compose) } diff --git a/app/src/main/java/dev/achmad/ledgerr/ui/screens/category/CategoryScreen.kt b/app/src/main/java/dev/achmad/ledgerr/ui/screens/category/CategoryScreen.kt index d0230b9..0c81c3e 100644 --- a/app/src/main/java/dev/achmad/ledgerr/ui/screens/category/CategoryScreen.kt +++ b/app/src/main/java/dev/achmad/ledgerr/ui/screens/category/CategoryScreen.kt @@ -3,7 +3,6 @@ package dev.achmad.ledgerr.ui.screens.category import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -15,11 +14,10 @@ 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.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.items as lazyListItems +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.Delete @@ -45,11 +43,14 @@ 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.toArgb import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow +import com.github.skydoves.colorpicker.compose.HsvColorPicker +import com.github.skydoves.colorpicker.compose.rememberColorPickerController import dev.achmad.ledgerr.R import dev.achmad.ledgerr.domain.category.model.Category import dev.achmad.ledgerr.ui.components.AppBar @@ -237,16 +238,7 @@ private fun CategoryEditDialog( var name by rememberSaveable(initial?.id) { mutableStateOf(initial?.name.orEmpty()) } var color by rememberSaveable(initial?.id) { mutableStateOf(initial?.color ?: Category.DEFAULT_COLOR_OTHER) } - val swatches = listOf( - Category.DEFAULT_COLOR_FOOD, - Category.DEFAULT_COLOR_TRANSPORT, - Category.DEFAULT_COLOR_HOUSING, - Category.DEFAULT_COLOR_HEALTH, - Category.DEFAULT_COLOR_ENTERTAINMENT, - Category.DEFAULT_COLOR_SHOPPING, - Category.DEFAULT_COLOR_EDUCATION, - Category.DEFAULT_COLOR_OTHER, - ) + val controller = rememberColorPickerController() AlertDialog( onDismissRequest = onDismiss, @@ -259,7 +251,7 @@ private fun CategoryEditDialog( ) }, text = { - Column { + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { OutlinedTextField( value = name, onValueChange = { name = it }, @@ -273,22 +265,18 @@ private fun CategoryEditDialog( style = MaterialTheme.typography.labelMedium, ) Spacer(modifier = Modifier.height(8.dp)) - LazyVerticalGrid( - columns = GridCells.Fixed(8), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + HsvColorPicker( modifier = Modifier .fillMaxWidth() - .height(56.dp), - ) { - items(items = swatches, key = { it }) { swatch -> - ColorSwatch( - color = Color(swatch), - selected = color == swatch, - onClick = { color = swatch }, - ) - } - } + .height(280.dp), + controller = controller, + initialColor = Color(color), + onColorChanged = { envelope -> + if (envelope.fromUser) { + color = envelope.color.toArgb() + } + }, + ) } }, confirmButton = { @@ -316,24 +304,3 @@ private fun CategoryEditDialog( }, ) } - -@Composable -private fun ColorSwatch( - color: Color, - selected: Boolean, - onClick: () -> Unit, -) { - Box( - modifier = Modifier - .size(40.dp) - .clip(CircleShape) - .background(color) - .clickable(onClick = onClick) - .border( - width = if (selected) 3.dp else 1.dp, - color = if (selected) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.outline.copy(alpha = 0.3f), - shape = CircleShape, - ), - ) -} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 74a037f..98f5ffb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,6 +22,7 @@ material = "1.14.0" room = "2.7.1" pdfboxAndroid = "2.0.27.0" vico = "2.0.0" +colorpickerCompose = "1.2.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -68,6 +69,7 @@ pdfbox-android = { group = "com.tom-roush", name = "pdfbox-android", version.ref vico-compose = { group = "com.patrykandpatrick.vico", name = "compose", version.ref = "vico" } vico-compose-m3 = { group = "com.patrykandpatrick.vico", name = "compose-m3", version.ref = "vico" } vico-core = { group = "com.patrykandpatrick.vico", name = "core", version.ref = "vico" } +colorpicker-compose = { module = "com.github.skydoves:colorpicker-compose", version.ref = "colorpickerCompose" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } From f0803431f981a82bec9c0878515ceaa48dee5bed Mon Sep 17 00:00:00 2001 From: Achmad Setyabudi Susilo Date: Sun, 28 Jun 2026 21:22:32 +0700 Subject: [PATCH 3/3] =?UTF-8?q?feat(#29,#32,#33):=20home=20polish=20?= =?UTF-8?q?=E2=80=94=20move=20Manage=20Categories=20to=20Settings,=20View?= =?UTF-8?q?=20All=20button,=20empty-state=20illustration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/components/EmptyStateIllustration.kt | 49 +++++++++++++ .../ui/screens/expenses/ExpenseListScreen.kt | 14 ++-- .../ledgerr/ui/screens/home/HomeScreen.kt | 68 +++++++------------ .../ui/screens/settings/SettingsScreen.kt | 19 ++++++ .../screens/settings/SettingsScreenModel.kt | 14 ++++ app/src/main/res/values/strings.xml | 9 ++- 6 files changed, 117 insertions(+), 56 deletions(-) create mode 100644 app/src/main/java/dev/achmad/ledgerr/ui/components/EmptyStateIllustration.kt 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