diff --git a/app/src/main/java/dev/achmad/ledgerr/ui/components/DateField.kt b/app/src/main/java/dev/achmad/ledgerr/ui/components/DateField.kt new file mode 100644 index 0000000..799a4a4 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/ui/components/DateField.kt @@ -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) + } + } +} diff --git a/app/src/main/java/dev/achmad/ledgerr/ui/screens/add_edit_expense/AddEditExpenseScreen.kt b/app/src/main/java/dev/achmad/ledgerr/ui/screens/add_edit_expense/AddEditExpenseScreen.kt new file mode 100644 index 0000000..c418cb0 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/ui/screens/add_edit_expense/AddEditExpenseScreen.kt @@ -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, + 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 + }, + ) + } + } + } +} diff --git a/app/src/main/java/dev/achmad/ledgerr/ui/screens/add_edit_expense/AddEditExpenseScreenModel.kt b/app/src/main/java/dev/achmad/ledgerr/ui/screens/add_edit_expense/AddEditExpenseScreenModel.kt new file mode 100644 index 0000000..493fe4a --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/ui/screens/add_edit_expense/AddEditExpenseScreenModel.kt @@ -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 = _amount.asStateFlow() + + private val _categoryId = MutableStateFlow(null) + val categoryId: StateFlow = _categoryId.asStateFlow() + + private val _date = MutableStateFlow(LocalDate.now()) + val date: StateFlow = _date.asStateFlow() + + private val _note = MutableStateFlow("") + val note: StateFlow = _note.asStateFlow() + + private val _isLoading = MutableStateFlow(isEditMode) + val isLoading: StateFlow = _isLoading.asStateFlow() + + private val _isValid = MutableStateFlow(false) + val isValid: StateFlow = _isValid.asStateFlow() + + val categories: StateFlow> = 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 + } +} diff --git a/app/src/main/java/dev/achmad/ledgerr/ui/screens/add_edit_recurring/AddEditRecurringScreen.kt b/app/src/main/java/dev/achmad/ledgerr/ui/screens/add_edit_recurring/AddEditRecurringScreen.kt new file mode 100644 index 0000000..150ab24 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/ui/screens/add_edit_recurring/AddEditRecurringScreen.kt @@ -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 +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( + categoryId: Long?, + categories: List, + 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) }, + ) + } +} diff --git a/app/src/main/java/dev/achmad/ledgerr/ui/screens/add_edit_recurring/AddEditRecurringScreenModel.kt b/app/src/main/java/dev/achmad/ledgerr/ui/screens/add_edit_recurring/AddEditRecurringScreenModel.kt new file mode 100644 index 0000000..8166095 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/ui/screens/add_edit_recurring/AddEditRecurringScreenModel.kt @@ -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 = _amount.asStateFlow() + + private val _categoryId = MutableStateFlow(null) + val categoryId: StateFlow = _categoryId.asStateFlow() + + private val _interval = MutableStateFlow(RecurringInterval.MONTHLY) + val interval: StateFlow = _interval.asStateFlow() + + private val _startDate = MutableStateFlow(LocalDate.now()) + val startDate: StateFlow = _startDate.asStateFlow() + + private val _note = MutableStateFlow("") + val note: StateFlow = _note.asStateFlow() + + private val _isActive = MutableStateFlow(true) + val isActive: StateFlow = _isActive.asStateFlow() + + private val _isLoading = MutableStateFlow(isEditMode) + val isLoading: StateFlow = _isLoading.asStateFlow() + + private val _isValid = MutableStateFlow(false) + val isValid: StateFlow = _isValid.asStateFlow() + + private var loadedNextDueDate: LocalDate? = null + + val categories: StateFlow> = 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 + } +} diff --git a/app/src/main/java/dev/achmad/ledgerr/ui/screens/expenses/ExpenseListScreen.kt b/app/src/main/java/dev/achmad/ledgerr/ui/screens/expenses/ExpenseListScreen.kt new file mode 100644 index 0000000..ea473c4 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/ui/screens/expenses/ExpenseListScreen.kt @@ -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(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, + 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, + 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 +} diff --git a/app/src/main/java/dev/achmad/ledgerr/ui/screens/expenses/ExpenseListScreenModel.kt b/app/src/main/java/dev/achmad/ledgerr/ui/screens/expenses/ExpenseListScreenModel.kt new file mode 100644 index 0000000..02b7bb6 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/ui/screens/expenses/ExpenseListScreenModel.kt @@ -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 = _searchQuery.asStateFlow() + + private val _dateRangeFilter = MutableStateFlow(DateRangeFilter.ALL) + val dateRangeFilter: StateFlow = _dateRangeFilter.asStateFlow() + + val expenses: StateFlow> = 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> = + 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) { + 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() + val categoryName = item.category.name + return note.contains(query, ignoreCase = true) || + amountStr.contains(query) || + categoryName.contains(query, ignoreCase = true) + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 762535c..567ee5c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -6,6 +6,8 @@ Not selected Selected Disabled + Save + Delete Pick @@ -61,4 +63,44 @@ This will permanently delete all expenses, categories, and recurring entries. The 8 default categories will be re-seeded. All data cleared Failed to clear data + + + Expenses + Expenses + Recurring + Search expenses + All + This week + This month + Manual + Import Bank Statement + Add Recurring + Delete expense? + This will permanently delete the expense. + No expenses yet + No recurring expenses + Active + + + Add Expense + Edit Expense + Amount + Category + Date + Note + Manage Categories + + + Add Recurring + Edit Recurring + Amount + Category + Interval + Start date + Note + Active + Daily + Weekly + Monthly + Yearly