diff --git a/app/src/main/java/dev/achmad/ledgerr/ui/components/ExportAction.kt b/app/src/main/java/dev/achmad/ledgerr/ui/components/ExportAction.kt new file mode 100644 index 0000000..c257545 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/ui/components/ExportAction.kt @@ -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, + val startDate: androidx.compose.runtime.MutableState, + val endDate: androidx.compose.runtime.MutableState, + val pendingRange: androidx.compose.runtime.MutableState, + 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.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/category/CategoryScreen.kt b/app/src/main/java/dev/achmad/ledgerr/ui/screens/category/CategoryScreen.kt new file mode 100644 index 0000000..d0230b9 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/ui/screens/category/CategoryScreen.kt @@ -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(null) } + var isCreating by rememberSaveable { mutableStateOf(false) } + var deletingCategory by remember { mutableStateOf(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, + ), + ) +} diff --git a/app/src/main/java/dev/achmad/ledgerr/ui/screens/category/CategoryScreenModel.kt b/app/src/main/java/dev/achmad/ledgerr/ui/screens/category/CategoryScreenModel.kt new file mode 100644 index 0000000..a9c4498 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/ui/screens/category/CategoryScreenModel.kt @@ -0,0 +1,47 @@ +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.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class CategoryScreenModel( + private val getCategories: GetCategories = inject(), + private val upsertCategory: UpsertCategory = inject(), + private val deleteCategory: DeleteCategory = inject(), +) : ScreenModel { + + val categories: StateFlow> = getCategories.subscribeAll() + .flowOn(Dispatchers.IO) + .stateIn( + scope = screenModelScope, + started = SharingStarted.Eagerly, + initialValue = emptyList(), + ) + + fun upsert(category: Category) { + screenModelScope.launch { + withContext(Dispatchers.IO) { + upsertCategory.await(category) + } + } + } + + fun delete(id: Long) { + screenModelScope.launch { + withContext(Dispatchers.IO) { + runCatching { deleteCategory.await(id) } + } + } + } +} diff --git a/app/src/main/java/dev/achmad/ledgerr/ui/screens/import_bank_statement/ImportBankStatementScreen.kt b/app/src/main/java/dev/achmad/ledgerr/ui/screens/import_bank_statement/ImportBankStatementScreen.kt new file mode 100644 index 0000000..e1e3eda --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/ui/screens/import_bank_statement/ImportBankStatementScreen.kt @@ -0,0 +1,440 @@ +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(null) } + val pdfLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument(), + ) { uri: Uri? -> + val importer = pendingImporter + if (uri != null && importer != null) { + screenModel.processPdf(uri, importer) + } + pendingImporter = null + } + + val importFailedText = stringResource(R.string.import_failed) + val importNoTransactionsText = stringResource(R.string.import_no_transactions) + val importNoItemsSelectedText = stringResource(R.string.import_no_items_selected) + + LaunchedEffect(Unit) { + screenModel.snackbar.collect { message -> + val text = when (message) { + is ImportSnackbarMessage.Resource -> when (message.id) { + R.string.import_failed -> importFailedText + R.string.import_no_transactions -> importNoTransactionsText + R.string.import_no_items_selected -> importNoItemsSelectedText + else -> null + } + is ImportSnackbarMessage.Dynamic -> message.text + } + text?.let { snackbarHostState.showSnackbar(it) } + } + } + + 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, + 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, + categories: List, + onToggleSelection: (Int) -> Unit, + onUpdateRow: (Int, PendingImportExpense) -> Unit, + onConfirm: () -> Unit, +) { + var editingIndex by remember { mutableStateOf(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, + 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() diff --git a/app/src/main/java/dev/achmad/ledgerr/ui/screens/import_bank_statement/ImportBankStatementScreenModel.kt b/app/src/main/java/dev/achmad/ledgerr/ui/screens/import_bank_statement/ImportBankStatementScreenModel.kt new file mode 100644 index 0000000..d39d8e9 --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/ui/screens/import_bank_statement/ImportBankStatementScreenModel.kt @@ -0,0 +1,143 @@ +package dev.achmad.ledgerr.ui.screens.import_bank_statement + +import android.net.Uri +import androidx.annotation.StringRes +import cafe.adriel.voyager.core.model.ScreenModel +import cafe.adriel.voyager.core.model.screenModelScope +import cafe.adriel.voyager.navigator.Navigator +import dev.achmad.ledgerr.R +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.Dispatchers +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.flowOn +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +sealed interface ImportState { + data class BankPicker(val importers: List) : ImportState + data class Processing(val bank: String) : ImportState + data class Confirmation( + val bank: String, + val rows: List, + ) : ImportState +} + +sealed interface ImportSnackbarMessage { + data class Resource(@StringRes val id: Int) : ImportSnackbarMessage + data class Dynamic(val text: String) : ImportSnackbarMessage +} + +class ImportBankStatementScreenModel( + private val importers: List = inject(), + private val getCategories: GetCategories = inject(), + private val insertExpenses: InsertExpenses = inject(), +) : ScreenModel { + + private val _state = MutableStateFlow(ImportState.BankPicker(importers)) + val state: StateFlow = _state.asStateFlow() + + private val _snackbar = MutableSharedFlow(extraBufferCapacity = 1) + val snackbar: SharedFlow = _snackbar.asSharedFlow() + + val categories: StateFlow> = getCategories.subscribeAll() + .flowOn(Dispatchers.IO) + .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 = withContext(Dispatchers.IO) { + importer.await(uri) + } + result + .onSuccess { rows -> + if (rows.isEmpty()) { + _state.value = ImportState.BankPicker(importers) + _snackbar.tryEmit(ImportSnackbarMessage.Resource(R.string.import_no_transactions)) + } else { + _state.value = ImportState.Confirmation(importer.bankName, rows) + } + } + .onFailure { error -> + _state.value = ImportState.BankPicker(importers) + val message = error.message?.takeIf { it.isNotBlank() } + ?.let { ImportSnackbarMessage.Dynamic(it) } + ?: ImportSnackbarMessage.Resource(R.string.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 + val selected = confirmation.rows.filter { it.isSelected } + if (selected.isEmpty()) { + _snackbar.tryEmit(ImportSnackbarMessage.Resource(R.string.import_no_items_selected)) + return@launch + } + withContext(Dispatchers.IO) { + if (defaultCategoryId == 0L) { + defaultCategoryId = getCategories.awaitDefault().id + } + val expenses = selected.map { pending -> + Expense( + amount = pending.amount, + categoryId = pending.suggestedCategoryId ?: defaultCategoryId, + date = pending.date, + note = pending.description, + ) + } + insertExpenses.awaitAll(expenses) + } + navigator.pop() + } + } +} diff --git a/app/src/main/java/dev/achmad/ledgerr/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/dev/achmad/ledgerr/ui/screens/settings/SettingsScreen.kt new file mode 100644 index 0000000..a78f09c --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/ui/screens/settings/SettingsScreen.kt @@ -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() + 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 + ) + } + } + }, + ) + ) + }, + ), + ) + }, + ) + } + } + } +} diff --git a/app/src/main/java/dev/achmad/ledgerr/ui/screens/settings/SettingsScreenModel.kt b/app/src/main/java/dev/achmad/ledgerr/ui/screens/settings/SettingsScreenModel.kt new file mode 100644 index 0000000..1bba75f --- /dev/null +++ b/app/src/main/java/dev/achmad/ledgerr/ui/screens/settings/SettingsScreenModel.kt @@ -0,0 +1,41 @@ +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.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +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) { + screenModelScope.launch { + val result = withContext(Dispatchers.IO) { + exportExpensesToCsv.await(range, uri) + } + onResult(result) + } + } + + fun clearData(onResult: (Result) -> Unit) { + screenModelScope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { + clearAllData.await() + seedDefaultCategories.await() + } + } + onResult(result) + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 78e3372..762535c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -6,4 +6,59 @@ Not selected Selected Disabled + Pick + + + Export + Select date range + Start date + End date + ledgerr-export.csv + + + Categories + No categories yet. Tap + to add one. + Add category + New category + Edit category + Delete category + Delete category? + Are you sure you want to delete \"%1$s\"? Expenses in this category will be moved to Uncategorized. + Name + Color + Default category + + + Import bank statement + Select your bank to import a PDF statement. + Reading %1$s statement… + %1$s — %2$d transactions found + Import %1$d item(s) + No category + Edit transaction + Amount + Date (yyyy-MM-dd) + Description + No transactions found in PDF + Import failed + No items selected + + + Settings + Appearance + Theme + Light + Dark + System + Data + Export CSV + Export expenses in a date range to a CSV file + Export complete + Export failed + Clear all data + Permanently delete all expenses, categories, and recurring entries + Clear all data? + 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