feat(#7): implement CategoryScreen, ImportBankStatementScreen, SettingsScreen, and ExportAction helper
This commit is contained in:
@@ -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