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 0e592e6..4d1671c 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 @@ -19,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 @@ -61,12 +59,15 @@ 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.EmptyStateIllustration 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 import dev.achmad.ledgerr.ui.screens.add_edit_recurring.AddEditRecurringScreen import dev.achmad.ledgerr.ui.screens.import_bank_statement.ImportBankStatementScreen @@ -100,11 +101,18 @@ object ExpenseListScreen : Screen { LaunchedEffect(selectedTab) { isFabExpanded = false } + BackHandler(enabled = isFabExpanded) { + isFabExpanded = false + } 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( @@ -124,10 +132,65 @@ object ExpenseListScreen : Screen { ) }, snackbarHost = { SnackbarHost(snackbarHostState) }, - floatingActionButton = { + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) { + Column(modifier = Modifier.fillMaxSize()) { + PrimaryTabRow(selectedTabIndex = selectedTab) { + 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 = { + coroutineScope.launch { + pagerState.animateScrollToPage(index) + } + }, + text = { TabText(text = title, badgeCount = count) }, + ) + } + } + 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, + searchQuery = searchQuery, + 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( @@ -157,51 +220,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)) }, - ) - } - } } } @@ -234,15 +252,20 @@ 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 + } EmptyStateIllustration( - message = stringResource(R.string.expense_list_empty), + message = stringResource(message), ) return } @@ -250,20 +273,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()) }, @@ -292,20 +301,19 @@ private fun ExpensesTabContent( private fun RecurringTabContent( screenModel: ExpenseListScreenModel, recurring: List, + searchQuery: String?, contentPadding: PaddingValues, onRecurringClick: (Long) -> Unit, ) { if (recurring.isEmpty()) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - Text( - text = stringResource(R.string.recurring_list_empty), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + val message = if (searchQuery.isNullOrBlank()) { + R.string.recurring_list_empty + } else { + R.string.expense_list_no_results } + EmptyStateIllustration( + message = stringResource(message), + ) return } 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 e050e40..5c9581e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -83,6 +83,7 @@ This will permanently delete the expense. No expenses yet No recurring expenses + No results Add Expense