Skip to content

Commit

Permalink
Allow deleting transactions (#137)
Browse files Browse the repository at this point in the history
Deleting can be done by tapping on a transaction (which selects it).
This changes the toolbar so that a delete button becomes visible.
Selection can be undone by tapping the transaction again, pressing the
back button, or tapping the back icon in the toolbar.
  • Loading branch information
chvp committed Jun 26, 2024
1 parent ac45aee commit 81e3253
Show file tree
Hide file tree
Showing 7 changed files with 255 additions and 56 deletions.
44 changes: 42 additions & 2 deletions app/src/main/java/be/chvp/nanoledger/data/LedgerRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,46 @@ class LedgerRepository
)
}

suspend fun deleteTransaction(
fileUri: Uri,
transaction: Transaction,
onFinish: suspend () -> Unit,
onMismatch: suspend () -> Unit,
onWriteError: suspend (IOException) -> Unit,
onReadError: suspend (IOException) -> Unit,
) {
try {
val result = ArrayList<String>()
fileUri
.let { context.contentResolver.openInputStream(it) }
?.let { BufferedReader(InputStreamReader(it)) }
?.use { it.lines().forEach { result.add(it) } }

if (!result.equals(fileContents.value)) {
onMismatch()
} else {
context.contentResolver.openOutputStream(fileUri, "wt")
?.let { OutputStreamWriter(it) }
?.use {
fileContents.value!!.forEachIndexed { i, line ->
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)
}
}

suspend fun appendTo(
fileUri: Uri,
text: String,
Expand All @@ -61,14 +101,14 @@ class LedgerRepository
}

suspend fun readFrom(
fileUri: Uri?,
fileUri: Uri,
onFinish: suspend () -> Unit,
onReadError: suspend (IOException) -> Unit,
) {
try {
val result = ArrayList<String>()
fileUri
?.let { context.contentResolver.openInputStream(it) }
.let { context.contentResolver.openInputStream(it) }
?.let { BufferedReader(InputStreamReader(it)) }
?.use { it.lines().forEach { result.add(it) } }
val extracted = extractTransactions(result)
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/be/chvp/nanoledger/data/Transaction.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ data class Posting(
}

data class Transaction(
val firstLine: Int,
val lastLine: Int,
val date: String,
val status: String?,
val payee: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ fun extractTransactions(lines: List<String>): List<Transaction> {
val match = headerRegex.find(lines[i])
i += 1
if (match != null) {
val firstLine = i - 1
var lastLine = i - 1
val groups = match.groups
val date = groups[1]!!.value
val status = groups[5]?.value
Expand All @@ -27,6 +29,7 @@ fun extractTransactions(lines: List<String>): List<Transaction> {
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]))
Expand All @@ -35,7 +38,7 @@ fun extractTransactions(lines: List<String>): List<Transaction> {
}
}
}
result.add(Transaction(date, status, payee, note, postings))
result.add(Transaction(firstLine, lastLine, date, status, payee, note, postings))
}
}
return result
Expand Down
171 changes: 130 additions & 41 deletions app/src/main/java/be/chvp/nanoledger/ui/main/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,11 @@ import androidx.compose.foundation.text.KeyboardActions
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.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
Expand Down Expand Up @@ -76,15 +78,44 @@ class MainActivity : ComponentActivity() {
setContent {
val context = LocalContext.current
val searching by mainViewModel.searching.observeAsState()
val latestError by mainViewModel.latestError.observeAsState()
val errorMessage = stringResource(R.string.error_reading_file)
LaunchedEffect(latestError) {
val error = latestError?.get()
val selected by mainViewModel.selectedIndex.observeAsState()

val latestReadError by mainViewModel.latestReadError.observeAsState()
val readErrorMessage = stringResource(R.string.error_reading_file)
LaunchedEffect(latestReadError) {
val error = latestReadError?.get()
if (error != null) {
Log.e("be.chvp.nanoledger", "Exception while reading file", error)
Toast.makeText(
context,
errorMessage,
readErrorMessage,
Toast.LENGTH_LONG,
).show()
}
}

val latestWriteError by mainViewModel.latestWriteError.observeAsState()
val writeErrorMessage = stringResource(R.string.error_writing_file)
LaunchedEffect(latestWriteError) {
val error = latestWriteError?.get()
if (error != null) {
Log.e("be.chvp.nanoledger", "Exception while writing file", error)
Toast.makeText(
context,
writeErrorMessage,
Toast.LENGTH_LONG,
).show()
}
}

val latestMismatch by mainViewModel.latestMismatch.observeAsState()
val mismatchMessage = stringResource(R.string.mismatch_no_delete)
LaunchedEffect(latestMismatch) {
val error = latestMismatch?.get()
if (error != null) {
Toast.makeText(
context,
mismatchMessage,
Toast.LENGTH_LONG,
).show()
}
Expand All @@ -97,7 +128,9 @@ class MainActivity : ComponentActivity() {
NanoLedgerTheme {
Scaffold(
topBar = {
if (searching ?: false) {
if (selected != null) {
SelectionBar()
} else if (searching ?: false) {
SearchBar()
} else {
MainBar()
Expand Down Expand Up @@ -176,6 +209,7 @@ fun MainContent(
val transactions by mainViewModel.filteredTransactions.observeAsState()
val query by mainViewModel.query.observeAsState()
val isRefreshing by mainViewModel.isRefreshing.observeAsState()
val selected by mainViewModel.selectedIndex.observeAsState()
val state = rememberPullToRefreshState()
if (state.isRefreshing && !(isRefreshing ?: false)) {
LaunchedEffect(true) {
Expand All @@ -199,7 +233,26 @@ fun MainContent(
if ((transactions?.size ?: 0) > 0 || (isRefreshing ?: true)) {
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,
Expand All @@ -208,41 +261,43 @@ fun MainContent(
if (it == transactions!!.size - 1) 8.dp else 4.dp,
),
) {
val tr = transactions!![transactions!!.size - it - 1]
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),
)
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),
)
}
}
}
}
Expand Down Expand Up @@ -320,6 +375,40 @@ fun MainBar(mainViewModel: MainViewModel = viewModel()) {
)
}

@Composable
fun SelectionBar(mainViewModel: MainViewModel = viewModel()) {
val selected by mainViewModel.selectedIndex.observeAsState()
TopAppBar(
navigationIcon = {
IconButton(
onClick = {
mainViewModel.toggleSelect(selected!!)
},
modifier = Modifier.padding(start = 8.dp),
) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.stop_selection))
}
},
title = { },
actions = {
IconButton(onClick = { mainViewModel.deleteSelected() }) {
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
}
},
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primary,
titleContentColor = MaterialTheme.colorScheme.onPrimary,
actionIconContentColor = MaterialTheme.colorScheme.onPrimary,
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary,
),
)

BackHandler {
mainViewModel.toggleSelect(selected!!)
}
}

@Composable
fun SearchBar(mainViewModel: MainViewModel = viewModel()) {
val keyboardController = LocalSoftwareKeyboardController.current
Expand Down
Loading

0 comments on commit 81e3253

Please sign in to comment.