From 1c08d52c6426796264704c703f454330ebb12717 Mon Sep 17 00:00:00 2001 From: Charlotte Van Petegem Date: Sun, 6 Oct 2024 00:20:35 +0200 Subject: [PATCH] Allow editing transactions (#181) Fixes #49. --- app/src/main/AndroidManifest.xml | 5 + .../chvp/nanoledger/data/LedgerRepository.kt | 84 ++- .../be/chvp/nanoledger/data/Transaction.kt | 4 +- .../data/parser/TransactionParser.kt | 55 +- .../be/chvp/nanoledger/ui/add/AddActivity.kt | 512 +----------------- .../be/chvp/nanoledger/ui/add/AddViewModel.kt | 205 +------ .../ui/common/TransactionFormComponents.kt | 498 +++++++++++++++++ .../ui/common/TransactionFormViewModel.kt | 225 ++++++++ .../chvp/nanoledger/ui/edit/EditActivity.kt | 149 +++++ .../chvp/nanoledger/ui/edit/EditViewModel.kt | 81 +++ .../chvp/nanoledger/ui/main/MainActivity.kt | 106 +--- .../nanoledger/ui/main/TransactionCard.kt | 87 +++ app/src/main/res/values/strings.xml | 2 + .../data/parser/TransactionParserTest.kt | 188 ++++++- 14 files changed, 1373 insertions(+), 828 deletions(-) create mode 100644 app/src/main/java/be/chvp/nanoledger/ui/common/TransactionFormComponents.kt create mode 100644 app/src/main/java/be/chvp/nanoledger/ui/common/TransactionFormViewModel.kt create mode 100644 app/src/main/java/be/chvp/nanoledger/ui/edit/EditActivity.kt create mode 100644 app/src/main/java/be/chvp/nanoledger/ui/edit/EditViewModel.kt create mode 100644 app/src/main/java/be/chvp/nanoledger/ui/main/TransactionCard.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 30bf0b7..6ec5d3d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -29,6 +29,11 @@ android:name=".ui.add.AddActivity" android:windowSoftInputMode="adjustResize" /> + diff --git a/app/src/main/java/be/chvp/nanoledger/data/LedgerRepository.kt b/app/src/main/java/be/chvp/nanoledger/data/LedgerRepository.kt index 89cefa9..c1168ae 100644 --- a/app/src/main/java/be/chvp/nanoledger/data/LedgerRepository.kt +++ b/app/src/main/java/be/chvp/nanoledger/data/LedgerRepository.kt @@ -38,6 +38,21 @@ class LedgerRepository ) } + suspend fun matches(fileUri: Uri): Boolean { + val result = ArrayList() + fileUri + .let { context.contentResolver.openInputStream(it) } + ?.let { BufferedReader(InputStreamReader(it)) } + ?.use { reader -> + var line = reader.readLine() + while (line != null) { + result.add(line) + line = reader.readLine() + } + } + return result.equals(fileContents.value) + } + suspend fun deleteTransaction( fileUri: Uri, transaction: Transaction, @@ -47,19 +62,7 @@ class LedgerRepository onReadError: suspend (IOException) -> Unit, ) { try { - val result = ArrayList() - fileUri - .let { context.contentResolver.openInputStream(it) } - ?.let { BufferedReader(InputStreamReader(it)) } - ?.use { reader -> - var line = reader.readLine() - while (line != null) { - result.add(line) - line = reader.readLine() - } - } - - if (!result.equals(fileContents.value)) { + if (!matches(fileUri)) { onMismatch() } else { context.contentResolver.openOutputStream(fileUri, "wt") @@ -84,8 +87,9 @@ class LedgerRepository } } - suspend fun appendTo( + suspend fun replaceTransaction( fileUri: Uri, + transaction: Transaction, text: String, onFinish: suspend () -> Unit, onMismatch: suspend () -> Unit, @@ -93,19 +97,49 @@ class LedgerRepository onReadError: suspend (IOException) -> Unit, ) { try { - val result = ArrayList() - fileUri - .let { context.contentResolver.openInputStream(it) } - ?.let { BufferedReader(InputStreamReader(it)) } - ?.use { reader -> - var line = reader.readLine() - while (line != null) { - result.add(line) - line = reader.readLine() + if (!matches(fileUri)) { + onMismatch() + } else { + context.contentResolver.openOutputStream(fileUri, "wt") + ?.let { OutputStreamWriter(it) } + ?.use { + fileContents.value!!.forEachIndexed { i, line -> + // If we encounter the first line of the transaction, write out the replacement + if (i == transaction.firstLine) { + it.write(text) + return@forEachIndexed + } + + // Just skip all the next lines + if (i > transaction.firstLine && i <= transaction.lastLine) { + return@forEachIndexed + } + + // If the line after the transaction is empty, consider it a + // divider for the next transaction and skip it as well + if (i == transaction.lastLine + 1 && line == "") { + return@forEachIndexed + } + it.write("${line}\n") + } } - } + readFrom(fileUri, onFinish, onReadError) + } + } catch (e: IOException) { + onWriteError(e) + } + } - if (!result.equals(fileContents.value)) { + suspend fun appendTo( + fileUri: Uri, + text: String, + onFinish: suspend () -> Unit, + onMismatch: suspend () -> Unit, + onWriteError: suspend (IOException) -> Unit, + onReadError: suspend (IOException) -> Unit, + ) { + try { + if (!matches(fileUri)) { onMismatch() } else { context.contentResolver.openOutputStream(fileUri, "w") diff --git a/app/src/main/java/be/chvp/nanoledger/data/Transaction.kt b/app/src/main/java/be/chvp/nanoledger/data/Transaction.kt index cf70da2..3c580d1 100644 --- a/app/src/main/java/be/chvp/nanoledger/data/Transaction.kt +++ b/app/src/main/java/be/chvp/nanoledger/data/Transaction.kt @@ -1,8 +1,10 @@ package be.chvp.nanoledger.data +data class Amount(val quantity: String, val currency: String, val original: String) + data class Posting( val account: String, - val amount: String?, + val amount: Amount?, ) { fun contains(query: String) = account.contains(query, ignoreCase = true) } diff --git a/app/src/main/java/be/chvp/nanoledger/data/parser/TransactionParser.kt b/app/src/main/java/be/chvp/nanoledger/data/parser/TransactionParser.kt index deb62dd..d257d53 100644 --- a/app/src/main/java/be/chvp/nanoledger/data/parser/TransactionParser.kt +++ b/app/src/main/java/be/chvp/nanoledger/data/parser/TransactionParser.kt @@ -1,13 +1,12 @@ package be.chvp.nanoledger.data.parser +import be.chvp.nanoledger.data.Amount import be.chvp.nanoledger.data.Posting import be.chvp.nanoledger.data.Transaction val datePart = "((\\d{4}[-/.])?\\d{1,2}[-/.]\\d{1,2}(=(\\d{4}[-/.])?\\d{1,2}[-/.]\\d{1,2})?)" val headerRegex = Regex("^$datePart[ \t]*(\\*|!)?([^|]*)(\\|(.*))?$") val postingRegex = Regex("^[ \t]+\\S.*$") -val postingSplitRegex = Regex("[ \\t]{2,}") -val commentRegex = Regex(";.*$") fun extractTransactions(lines: List): List { val result = ArrayList() @@ -26,20 +25,52 @@ fun extractTransactions(lines: List): List { val postings = ArrayList() while (i < lines.size && postingRegex.find(lines[i]) != null) { - val stripped = lines[i].trim().replace(commentRegex, "") - i += 1 - if (stripped.length > 0) { - lastLine = i - 1 - val components = stripped.split(postingSplitRegex, limit = 2) - if (components.size > 1) { - postings.add(Posting(components[0], components[1])) - } else { - postings.add(Posting(components[0], null)) - } + val posting = extractPosting(lines[i]) + if (posting != null) { + lastLine = i + postings.add(posting) } + i += 1 } result.add(Transaction(firstLine, lastLine, date, status, payee, note, postings)) } } return result } + +val commentRegex = Regex(";.*$") +val postingSplitRegex = Regex("[ \\t]{2,}") + +fun extractPosting(line: String): Posting? { + val stripped = line.trim().replace(commentRegex, "") + if (stripped.length == 0) { + return null + } + + val components = stripped.split(postingSplitRegex, limit = 2) + if (components.size == 1) { + return Posting(components[0], null) + } + + return Posting(components[0], extractAmount(components[1].trim())) +} + +val assertionRegex = Regex("=.*$") +val costRegex = Regex("@.*$") +val quantityAtStartRegex = Regex("^(-? *[0-9][0-9,.]*)(.*)") +val quantityAtEndRegex = Regex("(-? *[0-9][0-9,.]*)$") + +fun extractAmount(string: String): Amount { + val stripped = string.trim().replace(assertionRegex, "").trim().replace(costRegex, "").trim() + val matchForStart = quantityAtStartRegex.find(stripped) + if (matchForStart != null) { + val groups = matchForStart.groups + val quantity = groups[1]!!.value.trim() + val currency = groups[2]!!.value.trim() + return Amount(quantity, currency, string) + } + val quantity = quantityAtEndRegex.find(stripped)!!.value.trim() + val currency = stripped.replace(quantityAtEndRegex, "").trim() + + return Amount(quantity, currency, string) +} diff --git a/app/src/main/java/be/chvp/nanoledger/ui/add/AddActivity.kt b/app/src/main/java/be/chvp/nanoledger/ui/add/AddActivity.kt index 936914d..f97fd09 100644 --- a/app/src/main/java/be/chvp/nanoledger/ui/add/AddActivity.kt +++ b/app/src/main/java/be/chvp/nanoledger/ui/add/AddActivity.kt @@ -1,76 +1,38 @@ + package be.chvp.nanoledger.ui.add import android.app.Activity -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context import android.content.Intent import android.os.Bundle -import android.util.Log -import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent import androidx.activity.viewModels -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Done -import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DatePicker -import androidx.compose.material3.DatePickerDialog -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExposedDropdownMenuBox -import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.MenuAnchorType -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TextField import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberDatePickerState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -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.focus.onFocusChanged import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel import be.chvp.nanoledger.R +import be.chvp.nanoledger.ui.common.TransactionForm import be.chvp.nanoledger.ui.main.MainActivity import be.chvp.nanoledger.ui.theme.NanoLedgerTheme import dagger.hilt.android.AndroidEntryPoint @@ -85,53 +47,13 @@ class AddActivity() : ComponentActivity() { super.onCreate(savedInstanceState) setContent { val context = LocalContext.current - val latestError by addViewModel.latestError.observeAsState() - val showMessage = stringResource(R.string.show) val scope = rememberCoroutineScope() - val snackbarHostState = remember { SnackbarHostState() } - var openErrorDialog by rememberSaveable { mutableStateOf(false) } - - val errorMessage = stringResource(R.string.error_writing_file) - var errorDialogMessage by rememberSaveable { mutableStateOf("") } - LaunchedEffect(latestError) { - val error = latestError?.get() - if (error != null) { - Log.e("be.chvp.nanoledger", "Exception while writing file", error) - scope.launch { - val result = - snackbarHostState.showSnackbar( - message = errorMessage, - actionLabel = showMessage, - duration = SnackbarDuration.Long, - ) - if (result == SnackbarResult.ActionPerformed) { - openErrorDialog = true - errorDialogMessage = error.stackTraceToString() - } - } - } - } - val latestMismatch by addViewModel.latestMismatch.observeAsState() - val mismatchMessage = stringResource(R.string.mismatch_no_write) - LaunchedEffect(latestMismatch) { - val error = latestMismatch?.get() - if (error != null) { - Toast.makeText( - context, - mismatchMessage, - Toast.LENGTH_LONG, - ).show() - } - } + val snackbarHostState = remember { SnackbarHostState() } BackHandler(enabled = true) { finish() - startActivity( - Intent(context, MainActivity::class.java).setFlags( - Intent.FLAG_ACTIVITY_CLEAR_TOP, - ), - ) + startActivity(Intent(context, MainActivity::class.java).setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)) } val saving by addViewModel.saving.observeAsState() val valid by addViewModel.valid.observeAsState() @@ -144,7 +66,7 @@ class AddActivity() : ComponentActivity() { FloatingActionButton( onClick = { if (enabled) { - addViewModel.append { + addViewModel.save { scope.launch(Main) { finish() startActivity( @@ -177,83 +99,7 @@ class AddActivity() : ComponentActivity() { } }, ) { contentPadding -> - Box(modifier = Modifier.padding(contentPadding).fillMaxSize()) { - if (openErrorDialog) { - AlertDialog( - onDismissRequest = { openErrorDialog = false }, - confirmButton = { - TextButton(onClick = { - val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - - val clip: ClipData = ClipData.newPlainText("simple text", errorDialogMessage) - clipboard.setPrimaryClip(clip) - }) { Text(stringResource(R.string.copy)) } - }, - title = { Text(stringResource(R.string.error)) }, - text = { Text(errorDialogMessage) }, - dismissButton = { - TextButton(onClick = { openErrorDialog = false }) { Text(stringResource(R.string.dismiss)) } - }, - ) - } - Column( - modifier = - Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()), - ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(top = 4.dp, bottom = 2.dp), - verticalAlignment = Alignment.Bottom, - ) { - DateSelector( - modifier = - Modifier - .weight(0.3f) - .padding(start = 4.dp, end = 2.dp) - .fillMaxWidth(), - ) - StatusSelector( - modifier = - Modifier - .weight(0.12f) - .padding(horizontal = 2.dp) - .fillMaxWidth(), - ) - PayeeSelector( - modifier = - Modifier - .weight(0.58f) - .padding(start = 2.dp, end = 4.dp) - .fillMaxWidth(), - ) - } - Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) { - NoteSelector( - modifier = - Modifier - .weight(1f) - .padding(horizontal = 4.dp) - .fillMaxWidth(), - ) - } - val postings by addViewModel.postings.observeAsState() - var encounteredEmptyAmount = false - postings?.forEachIndexed { i, posting -> - val firstEmpty = - encounteredEmptyAmount == false && posting.third == "" - encounteredEmptyAmount = encounteredEmptyAmount || firstEmpty - PostingRow( - index = i, - posting = posting, - firstEmptyAmount = firstEmpty, - ) - } - } - } + TransactionForm(addViewModel, contentPadding, snackbarHostState) } } } @@ -290,349 +136,3 @@ fun Bar() { ), ) } - -@Composable -fun DateSelector( - modifier: Modifier = Modifier, - addViewModel: AddViewModel = viewModel(), -) { - val focusManager = LocalFocusManager.current - val date by addViewModel.date.observeAsState() - val formattedDate by addViewModel.formattedDate.observeAsState() - var dateDialogOpen by rememberSaveable { mutableStateOf(false) } - OutlinedTextField( - value = formattedDate ?: "", - readOnly = true, - singleLine = true, - onValueChange = {}, - label = { Text(stringResource(R.string.date)) }, - colors = - ExposedDropdownMenuDefaults.textFieldColors( - focusedContainerColor = MaterialTheme.colorScheme.surface, - unfocusedContainerColor = MaterialTheme.colorScheme.surface, - ), - modifier = - modifier.onFocusChanged { - if (it.isFocused) { - dateDialogOpen = true - } - }, - ) - if (dateDialogOpen) { - val datePickerState = rememberDatePickerState(initialSelectedDateMillis = date?.getTime()) - DatePickerDialog( - onDismissRequest = { dateDialogOpen = false }, - confirmButton = { - TextButton(onClick = { - datePickerState.selectedDateMillis?.let { addViewModel.setDate(it) } - dateDialogOpen = false - focusManager.clearFocus() - }) { - Text(stringResource(R.string.ok)) - } - }, - ) { - DatePicker(state = datePickerState) - } - } -} - -@Composable -fun StatusSelector( - modifier: Modifier = Modifier, - addViewModel: AddViewModel = viewModel(), -) { - val status by addViewModel.status.observeAsState() - val options = listOf(" ", "!", "*") - var expanded by rememberSaveable { mutableStateOf(false) } - ExposedDropdownMenuBox( - expanded = expanded, - onExpandedChange = { expanded = !expanded }, - modifier = modifier, - ) { - OutlinedTextField( - value = (status ?: ""), - onValueChange = {}, - readOnly = true, - modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryEditable), - colors = - ExposedDropdownMenuDefaults.textFieldColors( - focusedContainerColor = MaterialTheme.colorScheme.surface, - unfocusedContainerColor = MaterialTheme.colorScheme.surface, - ), - textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center), - ) - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - modifier = Modifier.exposedDropdownSize(true), - ) { - options.forEach { - DropdownMenuItem( - text = { Text(it) }, - onClick = { - addViewModel.setStatus(it) - expanded = false - }, - contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, - ) - } - } - } -} - -@Composable -fun PayeeSelector( - modifier: Modifier = Modifier, - addViewModel: AddViewModel = viewModel(), -) { - val payee by addViewModel.payee.observeAsState() - val options by addViewModel.possiblePayees.observeAsState() - OutlinedLooseDropdown( - options ?: emptyList(), - payee ?: "", - { addViewModel.setPayee(it) }, - modifier, - ) { Text(stringResource(R.string.payee)) } -} - -@Composable -fun NoteSelector( - modifier: Modifier = Modifier, - addViewModel: AddViewModel = viewModel(), -) { - val note by addViewModel.note.observeAsState() - val options by addViewModel.possibleNotes.observeAsState() - OutlinedLooseDropdown( - options ?: emptyList(), - note ?: "", - { addViewModel.setNote(it) }, - modifier, - ) { Text(stringResource(R.string.note)) } -} - -@Composable -fun PostingRow( - index: Int, - posting: Triple, - firstEmptyAmount: Boolean, - addViewModel: AddViewModel = viewModel(), -) { - val currencyBeforeAmount by addViewModel.currencyBeforeAmount.observeAsState() - Row(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp, horizontal = 2.dp)) { - AccountSelector( - index = index, - value = posting.first, - modifier = Modifier.weight(2.2f).padding(horizontal = 2.dp), - ) - if (currencyBeforeAmount ?: true) { - CurrencyField(index, posting, Modifier.weight(0.95f).padding(horizontal = 2.dp)) - AmountField( - index, - posting, - firstEmptyAmount, - Modifier.weight(1.25f).padding(horizontal = 2.dp), - ) - } else { - AmountField( - index, - posting, - firstEmptyAmount, - Modifier.weight(1.25f).padding(horizontal = 2.dp), - ) - CurrencyField(index, posting, Modifier.weight(0.95f).padding(horizontal = 2.dp)) - } - } -} - -@Composable -fun CurrencyField( - index: Int, - posting: Triple, - modifier: Modifier = Modifier, - addViewModel: AddViewModel = viewModel(), -) { - TextField( - value = posting.second, - onValueChange = { addViewModel.setCurrency(index, it) }, - singleLine = true, - modifier = modifier, - colors = - ExposedDropdownMenuDefaults.textFieldColors( - focusedContainerColor = MaterialTheme.colorScheme.surface, - unfocusedContainerColor = MaterialTheme.colorScheme.surface, - ), - textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center), - ) -} - -@Composable -fun AmountField( - index: Int, - posting: Triple, - firstEmptyAmount: Boolean, - modifier: Modifier = Modifier, - addViewModel: AddViewModel = viewModel(), -) { - val unbalancedAmount by addViewModel.unbalancedAmount.observeAsState() - TextField( - value = posting.third, - onValueChange = { addViewModel.setAmount(index, it) }, - singleLine = true, - colors = - ExposedDropdownMenuDefaults.textFieldColors( - focusedContainerColor = MaterialTheme.colorScheme.surface, - unfocusedContainerColor = MaterialTheme.colorScheme.surface, - ), - placeholder = { - if (firstEmptyAmount && unbalancedAmount != null) { - Text( - unbalancedAmount!!, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - maxLines = 1, - ) - } - }, - keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Decimal), - modifier = modifier, - textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center), - ) -} - -@Composable -fun AccountSelector( - index: Int, - value: String, - modifier: Modifier = Modifier, - addViewModel: AddViewModel = viewModel(), -) { - val options by addViewModel.accounts.observeAsState() - val filteredOptions = options?.filter { it.contains(value, ignoreCase = true) } ?: emptyList() - LooseDropdown(filteredOptions, value, { addViewModel.setAccount(index, it) }, modifier) -} - -@Composable -fun OutlinedLooseDropdown( - options: List, - value: String, - onValueChange: (String) -> Unit, - modifier: Modifier = Modifier, - content: (@Composable () -> Unit)? = null, -) { - val focusManager = LocalFocusManager.current - var expanded by rememberSaveable { mutableStateOf(false) } - ExposedDropdownMenuBox( - expanded = expanded, - onExpandedChange = { expanded = !expanded }, - modifier = modifier, - ) { - OutlinedTextField( - value = value, - onValueChange = { - if (it.length > value.length) { - expanded = true - } - onValueChange(it) - }, - singleLine = true, - label = content, - modifier = - Modifier.menuAnchor(MenuAnchorType.PrimaryEditable).fillMaxWidth().onFocusChanged { - if (!it.hasFocus) { - expanded = false - } - }, - colors = - ExposedDropdownMenuDefaults.textFieldColors( - focusedContainerColor = MaterialTheme.colorScheme.surface, - unfocusedContainerColor = MaterialTheme.colorScheme.surface, - ), - ) - if (shouldShowDropdown(options, value)) { - ExposedDropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - modifier = Modifier.exposedDropdownSize(true), - ) { - options.forEach { - DropdownMenuItem( - text = { Text(it) }, - onClick = { - onValueChange(it) - focusManager.clearFocus() - expanded = false - }, - contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, - ) - } - } - } - } -} - -@Composable -fun LooseDropdown( - options: List, - value: String, - onValueChange: (String) -> Unit, - modifier: Modifier = Modifier, - content: (@Composable () -> Unit)? = null, -) { - val focusManager = LocalFocusManager.current - var expanded by rememberSaveable { mutableStateOf(false) } - ExposedDropdownMenuBox( - expanded = expanded, - onExpandedChange = { expanded = !expanded }, - modifier = modifier, - ) { - TextField( - value = value, - onValueChange = { - if (it.length > value.length) { - expanded = true - } - onValueChange(it) - }, - singleLine = true, - modifier = - Modifier.menuAnchor(MenuAnchorType.PrimaryEditable).fillMaxWidth().onFocusChanged { - if (!it.hasFocus) { - expanded = false - } - }, - label = content, - colors = - ExposedDropdownMenuDefaults.textFieldColors( - focusedContainerColor = MaterialTheme.colorScheme.surface, - unfocusedContainerColor = MaterialTheme.colorScheme.surface, - ), - ) - if (shouldShowDropdown(options, value)) { - ExposedDropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - modifier = Modifier.exposedDropdownSize(true), - ) { - options.forEach { - DropdownMenuItem( - text = { Text(it) }, - onClick = { - onValueChange(it) - focusManager.clearFocus() - expanded = false - }, - contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, - ) - } - } - } - } -} - -fun shouldShowDropdown( - options: List, - currentValue: String, -): Boolean { - return options.size > 1 || (options.size == 1 && options[0] != currentValue) -} diff --git a/app/src/main/java/be/chvp/nanoledger/ui/add/AddViewModel.kt b/app/src/main/java/be/chvp/nanoledger/ui/add/AddViewModel.kt index 2bd8802..ad470b6 100644 --- a/app/src/main/java/be/chvp/nanoledger/ui/add/AddViewModel.kt +++ b/app/src/main/java/be/chvp/nanoledger/ui/add/AddViewModel.kt @@ -1,22 +1,14 @@ package be.chvp.nanoledger.ui.add import android.app.Application -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.map -import androidx.lifecycle.switchMap import androidx.lifecycle.viewModelScope import be.chvp.nanoledger.data.LedgerRepository import be.chvp.nanoledger.data.PreferencesDataSource +import be.chvp.nanoledger.ui.common.TransactionFormViewModel import be.chvp.nanoledger.ui.util.Event import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.launch -import java.io.IOException -import java.math.BigDecimal -import java.text.SimpleDateFormat -import java.util.Date import javax.inject.Inject @HiltViewModel @@ -24,138 +16,28 @@ class AddViewModel @Inject constructor( application: Application, - private val preferencesDataSource: PreferencesDataSource, - private val ledgerRepository: LedgerRepository, - ) : AndroidViewModel(application) { - private val _saving = MutableLiveData(false) - val saving: LiveData = _saving - - private val _date = MutableLiveData(Date()) - val date: LiveData = _date - val formattedDate: LiveData = _date.map { SimpleDateFormat("yyyy-MM-dd").format(it) } - - private val _status = MutableLiveData(preferencesDataSource.getDefaultStatus()) - val status: LiveData = _status - - private val _payee = MutableLiveData("") - val payee: LiveData = _payee - val possiblePayees: LiveData> = - ledgerRepository.payees.switchMap { payees -> - payee.map { search -> - payees.filter { it.contains(search, ignoreCase = true) }.sorted() - } - } - - private val _note = MutableLiveData("") - val note: LiveData = _note - val possibleNotes: LiveData> = - ledgerRepository.notes.switchMap { notes -> - note.map { search -> - notes.filter { it.contains(search, ignoreCase = true) }.sorted() - } - } - - private val _postings = - MutableLiveData>>( - listOf(emptyPosting()), - ) - val postings: LiveData>> = _postings - val accounts: LiveData> = ledgerRepository.accounts.map { it.sorted() } - val unbalancedAmount: LiveData = - postings.map { - it - .map { it.third } - .filter { it != "" } - .map { - try { - BigDecimal(it) - } catch (e: NumberFormatException) { - BigDecimal.ZERO - } - } - .fold(BigDecimal.ZERO) { l, r -> l + r } - .let { it.negate() } - .let { if (it == BigDecimal.ZERO.setScale(it.scale())) "" else it.toString() } - } - - val valid: LiveData = - payee.switchMap { payee -> - postings.switchMap { postings -> - unbalancedAmount.map { unbalancedAmount -> - if (postings.size < 2) { - return@map false - } - if (payee == "") { - return@map false - } - if (postings.dropLast(1).any { it.first == "" }) { - return@map false - } - if (unbalancedAmount != "" && postings.dropLast(1).all { it.third != "" }) { - return@map false - } - if (postings.dropLast(1).filter { it.third == "" }.size > 1) { - return@map false - } - return@map true - } - } - } - - private val _latestError = MutableLiveData?>(null) - val latestError: LiveData?> = _latestError - - private val _latestMismatch = MutableLiveData?>(null) - val latestMismatch: LiveData?> = _latestMismatch - - val currencyBeforeAmount: LiveData = preferencesDataSource.currencyBeforeAmount - - fun append(onFinish: suspend () -> Unit) { + preferencesDataSource: PreferencesDataSource, + ledgerRepository: LedgerRepository, + ) : TransactionFormViewModel(application, preferencesDataSource, ledgerRepository) { + override fun save(onFinish: suspend () -> Unit) { val uri = preferencesDataSource.getFileUri() if (uri != null) { - _saving.value = true + setSaving(true) viewModelScope.launch(IO) { - val transaction = StringBuilder() - transaction.append(SimpleDateFormat("yyyy-MM-dd").format(date.value!!)) - if (status.value!! != " ") { - transaction.append(" ${status.value}") - } - transaction.append(" ${payee.value}") - if (note.value!! != "") { - transaction.append(" | ${note.value}") - } - transaction.append('\n') - // Drop last element, it should always be an empty posting - for (posting in postings.value!!.dropLast(1)) { - if (posting.third == "") { - transaction.append( - " ${posting.first}\n", - ) - } else if (preferencesDataSource.getCurrencyBeforeAmount()) { - transaction.append( - " ${posting.first} ${posting.second} ${posting.third}\n", - ) - } else { - transaction.append( - " ${posting.first} ${posting.third} ${posting.second}\n", - ) - } - } - transaction.append('\n') ledgerRepository.appendTo( uri, - transaction.toString(), + toTransactionString(), { - _saving.postValue(false) + postSaving(false) onFinish() }, { - _saving.postValue(false) - _latestMismatch.postValue(Event(1)) + postSaving(false) + postMismatch(Event(1)) }, { - _saving.postValue(false) - _latestError.postValue(Event(it)) + postSaving(false) + postError(Event(it)) }, { // We ignore a read error, the write went through so the @@ -163,72 +45,11 @@ class AddViewModel // transaction not being in the transaction // overview. Which isn't optimal, but not a big problem // either. - _saving.postValue(false) + postSaving(false) onFinish() }, ) } } } - - fun setDate(dateMillis: Long) { - _date.value = Date(dateMillis) - } - - fun setStatus(newStatus: String) { - _status.value = newStatus - } - - fun setPayee(newPayee: String) { - _payee.value = newPayee - } - - fun setNote(newNote: String) { - _note.value = newNote - } - - fun setAccount( - index: Int, - newAccount: String, - ) { - val result = ArrayList(postings.value!!) - result[index] = Triple(newAccount, result[index].second, result[index].third) - val filteredResult = ArrayList>() - for (triple in result) { - if (triple.first != "" || triple.third != "") { - filteredResult.add(triple) - } - } - filteredResult.add(emptyPosting()) - _postings.value = filteredResult - } - - fun setCurrency( - index: Int, - newCurrency: String, - ) { - val result = ArrayList(postings.value!!) - result[index] = Triple(result[index].first, newCurrency, result[index].third) - _postings.value = result - } - - fun setAmount( - index: Int, - newAmount: String, - ) { - val result = ArrayList(postings.value!!) - result[index] = Triple(result[index].first, result[index].second, newAmount) - val filteredResult = ArrayList>() - for (triple in result) { - if (triple.first != "" || triple.third != "") { - filteredResult.add(triple) - } - } - filteredResult.add(emptyPosting()) - _postings.value = filteredResult - } - - fun emptyPosting(): Triple { - return Triple("", preferencesDataSource.getDefaultCurrency(), "") - } } diff --git a/app/src/main/java/be/chvp/nanoledger/ui/common/TransactionFormComponents.kt b/app/src/main/java/be/chvp/nanoledger/ui/common/TransactionFormComponents.kt new file mode 100644 index 0000000..c64ead8 --- /dev/null +++ b/app/src/main/java/be/chvp/nanoledger/ui/common/TransactionFormComponents.kt @@ -0,0 +1,498 @@ +package be.chvp.nanoledger.ui.common + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.util.Log +import android.widget.Toast +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuAnchorType +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +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.focus.onFocusChanged +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import be.chvp.nanoledger.R +import kotlinx.coroutines.launch + +@Composable +fun TransactionForm( + viewModel: TransactionFormViewModel, + contentPadding: PaddingValues, + snackbarHostState: SnackbarHostState, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + val latestError by viewModel.latestError.observeAsState() + val showMessage = stringResource(R.string.show) + var openErrorDialog by rememberSaveable { mutableStateOf(false) } + + val errorMessage = stringResource(R.string.error_writing_file) + var errorDialogMessage by rememberSaveable { mutableStateOf("") } + LaunchedEffect(latestError) { + val error = latestError?.get() + if (error != null) { + Log.e("be.chvp.nanoledger", "Exception while writing file", error) + scope.launch { + val result = + snackbarHostState.showSnackbar( + message = errorMessage, + actionLabel = showMessage, + duration = SnackbarDuration.Long, + ) + if (result == SnackbarResult.ActionPerformed) { + openErrorDialog = true + errorDialogMessage = error.stackTraceToString() + } + } + } + } + + val latestMismatch by viewModel.latestMismatch.observeAsState() + val mismatchMessage = stringResource(R.string.mismatch_no_write) + LaunchedEffect(latestMismatch) { + val error = latestMismatch?.get() + if (error != null) { + Toast.makeText( + context, + mismatchMessage, + Toast.LENGTH_LONG, + ).show() + } + } + + Box(modifier = Modifier.padding(contentPadding).fillMaxSize()) { + if (openErrorDialog) { + AlertDialog( + onDismissRequest = { openErrorDialog = false }, + confirmButton = { + TextButton(onClick = { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + + val clip: ClipData = ClipData.newPlainText("simple text", errorDialogMessage) + clipboard.setPrimaryClip(clip) + }) { Text(stringResource(R.string.copy)) } + }, + title = { Text(stringResource(R.string.error)) }, + text = { Text(errorDialogMessage) }, + dismissButton = { + TextButton(onClick = { openErrorDialog = false }) { Text(stringResource(R.string.dismiss)) } + }, + ) + } + Column( + modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 2.dp), + verticalAlignment = Alignment.Bottom, + ) { + DateSelector(viewModel, Modifier.weight(0.3f).padding(start = 4.dp, end = 2.dp).fillMaxWidth()) + StatusSelector(viewModel, Modifier.weight(0.12f).padding(horizontal = 2.dp).fillMaxWidth()) + PayeeSelector(viewModel, Modifier.weight(0.58f).padding(start = 2.dp, end = 4.dp).fillMaxWidth()) + } + Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) { + NoteSelector( + viewModel, + Modifier.weight(1f).padding(horizontal = 4.dp).fillMaxWidth(), + ) + } + val postings by viewModel.postings.observeAsState() + var encounteredEmptyAmount = false + postings?.forEachIndexed { i, posting -> + val firstEmpty = encounteredEmptyAmount == false && posting.third == "" + encounteredEmptyAmount = encounteredEmptyAmount || firstEmpty + PostingRow(i, posting, firstEmpty, viewModel) + } + } + } +} + +@Composable +fun DateSelector( + viewModel: TransactionFormViewModel, + modifier: Modifier = Modifier, +) { + val focusManager = LocalFocusManager.current + val date by viewModel.date.observeAsState() + val formattedDate by viewModel.formattedDate.observeAsState() + var dateDialogOpen by rememberSaveable { mutableStateOf(false) } + OutlinedTextField( + value = formattedDate ?: "", + readOnly = true, + singleLine = true, + onValueChange = {}, + label = { Text(stringResource(R.string.date)) }, + colors = + ExposedDropdownMenuDefaults.textFieldColors( + focusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedContainerColor = MaterialTheme.colorScheme.surface, + ), + modifier = + modifier.onFocusChanged { + if (it.isFocused) { + dateDialogOpen = true + } + }, + ) + if (dateDialogOpen) { + val datePickerState = rememberDatePickerState(initialSelectedDateMillis = date?.getTime()) + DatePickerDialog( + onDismissRequest = { dateDialogOpen = false }, + confirmButton = { + TextButton(onClick = { + datePickerState.selectedDateMillis?.let { viewModel.setDate(it) } + dateDialogOpen = false + focusManager.clearFocus() + }) { + Text(stringResource(R.string.ok)) + } + }, + ) { + DatePicker(state = datePickerState) + } + } +} + +@Composable +fun StatusSelector( + viewModel: TransactionFormViewModel, + modifier: Modifier = Modifier, +) { + val status by viewModel.status.observeAsState() + val options = listOf(" ", "!", "*") + var expanded by rememberSaveable { mutableStateOf(false) } + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = !expanded }, + modifier = modifier, + ) { + OutlinedTextField( + value = (status ?: ""), + onValueChange = {}, + readOnly = true, + modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryEditable), + colors = + ExposedDropdownMenuDefaults.textFieldColors( + focusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedContainerColor = MaterialTheme.colorScheme.surface, + ), + textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center), + ) + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = Modifier.exposedDropdownSize(true), + ) { + options.forEach { + DropdownMenuItem( + text = { Text(it) }, + onClick = { + viewModel.setStatus(it) + expanded = false + }, + contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, + ) + } + } + } +} + +@Composable +fun PayeeSelector( + viewModel: TransactionFormViewModel, + modifier: Modifier = Modifier, +) { + val payee by viewModel.payee.observeAsState() + val options by viewModel.possiblePayees.observeAsState() + OutlinedLooseDropdown( + options ?: emptyList(), + payee ?: "", + { viewModel.setPayee(it) }, + modifier, + ) { Text(stringResource(R.string.payee)) } +} + +@Composable +fun NoteSelector( + viewModel: TransactionFormViewModel, + modifier: Modifier = Modifier, +) { + val note by viewModel.note.observeAsState() + val options by viewModel.possibleNotes.observeAsState() + OutlinedLooseDropdown( + options ?: emptyList(), + note ?: "", + { viewModel.setNote(it) }, + modifier, + ) { Text(stringResource(R.string.note)) } +} + +@Composable +fun PostingRow( + index: Int, + posting: Triple, + firstEmptyAmount: Boolean, + viewModel: TransactionFormViewModel, +) { + val currencyBeforeAmount by viewModel.currencyBeforeAmount.observeAsState() + Row(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp, horizontal = 2.dp)) { + AccountSelector( + index = index, + value = posting.first, + viewModel, + modifier = Modifier.weight(2.2f).padding(horizontal = 2.dp), + ) + if (currencyBeforeAmount ?: true) { + CurrencyField(index, posting, viewModel, Modifier.weight(0.95f).padding(horizontal = 2.dp)) + AmountField( + index, + posting, + firstEmptyAmount, + viewModel, + Modifier.weight(1.25f).padding(horizontal = 2.dp), + ) + } else { + AmountField( + index, + posting, + firstEmptyAmount, + viewModel, + Modifier.weight(1.25f).padding(horizontal = 2.dp), + ) + CurrencyField(index, posting, viewModel, Modifier.weight(0.95f).padding(horizontal = 2.dp)) + } + } +} + +@Composable +fun CurrencyField( + index: Int, + posting: Triple, + viewModel: TransactionFormViewModel, + modifier: Modifier = Modifier, +) { + TextField( + value = posting.second, + onValueChange = { viewModel.setCurrency(index, it) }, + singleLine = true, + modifier = modifier, + colors = + ExposedDropdownMenuDefaults.textFieldColors( + focusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedContainerColor = MaterialTheme.colorScheme.surface, + ), + textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center), + ) +} + +@Composable +fun AmountField( + index: Int, + posting: Triple, + firstEmptyAmount: Boolean, + viewModel: TransactionFormViewModel, + modifier: Modifier = Modifier, +) { + val unbalancedAmount by viewModel.unbalancedAmount.observeAsState() + TextField( + value = posting.third, + onValueChange = { viewModel.setAmount(index, it) }, + singleLine = true, + colors = + ExposedDropdownMenuDefaults.textFieldColors( + focusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedContainerColor = MaterialTheme.colorScheme.surface, + ), + placeholder = { + if (firstEmptyAmount && unbalancedAmount != null) { + Text( + unbalancedAmount!!, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + maxLines = 1, + ) + } + }, + keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Decimal), + modifier = modifier, + textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center), + ) +} + +@Composable +fun AccountSelector( + index: Int, + value: String, + viewModel: TransactionFormViewModel, + modifier: Modifier = Modifier, +) { + val options by viewModel.accounts.observeAsState() + val filteredOptions = options?.filter { it.contains(value, ignoreCase = true) } ?: emptyList() + LooseDropdown(filteredOptions, value, { viewModel.setAccount(index, it) }, modifier) +} + +@Composable +fun OutlinedLooseDropdown( + options: List, + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + content: (@Composable () -> Unit)? = null, +) { + val focusManager = LocalFocusManager.current + var expanded by rememberSaveable { mutableStateOf(false) } + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = !expanded }, + modifier = modifier, + ) { + OutlinedTextField( + value = value, + onValueChange = { + if (it.length > value.length) { + expanded = true + } + onValueChange(it) + }, + singleLine = true, + label = content, + modifier = + Modifier.menuAnchor(MenuAnchorType.PrimaryEditable).fillMaxWidth().onFocusChanged { + if (!it.hasFocus) { + expanded = false + } + }, + colors = + ExposedDropdownMenuDefaults.textFieldColors( + focusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedContainerColor = MaterialTheme.colorScheme.surface, + ), + ) + if (shouldShowDropdown(options, value)) { + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = Modifier.exposedDropdownSize(true), + ) { + options.forEach { + DropdownMenuItem( + text = { Text(it) }, + onClick = { + onValueChange(it) + focusManager.clearFocus() + expanded = false + }, + contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, + ) + } + } + } + } +} + +@Composable +fun LooseDropdown( + options: List, + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + content: (@Composable () -> Unit)? = null, +) { + val focusManager = LocalFocusManager.current + var expanded by rememberSaveable { mutableStateOf(false) } + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = !expanded }, + modifier = modifier, + ) { + TextField( + value = value, + onValueChange = { + if (it.length > value.length) { + expanded = true + } + onValueChange(it) + }, + singleLine = true, + modifier = + Modifier.menuAnchor(MenuAnchorType.PrimaryEditable).fillMaxWidth().onFocusChanged { + if (!it.hasFocus) { + expanded = false + } + }, + label = content, + colors = + ExposedDropdownMenuDefaults.textFieldColors( + focusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedContainerColor = MaterialTheme.colorScheme.surface, + ), + ) + if (shouldShowDropdown(options, value)) { + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = Modifier.exposedDropdownSize(true), + ) { + options.forEach { + DropdownMenuItem( + text = { Text(it) }, + onClick = { + onValueChange(it) + focusManager.clearFocus() + expanded = false + }, + contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, + ) + } + } + } + } +} + +fun shouldShowDropdown( + options: List, + currentValue: String, +): Boolean { + return options.size > 1 || (options.size == 1 && options[0] != currentValue) +} diff --git a/app/src/main/java/be/chvp/nanoledger/ui/common/TransactionFormViewModel.kt b/app/src/main/java/be/chvp/nanoledger/ui/common/TransactionFormViewModel.kt new file mode 100644 index 0000000..7ef228d --- /dev/null +++ b/app/src/main/java/be/chvp/nanoledger/ui/common/TransactionFormViewModel.kt @@ -0,0 +1,225 @@ +package be.chvp.nanoledger.ui.common + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.map +import androidx.lifecycle.switchMap +import be.chvp.nanoledger.data.LedgerRepository +import be.chvp.nanoledger.data.PreferencesDataSource +import be.chvp.nanoledger.ui.util.Event +import java.io.IOException +import java.math.BigDecimal +import java.text.ParsePosition +import java.text.SimpleDateFormat +import java.util.Date + +val dateFormat = SimpleDateFormat("yyyy-MM-dd") + +abstract class TransactionFormViewModel + constructor( + application: Application, + protected val preferencesDataSource: PreferencesDataSource, + protected val ledgerRepository: LedgerRepository, + ) : AndroidViewModel(application) { + private val _saving = MutableLiveData(false) + val saving: LiveData = _saving + + protected fun setSaving(saving: Boolean) { + _saving.value = saving + } + + protected fun postSaving(saving: Boolean) { + _saving.postValue(saving) + } + + private val _date = MutableLiveData(Date()) + val date: LiveData = _date + val formattedDate: LiveData = _date.map { dateFormat.format(it) } + + private val _status = MutableLiveData(preferencesDataSource.getDefaultStatus()) + val status: LiveData = _status + + private val _payee = MutableLiveData("") + val payee: LiveData = _payee + val possiblePayees: LiveData> = + ledgerRepository.payees.switchMap { payees -> + payee.map { search -> + payees.filter { it.contains(search, ignoreCase = true) }.sorted() + } + } + + private val _note = MutableLiveData("") + val note: LiveData = _note + val possibleNotes: LiveData> = + ledgerRepository.notes.switchMap { notes -> + note.map { search -> + notes.filter { it.contains(search, ignoreCase = true) }.sorted() + } + } + + private val _postings = + MutableLiveData>>( + listOf(emptyPosting()), + ) + val postings: LiveData>> = _postings + val accounts: LiveData> = ledgerRepository.accounts.map { it.sorted() } + val unbalancedAmount: LiveData = + postings.map { + it + .map { it.third } + .filter { it != "" } + .map { + try { + BigDecimal(it) + } catch (e: NumberFormatException) { + BigDecimal.ZERO + } + } + .fold(BigDecimal.ZERO) { l, r -> l + r } + .let { it.negate() } + .let { if (it == BigDecimal.ZERO.setScale(it.scale())) "" else it.toString() } + } + + val valid: LiveData = + payee.switchMap { payee -> + postings.switchMap { postings -> + unbalancedAmount.map { unbalancedAmount -> + if (postings.size < 2) { + return@map false + } + if (payee == "") { + return@map false + } + if (postings.dropLast(1).any { it.first == "" }) { + return@map false + } + if (unbalancedAmount != "" && postings.dropLast(1).all { it.third != "" }) { + return@map false + } + if (postings.dropLast(1).filter { it.third == "" }.size > 1) { + return@map false + } + return@map true + } + } + } + + private val _latestError = MutableLiveData?>(null) + val latestError: LiveData?> = _latestError + + protected fun postError(error: Event) { + _latestError.postValue(error) + } + + private val _latestMismatch = MutableLiveData?>(null) + val latestMismatch: LiveData?> = _latestMismatch + + protected fun postMismatch(mismatch: Event) { + _latestMismatch.postValue(mismatch) + } + + val currencyBeforeAmount: LiveData = preferencesDataSource.currencyBeforeAmount + + protected fun toTransactionString(): String { + val transaction = StringBuilder() + transaction.append(dateFormat.format(date.value!!)) + if (status.value!! != " ") { + transaction.append(" ${status.value}") + } + transaction.append(" ${payee.value}") + if (note.value!! != "") { + transaction.append(" | ${note.value}") + } + transaction.append('\n') + // Drop last element, it should always be an empty posting + for (posting in postings.value!!.dropLast(1)) { + if (posting.third == "") { + transaction.append( + " ${posting.first}\n", + ) + } else if (preferencesDataSource.getCurrencyBeforeAmount()) { + transaction.append( + " ${posting.first} ${posting.second} ${posting.third}\n", + ) + } else { + transaction.append( + " ${posting.first} ${posting.third} ${posting.second}\n", + ) + } + } + transaction.append('\n') + return transaction.toString() + } + + abstract fun save(onFinish: suspend () -> Unit) + + fun setDate(dateMillis: Long) { + _date.value = Date(dateMillis) + } + + fun setDate(newDate: String) { + val parsed = dateFormat.parse(newDate, ParsePosition(0)) + if (parsed != null) { + _date.value = parsed + } + } + + fun setStatus(newStatus: String) { + _status.value = newStatus + } + + fun setPayee(newPayee: String) { + _payee.value = newPayee + } + + fun setNote(newNote: String) { + _note.value = newNote + } + + fun setAccount( + index: Int, + newAccount: String, + ) { + val result = ArrayList(postings.value!!) + result[index] = Triple(newAccount, result[index].second, result[index].third) + val filteredResult = ArrayList>() + for (triple in result) { + if (triple.first != "" || triple.third != "") { + filteredResult.add(triple) + } + } + filteredResult.add(emptyPosting()) + _postings.value = filteredResult + } + + fun setCurrency( + index: Int, + newCurrency: String, + ) { + val result = ArrayList(postings.value!!) + result[index] = Triple(result[index].first, newCurrency, result[index].third) + _postings.value = result + } + + fun setAmount( + index: Int, + newAmount: String, + ) { + val result = ArrayList(postings.value!!) + result[index] = Triple(result[index].first, result[index].second, newAmount) + val filteredResult = ArrayList>() + for (triple in result) { + if (triple.first != "" || triple.third != "") { + filteredResult.add(triple) + } + } + filteredResult.add(emptyPosting()) + _postings.value = filteredResult + } + + fun emptyPosting(): Triple { + return Triple("", preferencesDataSource.getDefaultCurrency(), "") + } + } diff --git a/app/src/main/java/be/chvp/nanoledger/ui/edit/EditActivity.kt b/app/src/main/java/be/chvp/nanoledger/ui/edit/EditActivity.kt new file mode 100644 index 0000000..282a9ef --- /dev/null +++ b/app/src/main/java/be/chvp/nanoledger/ui/edit/EditActivity.kt @@ -0,0 +1,149 @@ +package be.chvp.nanoledger.ui.edit + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Done +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import be.chvp.nanoledger.R +import be.chvp.nanoledger.ui.common.TransactionForm +import be.chvp.nanoledger.ui.main.MainActivity +import be.chvp.nanoledger.ui.theme.NanoLedgerTheme +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers.Main +import kotlinx.coroutines.launch + +val TRANSACTION_INDEX_KEY = "transaction_index" + +@AndroidEntryPoint +class EditActivity() : ComponentActivity() { + private val editViewModel: EditViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + if (!getIntent().hasExtra(TRANSACTION_INDEX_KEY)) { + Log.e("be.chvp.nanoledger", "Edit started without transaction index") + finish() + } + val transactionIndex = getIntent().getIntExtra(TRANSACTION_INDEX_KEY, 0) + editViewModel.setFromIndex(transactionIndex) + + setContent { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + val snackbarHostState = remember { SnackbarHostState() } + + BackHandler(enabled = true) { + finish() + startActivity(Intent(context, MainActivity::class.java).setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)) + } + val loading by editViewModel.loading.observeAsState() + val saving by editViewModel.saving.observeAsState() + val valid by editViewModel.valid.observeAsState() + val enabled = !(saving ?: true) && (valid ?: false) && !(loading ?: true) + NanoLedgerTheme { + Scaffold( + topBar = { Bar() }, + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + floatingActionButton = { + FloatingActionButton( + onClick = { + if (enabled) { + editViewModel.save { + scope.launch(Main) { + finish() + startActivity( + Intent(context, MainActivity::class.java).setFlags( + Intent.FLAG_ACTIVITY_CLEAR_TOP, + ), + ) + } + } + } + }, + containerColor = + if (enabled) { + FloatingActionButtonDefaults.containerColor + } else { + MaterialTheme.colorScheme.surface + }, + ) { + if (saving ?: true || loading ?: true) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.secondary, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + } else { + Icon( + Icons.Default.Done, + contentDescription = stringResource(R.string.save), + ) + } + } + }, + ) { contentPadding -> + TransactionForm(editViewModel, contentPadding, snackbarHostState) + } + } + } + } +} + +@Composable +fun Bar() { + val context = LocalContext.current + TopAppBar( + title = { Text(stringResource(R.string.edit_transaction)) }, + navigationIcon = { + IconButton(onClick = { + (context as Activity).apply { + startActivity( + Intent(context, MainActivity::class.java).setFlags( + Intent.FLAG_ACTIVITY_CLEAR_TOP, + ), + ) + finish() + } + }) { + Icon( + Icons.AutoMirrored.Default.ArrowBack, + contentDescription = stringResource(R.string.back), + ) + } + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primary, + titleContentColor = MaterialTheme.colorScheme.onPrimary, + navigationIconContentColor = MaterialTheme.colorScheme.onPrimary, + ), + ) +} diff --git a/app/src/main/java/be/chvp/nanoledger/ui/edit/EditViewModel.kt b/app/src/main/java/be/chvp/nanoledger/ui/edit/EditViewModel.kt new file mode 100644 index 0000000..0648fa8 --- /dev/null +++ b/app/src/main/java/be/chvp/nanoledger/ui/edit/EditViewModel.kt @@ -0,0 +1,81 @@ +package be.chvp.nanoledger.ui.edit + +import android.app.Application +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import be.chvp.nanoledger.data.LedgerRepository +import be.chvp.nanoledger.data.PreferencesDataSource +import be.chvp.nanoledger.data.Transaction +import be.chvp.nanoledger.ui.common.TransactionFormViewModel +import be.chvp.nanoledger.ui.util.Event +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class EditViewModel + @Inject + constructor( + application: Application, + preferencesDataSource: PreferencesDataSource, + ledgerRepository: LedgerRepository, + ) : TransactionFormViewModel(application, preferencesDataSource, ledgerRepository) { + private lateinit var sourceTransaction: Transaction + + private val _loading = MutableLiveData(true) + val loading: LiveData = _loading + + fun setFromIndex(index: Int) { + sourceTransaction = ledgerRepository.transactions.value!![index] + + setDate(sourceTransaction.date) + setStatus(sourceTransaction.status ?: "") + setPayee(sourceTransaction.payee) + setNote(sourceTransaction.note ?: "") + + sourceTransaction.postings.forEachIndexed { i, posting -> + setAccount(i, posting.account) + setCurrency(i, posting.amount?.currency ?: "") + setAmount(i, posting.amount?.quantity ?: "") + } + + _loading.value = false + } + + override fun save(onFinish: suspend () -> Unit) { + val uri = preferencesDataSource.getFileUri() + if (uri != null) { + setSaving(true) + viewModelScope.launch(IO) { + ledgerRepository.replaceTransaction( + uri, + sourceTransaction, + toTransactionString(), + { + postSaving(false) + onFinish() + }, + { + postSaving(false) + postMismatch(Event(1)) + }, + { + postSaving(false) + postError(Event(it)) + }, + { + // We ignore a read error, the write went through so the + // only thing the user will experience is the + // transaction not being in the transaction + // overview. Which isn't optimal, but not a big problem + // either. + postSaving(false) + onFinish() + }, + ) + } + } + } + } diff --git a/app/src/main/java/be/chvp/nanoledger/ui/main/MainActivity.kt b/app/src/main/java/be/chvp/nanoledger/ui/main/MainActivity.kt index 11f1ee8..c99522e 100644 --- a/app/src/main/java/be/chvp/nanoledger/ui/main/MainActivity.kt +++ b/app/src/main/java/be/chvp/nanoledger/ui/main/MainActivity.kt @@ -10,10 +10,8 @@ import androidx.activity.compose.setContent import androidx.activity.viewModels 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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -23,10 +21,9 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -53,16 +50,15 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import be.chvp.nanoledger.R -import be.chvp.nanoledger.data.Transaction import be.chvp.nanoledger.ui.add.AddActivity +import be.chvp.nanoledger.ui.edit.EditActivity +import be.chvp.nanoledger.ui.edit.TRANSACTION_INDEX_KEY import be.chvp.nanoledger.ui.preferences.PreferencesActivity import be.chvp.nanoledger.ui.theme.NanoLedgerTheme import dagger.hilt.android.AndroidEntryPoint @@ -190,14 +186,6 @@ class MainActivity : ComponentActivity() { } } -fun transactionHeader(t: Transaction): String { - var res = t.date - if (t.status != null) res += " ${t.status}" - res += " ${t.payee}" - if (t.note != null) res += " | ${t.note}" - return res -} - @Composable fun MainContent( contentPadding: PaddingValues, @@ -215,74 +203,17 @@ fun MainContent( LazyColumn(modifier = Modifier.fillMaxSize()) { items(transactions?.size ?: 0) { val index = transactions!!.size - it - 1 - Card( - colors = - if (index == selected) { - CardDefaults.outlinedCardColors() - } else { - CardDefaults.cardColors() - }, - elevation = - if (index == selected) { - CardDefaults.outlinedCardElevation() - } else { - CardDefaults.cardElevation() - }, - border = - if (index == selected) { - CardDefaults.outlinedCardBorder(true) - } else { - null - }, - modifier = - Modifier.fillMaxWidth().padding( - 8.dp, - if (it == 0) 8.dp else 4.dp, - 8.dp, - if (it == transactions!!.size - 1) 8.dp else 4.dp, - ), - ) { - Box(modifier = Modifier.clickable { mainViewModel.toggleSelect(index) }) { - val tr = transactions!![index] - Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) { - Text( - transactionHeader(tr), - softWrap = false, - style = - MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, - ), - overflow = TextOverflow.Ellipsis, - ) - for (p in tr.postings) { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth(), - ) { - Text( - " ${p.account}", - softWrap = false, - style = - MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, - ), - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - Text( - p.amount ?: "", - softWrap = false, - style = - MaterialTheme.typography.bodySmall.copy( - fontFamily = FontFamily.Monospace, - ), - modifier = Modifier.padding(start = 2.dp), - ) - } - } - } - } - } + TransactionCard( + transactions!![index], + index == selected, + { mainViewModel.toggleSelect(index) }, + Modifier.fillMaxWidth().padding( + 8.dp, + if (it == 0) 8.dp else 4.dp, + 8.dp, + if (it == transactions!!.size - 1) 8.dp else 4.dp, + ), + ) } } } else { @@ -357,6 +288,7 @@ fun MainBar(mainViewModel: MainViewModel = viewModel()) { @Composable fun SelectionBar(mainViewModel: MainViewModel = viewModel()) { + val context = LocalContext.current val selected by mainViewModel.selectedIndex.observeAsState() TopAppBar( navigationIcon = { @@ -371,6 +303,14 @@ fun SelectionBar(mainViewModel: MainViewModel = viewModel()) { }, title = { }, actions = { + IconButton(onClick = { + val intent = Intent(context, EditActivity::class.java) + intent.putExtra(TRANSACTION_INDEX_KEY, selected!!) + mainViewModel.toggleSelect(selected!!) + context.startActivity(intent) + }) { + Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit)) + } IconButton(onClick = { mainViewModel.deleteSelected() }) { Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete)) } diff --git a/app/src/main/java/be/chvp/nanoledger/ui/main/TransactionCard.kt b/app/src/main/java/be/chvp/nanoledger/ui/main/TransactionCard.kt new file mode 100644 index 0000000..1360b33 --- /dev/null +++ b/app/src/main/java/be/chvp/nanoledger/ui/main/TransactionCard.kt @@ -0,0 +1,87 @@ +package be.chvp.nanoledger.ui.main + +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.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import be.chvp.nanoledger.data.Transaction + +fun transactionHeader(t: Transaction): String { + var res = t.date + if (t.status != null) res += " ${t.status}" + res += " ${t.payee}" + if (t.note != null) res += " | ${t.note}" + return res +} + +@Composable +fun TransactionCard( + transaction: Transaction, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + colors = + if (selected) { + CardDefaults.outlinedCardColors() + } else { + CardDefaults.cardColors() + }, + elevation = + if (selected) { + CardDefaults.outlinedCardElevation() + } else { + CardDefaults.cardElevation() + }, + border = + if (selected) { + CardDefaults.outlinedCardBorder(true) + } else { + null + }, + modifier = modifier, + ) { + Box(modifier = Modifier.clickable { onClick() }) { + Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) { + Text( + transactionHeader(transaction), + softWrap = false, + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + overflow = TextOverflow.Ellipsis, + ) + for (p in transaction.postings) { + Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { + Text( + " ${p.account}", + softWrap = false, + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Text( + p.amount?.original ?: "", + softWrap = false, + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + modifier = Modifier.padding(start = 2.dp), + ) + } + } + } + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6001a62..b8199e9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -15,6 +15,8 @@ Default status Delete Dismiss + Edit + Edit transaction Error Error while reading file Error while writing file diff --git a/app/src/test/java/be/chvp/nanoledger/data/parser/TransactionParserTest.kt b/app/src/test/java/be/chvp/nanoledger/data/parser/TransactionParserTest.kt index afb0d6f..830c5fc 100644 --- a/app/src/test/java/be/chvp/nanoledger/data/parser/TransactionParserTest.kt +++ b/app/src/test/java/be/chvp/nanoledger/data/parser/TransactionParserTest.kt @@ -27,9 +27,9 @@ class TransactionParserTest { assertEquals("Note", transaction.note) assertEquals(2, transaction.postings.size) assertEquals("assets", transaction.postings[0].account) - assertEquals("€ -5.00", transaction.postings[0].amount) + assertEquals("€ -5.00", transaction.postings[0].amount?.original) assertEquals("expenses", transaction.postings[1].account) - assertEquals("€ 5.00", transaction.postings[1].amount) + assertEquals("€ 5.00", transaction.postings[1].amount?.original) } @Test @@ -54,9 +54,9 @@ class TransactionParserTest { assertEquals(null, transaction.note) assertEquals(2, transaction.postings.size) assertEquals("assets", transaction.postings[0].account) - assertEquals("€ -5.00", transaction.postings[0].amount) + assertEquals("€ -5.00", transaction.postings[0].amount?.original) assertEquals("expenses", transaction.postings[1].account) - assertEquals("€ 5.00", transaction.postings[1].amount) + assertEquals("€ 5.00", transaction.postings[1].amount?.original) } @Test @@ -83,9 +83,9 @@ class TransactionParserTest { assertEquals("Note", transactions[0].note) assertEquals(2, transactions[0].postings.size) assertEquals("assets", transactions[0].postings[0].account) - assertEquals("€ -5.00", transactions[0].postings[0].amount) + assertEquals("€ -5.00", transactions[0].postings[0].amount?.original) assertEquals("expenses", transactions[0].postings[1].account) - assertEquals("€ 5.00", transactions[0].postings[1].amount) + assertEquals("€ 5.00", transactions[0].postings[1].amount?.original) assertEquals(3, transactions[1].firstLine) assertEquals(6, transactions[1].lastLine) @@ -95,11 +95,11 @@ class TransactionParserTest { assertEquals("Note 2", transactions[1].note) assertEquals(3, transactions[1].postings.size) assertEquals("assets", transactions[1].postings[0].account) - assertEquals("€ -10.00", transactions[1].postings[0].amount) + assertEquals("€ -10.00", transactions[1].postings[0].amount?.original) assertEquals("expenses:thing", transactions[1].postings[1].account) - assertEquals("€ 6.00", transactions[1].postings[1].amount) + assertEquals("€ 6.00", transactions[1].postings[1].amount?.original) assertEquals("expenses:thing 2", transactions[1].postings[2].account) - assertEquals("€ 4.00", transactions[1].postings[2].amount) + assertEquals("€ 4.00", transactions[1].postings[2].amount?.original) } @Test @@ -146,4 +146,174 @@ class TransactionParserTest { assertEquals(1, transactions.size) assertEquals("2023-09-08=2023-09-09", transactions[0].date) } + + @Test + fun canParseAmountWithNoCurrency() { + val amountString = "1,000.00" + + val amount = extractAmount(amountString) + assertEquals("1,000.00", amount.original) + assertEquals("1,000.00", amount.quantity) + assertEquals("", amount.currency) + } + + @Test + fun canParseNegativeAmountWithNoCurrency() { + val amountString = "-1,000.00" + + val amount = extractAmount(amountString) + assertEquals("-1,000.00", amount.original) + assertEquals("-1,000.00", amount.quantity) + assertEquals("", amount.currency) + } + + @Test + fun canParseAmountWithSimpleCurrencyBefore1() { + val amountString = "€ 1,000.00" + + val amount = extractAmount(amountString) + assertEquals("€ 1,000.00", amount.original) + assertEquals("1,000.00", amount.quantity) + assertEquals("€", amount.currency) + } + + @Test + fun canParseAmountWithSimpleCurrencyBefore2() { + val amountString = "€- 1,000.00" + + val amount = extractAmount(amountString) + assertEquals("€- 1,000.00", amount.original) + assertEquals("- 1,000.00", amount.quantity) + assertEquals("€", amount.currency) + } + + @Test + fun canParseAmountWithSimpleCurrencyBefore3() { + val amountString = "EUR -1,000.00" + + val amount = extractAmount(amountString) + assertEquals("EUR -1,000.00", amount.original) + assertEquals("-1,000.00", amount.quantity) + assertEquals("EUR", amount.currency) + } + + @Test + fun canParseAmountWithSimpleCurrencyBefore4() { + val amountString = "EUR1,000.00" + + val amount = extractAmount(amountString) + assertEquals("EUR1,000.00", amount.original) + assertEquals("1,000.00", amount.quantity) + assertEquals("EUR", amount.currency) + } + + @Test + fun canParseAmountWithSimpleCurrencyAfter1() { + val amountString = "5,0.0 €" + + val amount = extractAmount(amountString) + assertEquals("5,0.0 €", amount.original) + assertEquals("5,0.0", amount.quantity) + assertEquals("€", amount.currency) + } + + @Test + fun canParseAmountWithSimpleCurrencyAfter2() { + val amountString = "5,0.0€" + + val amount = extractAmount(amountString) + assertEquals("5,0.0€", amount.original) + assertEquals("5,0.0", amount.quantity) + assertEquals("€", amount.currency) + } + + @Test + fun canParseAmountWithSimpleCurrencyAfter3() { + val amountString = "5,0.0 EUR" + + val amount = extractAmount(amountString) + assertEquals("5,0.0 EUR", amount.original) + assertEquals("5,0.0", amount.quantity) + assertEquals("EUR", amount.currency) + } + + @Test + fun canParseAmountWithSimpleCurrencyAfter4() { + val amountString = "5,0.0EUR" + + val amount = extractAmount(amountString) + assertEquals("5,0.0EUR", amount.original) + assertEquals("5,0.0", amount.quantity) + assertEquals("EUR", amount.currency) + } + + @Test + fun canParseAmountWithComplexCurrencyBefore1() { + val amountString = "\"5,0\" 5,0.0" + + val amount = extractAmount(amountString) + assertEquals("\"5,0\" 5,0.0", amount.original) + assertEquals("5,0.0", amount.quantity) + assertEquals("\"5,0\"", amount.currency) + } + + @Test + fun canParseAmountWithComplexCurrencyBefore2() { + val amountString = "\"5,0\"- 5,0.0" + + val amount = extractAmount(amountString) + assertEquals("\"5,0\"- 5,0.0", amount.original) + assertEquals("- 5,0.0", amount.quantity) + assertEquals("\"5,0\"", amount.currency) + } + + @Test + fun canParseAmountWithComplexCurrencyAfter1() { + val amountString = "100005,0.0\"a commodity with spaces, what will they think of next?\"" + + val amount = extractAmount(amountString) + assertEquals("100005,0.0\"a commodity with spaces, what will they think of next?\"", amount.original) + assertEquals("100005,0.0", amount.quantity) + assertEquals("\"a commodity with spaces, what will they think of next?\"", amount.currency) + } + + @Test + fun canParseAmountWithComplexCurrencyAfter2() { + val amountString = "- 100005,0.0 \"*&+\"" + + val amount = extractAmount(amountString) + assertEquals("- 100005,0.0 \"*&+\"", amount.original) + assertEquals("- 100005,0.0", amount.quantity) + assertEquals("\"*&+\"", amount.currency) + } + + @Test + fun canParseAmountWithComplexCurrencyAfterAndAssertion() { + val amountString = "- 100005,0.0 \"*&+\"=abc" + + val amount = extractAmount(amountString) + assertEquals("- 100005,0.0 \"*&+\"=abc", amount.original) + assertEquals("- 100005,0.0", amount.quantity) + assertEquals("\"*&+\"", amount.currency) + } + + @Test + fun canParseAmountWithComplexCurrencyAfterAndCost() { + val amountString = "- 100005,0.0 \"*&+\"@ 15 EUR" + + val amount = extractAmount(amountString) + assertEquals("- 100005,0.0 \"*&+\"@ 15 EUR", amount.original) + assertEquals("- 100005,0.0", amount.quantity) + assertEquals("\"*&+\"", amount.currency) + } + + @Test + fun canParseAmountWithSimpleCurrencyBeforeAndAssertion() { + val amountString = "€ 8.00 = € -2.00" + + val amount = extractAmount(amountString) + assertEquals("€ 8.00 = € -2.00", amount.original) + assertEquals("8.00", amount.quantity) + assertEquals("€", amount.currency) + } }