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.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,
@@ -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<ExpenseWithCategory>,
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<RecurringExpenseWithCategory>,
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
}
@@ -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<String> = _searchQuery.asStateFlow()
private val _searchQuery = MutableStateFlow<String?>(null)
val searchQuery: StateFlow<String?> = _searchQuery.asStateFlow()
private val _dateRangeFilter = MutableStateFlow(DateRangeFilter.ALL)
val dateRangeFilter: StateFlow<DateRangeFilter> = _dateRangeFilter.asStateFlow()
val expenses: StateFlow<List<ExpenseWithCategory>> = 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<List<RecurringExpenseWithCategory>> =
getRecurring.subscribeAll().stateIn(
scope = screenModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = emptyList(),
)
val recurring: StateFlow<List<RecurringExpenseWithCategory>> = 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)
}
}
+1
View File
@@ -83,6 +83,7 @@
<string name="expense_list_delete_message">This will permanently delete the expense.</string>
<string name="expense_list_empty">No expenses yet</string>
<string name="recurring_list_empty">No recurring expenses</string>
<string name="expense_list_no_results">No results</string>
<!-- Add / Edit expense (issue #6) -->
<string name="add_edit_expense_title_new">Add Expense</string>