fix(#25,#26,#28): Expenses screen polish — scrim, tab badges, shared SearchToolbar #37

Merged
admin merged 4 commits from fix/25-26-28-expenses-screen-polish into main 2026-06-28 14:33:43 +00:00
4 changed files with 147 additions and 89 deletions
@@ -5,10 +5,15 @@ import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically 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.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Add 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.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector 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 @Composable
fun MiniFab( fun MiniFab(
label: String, label: String,
@@ -1,5 +1,6 @@
package dev.achmad.ledgerr.ui.screens.expenses package dev.achmad.ledgerr.ui.screens.expenses
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable 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.Icons
import androidx.compose.material.icons.outlined.Edit import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.Repeat import androidx.compose.material.icons.outlined.Repeat
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.UploadFile import androidx.compose.material.icons.outlined.UploadFile
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState 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.expense.model.ExpenseWithCategory
import dev.achmad.ledgerr.domain.recurring.model.RecurringExpenseWithCategory import dev.achmad.ledgerr.domain.recurring.model.RecurringExpenseWithCategory
import dev.achmad.ledgerr.domain.recurring.model.RecurringInterval 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.EmptyStateIllustration
import dev.achmad.ledgerr.ui.components.ExpandedFab 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.ExportAction
import dev.achmad.ledgerr.ui.components.MiniFab 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.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_expense.AddEditExpenseScreen
import dev.achmad.ledgerr.ui.screens.add_edit_recurring.AddEditRecurringScreen import dev.achmad.ledgerr.ui.screens.add_edit_recurring.AddEditRecurringScreen
import dev.achmad.ledgerr.ui.screens.import_bank_statement.ImportBankStatementScreen import dev.achmad.ledgerr.ui.screens.import_bank_statement.ImportBankStatementScreen
@@ -100,11 +101,18 @@ object ExpenseListScreen : Screen {
LaunchedEffect(selectedTab) { LaunchedEffect(selectedTab) {
isFabExpanded = false isFabExpanded = false
} }
BackHandler(enabled = isFabExpanded) {
isFabExpanded = false
}
Scaffold( Scaffold(
topBar = { topBar = {
AppBar( SearchToolbar(
title = stringResource(R.string.expense_list_title), searchQuery = searchQuery,
onChangeSearchQuery = screenModel::setSearchQuery,
titleContent = {
AppBarTitle(title = stringResource(R.string.expense_list_title))
},
navigateUp = { navigator.pop() }, navigateUp = { navigator.pop() },
actions = { actions = {
ExportAction( ExportAction(
@@ -124,10 +132,65 @@ object ExpenseListScreen : Screen {
) )
}, },
snackbarHost = { SnackbarHost(snackbarHostState) }, 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( ExpandedFab(
expanded = isFabExpanded, expanded = isFabExpanded,
onToggle = { isFabExpanded = !isFabExpanded }, onToggle = { isFabExpanded = !isFabExpanded },
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(16.dp),
) { ) {
if (selectedTab == 0) { if (selectedTab == 0) {
MiniFab( 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( private fun ExpensesTabContent(
screenModel: ExpenseListScreenModel, screenModel: ExpenseListScreenModel,
expenses: List<ExpenseWithCategory>, expenses: List<ExpenseWithCategory>,
searchQuery: String, searchQuery: String?,
dateRangeFilter: DateRangeFilter, dateRangeFilter: DateRangeFilter,
contentPadding: PaddingValues, contentPadding: PaddingValues,
onExpenseClick: (Long) -> Unit, onExpenseClick: (Long) -> Unit,
onExpenseLongClick: (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( EmptyStateIllustration(
message = stringResource(R.string.expense_list_empty), message = stringResource(message),
) )
return return
} }
@@ -250,20 +273,6 @@ private fun ExpensesTabContent(
LazyColumn( LazyColumn(
contentPadding = contentPadding, 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 { item {
SingleSelectFilterChipGroup( SingleSelectFilterChipGroup(
options = DateRangeFilter.entries.map { it to stringResource(it.labelRes()) }, options = DateRangeFilter.entries.map { it to stringResource(it.labelRes()) },
@@ -292,20 +301,19 @@ private fun ExpensesTabContent(
private fun RecurringTabContent( private fun RecurringTabContent(
screenModel: ExpenseListScreenModel, screenModel: ExpenseListScreenModel,
recurring: List<RecurringExpenseWithCategory>, recurring: List<RecurringExpenseWithCategory>,
searchQuery: String?,
contentPadding: PaddingValues, contentPadding: PaddingValues,
onRecurringClick: (Long) -> Unit, onRecurringClick: (Long) -> Unit,
) { ) {
if (recurring.isEmpty()) { if (recurring.isEmpty()) {
Box( val message = if (searchQuery.isNullOrBlank()) {
modifier = Modifier.fillMaxSize(), R.string.recurring_list_empty
contentAlignment = Alignment.Center, } else {
) { R.string.expense_list_no_results
Text(
text = stringResource(R.string.recurring_list_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} }
EmptyStateIllustration(
message = stringResource(message),
)
return return
} }
@@ -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.GetRecurringExpenses
import dev.achmad.ledgerr.domain.recurring.interactor.UpsertRecurringExpense import dev.achmad.ledgerr.domain.recurring.interactor.UpsertRecurringExpense
import dev.achmad.ledgerr.domain.recurring.model.RecurringExpenseWithCategory 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.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -32,6 +36,7 @@ enum class DateRangeFilter {
} }
} }
@OptIn(FlowPreview::class)
class ExpenseListScreenModel( class ExpenseListScreenModel(
private val getExpenses: GetExpenses = inject(), private val getExpenses: GetExpenses = inject(),
private val getRecurring: GetRecurringExpenses = inject(), private val getRecurring: GetRecurringExpenses = inject(),
@@ -40,21 +45,21 @@ class ExpenseListScreenModel(
private val exportExpensesToCsv: ExportExpensesToCsv = inject(), private val exportExpensesToCsv: ExportExpensesToCsv = inject(),
) : ScreenModel { ) : ScreenModel {
private val _searchQuery = MutableStateFlow("") private val _searchQuery = MutableStateFlow<String?>(null)
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow() val searchQuery: StateFlow<String?> = _searchQuery.asStateFlow()
private val _dateRangeFilter = MutableStateFlow(DateRangeFilter.ALL) private val _dateRangeFilter = MutableStateFlow(DateRangeFilter.ALL)
val dateRangeFilter: StateFlow<DateRangeFilter> = _dateRangeFilter.asStateFlow() val dateRangeFilter: StateFlow<DateRangeFilter> = _dateRangeFilter.asStateFlow()
val expenses: StateFlow<List<ExpenseWithCategory>> = combine( val expenses: StateFlow<List<ExpenseWithCategory>> = combine(
getExpenses.subscribeAll(), getExpenses.subscribeAll(),
_searchQuery, _searchQuery.debounce(SEARCH_DEBOUNCE_MILLIS).distinctUntilChanged(),
_dateRangeFilter, _dateRangeFilter,
) { list, query, filter -> ) { list, query, filter ->
val range = filter.toDateRange() val range = filter.toDateRange()
list.asSequence() list.asSequence()
.filter { item -> inDateRange(item, range) } .filter { item -> inDateRange(item, range) }
.filter { item -> matchesQuery(item, query) } .filter { item -> matchesQuery(item, query.orEmpty()) }
.toList() .toList()
}.stateIn( }.stateIn(
scope = screenModelScope, scope = screenModelScope,
@@ -62,14 +67,18 @@ class ExpenseListScreenModel(
initialValue = emptyList(), initialValue = emptyList(),
) )
val recurring: StateFlow<List<RecurringExpenseWithCategory>> = val recurring: StateFlow<List<RecurringExpenseWithCategory>> = combine(
getRecurring.subscribeAll().stateIn( getRecurring.subscribeAll(),
scope = screenModelScope, _searchQuery.debounce(SEARCH_DEBOUNCE_MILLIS).distinctUntilChanged(),
started = SharingStarted.WhileSubscribed(5_000L), ) { list, query ->
initialValue = emptyList(), 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 _searchQuery.value = query
} }
@@ -112,4 +121,12 @@ class ExpenseListScreenModel(
amountStr.contains(query) || amountStr.contains(query) ||
categoryName.contains(query, ignoreCase = true) 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)
}
} }
+1
View File
@@ -83,6 +83,7 @@
<string name="expense_list_delete_message">This will permanently delete the expense.</string> <string name="expense_list_delete_message">This will permanently delete the expense.</string>
<string name="expense_list_empty">No expenses yet</string> <string name="expense_list_empty">No expenses yet</string>
<string name="recurring_list_empty">No recurring expenses</string> <string name="recurring_list_empty">No recurring expenses</string>
<string name="expense_list_no_results">No results</string>
<!-- Add / Edit expense (issue #6) --> <!-- Add / Edit expense (issue #6) -->
<string name="add_edit_expense_title_new">Add Expense</string> <string name="add_edit_expense_title_new">Add Expense</string>