Implement CategoryScreen, ImportBankStatementScreen, SettingsScreen, and ExportAction helper (#7) #17
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
+427
@@ -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()
|
||||
+128
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,4 +6,56 @@
|
||||
<string name="general_not_selected">Not selected</string>
|
||||
<string name="general_selected">Selected</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>
|
||||
|
||||
Reference in New Issue
Block a user
Blocking:
DateFieldis inconsistent about time zones. Line 188 seedsinitialMilliswithZoneId.systemDefault()(so the OutlinedTextField shows the date in the user's zone), but line 199 converts the pick back withZoneId.of("UTC"). The ComposeDatePickerState.selectedDateMillisis documented as "UTC milliseconds at the start of the day for the date in the user's time zone" — i.e. the picker encodes the picked day as midnight UTC of that day. Concretely, for a user in UTC+7 with the field seeded to2025-01-15:initialMillis = 2025-01-15T00:00:00+07:00 = 2025-01-14T17:00:00ZatZone(UTC).toLocalDate()returns 2025-01-14startDate/endDateare written as 2025-01-14, silently shifting the export range by a day in the common case.Fix: pick one zone and apply it on both sides. The simpler change is
ZoneId.of("UTC")on line 188 instead ofZoneId.systemDefault()— that way both seed and read agree on UTC and the picker/field always match. (UsingsystemDefault()on the read side would work too, but introduces a second zone dependency that's harder to reason about.)