From 16236c6d6cc754452ac6a245e11c0d6b7bbeff4e Mon Sep 17 00:00:00 2001 From: Achmad Setyabudi Susilo Date: Sun, 28 Jun 2026 21:23:13 +0700 Subject: [PATCH] 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