From a9a303478c350544016d12343dd6ca5ab9d3b934 Mon Sep 17 00:00:00 2001 From: soraefir Date: Sun, 14 Jun 2026 21:33:08 +0200 Subject: [PATCH] better currency handling --- .../android/bill_edit/EditBillActivity.kt | 45 ++++++-- .../android/bill_edit/EditBillScreen.kt | 46 +++++--- .../android/bill_edit/EditBillViewModel.kt | 104 +++++++++++++----- .../currencies/ManageCurrenciesActivity.kt | 17 ++- .../currencies/ManageCurrenciesScreen.kt | 74 +++++++++++-- .../helcel/cowspent/android/drawer/Drawer.kt | 36 +++++- .../cowspent/android/helper/AlertDialog.kt | 61 ++++++++-- .../cowspent/android/helper/IconUtils.kt | 33 ++++++ .../android/main/BillsListViewActivity.kt | 4 +- .../persistence/CowspentSQLiteOpenHelper.kt | 6 +- 10 files changed, 344 insertions(+), 82 deletions(-) create mode 100644 app/src/main/java/net/helcel/cowspent/android/helper/IconUtils.kt diff --git a/app/src/main/java/net/helcel/cowspent/android/bill_edit/EditBillActivity.kt b/app/src/main/java/net/helcel/cowspent/android/bill_edit/EditBillActivity.kt index 8f3898e..611dd12 100644 --- a/app/src/main/java/net/helcel/cowspent/android/bill_edit/EditBillActivity.kt +++ b/app/src/main/java/net/helcel/cowspent/android/bill_edit/EditBillActivity.kt @@ -27,6 +27,7 @@ import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper import net.helcel.cowspent.theme.ThemeUtils import net.helcel.cowspent.util.BillParser import net.helcel.cowspent.util.CategoryUtils +import net.helcel.cowspent.util.SupportUtil import java.text.ParseException import java.time.ZoneId import java.util.Calendar @@ -107,7 +108,8 @@ class EditBillActivity : AppCompatActivity() { val createIntent = Intent(this@EditBillActivity, QrCodeScannerActivity::class.java) 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 ) } @@ -191,6 +193,7 @@ class EditBillActivity : AppCompatActivity() { withContext(Dispatchers.Main) { viewModel.currencies = currencies viewModel.mainCurrencyName = project?.currencyName ?: "" + viewModel.isNewBill = (bill.id == 0L) 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() { if (!valuesHaveChanged()) { finish() @@ -298,9 +314,9 @@ class EditBillActivity : AppCompatActivity() { return !(bill.what == viewModel.what && bill.timestamp == viewModel.timestamp && - bill.amount == viewModel.amountAsDouble && + bill.amount == viewModel.getFinalAmount() && bill.payerId == viewModel.payerId && - bill.comment == viewModel.comment && + bill.comment == viewModel.getFinalComment() && bill.repeat == viewModel.repeat && bill.categoryRemoteId == viewModel.categoryRemoteId && bill.paymentModeRemoteId == viewModel.paymentModeRemoteId && @@ -312,13 +328,17 @@ class EditBillActivity : AppCompatActivity() { val isCustomSplit = viewModel.isCustomSplit if (isCustomSplit) { - val splits = viewModel.owersCustomSplit.filter { (id, amount) -> - viewModel.owersSelection[id] == true && (amount.replace(',', '.').toDoubleOrNull() + val splits: Map = viewModel.owersCustomSplit.filter { (id, amountStr) -> + viewModel.owersSelection[id] == true && (amountStr.replace(',', '.').toDoubleOrNull() ?: 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 - + + val finalComment = viewModel.getFinalComment() val splitEntries = splits.entries.toList() // Pool of existing bills in this group that we can potentially reuse @@ -360,7 +380,7 @@ class EditBillActivity : AppCompatActivity() { existingBill.paymentMode, viewModel.paymentModeRemoteId, viewModel.categoryRemoteId, - viewModel.comment + finalComment ) if (firstSavedId == 0L) firstSavedId = billToUseId } else { @@ -368,7 +388,7 @@ class EditBillActivity : AppCompatActivity() { val newBill = DBBill( 0, 0, bill.projectId, viewModel.payerId, amount, 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)) val newId = db.addBill(newBill) @@ -398,7 +418,8 @@ class EditBillActivity : AppCompatActivity() { return@withContext firstSavedId } else { - val newAmount = viewModel.amountAsDouble + val newAmount = viewModel.getFinalAmount() + val finalComment = viewModel.getFinalComment() val newOwersIds = viewModel.getOwersIds() if (bill.id != 0L) { @@ -414,7 +435,7 @@ class EditBillActivity : AppCompatActivity() { bill.paymentMode, viewModel.paymentModeRemoteId, viewModel.categoryRemoteId, - viewModel.comment + finalComment ) if (groupedBillIds != null) { for (id in groupedBillIds) { @@ -431,7 +452,7 @@ class EditBillActivity : AppCompatActivity() { val newBill = DBBill( 0, 0, bill.projectId, viewModel.payerId, newAmount, 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) } val newBillId = db.addBill(newBill) diff --git a/app/src/main/java/net/helcel/cowspent/android/bill_edit/EditBillScreen.kt b/app/src/main/java/net/helcel/cowspent/android/bill_edit/EditBillScreen.kt index 242356c..212e41f 100644 --- a/app/src/main/java/net/helcel/cowspent/android/bill_edit/EditBillScreen.kt +++ b/app/src/main/java/net/helcel/cowspent/android/bill_edit/EditBillScreen.kt @@ -45,6 +45,7 @@ fun EditBillScreen( onDateClick: () -> Unit, onTimeClick: () -> Unit, onScan: () -> Unit, + onDuplicate: (() -> Unit)? = null, onDelete: (() -> Unit)? = null, accessLevel: Int = DBProject.ACCESS_LEVEL_ADMIN ) { @@ -59,7 +60,7 @@ fun EditBillScreen( Scaffold( topBar = { 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 = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) @@ -70,6 +71,11 @@ fun EditBillScreen( IconButton(onClick = onScan) { Icon(Icons.Default.QrCodeScanner, contentDescription = null) } + if (onDuplicate != null) { + IconButton(onClick = onDuplicate) { + Icon(Icons.Default.ContentCopy, contentDescription = "Duplicate") + } + } if (onDelete != null) { IconButton(onClick = onDelete) { Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.action_delete)) @@ -160,7 +166,6 @@ fun BillBasicInfoSection( val context = LocalContext.current val currencyDialogTitle = stringResource(R.string.currency_dialog_title, viewModel.mainCurrencyName) - val noCurrencyError = stringResource(R.string.no_currency_error) OutlinedTextField( value = viewModel.what, @@ -182,25 +187,37 @@ fun BillBasicInfoSection( enabled = canEdit, placeholder = { Text("0") }, 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 = { IconButton( enabled = canEdit, onClick = { - if (viewModel.currencies.isNotEmpty()) { - viewModel.showDialog( - title = currencyDialogTitle, - items = viewModel.currencies.map { "${it.name} (${it.exchangeRate})" }, - onItemSelected = { index -> - viewModel.convertCurrency(viewModel.currencies[index]) - } - ) - } else { - showToast(context, noCurrencyError) + val mainLabel = viewModel.mainCurrencyName.ifEmpty { "Main" } + val options = listOf("$mainLabel | Base") + viewModel.currencies.map { + "${it.name} | 1 $mainLabel = ${it.exchangeRate} ${it.name}" } + 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) @@ -587,6 +604,7 @@ fun EditBillScreenPreview() { viewModel = EditBillViewModel().apply { what = "Pizza" amount = "12.50" + mainCurrencyName = "EUR" members = listOf( 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) diff --git a/app/src/main/java/net/helcel/cowspent/android/bill_edit/EditBillViewModel.kt b/app/src/main/java/net/helcel/cowspent/android/bill_edit/EditBillViewModel.kt index 34f0592..7e71cbe 100644 --- a/app/src/main/java/net/helcel/cowspent/android/bill_edit/EditBillViewModel.kt +++ b/app/src/main/java/net/helcel/cowspent/android/bill_edit/EditBillViewModel.kt @@ -25,9 +25,12 @@ class EditBillViewModel : ViewModel() { var repeat by mutableStateOf(DBBill.NON_REPEATED) var paymentModeRemoteId by mutableIntStateOf(0) var categoryRemoteId by mutableIntStateOf(0) + var isNewBill by mutableStateOf(false) var currencies by mutableStateOf>(emptyList()) var mainCurrencyName by mutableStateOf("") + var selectedCurrencyName by mutableStateOf("") + var selectedCurrencyRate by mutableStateOf(1.0) var members by mutableStateOf>(emptyList()) var owersSelection = mutableStateMapOf() @@ -128,46 +131,77 @@ class EditBillViewModel : ViewModel() { } fun convertCurrency(currency: DBCurrency) { - val originalAmountStr = amount - 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() - } - } - } + if (amountAsDouble == 0.0) return + selectedCurrencyName = currency.name ?: "" + selectedCurrencyRate = currency.exchangeRate + + // We don't change 'amount' here anymore as per request. + // It stays as the original amount. + 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, customSplits: Map? = null) { this.members = members what = bill.what - amount = if (bill.amount == 0.0) "" else bill.amount.toString() - comment = bill.comment ?: "" timestamp = bill.timestamp payerId = bill.payerId repeat = bill.repeat ?: DBBill.NON_REPEATED paymentModeRemoteId = bill.paymentModeRemoteId 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() owersCustomSplit.clear() @@ -178,13 +212,23 @@ class EditBillViewModel : ViewModel() { val selected = customSplits.containsKey(member.id) owersSelection[member.id] = 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 { + // Even split logic val billOwerIds = bill.billOwersIds 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() for (member in members) { diff --git a/app/src/main/java/net/helcel/cowspent/android/currencies/ManageCurrenciesActivity.kt b/app/src/main/java/net/helcel/cowspent/android/currencies/ManageCurrenciesActivity.kt index be736c5..bca038e 100644 --- a/app/src/main/java/net/helcel/cowspent/android/currencies/ManageCurrenciesActivity.kt +++ b/app/src/main/java/net/helcel/cowspent/android/currencies/ManageCurrenciesActivity.kt @@ -15,6 +15,7 @@ import net.helcel.cowspent.R import net.helcel.cowspent.android.helper.showToast import net.helcel.cowspent.model.DBBill import net.helcel.cowspent.model.DBCurrency +import net.helcel.cowspent.model.ProjectType import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper import net.helcel.cowspent.theme.ThemeUtils import net.helcel.cowspent.util.ICallback @@ -86,11 +87,17 @@ class ManageCurrenciesActivity : AppCompatActivity() { val project = db!!.getProject(selectedProjectID) if (project != null) { db!!.syncIfRemote(project) - withContext(Dispatchers.Main) { - 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) + if (project.type == ProjectType.COSPEND) { + withContext(Dispatchers.Main) { + 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) + } + } + } else { + withContext(Dispatchers.Main) { + showToast(this@ManageCurrenciesActivity, getString(R.string.currency_saved_success), Toast.LENGTH_LONG) } } } diff --git a/app/src/main/java/net/helcel/cowspent/android/currencies/ManageCurrenciesScreen.kt b/app/src/main/java/net/helcel/cowspent/android/currencies/ManageCurrenciesScreen.kt index 346fe2a..abf976d 100644 --- a/app/src/main/java/net/helcel/cowspent/android/currencies/ManageCurrenciesScreen.kt +++ b/app/src/main/java/net/helcel/cowspent/android/currencies/ManageCurrenciesScreen.kt @@ -4,6 +4,7 @@ import android.annotation.SuppressLint import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.material.icons.Icons 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.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import net.helcel.cowspent.R @@ -93,19 +95,42 @@ fun ManageCurrenciesScreen( Spacer(modifier = Modifier.height(24.dp)) 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) { OutlinedTextField( value = viewModel.newCurrencyName, onValueChange = { viewModel.newCurrencyName = it }, 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)) OutlinedTextField( value = viewModel.newCurrencyRate, onValueChange = { viewModel.newCurrencyRate = it }, 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)) @@ -121,7 +146,7 @@ fun ManageCurrenciesScreen( LazyColumn(modifier = Modifier.weight(1f)) { items(viewModel.currencies) { currency -> - CurrencyRow(currency, onDelete = { onDelete(currency) }) + CurrencyRow(currency, viewModel.mainCurrencyName, onDelete = { onDelete(currency) }) } } } @@ -129,15 +154,47 @@ fun ManageCurrenciesScreen( } @Composable -fun CurrencyRow(currency: DBCurrency, onDelete: () -> Unit) { +fun CurrencyRow(currency: DBCurrency, mainCurrencyName: String, onDelete: () -> Unit) { Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), verticalAlignment = Alignment.CenterVertically ) { - Column(modifier = Modifier.weight(1f)) { - Text(currency.name ?: "", style = MaterialTheme.typography.subtitle1) - Text("Rate: ${currency.exchangeRate}", style = MaterialTheme.typography.caption) + // Table-like layout + Row( + 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) { Icon(Icons.Default.Delete, contentDescription = null, tint = MaterialTheme.colors.error) } @@ -151,6 +208,7 @@ fun CurrencyRowPreview() { MaterialTheme { CurrencyRow( currency = DBCurrency(1, 0, 0, "USD", 1.0, 0), + mainCurrencyName = "EUR", onDelete = {} ) } diff --git a/app/src/main/java/net/helcel/cowspent/android/drawer/Drawer.kt b/app/src/main/java/net/helcel/cowspent/android/drawer/Drawer.kt index a50188d..f46b967 100644 --- a/app/src/main/java/net/helcel/cowspent/android/drawer/Drawer.kt +++ b/app/src/main/java/net/helcel/cowspent/android/drawer/Drawer.kt @@ -11,6 +11,7 @@ import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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.formatBalance 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.DBProject import net.helcel.cowspent.model.ProjectType @@ -38,6 +41,7 @@ fun Drawer( selectedProjectId: Long, selectedMemberId: Long?, lastSyncText: String, + mainCurrency: String? = null, showArchived: Boolean = false, onProjectClick: (Long) -> Unit, onProjectOptionsClick: (Long) -> Unit, @@ -96,22 +100,37 @@ fun Drawer( // Members Section val membersState = rememberLazyListState() + val sortedMembers = remember(members, memberBalances) { + members.sortedWith(compareBy { + 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( state = membersState, modifier = Modifier .heightIn(max = maxBottomHeight) .lazyVerticalScrollbar(membersState) ) { - if (members.isNotEmpty()) { + if (sortedMembers.isNotEmpty()) { item { DrawerItem( icon = Icons.Default.Receipt, text = stringResource(R.string.label_all_bills), + secondaryText = mainCurrency, selected = selectedMemberId == null, onClick = { onMemberClick(null) } ) } - items(members) { member -> + items(sortedMembers) { member -> val balance = memberBalances[member.id] ?: 0.0 MemberDrawerItem( member = member, @@ -241,6 +260,7 @@ fun DrawerItem( member: DBMember? = null, text: String? = null, balanceText: String? = null, + secondaryText: String? = null, balanceColor: Color = Color.Unspecified, selected: Boolean = false, alpha: Float = 1f, @@ -294,6 +314,17 @@ fun DrawerItem( 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) { IconButton(onClick = onSecondaryClick) { @@ -340,6 +371,7 @@ fun DrawerPreview() { selectedProjectId = 1, selectedMemberId = null, lastSyncText = "Last sync: 5 mins ago", + mainCurrency = "EUR", onProjectClick = {}, onProjectOptionsClick = {}, onMemberClick = {}, diff --git a/app/src/main/java/net/helcel/cowspent/android/helper/AlertDialog.kt b/app/src/main/java/net/helcel/cowspent/android/helper/AlertDialog.kt index 55ae77f..284d1af 100644 --- a/app/src/main/java/net/helcel/cowspent/android/helper/AlertDialog.kt +++ b/app/src/main/java/net/helcel/cowspent/android/helper/AlertDialog.kt @@ -11,6 +11,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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.unit.dp @@ -128,10 +129,32 @@ fun AlertDialog( ) Spacer(Modifier.width(16.dp)) } - Text( - text = item.toString(), - style = MaterialTheme.typography.body1 - ) + + val text = item.toString() + 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)) } - Text( - text = item.toString(), - style = MaterialTheme.typography.body1 - ) + + val text = item.toString() + 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 + ) + } } } } diff --git a/app/src/main/java/net/helcel/cowspent/android/helper/IconUtils.kt b/app/src/main/java/net/helcel/cowspent/android/helper/IconUtils.kt new file mode 100644 index 0000000..e0a2908 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/helper/IconUtils.kt @@ -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 + ) + } +} diff --git a/app/src/main/java/net/helcel/cowspent/android/main/BillsListViewActivity.kt b/app/src/main/java/net/helcel/cowspent/android/main/BillsListViewActivity.kt index e479865..799a8f5 100644 --- a/app/src/main/java/net/helcel/cowspent/android/main/BillsListViewActivity.kt +++ b/app/src/main/java/net/helcel/cowspent/android/main/BillsListViewActivity.kt @@ -444,11 +444,11 @@ class BillsListViewActivity : fun onManageCurrenciesClick(projectId: Long) { lifecycleScope.launch { 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 { putExtra(ManageCurrenciesActivity.EXTRA_PROJECT_ID, projectId) }) - } else showToast(this@BillsListViewActivity, getString(R.string.currency_management_unavailable)) + } } } diff --git a/app/src/main/java/net/helcel/cowspent/persistence/CowspentSQLiteOpenHelper.kt b/app/src/main/java/net/helcel/cowspent/persistence/CowspentSQLiteOpenHelper.kt index cc73377..ca107fd 100644 --- a/app/src/main/java/net/helcel/cowspent/persistence/CowspentSQLiteOpenHelper.kt +++ b/app/src/main/java/net/helcel/cowspent/persistence/CowspentSQLiteOpenHelper.kt @@ -553,7 +553,11 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) : } fun getCurrencies(projectId: Long): List { - return getCurrenciesCustom("$key_projectid = ?", arrayOf(projectId.toString()), null) + return getCurrenciesCustom( + "$key_projectid = ? AND $key_state != ?", + arrayOf(projectId.toString(), DBBill.STATE_DELETED.toString()), + null + ) } @WorkerThread