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)
+ }
}