better currency handling

This commit is contained in:
soraefir
2026-06-14 21:33:08 +02:00
parent 110ab397b8
commit a9a303478c
10 changed files with 344 additions and 82 deletions
@@ -27,6 +27,7 @@ import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.theme.ThemeUtils import net.helcel.cowspent.theme.ThemeUtils
import net.helcel.cowspent.util.BillParser import net.helcel.cowspent.util.BillParser
import net.helcel.cowspent.util.CategoryUtils import net.helcel.cowspent.util.CategoryUtils
import net.helcel.cowspent.util.SupportUtil
import java.text.ParseException import java.text.ParseException
import java.time.ZoneId import java.time.ZoneId
import java.util.Calendar import java.util.Calendar
@@ -107,7 +108,8 @@ class EditBillActivity : AppCompatActivity() {
val createIntent = Intent(this@EditBillActivity, QrCodeScannerActivity::class.java) val createIntent = Intent(this@EditBillActivity, QrCodeScannerActivity::class.java)
scanQRCodeLauncher.launch(createIntent) scanQRCodeLauncher.launch(createIntent)
}, },
onDelete = if (bill.id > 0) { { deleteBillAsked() } } else null, onDuplicate = if (!viewModel.isNewBill) { { duplicateCurrentBill() } } else null,
onDelete = if (!viewModel.isNewBill) { { deleteBillAsked() } } else null,
accessLevel = db.getProject(bill.projectId)?.myAccessLevel ?: DBProject.ACCESS_LEVEL_ADMIN accessLevel = db.getProject(bill.projectId)?.myAccessLevel ?: DBProject.ACCESS_LEVEL_ADMIN
) )
} }
@@ -191,6 +193,7 @@ class EditBillActivity : AppCompatActivity() {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
viewModel.currencies = currencies viewModel.currencies = currencies
viewModel.mainCurrencyName = project?.currencyName ?: "" viewModel.mainCurrencyName = project?.currencyName ?: ""
viewModel.isNewBill = (bill.id == 0L)
viewModel.initFromBill(bill, members, customSplits) viewModel.initFromBill(bill, members, customSplits)
} }
} }
@@ -225,6 +228,19 @@ class EditBillActivity : AppCompatActivity() {
} }
} }
private fun duplicateCurrentBill() {
bill = DBBill(
0, 0, bill.projectId, viewModel.payerId, viewModel.amountAsDouble,
System.currentTimeMillis() / 1000, viewModel.what, DBBill.STATE_ADDED,
viewModel.repeat, bill.paymentMode, viewModel.categoryRemoteId,
viewModel.getFinalComment(), viewModel.paymentModeRemoteId
)
viewModel.timestamp = bill.timestamp
viewModel.isNewBill = true
showToast(this, "Duplicating bill...")
}
private fun onBack() { private fun onBack() {
if (!valuesHaveChanged()) { if (!valuesHaveChanged()) {
finish() finish()
@@ -298,9 +314,9 @@ class EditBillActivity : AppCompatActivity() {
return !(bill.what == viewModel.what && return !(bill.what == viewModel.what &&
bill.timestamp == viewModel.timestamp && bill.timestamp == viewModel.timestamp &&
bill.amount == viewModel.amountAsDouble && bill.amount == viewModel.getFinalAmount() &&
bill.payerId == viewModel.payerId && bill.payerId == viewModel.payerId &&
bill.comment == viewModel.comment && bill.comment == viewModel.getFinalComment() &&
bill.repeat == viewModel.repeat && bill.repeat == viewModel.repeat &&
bill.categoryRemoteId == viewModel.categoryRemoteId && bill.categoryRemoteId == viewModel.categoryRemoteId &&
bill.paymentModeRemoteId == viewModel.paymentModeRemoteId && bill.paymentModeRemoteId == viewModel.paymentModeRemoteId &&
@@ -312,13 +328,17 @@ class EditBillActivity : AppCompatActivity() {
val isCustomSplit = viewModel.isCustomSplit val isCustomSplit = viewModel.isCustomSplit
if (isCustomSplit) { if (isCustomSplit) {
val splits = viewModel.owersCustomSplit.filter { (id, amount) -> val splits: Map<Long, Double> = viewModel.owersCustomSplit.filter { (id, amountStr) ->
viewModel.owersSelection[id] == true && (amount.replace(',', '.').toDoubleOrNull() viewModel.owersSelection[id] == true && (amountStr.replace(',', '.').toDoubleOrNull()
?: 0.0) > 0 ?: 0.0) > 0
}.mapValues { it.value.replace(',', '.').toDoubleOrNull() ?: 0.0 } }.mapValues {
val uiAmount = it.value.replace(',', '.').toDoubleOrNull() ?: 0.0
SupportUtil.round2(uiAmount / viewModel.selectedCurrencyRate)
}
if (splits.isEmpty()) return@withContext 0L if (splits.isEmpty()) return@withContext 0L
val finalComment = viewModel.getFinalComment()
val splitEntries = splits.entries.toList() val splitEntries = splits.entries.toList()
// Pool of existing bills in this group that we can potentially reuse // Pool of existing bills in this group that we can potentially reuse
@@ -360,7 +380,7 @@ class EditBillActivity : AppCompatActivity() {
existingBill.paymentMode, existingBill.paymentMode,
viewModel.paymentModeRemoteId, viewModel.paymentModeRemoteId,
viewModel.categoryRemoteId, viewModel.categoryRemoteId,
viewModel.comment finalComment
) )
if (firstSavedId == 0L) firstSavedId = billToUseId if (firstSavedId == 0L) firstSavedId = billToUseId
} else { } else {
@@ -368,7 +388,7 @@ class EditBillActivity : AppCompatActivity() {
val newBill = DBBill( val newBill = DBBill(
0, 0, bill.projectId, viewModel.payerId, amount, 0, 0, bill.projectId, viewModel.payerId, amount,
viewModel.timestamp, viewModel.what, DBBill.STATE_ADDED, viewModel.repeat, viewModel.timestamp, viewModel.what, DBBill.STATE_ADDED, viewModel.repeat,
bill.paymentMode, viewModel.categoryRemoteId, viewModel.comment, viewModel.paymentModeRemoteId bill.paymentMode, viewModel.categoryRemoteId, finalComment, viewModel.paymentModeRemoteId
) )
newBill.billOwers = listOf(DBBillOwer(0, 0, memberId)) newBill.billOwers = listOf(DBBillOwer(0, 0, memberId))
val newId = db.addBill(newBill) val newId = db.addBill(newBill)
@@ -398,7 +418,8 @@ class EditBillActivity : AppCompatActivity() {
return@withContext firstSavedId return@withContext firstSavedId
} else { } else {
val newAmount = viewModel.amountAsDouble val newAmount = viewModel.getFinalAmount()
val finalComment = viewModel.getFinalComment()
val newOwersIds = viewModel.getOwersIds() val newOwersIds = viewModel.getOwersIds()
if (bill.id != 0L) { if (bill.id != 0L) {
@@ -414,7 +435,7 @@ class EditBillActivity : AppCompatActivity() {
bill.paymentMode, bill.paymentMode,
viewModel.paymentModeRemoteId, viewModel.paymentModeRemoteId,
viewModel.categoryRemoteId, viewModel.categoryRemoteId,
viewModel.comment finalComment
) )
if (groupedBillIds != null) { if (groupedBillIds != null) {
for (id in groupedBillIds) { for (id in groupedBillIds) {
@@ -431,7 +452,7 @@ class EditBillActivity : AppCompatActivity() {
val newBill = DBBill( val newBill = DBBill(
0, 0, bill.projectId, viewModel.payerId, newAmount, 0, 0, bill.projectId, viewModel.payerId, newAmount,
viewModel.timestamp, viewModel.what, DBBill.STATE_ADDED, viewModel.repeat, viewModel.timestamp, viewModel.what, DBBill.STATE_ADDED, viewModel.repeat,
bill.paymentMode, viewModel.categoryRemoteId, viewModel.comment, viewModel.paymentModeRemoteId bill.paymentMode, viewModel.categoryRemoteId, finalComment, viewModel.paymentModeRemoteId
) )
newOwersIds.forEach { newBill.billOwers += DBBillOwer(0, 0, it) } newOwersIds.forEach { newBill.billOwers += DBBillOwer(0, 0, it) }
val newBillId = db.addBill(newBill) val newBillId = db.addBill(newBill)
@@ -45,6 +45,7 @@ fun EditBillScreen(
onDateClick: () -> Unit, onDateClick: () -> Unit,
onTimeClick: () -> Unit, onTimeClick: () -> Unit,
onScan: () -> Unit, onScan: () -> Unit,
onDuplicate: (() -> Unit)? = null,
onDelete: (() -> Unit)? = null, onDelete: (() -> Unit)? = null,
accessLevel: Int = DBProject.ACCESS_LEVEL_ADMIN accessLevel: Int = DBProject.ACCESS_LEVEL_ADMIN
) { ) {
@@ -59,7 +60,7 @@ fun EditBillScreen(
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text(stringResource(if (viewModel.what.isEmpty()) R.string.simple_new_bill else R.string.simple_edit_bill)) }, title = { Text(stringResource(if (viewModel.isNewBill) R.string.simple_new_bill else R.string.simple_edit_bill)) },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
@@ -70,6 +71,11 @@ fun EditBillScreen(
IconButton(onClick = onScan) { IconButton(onClick = onScan) {
Icon(Icons.Default.QrCodeScanner, contentDescription = null) Icon(Icons.Default.QrCodeScanner, contentDescription = null)
} }
if (onDuplicate != null) {
IconButton(onClick = onDuplicate) {
Icon(Icons.Default.ContentCopy, contentDescription = "Duplicate")
}
}
if (onDelete != null) { if (onDelete != null) {
IconButton(onClick = onDelete) { IconButton(onClick = onDelete) {
Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.action_delete)) Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.action_delete))
@@ -160,7 +166,6 @@ fun BillBasicInfoSection(
val context = LocalContext.current val context = LocalContext.current
val currencyDialogTitle = val currencyDialogTitle =
stringResource(R.string.currency_dialog_title, viewModel.mainCurrencyName) stringResource(R.string.currency_dialog_title, viewModel.mainCurrencyName)
val noCurrencyError = stringResource(R.string.no_currency_error)
OutlinedTextField( OutlinedTextField(
value = viewModel.what, value = viewModel.what,
@@ -182,25 +187,37 @@ fun BillBasicInfoSection(
enabled = canEdit, enabled = canEdit,
placeholder = { Text("0") }, placeholder = { Text("0") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.Default.AttachMoney, contentDescription = null) }, leadingIcon = {
val currencyToShow = viewModel.selectedCurrencyName.ifEmpty { viewModel.mainCurrencyName }
TextIconDisplay(
textIcon = TextIcon.Symbol(currencyToShow),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
)
},
trailingIcon = { trailingIcon = {
IconButton( IconButton(
enabled = canEdit, enabled = canEdit,
onClick = { onClick = {
if (viewModel.currencies.isNotEmpty()) { val mainLabel = viewModel.mainCurrencyName.ifEmpty { "Main" }
viewModel.showDialog( val options = listOf("$mainLabel | Base") + viewModel.currencies.map {
title = currencyDialogTitle, "${it.name} | 1 $mainLabel = ${it.exchangeRate} ${it.name}"
items = viewModel.currencies.map { "${it.name} (${it.exchangeRate})" },
onItemSelected = { index ->
viewModel.convertCurrency(viewModel.currencies[index])
}
)
} else {
showToast(context, noCurrencyError)
} }
viewModel.showDialog(
title = currencyDialogTitle,
items = options,
onItemSelected = { index ->
if (index == 0) {
viewModel.selectedCurrencyName = ""
viewModel.selectedCurrencyRate = 1.0
viewModel.updateSplits()
} else {
viewModel.convertCurrency(viewModel.currencies[index - 1])
}
}
)
} }
) { ) {
Icon(Icons.Default.CurrencyExchange, contentDescription = null) Icon(Icons.Default.SwapHoriz, contentDescription = null)
} }
}, },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
@@ -587,6 +604,7 @@ fun EditBillScreenPreview() {
viewModel = EditBillViewModel().apply { viewModel = EditBillViewModel().apply {
what = "Pizza" what = "Pizza"
amount = "12.50" amount = "12.50"
mainCurrencyName = "EUR"
members = listOf( members = listOf(
DBMember(1, 0, 0, "Alice", true, 1.0, 0, null, null, null, null, null), DBMember(1, 0, 0, "Alice", true, 1.0, 0, null, null, null, null, null),
DBMember(2, 0, 0, "Bob", true, 1.0, 0, null, null, null, null, null) DBMember(2, 0, 0, "Bob", true, 1.0, 0, null, null, null, null, null)
@@ -25,9 +25,12 @@ class EditBillViewModel : ViewModel() {
var repeat by mutableStateOf(DBBill.NON_REPEATED) var repeat by mutableStateOf(DBBill.NON_REPEATED)
var paymentModeRemoteId by mutableIntStateOf(0) var paymentModeRemoteId by mutableIntStateOf(0)
var categoryRemoteId by mutableIntStateOf(0) var categoryRemoteId by mutableIntStateOf(0)
var isNewBill by mutableStateOf(false)
var currencies by mutableStateOf<List<DBCurrency>>(emptyList()) var currencies by mutableStateOf<List<DBCurrency>>(emptyList())
var mainCurrencyName by mutableStateOf("") var mainCurrencyName by mutableStateOf("")
var selectedCurrencyName by mutableStateOf("")
var selectedCurrencyRate by mutableStateOf(1.0)
var members by mutableStateOf<List<DBMember>>(emptyList()) var members by mutableStateOf<List<DBMember>>(emptyList())
var owersSelection = mutableStateMapOf<Long, Boolean>() var owersSelection = mutableStateMapOf<Long, Boolean>()
@@ -128,46 +131,77 @@ class EditBillViewModel : ViewModel() {
} }
fun convertCurrency(currency: DBCurrency) { fun convertCurrency(currency: DBCurrency) {
val originalAmountStr = amount if (amountAsDouble == 0.0) return
val originalAmount = amountAsDouble
if (originalAmount == 0.0) return
val newAmount = originalAmount / currency.exchangeRate
amount = SupportUtil.round2(newAmount).toString()
val currencyLabel = currency.name ?: ""
val conversionNote = "($originalAmountStr $currencyLabel)"
if (!comment.contains(conversionNote)) {
if (comment.isNotEmpty() && !comment.endsWith(" ")) {
comment += " "
}
comment += conversionNote
}
if (isCustomSplit) {
owersCustomSplit.keys.toList().forEach { id ->
val value = owersCustomSplit[id] ?: ""
val partAmount = value.replace(',', '.').toDoubleOrNull() ?: 0.0
if (partAmount != 0.0) {
val newPartAmount = partAmount / currency.exchangeRate
owersCustomSplit[id] = SupportUtil.round2(newPartAmount).toString()
}
}
}
selectedCurrencyName = currency.name ?: ""
selectedCurrencyRate = currency.exchangeRate
// We don't change 'amount' here anymore as per request.
// It stays as the original amount.
updateSplits() updateSplits()
} }
fun getFinalAmount(): Double {
return SupportUtil.round2(amountAsDouble / selectedCurrencyRate)
}
fun getFinalComment(): String {
var baseComment = comment
val regex = "\\s*#[^:]+:[\\d.,]+@[\\d.,]+".toRegex()
baseComment = baseComment.replace(regex, "").trim()
return if (selectedCurrencyName.isNotEmpty() && selectedCurrencyRate != 1.0) {
val metadata = "#$selectedCurrencyName:$amount@$selectedCurrencyRate"
if (baseComment.isEmpty()) metadata else "$baseComment $metadata"
} else {
baseComment
}
}
fun initFromBill(bill: DBBill, members: List<DBMember>, customSplits: Map<Long, Double>? = null) { fun initFromBill(bill: DBBill, members: List<DBMember>, customSplits: Map<Long, Double>? = null) {
this.members = members this.members = members
what = bill.what what = bill.what
amount = if (bill.amount == 0.0) "" else bill.amount.toString()
comment = bill.comment ?: ""
timestamp = bill.timestamp timestamp = bill.timestamp
payerId = bill.payerId payerId = bill.payerId
repeat = bill.repeat ?: DBBill.NON_REPEATED repeat = bill.repeat ?: DBBill.NON_REPEATED
paymentModeRemoteId = bill.paymentModeRemoteId paymentModeRemoteId = bill.paymentModeRemoteId
categoryRemoteId = bill.categoryRemoteId categoryRemoteId = bill.categoryRemoteId
val rawComment = bill.comment ?: ""
// Try to parse existing conversion metadata #CURR:ORIG@RATE
// Using [^:]+ for currency name to support symbols and names with spaces
val regex = "#([^:]+):([\\d.,]+)@([\\d.,]+)".toRegex()
val match = regex.find(rawComment)
if (match != null) {
val (currName, origAmount, rate) = match.destructured
selectedCurrencyName = currName
selectedCurrencyRate = rate.replace(',', '.').toDoubleOrNull() ?: 1.0
amount = origAmount
comment = rawComment.replace(match.value, "").trim()
// Check if latest rate is different
val latestCurrency = currencies.find { it.name == selectedCurrencyName }
if (latestCurrency != null && latestCurrency.exchangeRate != selectedCurrencyRate) {
showDialog(
title = "Update Exchange Rate?",
message = "The exchange rate for $selectedCurrencyName has changed from $selectedCurrencyRate to ${latestCurrency.exchangeRate}. Do you want to update the conversion for the saved total?",
positiveText = "Update Rate",
negativeText = "Keep Old",
onConfirm = {
selectedCurrencyRate = latestCurrency.exchangeRate
updateSplits()
}
)
}
} else {
selectedCurrencyName = ""
selectedCurrencyRate = 1.0
amount = if (bill.amount == 0.0) "" else bill.amount.toString()
comment = rawComment
}
owersSelection.clear() owersSelection.clear()
owersCustomSplit.clear() owersCustomSplit.clear()
@@ -178,13 +212,23 @@ class EditBillViewModel : ViewModel() {
val selected = customSplits.containsKey(member.id) val selected = customSplits.containsKey(member.id)
owersSelection[member.id] = selected owersSelection[member.id] = selected
if (selected) { if (selected) {
owersCustomSplit[member.id] = SupportUtil.round2(customSplits[member.id]!!).toString() // If we have metadata, the custom splits from DB are also converted.
// We should show them as "Original" if possible?
// Actually, if we use metadata, we should probably store original splits too,
// but for now let's just reverse the rate for display.
val dbPart = customSplits[member.id]!!
val uiPart = if (selectedCurrencyRate != 1.0) dbPart * selectedCurrencyRate else dbPart
owersCustomSplit[member.id] = SupportUtil.round2(uiPart).toString()
} }
} }
} else { } else {
// Even split logic
val billOwerIds = bill.billOwersIds val billOwerIds = bill.billOwersIds
val selectedCount = billOwerIds.size val selectedCount = billOwerIds.size
val evenSplit = if (selectedCount > 0) bill.amount / selectedCount else 0.0
// Use UI amount for even split calculation
val uiAmount = amountAsDouble
val evenSplit = if (selectedCount > 0) uiAmount / selectedCount else 0.0
val evenSplitStr = if (evenSplit == 0.0) "" else SupportUtil.round2(evenSplit).toString() val evenSplitStr = if (evenSplit == 0.0) "" else SupportUtil.round2(evenSplit).toString()
for (member in members) { for (member in members) {
@@ -15,6 +15,7 @@ import net.helcel.cowspent.R
import net.helcel.cowspent.android.helper.showToast import net.helcel.cowspent.android.helper.showToast
import net.helcel.cowspent.model.DBBill import net.helcel.cowspent.model.DBBill
import net.helcel.cowspent.model.DBCurrency import net.helcel.cowspent.model.DBCurrency
import net.helcel.cowspent.model.ProjectType
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.theme.ThemeUtils import net.helcel.cowspent.theme.ThemeUtils
import net.helcel.cowspent.util.ICallback import net.helcel.cowspent.util.ICallback
@@ -86,11 +87,17 @@ class ManageCurrenciesActivity : AppCompatActivity() {
val project = db!!.getProject(selectedProjectID) val project = db!!.getProject(selectedProjectID)
if (project != null) { if (project != null) {
db!!.syncIfRemote(project) db!!.syncIfRemote(project)
withContext(Dispatchers.Main) { if (project.type == ProjectType.COSPEND) {
if (!db!!.cowspentServerSyncHelper withContext(Dispatchers.Main) {
.editRemoteProject(selectedProjectID, project.name, null, null, newMainCurrencyName, editMainCurrencyCallBack) if (!db!!.cowspentServerSyncHelper
) { .editRemoteProject(selectedProjectID, project.name, null, null, newMainCurrencyName, editMainCurrencyCallBack)
showToast(this@ManageCurrenciesActivity, getString(R.string.remote_project_operation_no_network), Toast.LENGTH_LONG) ) {
showToast(this@ManageCurrenciesActivity, getString(R.string.remote_project_operation_no_network), Toast.LENGTH_LONG)
}
}
} else {
withContext(Dispatchers.Main) {
showToast(this@ManageCurrenciesActivity, getString(R.string.currency_saved_success), Toast.LENGTH_LONG)
} }
} }
} }
@@ -4,6 +4,7 @@ import android.annotation.SuppressLint
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.* import androidx.compose.material.*
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
@@ -12,6 +13,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import net.helcel.cowspent.R import net.helcel.cowspent.R
@@ -93,19 +95,42 @@ fun ManageCurrenciesScreen(
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
Text(stringResource(R.string.add_currency_title), style = MaterialTheme.typography.h6) Text(stringResource(R.string.add_currency_title), style = MaterialTheme.typography.h6)
// Visual relationship indicator
Surface(
color = MaterialTheme.colors.primary.copy(alpha = 0.05f),
shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Text("1 ", style = MaterialTheme.typography.body1)
Text(viewModel.mainCurrencyName.ifEmpty { "Base" }, fontWeight = FontWeight.Bold)
Text(" = ", style = MaterialTheme.typography.h6)
Text(viewModel.newCurrencyRate.ifEmpty { "0.0" }, fontWeight = FontWeight.Bold, color = MaterialTheme.colors.primary)
Text(" ")
Text(viewModel.newCurrencyName.ifEmpty { "Currency" }, fontWeight = FontWeight.Bold)
}
}
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField( OutlinedTextField(
value = viewModel.newCurrencyName, value = viewModel.newCurrencyName,
onValueChange = { viewModel.newCurrencyName = it }, onValueChange = { viewModel.newCurrencyName = it },
label = { Text(stringResource(R.string.currency_edit_name)) }, label = { Text(stringResource(R.string.currency_edit_name)) },
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f),
placeholder = { Text("e.g. USD") }
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
OutlinedTextField( OutlinedTextField(
value = viewModel.newCurrencyRate, value = viewModel.newCurrencyRate,
onValueChange = { viewModel.newCurrencyRate = it }, onValueChange = { viewModel.newCurrencyRate = it },
label = { Text(stringResource(R.string.currency_rate)) }, label = { Text(stringResource(R.string.currency_rate)) },
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f),
placeholder = { Text("1.0") }
) )
} }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
@@ -121,7 +146,7 @@ fun ManageCurrenciesScreen(
LazyColumn(modifier = Modifier.weight(1f)) { LazyColumn(modifier = Modifier.weight(1f)) {
items(viewModel.currencies) { currency -> items(viewModel.currencies) { currency ->
CurrencyRow(currency, onDelete = { onDelete(currency) }) CurrencyRow(currency, viewModel.mainCurrencyName, onDelete = { onDelete(currency) })
} }
} }
} }
@@ -129,15 +154,47 @@ fun ManageCurrenciesScreen(
} }
@Composable @Composable
fun CurrencyRow(currency: DBCurrency, onDelete: () -> Unit) { fun CurrencyRow(currency: DBCurrency, mainCurrencyName: String, onDelete: () -> Unit) {
Row( Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Column(modifier = Modifier.weight(1f)) { // Table-like layout
Text(currency.name ?: "", style = MaterialTheme.typography.subtitle1) Row(
Text("Rate: ${currency.exchangeRate}", style = MaterialTheme.typography.caption) modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "1 ",
style = MaterialTheme.typography.body1,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
)
Text(
text = mainCurrencyName,
style = MaterialTheme.typography.body1,
fontWeight = FontWeight.Bold
)
Text(
text = " = ",
style = MaterialTheme.typography.h6,
color = MaterialTheme.colors.primary,
modifier = Modifier.padding(horizontal = 8.dp)
)
Column {
Text(
text = currency.exchangeRate.toString(),
style = MaterialTheme.typography.body1,
fontWeight = FontWeight.ExtraBold
)
Text(
text = currency.name ?: "",
style = MaterialTheme.typography.caption,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
)
}
} }
IconButton(onClick = onDelete) { IconButton(onClick = onDelete) {
Icon(Icons.Default.Delete, contentDescription = null, tint = MaterialTheme.colors.error) Icon(Icons.Default.Delete, contentDescription = null, tint = MaterialTheme.colors.error)
} }
@@ -151,6 +208,7 @@ fun CurrencyRowPreview() {
MaterialTheme { MaterialTheme {
CurrencyRow( CurrencyRow(
currency = DBCurrency(1, 0, 0, "USD", 1.0, 0), currency = DBCurrency(1, 0, 0, "USD", 1.0, 0),
mainCurrencyName = "EUR",
onDelete = {} onDelete = {}
) )
} }
@@ -11,6 +11,7 @@ import androidx.compose.material.*
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale import androidx.compose.ui.draw.scale
@@ -26,6 +27,8 @@ import net.helcel.cowspent.R
import net.helcel.cowspent.android.helper.UserAvatar import net.helcel.cowspent.android.helper.UserAvatar
import net.helcel.cowspent.android.helper.formatBalance import net.helcel.cowspent.android.helper.formatBalance
import net.helcel.cowspent.android.helper.lazyVerticalScrollbar import net.helcel.cowspent.android.helper.lazyVerticalScrollbar
import net.helcel.cowspent.android.helper.TextIcon
import net.helcel.cowspent.android.helper.TextIconDisplay
import net.helcel.cowspent.model.DBMember import net.helcel.cowspent.model.DBMember
import net.helcel.cowspent.model.DBProject import net.helcel.cowspent.model.DBProject
import net.helcel.cowspent.model.ProjectType import net.helcel.cowspent.model.ProjectType
@@ -38,6 +41,7 @@ fun Drawer(
selectedProjectId: Long, selectedProjectId: Long,
selectedMemberId: Long?, selectedMemberId: Long?,
lastSyncText: String, lastSyncText: String,
mainCurrency: String? = null,
showArchived: Boolean = false, showArchived: Boolean = false,
onProjectClick: (Long) -> Unit, onProjectClick: (Long) -> Unit,
onProjectOptionsClick: (Long) -> Unit, onProjectOptionsClick: (Long) -> Unit,
@@ -96,22 +100,37 @@ fun Drawer(
// Members Section // Members Section
val membersState = rememberLazyListState() val membersState = rememberLazyListState()
val sortedMembers = remember(members, memberBalances) {
members.sortedWith(compareBy<DBMember> {
val balance = memberBalances[it.id] ?: 0.0
when {
balance > 0.01 -> 0
balance < -0.01 -> 1
else -> 2
}
}.thenBy {
val balance = memberBalances[it.id] ?: 0.0
if (balance > 0.01) -balance else balance
}.thenBy { it.name })
}
LazyColumn( LazyColumn(
state = membersState, state = membersState,
modifier = Modifier modifier = Modifier
.heightIn(max = maxBottomHeight) .heightIn(max = maxBottomHeight)
.lazyVerticalScrollbar(membersState) .lazyVerticalScrollbar(membersState)
) { ) {
if (members.isNotEmpty()) { if (sortedMembers.isNotEmpty()) {
item { item {
DrawerItem( DrawerItem(
icon = Icons.Default.Receipt, icon = Icons.Default.Receipt,
text = stringResource(R.string.label_all_bills), text = stringResource(R.string.label_all_bills),
secondaryText = mainCurrency,
selected = selectedMemberId == null, selected = selectedMemberId == null,
onClick = { onMemberClick(null) } onClick = { onMemberClick(null) }
) )
} }
items(members) { member -> items(sortedMembers) { member ->
val balance = memberBalances[member.id] ?: 0.0 val balance = memberBalances[member.id] ?: 0.0
MemberDrawerItem( MemberDrawerItem(
member = member, member = member,
@@ -241,6 +260,7 @@ fun DrawerItem(
member: DBMember? = null, member: DBMember? = null,
text: String? = null, text: String? = null,
balanceText: String? = null, balanceText: String? = null,
secondaryText: String? = null,
balanceColor: Color = Color.Unspecified, balanceColor: Color = Color.Unspecified,
selected: Boolean = false, selected: Boolean = false,
alpha: Float = 1f, alpha: Float = 1f,
@@ -294,6 +314,17 @@ fun DrawerItem(
modifier = Modifier.padding(end = 8.dp) modifier = Modifier.padding(end = 8.dp)
) )
} }
if (secondaryText != null) {
Box(modifier = Modifier
.padding(end = 8.dp)
) {
TextIconDisplay(
textIcon = TextIcon.Symbol(secondaryText),
tint = contentColor.copy(alpha = 0.6f)
)
}
}
if (onSecondaryClick != null) { if (onSecondaryClick != null) {
IconButton(onClick = onSecondaryClick) { IconButton(onClick = onSecondaryClick) {
@@ -340,6 +371,7 @@ fun DrawerPreview() {
selectedProjectId = 1, selectedProjectId = 1,
selectedMemberId = null, selectedMemberId = null,
lastSyncText = "Last sync: 5 mins ago", lastSyncText = "Last sync: 5 mins ago",
mainCurrency = "EUR",
onProjectClick = {}, onProjectClick = {},
onProjectOptionsClick = {}, onProjectOptionsClick = {},
onMemberClick = {}, onMemberClick = {},
@@ -11,6 +11,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -128,10 +129,32 @@ fun AlertDialog(
) )
Spacer(Modifier.width(16.dp)) Spacer(Modifier.width(16.dp))
} }
Text(
text = item.toString(), val text = item.toString()
style = MaterialTheme.typography.body1 if (text.contains("|")) {
) val parts = text.split("|")
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = parts[0].trim(),
style = MaterialTheme.typography.body1,
fontWeight = FontWeight.Bold
)
Text(
text = parts[1].trim(),
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
)
}
} else {
Text(
text = text,
style = MaterialTheme.typography.body1
)
}
} }
} }
} }
@@ -234,10 +257,32 @@ fun AlertDialogContent(
) )
Spacer(Modifier.width(16.dp)) Spacer(Modifier.width(16.dp))
} }
Text(
text = item.toString(), val text = item.toString()
style = MaterialTheme.typography.body1 if (text.contains("|")) {
) val parts = text.split("|")
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = parts[0].trim(),
style = MaterialTheme.typography.body1,
fontWeight = FontWeight.Bold
)
Text(
text = parts[1].trim(),
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
)
}
} else {
Text(
text = text,
style = MaterialTheme.typography.body1
)
}
} }
} }
} }
@@ -0,0 +1,33 @@
package net.helcel.cowspent.android.helper
import androidx.compose.material.LocalContentColor
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
sealed class TextIcon {
data class Symbol(val text: String) : TextIcon()
}
/**
* A helper Composable to display a [TextIcon].
*/
@Composable
fun TextIconDisplay(
textIcon: TextIcon,
modifier: Modifier = Modifier,
tint: Color = LocalContentColor.current
) {
when (textIcon) {
is TextIcon.Symbol -> Text(
text = textIcon.text,
modifier = modifier,
color = tint,
style = MaterialTheme.typography.body1,
fontWeight = FontWeight.Bold
)
}
}
@@ -444,11 +444,11 @@ class BillsListViewActivity :
fun onManageCurrenciesClick(projectId: Long) { fun onManageCurrenciesClick(projectId: Long) {
lifecycleScope.launch { lifecycleScope.launch {
val proj = withContext(Dispatchers.IO) { db.getProject(projectId) } val proj = withContext(Dispatchers.IO) { db.getProject(projectId) }
if (proj != null && proj.type == ProjectType.COSPEND) { if (proj != null) {
startActivity(Intent(applicationContext, ManageCurrenciesActivity::class.java).apply { startActivity(Intent(applicationContext, ManageCurrenciesActivity::class.java).apply {
putExtra(ManageCurrenciesActivity.EXTRA_PROJECT_ID, projectId) putExtra(ManageCurrenciesActivity.EXTRA_PROJECT_ID, projectId)
}) })
} else showToast(this@BillsListViewActivity, getString(R.string.currency_management_unavailable)) }
} }
} }
@@ -553,7 +553,11 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
} }
fun getCurrencies(projectId: Long): List<DBCurrency> { fun getCurrencies(projectId: Long): List<DBCurrency> {
return getCurrenciesCustom("$key_projectid = ?", arrayOf(projectId.toString()), null) return getCurrenciesCustom(
"$key_projectid = ? AND $key_state != ?",
arrayOf(projectId.toString(), DBBill.STATE_DELETED.toString()),
null
)
} }
@WorkerThread @WorkerThread