From b698f5084f2b62984bf8f64a8a80686de6ccd416 Mon Sep 17 00:00:00 2001 From: Achmad Setyabudi Susilo Date: Sun, 28 Jun 2026 21:21:36 +0700 Subject: [PATCH 1/3] fix(#28): add scrim when ExpandedFab is open Add ExpandedFabScrim composable that renders a Material 3 scrim overlay fading in/out in sync with the mini-FABs. Tapping the scrim dismisses the FAB. Move the ExpandedFab out of Scaffold's floatingActionButton slot and into the body inside a Box, so the scrim can match the body size via matchParentSize() and stack above the list but below the FAB. Add a BackHandler that dismisses the FAB on system back while it is open. --- .../ledgerr/ui/components/ExpandedFab.kt | 32 ++++++ .../ui/screens/expenses/ExpenseListScreen.kt | 105 ++++++++++-------- 2 files changed, 91 insertions(+), 46 deletions(-) diff --git a/app/src/main/java/dev/achmad/ledgerr/ui/components/ExpandedFab.kt b/app/src/main/java/dev/achmad/ledgerr/ui/components/ExpandedFab.kt index c43bbbf..dcac0fb 100644 --- a/app/src/main/java/dev/achmad/ledgerr/ui/components/ExpandedFab.kt +++ b/app/src/main/java/dev/achmad/ledgerr/ui/components/ExpandedFab.kt @@ -5,10 +5,15 @@ 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.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Add @@ -20,6 +25,7 @@ import androidx.compose.material3.SmallFloatingActionButton import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector @@ -57,6 +63,32 @@ fun ExpandedFab( } } +@Composable +fun ExpandedFabScrim( + visible: Boolean, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val interactionSource = remember { MutableInteractionSource() } + AnimatedVisibility( + visible = visible, + enter = fadeIn(), + exit = fadeOut(), + modifier = modifier, + ) { + Box( + modifier = Modifier + .fillMaxSize() + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = onDismiss, + ) + .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.32f)), + ) + } +} + @Composable fun MiniFab( label: String, 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..ba08737 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 @@ -1,5 +1,6 @@ package dev.achmad.ledgerr.ui.screens.expenses +import androidx.activity.compose.BackHandler import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable @@ -63,6 +64,7 @@ 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.ExpandedFabScrim import dev.achmad.ledgerr.ui.components.ExportAction import dev.achmad.ledgerr.ui.components.MiniFab import dev.achmad.ledgerr.ui.components.SingleSelectFilterChipGroup @@ -99,6 +101,9 @@ object ExpenseListScreen : Screen { LaunchedEffect(selectedTab) { isFabExpanded = false } + BackHandler(enabled = isFabExpanded) { + isFabExpanded = false + } Scaffold( topBar = { @@ -123,10 +128,63 @@ object ExpenseListScreen : Screen { ) }, snackbarHost = { SnackbarHost(snackbarHostState) }, - floatingActionButton = { + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) { + Column(modifier = Modifier.fillMaxSize()) { + PrimaryTabRow(selectedTabIndex = selectedTab) { + listOf( + stringResource(R.string.expense_list_tab_expenses), + stringResource(R.string.expense_list_tab_recurring), + ).forEachIndexed { index, title -> + Tab( + selected = selectedTab == index, + onClick = { + coroutineScope.launch { + pagerState.animateScrollToPage(index) + } + }, + text = { Text(text = title) }, + ) + } + } + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + ) { page -> + when (page) { + 0 -> ExpensesTabContent( + screenModel = screenModel, + expenses = expenses, + searchQuery = searchQuery, + dateRangeFilter = dateRangeFilter, + contentPadding = PaddingValues(bottom = 88.dp), + onExpenseClick = { id -> navigator.push(AddEditExpenseScreen(expenseId = id)) }, + onExpenseLongClick = { id -> pendingDeleteId = id }, + ) + 1 -> RecurringTabContent( + screenModel = screenModel, + recurring = recurring, + contentPadding = PaddingValues(bottom = 88.dp), + onRecurringClick = { id -> navigator.push(AddEditRecurringScreen(recurringId = id)) }, + ) + } + } + } + ExpandedFabScrim( + visible = isFabExpanded, + onDismiss = { isFabExpanded = false }, + modifier = Modifier.matchParentSize(), + ) ExpandedFab( expanded = isFabExpanded, onToggle = { isFabExpanded = !isFabExpanded }, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(16.dp), ) { if (selectedTab == 0) { MiniFab( @@ -156,51 +214,6 @@ object ExpenseListScreen : Screen { ) } } - }, - ) { padding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(padding), - ) { - PrimaryTabRow(selectedTabIndex = selectedTab) { - listOf( - stringResource(R.string.expense_list_tab_expenses), - stringResource(R.string.expense_list_tab_recurring), - ).forEachIndexed { index, title -> - Tab( - selected = selectedTab == index, - onClick = { - coroutineScope.launch { - pagerState.animateScrollToPage(index) - } - }, - text = { Text(text = title) }, - ) - } - } - HorizontalPager( - state = pagerState, - modifier = Modifier.fillMaxSize(), - ) { page -> - when (page) { - 0 -> ExpensesTabContent( - screenModel = screenModel, - expenses = expenses, - searchQuery = searchQuery, - dateRangeFilter = dateRangeFilter, - contentPadding = PaddingValues(bottom = 88.dp), - onExpenseClick = { id -> navigator.push(AddEditExpenseScreen(expenseId = id)) }, - onExpenseLongClick = { id -> pendingDeleteId = id }, - ) - 1 -> RecurringTabContent( - screenModel = screenModel, - recurring = recurring, - contentPadding = PaddingValues(bottom = 88.dp), - onRecurringClick = { id -> navigator.push(AddEditRecurringScreen(recurringId = id)) }, - ) - } - } } } From 5953111897f8c8ef93e4de08894d3d2d38c80163 Mon Sep 17 00:00:00 2001 From: Achmad Setyabudi Susilo Date: Sun, 28 Jun 2026 21:22:09 +0700 Subject: [PATCH 2/3] fix(#25): render expense tab counts via TabText Replace the plain Text inside PrimaryTabRow's Tab with the shared TabText composable. Pass the current size of the expenses and recurring state flows as badgeCount so each tab shows a small pill with the (post-filter) list size. Counts update reactively as items are added, deleted, or filtered by search/date. --- .../ledgerr/ui/screens/expenses/ExpenseListScreen.kt | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) 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 ba08737..51715dc 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 @@ -68,6 +68,7 @@ import dev.achmad.ledgerr.ui.components.ExpandedFabScrim 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.components.TabText import dev.achmad.ledgerr.ui.screens.add_edit_expense.AddEditExpenseScreen import dev.achmad.ledgerr.ui.screens.add_edit_recurring.AddEditRecurringScreen import dev.achmad.ledgerr.ui.screens.import_bank_statement.ImportBankStatementScreen @@ -136,10 +137,11 @@ object ExpenseListScreen : Screen { ) { Column(modifier = Modifier.fillMaxSize()) { PrimaryTabRow(selectedTabIndex = selectedTab) { - listOf( - stringResource(R.string.expense_list_tab_expenses), - stringResource(R.string.expense_list_tab_recurring), - ).forEachIndexed { index, title -> + val tabs = listOf( + stringResource(R.string.expense_list_tab_expenses) to expenses.size, + stringResource(R.string.expense_list_tab_recurring) to recurring.size, + ) + tabs.forEachIndexed { index, (title, count) -> Tab( selected = selectedTab == index, onClick = { @@ -147,7 +149,7 @@ object ExpenseListScreen : Screen { pagerState.animateScrollToPage(index) } }, - text = { Text(text = title) }, + text = { TabText(text = title, badgeCount = count) }, ) } } From 16236c6d6cc754452ac6a245e11c0d6b7bbeff4e Mon Sep 17 00:00:00 2001 From: Achmad Setyabudi Susilo Date: Sun, 28 Jun 2026 21:23:13 +0700 Subject: [PATCH 3/3] fix(#26): replace inline search with SearchToolbar (debounced, shared) Replace the inline OutlinedTextField inside ExpensesTabContent with the shared SearchToolbar in the topBar. The query is shared across both tabs; closing the toolbar (X / navigate-up) deactivates search and clears the filter. In ExpenseListScreenModel: - searchQuery becomes StateFlow (null = inactive, "" = active empty, "foo" = active query) - setSearchQuery now accepts String? to match SearchToolbar's onChangeSearchQuery signature - expenses and recurring combine on _searchQuery.debounce(SEARCH_DEBOUNCE_MILLIS).distinctUntilChanged() so fast typing does not re-filter on every keystroke - recurring is now filtered by query against category name and recurring note (case-insensitive substring) In ExpenseListScreen: - Remove the inline search field, the date-range-filter search hint label, and the now-unused OutlinedTextField / Icons.Outlined.Search imports - Both ExpensesTabContent and RecurringTabContent now take a nullable searchQuery and show a generic 'No results' message when the active filter empties the list, or the per-tab empty copy otherwise - Add the expense_list_no_results string --- .../ui/screens/expenses/ExpenseListScreen.kt | 48 +++++++++---------- .../expenses/ExpenseListScreenModel.kt | 39 ++++++++++----- app/src/main/res/values/strings.xml | 1 + 3 files changed, 53 insertions(+), 35 deletions(-) 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 51715dc..c677c8b 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 @@ -20,14 +20,11 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons 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.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.SnackbarHost import androidx.compose.material3.SnackbarHostState @@ -62,11 +59,12 @@ import dev.achmad.ledgerr.R 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.AppBarTitle import dev.achmad.ledgerr.ui.components.ExpandedFab import dev.achmad.ledgerr.ui.components.ExpandedFabScrim import dev.achmad.ledgerr.ui.components.ExportAction import dev.achmad.ledgerr.ui.components.MiniFab +import dev.achmad.ledgerr.ui.components.SearchToolbar import dev.achmad.ledgerr.ui.components.SingleSelectFilterChipGroup import dev.achmad.ledgerr.ui.components.TabText import dev.achmad.ledgerr.ui.screens.add_edit_expense.AddEditExpenseScreen @@ -108,8 +106,12 @@ object ExpenseListScreen : Screen { Scaffold( topBar = { - AppBar( - title = stringResource(R.string.expense_list_title), + SearchToolbar( + searchQuery = searchQuery, + onChangeSearchQuery = screenModel::setSearchQuery, + titleContent = { + AppBarTitle(title = stringResource(R.string.expense_list_title)) + }, navigateUp = { navigator.pop() }, actions = { ExportAction( @@ -170,6 +172,7 @@ object ExpenseListScreen : Screen { 1 -> RecurringTabContent( screenModel = screenModel, recurring = recurring, + searchQuery = searchQuery, contentPadding = PaddingValues(bottom = 88.dp), onRecurringClick = { id -> navigator.push(AddEditRecurringScreen(recurringId = id)) }, ) @@ -248,19 +251,24 @@ object ExpenseListScreen : Screen { private fun ExpensesTabContent( screenModel: ExpenseListScreenModel, expenses: List, - searchQuery: String, + searchQuery: String?, dateRangeFilter: DateRangeFilter, contentPadding: PaddingValues, onExpenseClick: (Long) -> Unit, onExpenseLongClick: (Long) -> Unit, ) { - if (expenses.isEmpty() && searchQuery.isBlank() && dateRangeFilter == DateRangeFilter.ALL) { + if (expenses.isEmpty()) { + val message = when { + !searchQuery.isNullOrBlank() -> R.string.expense_list_no_results + dateRangeFilter != DateRangeFilter.ALL -> R.string.expense_list_no_results + else -> R.string.expense_list_empty + } Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { Text( - text = stringResource(R.string.expense_list_empty), + text = stringResource(message), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -271,20 +279,6 @@ private fun ExpensesTabContent( LazyColumn( contentPadding = contentPadding, ) { - item { - OutlinedTextField( - value = searchQuery, - onValueChange = screenModel::setSearchQuery, - label = { Text(stringResource(R.string.expense_list_search_hint)) }, - leadingIcon = { - Icon(imageVector = Icons.Outlined.Search, contentDescription = null) - }, - singleLine = true, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - ) - } item { SingleSelectFilterChipGroup( options = DateRangeFilter.entries.map { it to stringResource(it.labelRes()) }, @@ -313,16 +307,22 @@ private fun ExpensesTabContent( private fun RecurringTabContent( screenModel: ExpenseListScreenModel, recurring: List, + searchQuery: String?, contentPadding: PaddingValues, onRecurringClick: (Long) -> Unit, ) { if (recurring.isEmpty()) { + val message = if (searchQuery.isNullOrBlank()) { + R.string.recurring_list_empty + } else { + R.string.expense_list_no_results + } Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { Text( - text = stringResource(R.string.recurring_list_empty), + text = stringResource(message), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/app/src/main/java/dev/achmad/ledgerr/ui/screens/expenses/ExpenseListScreenModel.kt b/app/src/main/java/dev/achmad/ledgerr/ui/screens/expenses/ExpenseListScreenModel.kt index 2e35de6..78dd22c 100644 --- a/app/src/main/java/dev/achmad/ledgerr/ui/screens/expenses/ExpenseListScreenModel.kt +++ b/app/src/main/java/dev/achmad/ledgerr/ui/screens/expenses/ExpenseListScreenModel.kt @@ -12,11 +12,15 @@ import dev.achmad.ledgerr.domain.export.interactor.ExportExpensesToCsv import dev.achmad.ledgerr.domain.recurring.interactor.GetRecurringExpenses import dev.achmad.ledgerr.domain.recurring.interactor.UpsertRecurringExpense import dev.achmad.ledgerr.domain.recurring.model.RecurringExpenseWithCategory +import dev.achmad.ledgerr.ui.components.SEARCH_DEBOUNCE_MILLIS +import kotlinx.coroutines.FlowPreview 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.debounce +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -32,6 +36,7 @@ enum class DateRangeFilter { } } +@OptIn(FlowPreview::class) class ExpenseListScreenModel( private val getExpenses: GetExpenses = inject(), private val getRecurring: GetRecurringExpenses = inject(), @@ -40,21 +45,21 @@ class ExpenseListScreenModel( private val exportExpensesToCsv: ExportExpensesToCsv = inject(), ) : ScreenModel { - private val _searchQuery = MutableStateFlow("") - val searchQuery: StateFlow = _searchQuery.asStateFlow() + private val _searchQuery = MutableStateFlow(null) + val searchQuery: StateFlow = _searchQuery.asStateFlow() private val _dateRangeFilter = MutableStateFlow(DateRangeFilter.ALL) val dateRangeFilter: StateFlow = _dateRangeFilter.asStateFlow() val expenses: StateFlow> = combine( getExpenses.subscribeAll(), - _searchQuery, + _searchQuery.debounce(SEARCH_DEBOUNCE_MILLIS).distinctUntilChanged(), _dateRangeFilter, ) { list, query, filter -> val range = filter.toDateRange() list.asSequence() .filter { item -> inDateRange(item, range) } - .filter { item -> matchesQuery(item, query) } + .filter { item -> matchesQuery(item, query.orEmpty()) } .toList() }.stateIn( scope = screenModelScope, @@ -62,14 +67,18 @@ class ExpenseListScreenModel( initialValue = emptyList(), ) - val recurring: StateFlow> = - getRecurring.subscribeAll().stateIn( - scope = screenModelScope, - started = SharingStarted.WhileSubscribed(5_000L), - initialValue = emptyList(), - ) + val recurring: StateFlow> = combine( + getRecurring.subscribeAll(), + _searchQuery.debounce(SEARCH_DEBOUNCE_MILLIS).distinctUntilChanged(), + ) { list, query -> + list.filter { item -> matchesQuery(item, query.orEmpty()) } + }.stateIn( + scope = screenModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = emptyList(), + ) - fun setSearchQuery(query: String) { + fun setSearchQuery(query: String?) { _searchQuery.value = query } @@ -112,4 +121,12 @@ class ExpenseListScreenModel( amountStr.contains(query) || categoryName.contains(query, ignoreCase = true) } + + private fun matchesQuery(item: RecurringExpenseWithCategory, query: String): Boolean { + if (query.isBlank()) return true + val note = item.recurring.note.orEmpty() + val categoryName = item.category.name + return note.contains(query, ignoreCase = true) || + categoryName.contains(query, ignoreCase = true) + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 85d4ed9..9cecdec 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -77,6 +77,7 @@ This will permanently delete the expense. No expenses yet No recurring expenses + No results Add Expense