Merge pull request 'Implement ExpenseListScreen, AddEditExpenseScreen, AddEditRecurringScreen (#6)' (#18) from feat/6-implement-expense-list-add-edit-screens into main

Reviewed-on: #18
This commit was merged in pull request #18.
This commit is contained in:
2026-06-28 12:57:17 +00:00
9 changed files with 1359 additions and 0 deletions
@@ -0,0 +1,61 @@
package dev.achmad.ledgerr.ui.components
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import dev.achmad.ledgerr.domain.category.model.Category
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CategoryDropdownField(
categoryId: Long?,
categories: List<Category>,
onCategoryChange: (Long) -> Unit,
label: String,
modifier: Modifier = Modifier,
) {
var expanded by remember { mutableStateOf(false) }
val selectedName = categories.firstOrNull { it.id == categoryId }?.name.orEmpty()
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = !expanded },
modifier = modifier,
) {
OutlinedTextField(
value = selectedName,
onValueChange = {},
readOnly = true,
label = { Text(label) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable, enabled = true),
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
) {
categories.forEach { category ->
DropdownMenuItem(
text = { Text(category.name) },
onClick = {
onCategoryChange(category.id)
expanded = false
},
)
}
}
}
}
@@ -0,0 +1,78 @@
package dev.achmad.ledgerr.ui.components
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import dev.achmad.ledgerr.R
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DateField(
label: String,
date: LocalDate,
onDateChange: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
) {
var showPicker by remember { mutableStateOf(false) }
OutlinedTextField(
value = date.format(DateTimeFormatter.ISO_LOCAL_DATE),
onValueChange = {},
label = { Text(label) },
readOnly = true,
modifier = modifier.fillMaxWidth(),
trailingIcon = {
TextButton(onClick = { showPicker = true }) {
Text(text = stringResource(R.string.action_pick))
}
},
)
if (showPicker) {
val initialMillis = date.atStartOfDay(ZoneId.of("UTC"))
.toInstant()
.toEpochMilli()
val datePickerState = rememberDatePickerState(initialSelectedDateMillis = initialMillis)
DatePickerDialog(
onDismissRequest = { showPicker = false },
confirmButton = {
TextButton(
onClick = {
datePickerState.selectedDateMillis?.let { millis ->
val picked = Instant.ofEpochMilli(millis)
.atZone(ZoneId.of("UTC"))
.toLocalDate()
onDateChange(picked)
}
showPicker = false
},
) {
Text(text = stringResource(R.string.confirm))
}
},
dismissButton = {
TextButton(onClick = { showPicker = false }) {
Text(text = stringResource(R.string.cancel))
}
},
) {
DatePicker(state = datePickerState)
}
}
}
@@ -0,0 +1,130 @@
package dev.achmad.ledgerr.ui.screens.add_edit_expense
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import dev.achmad.ledgerr.R
import dev.achmad.ledgerr.ui.components.AppBar
import dev.achmad.ledgerr.ui.components.CategoryDropdownField
import dev.achmad.ledgerr.ui.components.DateField
import dev.achmad.ledgerr.ui.screens.category.CategoryScreen
data class AddEditExpenseScreen(
val expenseId: Long? = null,
) : Screen {
@OptIn(ExperimentalMaterial3Api::class)
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
val screenModel = rememberScreenModel { AddEditExpenseScreenModel(expenseId) }
val amount by screenModel.amount.collectAsState()
val categoryId by screenModel.categoryId.collectAsState()
val date by screenModel.date.collectAsState()
val note by screenModel.note.collectAsState()
val isValid by screenModel.isValid.collectAsState()
val isLoading by screenModel.isLoading.collectAsState()
val categories by screenModel.categories.collectAsState()
Scaffold(
topBar = {
AppBar(
title = stringResource(
if (screenModel.isEditMode) R.string.add_edit_expense_title_edit
else R.string.add_edit_expense_title_new
),
navigateUp = { navigator.pop() },
actions = {
TextButton(
onClick = { screenModel.save { navigator.pop() } },
enabled = isValid && !isLoading,
) {
Text(stringResource(R.string.save))
}
},
)
},
) { padding ->
if (isLoading) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
} else {
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
OutlinedTextField(
value = amount,
onValueChange = screenModel::setAmount,
label = { Text(stringResource(R.string.add_edit_expense_field_amount)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
CategoryDropdownField(
categoryId = categoryId,
categories = categories,
onCategoryChange = screenModel::setCategory,
label = stringResource(R.string.add_edit_expense_field_category),
)
DateField(
label = stringResource(R.string.add_edit_expense_field_date),
date = date,
onDateChange = screenModel::setDate,
)
OutlinedTextField(
value = note,
onValueChange = screenModel::setNote,
label = { Text(stringResource(R.string.add_edit_expense_field_note)) },
maxLines = 3,
modifier = Modifier.fillMaxWidth(),
)
TextButton(
onClick = { navigator.push(CategoryScreen) },
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.add_edit_expense_manage_categories))
}
}
}
}
}
}
@@ -0,0 +1,110 @@
package dev.achmad.ledgerr.ui.screens.add_edit_expense
import cafe.adriel.voyager.core.model.ScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import dev.achmad.ledgerr.di.util.inject
import dev.achmad.ledgerr.domain.category.interactor.GetCategories
import dev.achmad.ledgerr.domain.category.model.Category
import dev.achmad.ledgerr.domain.expense.interactor.GetExpenses
import dev.achmad.ledgerr.domain.expense.interactor.UpsertExpense
import dev.achmad.ledgerr.domain.expense.model.Expense
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.time.LocalDate
class AddEditExpenseScreenModel(
val expenseId: Long?,
private val getExpenses: GetExpenses = inject(),
private val upsertExpense: UpsertExpense = inject(),
private val getCategories: GetCategories = inject(),
) : ScreenModel {
val isEditMode: Boolean = expenseId != null
private val _amount = MutableStateFlow("")
val amount: StateFlow<String> = _amount.asStateFlow()
private val _categoryId = MutableStateFlow<Long?>(null)
val categoryId: StateFlow<Long?> = _categoryId.asStateFlow()
private val _date = MutableStateFlow(LocalDate.now())
val date: StateFlow<LocalDate> = _date.asStateFlow()
private val _note = MutableStateFlow("")
val note: StateFlow<String> = _note.asStateFlow()
private val _isLoading = MutableStateFlow(isEditMode)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
private val _isValid = MutableStateFlow(false)
val isValid: StateFlow<Boolean> = _isValid.asStateFlow()
val categories: StateFlow<List<Category>> = getCategories.subscribeAll().stateIn(
scope = screenModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = emptyList(),
)
init {
if (isEditMode) {
screenModelScope.launch {
val existing = expenseId?.let { getExpenses.awaitOne(it) }
if (existing != null) {
_amount.value = "%.2f".format(existing.expense.amount)
_categoryId.value = existing.expense.categoryId
_date.value = existing.expense.date
_note.value = existing.expense.note.orEmpty()
}
_isLoading.value = false
refreshIsValid()
}
} else {
refreshIsValid()
}
}
fun setAmount(value: String) {
_amount.value = value
refreshIsValid()
}
fun setCategory(id: Long) {
_categoryId.value = id
refreshIsValid()
}
fun setDate(value: LocalDate) {
_date.value = value
}
fun setNote(value: String) {
_note.value = value
}
fun save(onSuccess: () -> Unit) {
val amount = _amount.value.toDoubleOrNull()?.takeIf { it > 0.0 } ?: return
val categoryId = _categoryId.value ?: return
val expense = Expense(
id = expenseId ?: 0L,
amount = amount,
categoryId = categoryId,
date = _date.value,
note = _note.value.takeIf { it.isNotBlank() },
recurringExpenseId = null,
)
screenModelScope.launch {
upsertExpense.await(expense)
onSuccess()
}
}
private fun refreshIsValid() {
val amountOk = _amount.value.toDoubleOrNull()?.let { it > 0.0 } == true
val categoryOk = _categoryId.value != null
_isValid.value = amountOk && categoryOk
}
}
@@ -0,0 +1,176 @@
package dev.achmad.ledgerr.ui.screens.add_edit_recurring
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import dev.achmad.ledgerr.R
import dev.achmad.ledgerr.domain.recurring.model.RecurringInterval
import dev.achmad.ledgerr.ui.components.AppBar
import dev.achmad.ledgerr.ui.components.CategoryDropdownField
import dev.achmad.ledgerr.ui.components.DateField
import dev.achmad.ledgerr.ui.components.LabeledRadioButton
import dev.achmad.ledgerr.ui.components.ToggleItem
data class AddEditRecurringScreen(
val recurringId: Long? = null,
) : Screen {
@OptIn(ExperimentalMaterial3Api::class)
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
val screenModel = rememberScreenModel { AddEditRecurringScreenModel(recurringId) }
val amount by screenModel.amount.collectAsState()
val categoryId by screenModel.categoryId.collectAsState()
val interval by screenModel.interval.collectAsState()
val startDate by screenModel.startDate.collectAsState()
val note by screenModel.note.collectAsState()
val isActive by screenModel.isActive.collectAsState()
val isValid by screenModel.isValid.collectAsState()
val isLoading by screenModel.isLoading.collectAsState()
val categories by screenModel.categories.collectAsState()
Scaffold(
topBar = {
AppBar(
title = stringResource(
if (screenModel.isEditMode) R.string.add_edit_recurring_title_edit
else R.string.add_edit_recurring_title_new
),
navigateUp = { navigator.pop() },
actions = {
TextButton(
onClick = { screenModel.save { navigator.pop() } },
enabled = isValid && !isLoading,
) {
Text(stringResource(R.string.save))
}
},
)
},
) { padding ->
if (isLoading) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
} else {
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
OutlinedTextField(
value = amount,
onValueChange = screenModel::setAmount,
label = { Text(stringResource(R.string.add_edit_recurring_field_amount)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
CategoryDropdownField(
categoryId = categoryId,
categories = categories,
onCategoryChange = screenModel::setCategory,
label = stringResource(R.string.add_edit_recurring_field_category),
)
IntervalSection(
selected = interval,
onSelect = screenModel::setInterval,
)
DateField(
label = stringResource(R.string.add_edit_recurring_field_start_date),
date = startDate,
onDateChange = screenModel::setStartDate,
)
OutlinedTextField(
value = note,
onValueChange = screenModel::setNote,
label = { Text(stringResource(R.string.add_edit_recurring_field_note)) },
maxLines = 3,
modifier = Modifier.fillMaxWidth(),
)
HorizontalDivider()
ToggleItem(
text = stringResource(R.string.add_edit_recurring_field_active),
isChecked = isActive,
onCheckedChange = screenModel::setActive,
)
}
}
}
}
}
@Composable
private fun IntervalSection(
selected: RecurringInterval,
onSelect: (RecurringInterval) -> Unit,
) {
Column {
Text(
text = stringResource(R.string.add_edit_recurring_field_interval),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
LabeledRadioButton(
label = stringResource(R.string.add_edit_recurring_interval_daily),
selected = selected == RecurringInterval.DAILY,
onClick = { onSelect(RecurringInterval.DAILY) },
)
LabeledRadioButton(
label = stringResource(R.string.add_edit_recurring_interval_weekly),
selected = selected == RecurringInterval.WEEKLY,
onClick = { onSelect(RecurringInterval.WEEKLY) },
)
LabeledRadioButton(
label = stringResource(R.string.add_edit_recurring_interval_monthly),
selected = selected == RecurringInterval.MONTHLY,
onClick = { onSelect(RecurringInterval.MONTHLY) },
)
LabeledRadioButton(
label = stringResource(R.string.add_edit_recurring_interval_yearly),
selected = selected == RecurringInterval.YEARLY,
onClick = { onSelect(RecurringInterval.YEARLY) },
)
}
}
@@ -0,0 +1,134 @@
package dev.achmad.ledgerr.ui.screens.add_edit_recurring
import cafe.adriel.voyager.core.model.ScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import dev.achmad.ledgerr.di.util.inject
import dev.achmad.ledgerr.domain.category.interactor.GetCategories
import dev.achmad.ledgerr.domain.category.model.Category
import dev.achmad.ledgerr.domain.recurring.interactor.GetRecurringExpenses
import dev.achmad.ledgerr.domain.recurring.interactor.UpsertRecurringExpense
import dev.achmad.ledgerr.domain.recurring.model.RecurringExpense
import dev.achmad.ledgerr.domain.recurring.model.RecurringInterval
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.time.LocalDate
class AddEditRecurringScreenModel(
val recurringId: Long?,
private val getRecurringExpenses: GetRecurringExpenses = inject(),
private val upsertRecurringExpense: UpsertRecurringExpense = inject(),
private val getCategories: GetCategories = inject(),
) : ScreenModel {
val isEditMode: Boolean = recurringId != null
private val _amount = MutableStateFlow("")
val amount: StateFlow<String> = _amount.asStateFlow()
private val _categoryId = MutableStateFlow<Long?>(null)
val categoryId: StateFlow<Long?> = _categoryId.asStateFlow()
private val _interval = MutableStateFlow(RecurringInterval.MONTHLY)
val interval: StateFlow<RecurringInterval> = _interval.asStateFlow()
private val _startDate = MutableStateFlow(LocalDate.now())
val startDate: StateFlow<LocalDate> = _startDate.asStateFlow()
private val _note = MutableStateFlow("")
val note: StateFlow<String> = _note.asStateFlow()
private val _isActive = MutableStateFlow(true)
val isActive: StateFlow<Boolean> = _isActive.asStateFlow()
private val _isLoading = MutableStateFlow(isEditMode)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
private val _isValid = MutableStateFlow(false)
val isValid: StateFlow<Boolean> = _isValid.asStateFlow()
private var loadedNextDueDate: LocalDate? = null
val categories: StateFlow<List<Category>> = getCategories.subscribeAll().stateIn(
scope = screenModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = emptyList(),
)
init {
if (isEditMode) {
screenModelScope.launch {
val existing = recurringId?.let { getRecurringExpenses.awaitOne(it) }
if (existing != null) {
_amount.value = "%.2f".format(existing.amount)
_categoryId.value = existing.categoryId
_interval.value = existing.interval
_startDate.value = existing.startDate
_note.value = existing.note.orEmpty()
_isActive.value = existing.isActive
loadedNextDueDate = existing.nextDueDate
}
_isLoading.value = false
refreshIsValid()
}
} else {
refreshIsValid()
}
}
fun setAmount(value: String) {
_amount.value = value
refreshIsValid()
}
fun setCategory(id: Long) {
_categoryId.value = id
refreshIsValid()
}
fun setInterval(value: RecurringInterval) {
_interval.value = value
}
fun setStartDate(value: LocalDate) {
_startDate.value = value
}
fun setNote(value: String) {
_note.value = value
}
fun setActive(value: Boolean) {
_isActive.value = value
}
fun save(onSuccess: () -> Unit) {
val amount = _amount.value.toDoubleOrNull()?.takeIf { it > 0.0 } ?: return
val categoryId = _categoryId.value ?: return
val startDate = _startDate.value
val nextDueDate = loadedNextDueDate ?: startDate
val recurring = RecurringExpense(
id = recurringId ?: 0L,
amount = amount,
categoryId = categoryId,
note = _note.value.takeIf { it.isNotBlank() },
interval = _interval.value,
startDate = startDate,
nextDueDate = nextDueDate,
isActive = _isActive.value,
)
screenModelScope.launch {
upsertRecurringExpense.await(recurring)
onSuccess()
}
}
private fun refreshIsValid() {
val amountOk = _amount.value.toDoubleOrNull()?.let { it > 0.0 } == true
val categoryOk = _categoryId.value != null
_isValid.value = amountOk && categoryOk
}
}
@@ -0,0 +1,514 @@
package dev.achmad.ledgerr.ui.screens.expenses
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.Close
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.FloatingActionButton
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.SmallFloatingActionButton
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Tab
import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
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.ExportAction
import dev.achmad.ledgerr.ui.components.SingleSelectFilterChipGroup
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
import kotlinx.coroutines.launch
import java.time.format.DateTimeFormatter
object ExpenseListScreen : Screen {
@Suppress("unused")
private fun readResolve(): Any = ExpenseListScreen
@OptIn(ExperimentalMaterial3Api::class)
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
val screenModel = rememberScreenModel { ExpenseListScreenModel() }
val expenses by screenModel.expenses.collectAsState()
val recurring by screenModel.recurring.collectAsState()
val searchQuery by screenModel.searchQuery.collectAsState()
val dateRangeFilter by screenModel.dateRangeFilter.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
val coroutineScope = rememberCoroutineScope()
val context = LocalContext.current
var pendingDeleteId by remember { mutableStateOf<Long?>(null) }
val pagerState = rememberPagerState(pageCount = { 2 })
val selectedTab = pagerState.currentPage
Scaffold(
topBar = {
AppBar(
title = stringResource(R.string.expense_list_title),
navigateUp = { navigator.pop() },
actions = {
ExportAction(
onExportConfirmed = { range, uri ->
screenModel.exportToCsv(uri, range) { result ->
if (result.isFailure) {
coroutineScope.launch {
snackbarHostState.showSnackbar(
context.getString(R.string.settings_export_failure)
)
}
}
}
},
)
},
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
floatingActionButton = {
TabAwareFab(
selectedTab = selectedTab,
onAddExpense = { navigator.push(AddEditExpenseScreen(expenseId = null)) },
onImportBankStatement = { navigator.push(ImportBankStatementScreen) },
onAddRecurring = { navigator.push(AddEditRecurringScreen(recurringId = null)) },
)
},
) { 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)) },
)
}
}
}
}
pendingDeleteId?.let { id ->
AlertDialog(
onDismissRequest = { pendingDeleteId = null },
title = { Text(text = stringResource(R.string.expense_list_delete_title)) },
text = { Text(text = stringResource(R.string.expense_list_delete_message)) },
confirmButton = {
TextButton(
onClick = {
screenModel.deleteExpense(id)
pendingDeleteId = null
},
) {
Text(text = stringResource(R.string.delete))
}
},
dismissButton = {
TextButton(onClick = { pendingDeleteId = null }) {
Text(text = stringResource(R.string.cancel))
}
},
)
}
}
}
@Composable
private fun ExpensesTabContent(
screenModel: ExpenseListScreenModel,
expenses: List<ExpenseWithCategory>,
searchQuery: String,
dateRangeFilter: DateRangeFilter,
contentPadding: PaddingValues,
onExpenseClick: (Long) -> Unit,
onExpenseLongClick: (Long) -> Unit,
) {
if (expenses.isEmpty() && searchQuery.isBlank() && dateRangeFilter == DateRangeFilter.ALL) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(R.string.expense_list_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
return
}
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()) },
selectedOption = dateRangeFilter to stringResource(dateRangeFilter.labelRes()),
onSelectionChanged = { (filter, _) -> screenModel.setDateRangeFilter(filter) },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
)
}
item {
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
}
items(items = expenses, key = { it.expense.id }) { item ->
ExpenseRow(
item = item,
onClick = { onExpenseClick(item.expense.id) },
onLongClick = { onExpenseLongClick(item.expense.id) },
)
HorizontalDivider()
}
}
}
@Composable
private fun RecurringTabContent(
screenModel: ExpenseListScreenModel,
recurring: List<RecurringExpenseWithCategory>,
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,
)
}
return
}
LazyColumn(
contentPadding = contentPadding,
) {
items(items = recurring, key = { it.recurring.id }) { item ->
RecurringRow(
item = item,
onClick = { onRecurringClick(item.recurring.id) },
onActiveChange = { screenModel.toggleRecurringActive(item) },
)
HorizontalDivider()
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun ExpenseRow(
item: ExpenseWithCategory,
onClick: () -> Unit,
onLongClick: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(12.dp)
.clip(CircleShape)
.background(Color(item.category.color)),
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = item.category.name,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val note = item.expense.note
if (!note.isNullOrBlank()) {
Text(
text = note,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Text(
text = item.expense.date.format(DateTimeFormatter.ISO_LOCAL_DATE),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
text = "%.2f".format(item.expense.amount),
style = MaterialTheme.typography.bodyLarge,
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun RecurringRow(
item: RecurringExpenseWithCategory,
onClick: () -> Unit,
onActiveChange: (Boolean) -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.combinedClickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(12.dp)
.clip(CircleShape)
.background(Color(item.category.color)),
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = item.category.name,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val intervalText = when (item.recurring.interval) {
RecurringInterval.DAILY -> stringResource(R.string.add_edit_recurring_interval_daily)
RecurringInterval.WEEKLY -> stringResource(R.string.add_edit_recurring_interval_weekly)
RecurringInterval.MONTHLY -> stringResource(R.string.add_edit_recurring_interval_monthly)
RecurringInterval.YEARLY -> stringResource(R.string.add_edit_recurring_interval_yearly)
}
Text(
text = "$intervalText · ${item.recurring.nextDueDate.format(DateTimeFormatter.ISO_LOCAL_DATE)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
text = "%.2f".format(item.recurring.amount),
style = MaterialTheme.typography.bodyLarge,
)
Spacer(modifier = Modifier.width(8.dp))
Switch(
checked = item.recurring.isActive,
onCheckedChange = onActiveChange,
)
}
}
@Composable
private fun TabAwareFab(
selectedTab: Int,
onAddExpense: () -> Unit,
onImportBankStatement: () -> Unit,
onAddRecurring: () -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
LaunchedEffect(selectedTab) {
expanded = false
}
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
AnimatedVisibility(
visible = expanded,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (selectedTab == 0) {
MiniFab(
label = stringResource(R.string.expense_list_fab_manual),
icon = Icons.Outlined.Edit,
onClick = {
expanded = false
onAddExpense()
},
)
MiniFab(
label = stringResource(R.string.expense_list_fab_import),
icon = Icons.Outlined.UploadFile,
onClick = {
expanded = false
onImportBankStatement()
},
)
} else {
MiniFab(
label = stringResource(R.string.expense_list_fab_add_recurring),
icon = Icons.Outlined.Repeat,
onClick = {
expanded = false
onAddRecurring()
},
)
}
}
}
FloatingActionButton(
onClick = { expanded = !expanded },
) {
Icon(
imageVector = if (expanded) Icons.Outlined.Close else Icons.Outlined.Add,
contentDescription = null,
)
}
}
}
@Composable
private fun MiniFab(
label: String,
icon: ImageVector,
onClick: () -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Surface(
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.surface,
shadowElevation = 2.dp,
) {
Text(
text = label,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
style = MaterialTheme.typography.bodyMedium,
)
}
SmallFloatingActionButton(onClick = onClick) {
Icon(imageVector = icon, contentDescription = null)
}
}
}
private fun DateRangeFilter.labelRes(): Int = when (this) {
DateRangeFilter.ALL -> R.string.expense_list_filter_all
DateRangeFilter.THIS_WEEK -> R.string.expense_list_filter_this_week
DateRangeFilter.THIS_MONTH -> R.string.expense_list_filter_this_month
}
@@ -0,0 +1,115 @@
package dev.achmad.ledgerr.ui.screens.expenses
import android.net.Uri
import cafe.adriel.voyager.core.model.ScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import dev.achmad.ledgerr.di.util.inject
import dev.achmad.ledgerr.domain.expense.interactor.DeleteExpense
import dev.achmad.ledgerr.domain.expense.interactor.GetExpenses
import dev.achmad.ledgerr.domain.expense.model.DateRange
import dev.achmad.ledgerr.domain.expense.model.ExpenseWithCategory
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 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.stateIn
import kotlinx.coroutines.launch
enum class DateRangeFilter {
ALL,
THIS_WEEK,
THIS_MONTH;
fun toDateRange(): DateRange? = when (this) {
ALL -> null
THIS_WEEK -> DateRange.thisWeek()
THIS_MONTH -> DateRange.thisMonth()
}
}
class ExpenseListScreenModel(
private val getExpenses: GetExpenses = inject(),
private val getRecurring: GetRecurringExpenses = inject(),
private val deleteExpense: DeleteExpense = inject(),
private val upsertRecurringExpense: UpsertRecurringExpense = inject(),
private val exportExpensesToCsv: ExportExpensesToCsv = inject(),
) : ScreenModel {
private val _searchQuery = MutableStateFlow("")
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,
_dateRangeFilter,
) { list, query, filter ->
val range = filter.toDateRange()
list.asSequence()
.filter { item -> inDateRange(item, range) }
.filter { item -> matchesQuery(item, query) }
.toList()
}.stateIn(
scope = screenModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = emptyList(),
)
val recurring: StateFlow<List<RecurringExpenseWithCategory>> =
getRecurring.subscribeAll().stateIn(
scope = screenModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = emptyList(),
)
fun setSearchQuery(query: String) {
_searchQuery.value = query
}
fun setDateRangeFilter(filter: DateRangeFilter) {
_dateRangeFilter.value = filter
}
fun deleteExpense(id: Long) {
screenModelScope.launch {
deleteExpense.await(id)
}
}
fun toggleRecurringActive(item: RecurringExpenseWithCategory) {
screenModelScope.launch {
val flipped = item.recurring.copy(isActive = !item.recurring.isActive)
upsertRecurringExpense.await(flipped)
}
}
fun exportToCsv(uri: Uri, range: DateRange, onResult: (Result<Unit>) -> Unit) {
screenModelScope.launch {
val result = exportExpensesToCsv.await(range, uri)
onResult(result)
}
}
private fun inDateRange(item: ExpenseWithCategory, range: DateRange?): Boolean {
if (range == null) return true
val date = item.expense.date
return !date.isBefore(range.start) && !date.isAfter(range.end)
}
private fun matchesQuery(item: ExpenseWithCategory, query: String): Boolean {
if (query.isBlank()) return true
val note = item.expense.note.orEmpty()
val amountStr = "%.2f".format(item.expense.amount)
val categoryName = item.category.name
return note.contains(query, ignoreCase = true) ||
amountStr.contains(query) ||
categoryName.contains(query, ignoreCase = true)
}
}
+41
View File
@@ -6,6 +6,8 @@
<string name="general_not_selected">Not selected</string>
<string name="general_selected">Selected</string>
<string name="disabled">Disabled</string>
<string name="save">Save</string>
<string name="delete">Delete</string>
<string name="action_pick">Pick</string>
<!-- Export -->
@@ -61,4 +63,43 @@
<string name="settings_clear_data_confirm_message">This will permanently delete all expenses, categories, and recurring entries. The 8 default categories will be re-seeded.</string>
<string name="settings_clear_data_success">All data cleared</string>
<string name="settings_clear_data_failure">Failed to clear data</string>
<!-- Expense list (issue #6) -->
<string name="expense_list_title">Expenses</string>
<string name="expense_list_tab_expenses">Expenses</string>
<string name="expense_list_tab_recurring">Recurring</string>
<string name="expense_list_search_hint">Search expenses</string>
<string name="expense_list_filter_all">All</string>
<string name="expense_list_filter_this_week">This week</string>
<string name="expense_list_filter_this_month">This month</string>
<string name="expense_list_fab_manual">Manual</string>
<string name="expense_list_fab_import">Import Bank Statement</string>
<string name="expense_list_fab_add_recurring">Add Recurring</string>
<string name="expense_list_delete_title">Delete 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="recurring_list_empty">No recurring expenses</string>
<!-- Add / Edit expense (issue #6) -->
<string name="add_edit_expense_title_new">Add Expense</string>
<string name="add_edit_expense_title_edit">Edit Expense</string>
<string name="add_edit_expense_field_amount">Amount</string>
<string name="add_edit_expense_field_category">Category</string>
<string name="add_edit_expense_field_date">Date</string>
<string name="add_edit_expense_field_note">Note</string>
<string name="add_edit_expense_manage_categories">Manage Categories</string>
<!-- Add / Edit recurring (issue #6) -->
<string name="add_edit_recurring_title_new">Add Recurring</string>
<string name="add_edit_recurring_title_edit">Edit Recurring</string>
<string name="add_edit_recurring_field_amount">Amount</string>
<string name="add_edit_recurring_field_category">Category</string>
<string name="add_edit_recurring_field_interval">Interval</string>
<string name="add_edit_recurring_field_start_date">Start date</string>
<string name="add_edit_recurring_field_note">Note</string>
<string name="add_edit_recurring_field_active">Active</string>
<string name="add_edit_recurring_interval_daily">Daily</string>
<string name="add_edit_recurring_interval_weekly">Weekly</string>
<string name="add_edit_recurring_interval_monthly">Monthly</string>
<string name="add_edit_recurring_interval_yearly">Yearly</string>
</resources>