fix(#25,#26,#28): Expenses screen polish — scrim, tab badges, shared SearchToolbar #37
@@ -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<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
|
||||
}
|
||||
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<RecurringExpenseWithCategory>,
|
||||
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,
|
||||
)
|
||||
|
||||
+28
-11
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +77,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>
|
||||
|
||||
Reference in New Issue
Block a user