feat(#7): implement CategoryScreen, ImportBankStatementScreen, SettingsScreen, and ExportAction helper

This commit is contained in:
Achmad Setyabudi Susilo
2026-06-28 17:55:22 +07:00
parent 567f6a7cee
commit f6860544e4
8 changed files with 1364 additions and 0 deletions
@@ -0,0 +1,218 @@
package dev.achmad.ledgerr.ui.components
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.IosShare
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
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 androidx.compose.ui.unit.dp
import dev.achmad.ledgerr.R
import dev.achmad.ledgerr.domain.expense.model.DateRange
import dev.achmad.ledgerr.ui.components.preference.widget.TextPreferenceWidget
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ExportAction(
onExportConfirmed: (DateRange, Uri) -> Unit,
modifier: Modifier = Modifier,
) {
val controller = rememberExportController(onExportConfirmed)
IconButton(
modifier = modifier,
onClick = { controller.open() },
) {
Icon(
imageVector = Icons.Outlined.IosShare,
contentDescription = stringResource(R.string.export_title),
)
}
ExportDialogHost(controller = controller)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ExportPreferenceAction(
title: String,
subtitle: String? = null,
onExportConfirmed: (DateRange, Uri) -> Unit,
) {
val controller = rememberExportController(onExportConfirmed)
TextPreferenceWidget(
title = title,
subtitle = subtitle,
onPreferenceClick = { controller.open() },
)
ExportDialogHost(controller = controller)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun rememberExportController(
onExportConfirmed: (DateRange, Uri) -> Unit,
): ExportController {
val initial = DateRange.thisMonth()
val pendingRange = remember { mutableStateOf(initial) }
val startDate = remember { mutableStateOf(initial.start) }
val endDate = remember { mutableStateOf(initial.end) }
val showRangeDialog = remember { mutableStateOf(false) }
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("text/csv"),
) { uri ->
if (uri != null) {
onExportConfirmed(pendingRange.value, uri)
}
}
return ExportController(
showRangeDialog = showRangeDialog,
startDate = startDate,
endDate = endDate,
pendingRange = pendingRange,
onConfirmRange = {
pendingRange.value = DateRange(startDate.value, endDate.value)
showRangeDialog.value = false
launcher.launch(
"ledgerr-export-${LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE)}.csv"
)
},
onCancelRange = { showRangeDialog.value = false },
onOpen = {
startDate.value = pendingRange.value.start
endDate.value = pendingRange.value.end
showRangeDialog.value = true
},
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ExportDialogHost(controller: ExportController) {
if (controller.showRangeDialog.value) {
AlertDialog(
onDismissRequest = controller.onCancelRange,
title = { Text(text = stringResource(R.string.export_date_range)) },
text = {
Column {
DateField(
label = stringResource(R.string.export_start_date),
date = controller.startDate.value,
onDateChange = { controller.startDate.value = it },
)
DateField(
label = stringResource(R.string.export_end_date),
date = controller.endDate.value,
onDateChange = { controller.endDate.value = it },
)
}
},
confirmButton = {
TextButton(
enabled = !controller.startDate.value.isAfter(controller.endDate.value),
onClick = controller.onConfirmRange,
) {
Text(text = stringResource(R.string.confirm))
}
},
dismissButton = {
TextButton(onClick = controller.onCancelRange) {
Text(text = stringResource(R.string.cancel))
}
},
)
}
}
private class ExportController(
val showRangeDialog: androidx.compose.runtime.MutableState<Boolean>,
val startDate: androidx.compose.runtime.MutableState<LocalDate>,
val endDate: androidx.compose.runtime.MutableState<LocalDate>,
val pendingRange: androidx.compose.runtime.MutableState<DateRange>,
val onConfirmRange: () -> Unit,
val onCancelRange: () -> Unit,
val onOpen: () -> Unit,
) {
fun open() = onOpen()
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DateField(
label: String,
date: LocalDate,
onDateChange: (LocalDate) -> Unit,
) {
var showPicker by remember { mutableStateOf(false) }
OutlinedTextField(
value = date.format(DateTimeFormatter.ISO_LOCAL_DATE),
onValueChange = {},
label = { Text(label) },
readOnly = true,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
trailingIcon = {
TextButton(onClick = { showPicker = true }) {
Text(text = stringResource(R.string.action_pick))
}
},
)
if (showPicker) {
val initialMillis = date.atStartOfDay(ZoneId.systemDefault())
.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,339 @@
package dev.achmad.ledgerr.ui.screens.category
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items as lazyListItems
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.Delete
import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
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.res.stringResource
import androidx.compose.ui.unit.dp
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.ScrollbarLazyColumn
import cafe.adriel.voyager.core.model.rememberScreenModel
object CategoryScreen : Screen {
override val key: String = "CategoryScreen"
@OptIn(ExperimentalMaterial3Api::class)
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
val screenModel = rememberScreenModel { CategoryScreenModel() }
val categories by screenModel.categories.collectAsState()
var editingCategory by remember { mutableStateOf<Category?>(null) }
var isCreating by rememberSaveable { mutableStateOf(false) }
var deletingCategory by remember { mutableStateOf<Category?>(null) }
Scaffold(
topBar = {
AppBar(
title = stringResource(R.string.categories_title),
navigateUp = { navigator.pop() },
)
},
floatingActionButton = {
FloatingActionButton(
onClick = { isCreating = true },
) {
Icon(
imageVector = Icons.Outlined.Add,
contentDescription = stringResource(R.string.category_add),
)
}
},
) { padding ->
if (categories.isEmpty()) {
EmptyCategories(modifier = Modifier
.fillMaxSize()
.padding(padding))
} else {
ScrollbarLazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
top = padding.calculateTopPadding() + 8.dp,
bottom = padding.calculateBottomPadding() + 88.dp,
start = 16.dp,
end = 16.dp,
),
) {
lazyListItems(
items = categories,
key = { it.id },
) { category ->
CategoryRow(
category = category,
onClick = { editingCategory = category },
onDelete = { deletingCategory = category },
)
}
}
}
}
if (isCreating) {
CategoryEditDialog(
initial = null,
onDismiss = { isCreating = false },
onConfirm = { category ->
screenModel.upsert(category)
isCreating = false
},
)
}
editingCategory?.let { category ->
CategoryEditDialog(
initial = category,
onDismiss = { editingCategory = null },
onConfirm = { updated ->
screenModel.upsert(updated)
editingCategory = null
},
)
}
deletingCategory?.let { category ->
if (!category.isDefault) {
AlertDialog(
onDismissRequest = { deletingCategory = null },
title = { Text(text = stringResource(R.string.category_delete_confirm_title)) },
text = {
Text(
text = stringResource(
R.string.category_delete_confirm_message,
category.name,
)
)
},
confirmButton = {
TextButton(
onClick = {
screenModel.delete(category.id)
deletingCategory = null
},
) {
Text(text = stringResource(R.string.category_delete))
}
},
dismissButton = {
TextButton(onClick = { deletingCategory = null }) {
Text(text = stringResource(R.string.cancel))
}
},
)
}
}
}
}
@Composable
private fun CategoryRow(
category: Category,
onClick: () -> Unit,
onDelete: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.medium)
.clickable(onClick = onClick)
.padding(vertical = 12.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(24.dp)
.clip(CircleShape)
.background(Color(category.color))
.border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.3f), CircleShape),
)
Spacer(modifier = Modifier.width(16.dp))
Text(
text = category.name,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f),
)
if (category.isDefault) {
Icon(
imageVector = Icons.Outlined.Lock,
contentDescription = stringResource(R.string.category_default_lock),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
IconButton(onClick = onDelete) {
Icon(
imageVector = Icons.Outlined.Delete,
contentDescription = stringResource(R.string.category_delete),
)
}
}
}
}
@Composable
private fun EmptyCategories(modifier: Modifier = Modifier) {
Box(modifier = modifier, contentAlignment = Alignment.Center) {
Text(
text = stringResource(R.string.categories_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun CategoryEditDialog(
initial: Category?,
onDismiss: () -> Unit,
onConfirm: (Category) -> Unit,
) {
var name by rememberSaveable(initial?.id) { mutableStateOf(initial?.name.orEmpty()) }
var color by rememberSaveable(initial?.id) { mutableStateOf(initial?.color ?: Category.DEFAULT_COLOR_OTHER) }
val swatches = listOf(
Category.DEFAULT_COLOR_FOOD,
Category.DEFAULT_COLOR_TRANSPORT,
Category.DEFAULT_COLOR_HOUSING,
Category.DEFAULT_COLOR_HEALTH,
Category.DEFAULT_COLOR_ENTERTAINMENT,
Category.DEFAULT_COLOR_SHOPPING,
Category.DEFAULT_COLOR_EDUCATION,
Category.DEFAULT_COLOR_OTHER,
)
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
text = stringResource(
if (initial == null) R.string.category_add_title
else R.string.category_edit_title,
)
)
},
text = {
Column {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text(stringResource(R.string.category_name)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.category_color),
style = MaterialTheme.typography.labelMedium,
)
Spacer(modifier = Modifier.height(8.dp))
LazyVerticalGrid(
columns = GridCells.Fixed(8),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
) {
items(items = swatches, key = { it }) { swatch ->
ColorSwatch(
color = Color(swatch),
selected = color == swatch,
onClick = { color = swatch },
)
}
}
}
},
confirmButton = {
TextButton(
enabled = name.isNotBlank(),
onClick = {
onConfirm(
Category(
id = initial?.id ?: 0L,
name = name.trim(),
color = color,
iconName = initial?.iconName,
isDefault = initial?.isDefault ?: false,
)
)
},
) {
Text(stringResource(R.string.confirm))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.cancel))
}
},
)
}
@Composable
private fun ColorSwatch(
color: Color,
selected: Boolean,
onClick: () -> Unit,
) {
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(color)
.clickable(onClick = onClick)
.border(
width = if (selected) 3.dp else 1.dp,
color = if (selected) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
shape = CircleShape,
),
)
}
@@ -0,0 +1,39 @@
package dev.achmad.ledgerr.ui.screens.category
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.DeleteCategory
import dev.achmad.ledgerr.domain.category.interactor.GetCategories
import dev.achmad.ledgerr.domain.category.interactor.UpsertCategory
import dev.achmad.ledgerr.domain.category.model.Category
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class CategoryScreenModel(
private val getCategories: GetCategories = inject(),
private val upsertCategory: UpsertCategory = inject(),
private val deleteCategory: DeleteCategory = inject(),
) : ScreenModel {
val categories: StateFlow<List<Category>> = getCategories.subscribeAll()
.stateIn(
scope = screenModelScope,
started = SharingStarted.Eagerly,
initialValue = emptyList(),
)
fun upsert(category: Category) {
screenModelScope.launch {
upsertCategory.await(category)
}
}
fun delete(id: Long) {
screenModelScope.launch {
runCatching { deleteCategory.await(id) }
}
}
}
@@ -0,0 +1,427 @@
package dev.achmad.ledgerr.ui.screens.import_bank_statement
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items as lazyListItems
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
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.bankstatement.interactor.BankStatementImporter
import dev.achmad.ledgerr.domain.bankstatement.model.PendingImportExpense
import dev.achmad.ledgerr.domain.category.model.Category
import dev.achmad.ledgerr.ui.components.AppBar
import cafe.adriel.voyager.core.model.rememberScreenModel
import dev.achmad.ledgerr.ui.util.onClickInput
object ImportBankStatementScreen : Screen {
override val key: String = "ImportBankStatementScreen"
@OptIn(ExperimentalMaterial3Api::class)
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
val screenModel = rememberScreenModel { ImportBankStatementScreenModel() }
val state by screenModel.state.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
var pendingImporter by remember { mutableStateOf<BankStatementImporter?>(null) }
val pdfLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument(),
) { uri: Uri? ->
val importer = pendingImporter
if (uri != null && importer != null) {
screenModel.processPdf(uri, importer)
}
pendingImporter = null
}
LaunchedEffect(Unit) {
screenModel.snackbar.collect { message ->
snackbarHostState.showSnackbar(message)
}
}
Scaffold(
topBar = {
AppBar(
title = stringResource(R.string.import_title),
navigateUp = {
if (state is ImportState.Confirmation) {
screenModel.backToPicker()
} else {
navigator.pop()
}
},
navigationIcon = Icons.AutoMirrored.Outlined.ArrowBack,
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
when (val current = state) {
is ImportState.BankPicker -> ImportBankStatementPickerContent(
importers = current.importers,
onBankSelected = { importer ->
pendingImporter = importer
pdfLauncher.launch(arrayOf("application/pdf"))
},
)
is ImportState.Processing -> {
ImportBankStatementPickerContent(
importers = emptyList(),
onBankSelected = {},
)
ProcessingOverlay(bankName = current.bank)
}
is ImportState.Confirmation -> ImportBankStatementConfirmationContent(
bankName = current.bank,
rows = current.rows,
categories = screenModel.categories.collectAsState().value,
onToggleSelection = screenModel::toggleSelection,
onUpdateRow = screenModel::updateRow,
onConfirm = { screenModel.confirm(navigator) },
)
}
}
}
}
}
@Composable
private fun ImportBankStatementPickerContent(
importers: List<BankStatementImporter>,
onBankSelected: (BankStatementImporter) -> Unit,
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
) {
Text(
text = stringResource(R.string.import_picker_help),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(16.dp))
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
lazyListItems(items = importers, key = { it.bankName }) { importer ->
BankRow(
name = importer.bankName,
onClick = { onBankSelected(importer) },
)
}
}
}
}
@Composable
private fun BankRow(
name: String,
onClick: () -> Unit,
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.medium)
.clickable(onClick = onClick),
tonalElevation = 2.dp,
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = name,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
}
}
}
@Composable
private fun ProcessingOverlay(bankName: String) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.5f))
.onClickInput(
pass = PointerEventPass.Initial,
ripple = false,
onUp = {},
),
contentAlignment = Alignment.Center,
) {
Surface(
shape = RoundedCornerShape(16.dp),
tonalElevation = 4.dp,
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.import_processing, bankName),
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
}
@Composable
private fun ImportBankStatementConfirmationContent(
bankName: String,
rows: List<PendingImportExpense>,
categories: List<Category>,
onToggleSelection: (Int) -> Unit,
onUpdateRow: (Int, PendingImportExpense) -> Unit,
onConfirm: () -> Unit,
) {
var editingIndex by remember { mutableStateOf<Int?>(null) }
Column(modifier = Modifier.fillMaxSize()) {
Text(
text = stringResource(R.string.import_confirmation_header, bankName, rows.size),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(16.dp),
)
HorizontalDivider()
LazyColumn(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
contentPadding = PaddingValues(vertical = 8.dp),
) {
itemsIndexed(items = rows) { index, row ->
ImportRow(
row = row,
categories = categories,
onToggle = { onToggleSelection(index) },
onEdit = { editingIndex = index },
onCategoryChange = { id -> onUpdateRow(index, row.copy(suggestedCategoryId = id)) },
)
}
}
HorizontalDivider()
val selectedCount = rows.count { it.isSelected }
Button(
onClick = onConfirm,
enabled = selectedCount > 0,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
) {
Text(stringResource(R.string.import_action, selectedCount))
}
}
editingIndex?.let { index ->
EditRowDialog(
row = rows[index],
onDismiss = { editingIndex = null },
onSave = { updated ->
onUpdateRow(index, updated)
editingIndex = null
},
)
}
}
@Composable
private fun ImportRow(
row: PendingImportExpense,
categories: List<Category>,
onToggle: () -> Unit,
onEdit: () -> Unit,
onCategoryChange: (Long?) -> Unit,
) {
var categoryMenuExpanded by remember { mutableStateOf(false) }
val currentCategory = categories.firstOrNull { it.id == row.suggestedCategoryId }
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onEdit)
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(checked = row.isSelected, onCheckedChange = { onToggle() })
Spacer(modifier = Modifier.size(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = row.description,
style = MaterialTheme.typography.bodyMedium,
maxLines = 2,
)
Text(
text = row.date.toString(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.size(4.dp))
Box {
TextButton(
onClick = { categoryMenuExpanded = true },
contentPadding = PaddingValues(horizontal = 4.dp, vertical = 0.dp),
) {
Text(
text = currentCategory?.name ?: stringResource(R.string.import_category_unset),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
)
}
DropdownMenu(
expanded = categoryMenuExpanded,
onDismissRequest = { categoryMenuExpanded = false },
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.import_category_unset)) },
onClick = {
onCategoryChange(null)
categoryMenuExpanded = false
},
)
categories.forEach { category ->
DropdownMenuItem(
text = { Text(category.name) },
onClick = {
onCategoryChange(category.id)
categoryMenuExpanded = false
},
)
}
}
}
}
Spacer(modifier = Modifier.size(8.dp))
Text(
text = "%.2f".format(row.amount),
style = MaterialTheme.typography.titleMedium,
)
}
}
@Composable
private fun EditRowDialog(
row: PendingImportExpense,
onDismiss: () -> Unit,
onSave: (PendingImportExpense) -> Unit,
) {
var amount by remember(row) { mutableStateOf("%.2f".format(row.amount)) }
var description by remember(row) { mutableStateOf(row.description) }
var date by remember(row) { mutableStateOf(row.date.toString()) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.import_edit_title)) },
text = {
Column {
OutlinedTextField(
value = amount,
onValueChange = { amount = it },
label = { Text(stringResource(R.string.import_amount)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = date,
onValueChange = { date = it },
label = { Text(stringResource(R.string.import_date)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text(stringResource(R.string.import_description)) },
modifier = Modifier.fillMaxWidth(),
)
}
},
confirmButton = {
TextButton(
enabled = amount.toDoubleOrNull() != null,
onClick = {
val parsed = date.toLocalDateOrNull() ?: row.date
onSave(
row.copy(
amount = amount.toDoubleOrNull() ?: row.amount,
date = parsed,
description = description,
)
)
},
) {
Text(stringResource(R.string.confirm))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.cancel))
}
},
)
}
private fun String.toLocalDateOrNull(): java.time.LocalDate? =
runCatching { java.time.LocalDate.parse(this) }.getOrNull()
@@ -0,0 +1,128 @@
package dev.achmad.ledgerr.ui.screens.import_bank_statement
import android.net.Uri
import cafe.adriel.voyager.core.model.ScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import cafe.adriel.voyager.navigator.Navigator
import dev.achmad.ledgerr.di.util.inject
import dev.achmad.ledgerr.domain.bankstatement.interactor.BankStatementImporter
import dev.achmad.ledgerr.domain.bankstatement.model.PendingImportExpense
import dev.achmad.ledgerr.domain.category.interactor.GetCategories
import dev.achmad.ledgerr.domain.category.model.Category
import dev.achmad.ledgerr.domain.expense.interactor.InsertExpenses
import dev.achmad.ledgerr.domain.expense.model.Expense
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
sealed interface ImportState {
data class BankPicker(val importers: List<BankStatementImporter>) : ImportState
data class Processing(val bank: String) : ImportState
data class Confirmation(
val bank: String,
val rows: List<PendingImportExpense>,
) : ImportState
}
class ImportBankStatementScreenModel(
private val importers: List<BankStatementImporter> = inject(),
private val getCategories: GetCategories = inject(),
private val insertExpenses: InsertExpenses = inject(),
) : ScreenModel {
private val _state = MutableStateFlow<ImportState>(ImportState.BankPicker(importers))
val state: StateFlow<ImportState> = _state.asStateFlow()
private val _snackbar = MutableSharedFlow<String>(extraBufferCapacity = 1)
val snackbar: SharedFlow<String> = _snackbar.asSharedFlow()
val categories: StateFlow<List<Category>> = getCategories.subscribeAll()
.stateIn(
scope = screenModelScope,
started = SharingStarted.Eagerly,
initialValue = emptyList(),
)
private var defaultCategoryId: Long = 0L
fun processPdf(uri: Uri, importer: BankStatementImporter) {
screenModelScope.launch {
_state.value = ImportState.Processing(importer.bankName)
val result = importer.await(uri)
result
.onSuccess { rows ->
if (rows.isEmpty()) {
_state.value = ImportState.BankPicker(importers)
_snackbar.tryEmit("No transactions found in PDF")
} else {
_state.value = ImportState.Confirmation(importer.bankName, rows)
}
}
.onFailure { error ->
_state.value = ImportState.BankPicker(importers)
val message = error.message
?: error::class.simpleName
?: "Import failed"
_snackbar.tryEmit(message)
}
}
}
fun toggleSelection(index: Int) {
_state.update { current ->
if (current is ImportState.Confirmation) {
val newRows = current.rows.toMutableList().apply {
val row = this[index]
this[index] = row.copy(isSelected = !row.isSelected)
}
current.copy(rows = newRows)
} else current
}
}
fun updateRow(index: Int, row: PendingImportExpense) {
_state.update { current ->
if (current is ImportState.Confirmation) {
val newRows = current.rows.toMutableList().apply {
this[index] = row
}
current.copy(rows = newRows)
} else current
}
}
fun backToPicker() {
_state.value = ImportState.BankPicker(importers)
}
fun confirm(navigator: Navigator) {
screenModelScope.launch {
val confirmation = _state.value as? ImportState.Confirmation ?: return@launch
if (defaultCategoryId == 0L) {
defaultCategoryId = getCategories.awaitDefault().id
}
val selected = confirmation.rows.filter { it.isSelected }
if (selected.isEmpty()) {
_snackbar.tryEmit("No items selected")
return@launch
}
val expenses = selected.map { pending ->
Expense(
amount = pending.amount,
categoryId = pending.suggestedCategoryId ?: defaultCategoryId,
date = pending.date,
note = pending.description,
)
}
insertExpenses.awaitAll(expenses)
navigator.pop()
}
}
}
@@ -0,0 +1,127 @@
package dev.achmad.ledgerr.ui.screens.settings
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
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.preference.AppPreference
import dev.achmad.ledgerr.domain.preference.AppTheme
import dev.achmad.ledgerr.ui.components.AppBar
import dev.achmad.ledgerr.ui.components.ExportPreferenceAction
import dev.achmad.ledgerr.ui.components.preference.Preference
import dev.achmad.ledgerr.ui.components.preference.PreferenceScreen
import cafe.adriel.voyager.core.model.rememberScreenModel
import dev.achmad.ledgerr.di.util.inject
import kotlinx.coroutines.launch
object SettingsScreen : Screen {
override val key: String = "SettingsScreen"
@OptIn(ExperimentalMaterial3Api::class)
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
val screenModel = rememberScreenModel { SettingsScreenModel() }
val appPreference = inject<AppPreference>()
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val exportSuccess = stringResource(R.string.settings_export_success)
val exportFailure = stringResource(R.string.settings_export_failure)
val clearSuccess = stringResource(R.string.settings_clear_data_success)
val clearFailure = stringResource(R.string.settings_clear_data_failure)
Scaffold(
topBar = {
AppBar(
title = stringResource(R.string.settings_title),
navigateUp = { navigator.pop() },
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
PreferenceScreen(
title = null,
onBackPressed = { navigator.pop() },
itemsProvider = {
listOf(
Preference.PreferenceGroup(
title = stringResource(R.string.settings_appearance),
preferenceItems = listOf(
Preference.PreferenceItem.ListPreference(
title = stringResource(R.string.settings_theme),
preference = appPreference.appTheme(),
entries = mapOf(
AppTheme.LIGHT to stringResource(R.string.settings_theme_light),
AppTheme.DARK to stringResource(R.string.settings_theme_dark),
AppTheme.SYSTEM to stringResource(R.string.settings_theme_system),
),
),
),
),
Preference.PreferenceGroup(
title = stringResource(R.string.settings_data),
preferenceItems = buildList {
add(
Preference.PreferenceItem.CustomPreference {
ExportPreferenceAction(
title = stringResource(R.string.settings_export_csv),
subtitle = stringResource(R.string.settings_export_csv_subtitle),
onExportConfirmed = { range, uri ->
screenModel.exportToCsv(range, uri) { result ->
scope.launch {
snackbarHostState.showSnackbar(
if (result.isSuccess) exportSuccess
else exportFailure
)
}
}
},
)
}
)
add(
Preference.PreferenceItem.AlertDialogPreference(
title = stringResource(R.string.settings_clear_data),
subtitle = stringResource(R.string.settings_clear_data_subtitle),
dialogTitle = stringResource(R.string.settings_clear_data_confirm_title),
dialogText = stringResource(R.string.settings_clear_data_confirm_message),
onConfirm = {
screenModel.clearData { result ->
scope.launch {
snackbarHostState.showSnackbar(
if (result.isSuccess) clearSuccess
else clearFailure
)
}
}
},
)
)
},
),
)
},
)
}
}
}
}
@@ -0,0 +1,34 @@
package dev.achmad.ledgerr.ui.screens.settings
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.category.interactor.SeedDefaultCategories
import dev.achmad.ledgerr.domain.data.interactor.ClearAllData
import dev.achmad.ledgerr.domain.expense.model.DateRange
import dev.achmad.ledgerr.domain.export.interactor.ExportExpensesToCsv
import kotlinx.coroutines.launch
class SettingsScreenModel(
private val exportExpensesToCsv: ExportExpensesToCsv = inject(),
private val clearAllData: ClearAllData = inject(),
private val seedDefaultCategories: SeedDefaultCategories = inject(),
) : ScreenModel {
fun exportToCsv(range: DateRange, uri: Uri, onResult: (Result<Unit>) -> Unit) {
screenModelScope.launch {
onResult(exportExpensesToCsv.await(range, uri))
}
}
fun clearData(onResult: (Result<Unit>) -> Unit) {
screenModelScope.launch {
val result = runCatching {
clearAllData.await()
seedDefaultCategories.await()
}
onResult(result)
}
}
}
+52
View File
@@ -6,4 +6,56 @@
<string name="general_not_selected">Not selected</string> <string name="general_not_selected">Not selected</string>
<string name="general_selected">Selected</string> <string name="general_selected">Selected</string>
<string name="disabled">Disabled</string> <string name="disabled">Disabled</string>
<string name="action_pick">Pick</string>
<!-- Export -->
<string name="export_title">Export</string>
<string name="export_date_range">Select date range</string>
<string name="export_start_date">Start date</string>
<string name="export_end_date">End date</string>
<string name="export_default_filename">ledgerr-export.csv</string>
<!-- Categories -->
<string name="categories_title">Categories</string>
<string name="categories_empty">No categories yet. Tap + to add one.</string>
<string name="category_add">Add category</string>
<string name="category_add_title">New category</string>
<string name="category_edit_title">Edit category</string>
<string name="category_delete">Delete category</string>
<string name="category_delete_confirm_title">Delete category?</string>
<string name="category_delete_confirm_message">Are you sure you want to delete \"%1$s\"? Expenses in this category will be moved to Uncategorized.</string>
<string name="category_name">Name</string>
<string name="category_color">Color</string>
<string name="category_default_lock">Default category</string>
<!-- Import bank statement -->
<string name="import_title">Import bank statement</string>
<string name="import_picker_help">Select your bank to import a PDF statement.</string>
<string name="import_processing">Reading %1$s statement…</string>
<string name="import_confirmation_header">%1$s — %2$d transactions found</string>
<string name="import_action">Import %1$d item(s)</string>
<string name="import_category_unset">No category</string>
<string name="import_edit_title">Edit transaction</string>
<string name="import_amount">Amount</string>
<string name="import_date">Date (yyyy-MM-dd)</string>
<string name="import_description">Description</string>
<!-- Settings -->
<string name="settings_title">Settings</string>
<string name="settings_appearance">Appearance</string>
<string name="settings_theme">Theme</string>
<string name="settings_theme_light">Light</string>
<string name="settings_theme_dark">Dark</string>
<string name="settings_theme_system">System</string>
<string name="settings_data">Data</string>
<string name="settings_export_csv">Export CSV</string>
<string name="settings_export_csv_subtitle">Export expenses in a date range to a CSV file</string>
<string name="settings_export_success">Export complete</string>
<string name="settings_export_failure">Export failed</string>
<string name="settings_clear_data">Clear all data</string>
<string name="settings_clear_data_subtitle">Permanently delete all expenses, categories, and recurring entries</string>
<string name="settings_clear_data_confirm_title">Clear all data?</string>
<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>
</resources> </resources>