Implement ExpenseListScreen, AddEditExpenseScreen, AddEditRecurringScreen (#6) #18
@@ -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,180 @@
|
||||
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.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.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.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
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.category.model.Category
|
||||
import dev.achmad.ledgerr.ui.components.AppBar
|
||||
import dev.achmad.ledgerr.ui.components.DateField
|
||||
import dev.achmad.ledgerr.ui.screens.category.CategoryScreen
|
||||
import java.time.LocalDate
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun CategoryDropdownField(
|
||||
|
|
||||
categoryId: Long?,
|
||||
categories: List<Category>,
|
||||
onCategoryChange: (Long) -> Unit,
|
||||
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(stringResource(R.string.add_edit_expense_field_category)) },
|
||||
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,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,226 @@
|
||||
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.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.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
|
admin
commented
Nit: The **Nit:** The `import androidx.compose.material3.Switch` is unused. The active toggle on the form is rendered via `ToggleItem` (from `ui/components/CardSection.kt`), which wraps `Switch` internally. Remove this import.
|
||||
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.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
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.category.model.Category
|
||||
import dev.achmad.ledgerr.domain.recurring.model.RecurringInterval
|
||||
import dev.achmad.ledgerr.ui.components.AppBar
|
||||
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,
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun CategoryDropdownField(
|
||||
|
admin
commented
Nit: The matching duplicate in the recurring screen. See the comment on **Nit:** The matching duplicate in the recurring screen. See the comment on `AddEditExpenseScreen.kt:141` — the two `CategoryDropdownField` implementations should be consolidated into a single shared component in `ui/components/`.
|
||||
categoryId: Long?,
|
||||
categories: List<Category>,
|
||||
onCategoryChange: (Long) -> Unit,
|
||||
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(stringResource(R.string.add_edit_recurring_field_category)) },
|
||||
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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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
|
||||
|
admin
commented
Suggestion: **Suggestion:** `TabAwareFab` (and the inner `MiniFab`) is defined as a private helper inside `ExpenseListScreen.kt`. The design doc and the implementor rules (`docs/04-implementation-plan.md:288`, `.opencode/agent/implementor.md:156`) describe the expanded mini-FAB as a shared component used by both `HomeScreen` and `ExpenseListScreen`. `HomeScreen` is currently a stub (`ui/screens/home/HomeScreen.kt`), so duplication isn't a problem yet — but once the home screen grows its FAB, move `TabAwareFab` + `MiniFab` into `ui/components/TabAwareFab.kt` (taking the action list as a parameter) so both screens share the same expansion UI and only differ in which actions they pass in.
|
||||
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 = item.expense.amount.toString()
|
||||
|
admin
commented
Suggestion: **Suggestion:** `item.expense.amount.toString()` uses Java's default `Double.toString`, which is locale-naive and uses scientific notation for very large or very small values. A user searching for `"1000000"` won't find an amount of `1_000_000.0` (which prints as `"1.0E6"`), and a user searching for `"12.34"` won't find `12.345` (which prints as `"12.345"`, not the `"12.35"` they see in the row). For consistency with the display format in `ExpenseRow` (`"%.2f".format(item.expense.amount)` at `ExpenseListScreen.kt:359`), use the same formatter here — or strip scientific notation explicitly.
|
||||
val categoryName = item.category.name
|
||||
return note.contains(query, ignoreCase = true) ||
|
||||
amountStr.contains(query) ||
|
||||
categoryName.contains(query, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
@@ -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,44 @@
|
||||
<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>
|
||||
<string name="recurring_list_active">Active</string>
|
||||
|
admin
commented
Nit: **Nit:** `recurring_list_active` is defined here but never referenced anywhere in the new code (I searched the whole tree for `R.string.recurring_list_active` and there are zero call sites). Either remove it or wire it up — e.g., as a label next to the `Switch` in `RecurringRow` (`ExpenseListScreen.kt:410-413`).
|
||||
|
||||
<!-- 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>
|
||||
|
||||
Nit:
CategoryDropdownFieldis duplicated almost verbatim betweenAddEditExpenseScreen.kt(lines 141–180) andAddEditRecurringScreen.kt(lines 153–192) — only the label string resource differs. Extract it toui/components/CategoryDropdownField.kt, taking thelabel: Stringas a parameter, so both screens share one implementation. The component already only depends on public models and strings.