Currency parsing and formating

# Conflicts:
#	app/src/main/java/net/helcel/cowspent/android/main/BillsListComponents.kt
This commit is contained in:
soraefir
2026-06-29 21:30:35 +02:00
parent 5c74336c10
commit ec4d60897a
6 changed files with 83 additions and 18 deletions
@@ -187,7 +187,12 @@ class EditBillActivity : AppCompatActivity() {
} }
} }
calendar.timeInMillis = bill.timestamp * 1000 calendar.timeInMillis = bill.timestamp * 1000
val members = db.getMembersOfProject(bill.projectId, null) val allMembers = db.getMembersOfProject(bill.projectId, null)
val billOwerIds = bill.billOwersIds
val members = allMembers.filter { m ->
m.isActivated || m.id == bill.payerId || billOwerIds.contains(m.id)
}
val project = db.getProject(bill.projectId) val project = db.getProject(bill.projectId)
val currencies = db.getCurrencies(bill.projectId) val currencies = db.getCurrencies(bill.projectId)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
@@ -188,7 +188,9 @@ fun BillBasicInfoSection(
placeholder = { Text("0") }, placeholder = { Text("0") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
leadingIcon = { leadingIcon = {
val currencyToShow = viewModel.selectedCurrencyName.ifEmpty { viewModel.mainCurrencyName } val currencyToShow = viewModel.selectedCurrencyName.ifEmpty {
viewModel.mainCurrencyName.ifEmpty { "$" }
}
TextIconDisplay( TextIconDisplay(
textIcon = TextIcon.Symbol(currencyToShow), textIcon = TextIcon.Symbol(currencyToShow),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.6f) tint = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
@@ -198,7 +200,7 @@ fun BillBasicInfoSection(
IconButton( IconButton(
enabled = canEdit, enabled = canEdit,
onClick = { onClick = {
val mainLabel = viewModel.mainCurrencyName.ifEmpty { "Main" } val mainLabel = viewModel.mainCurrencyName.ifEmpty { "$" }
val options = listOf("$mainLabel | Base") + viewModel.currencies.map { val options = listOf("$mainLabel | Base") + viewModel.currencies.map {
"${it.name} | 1 $mainLabel = ${it.exchangeRate} ${it.name}" "${it.name} | 1 $mainLabel = ${it.exchangeRate} ${it.name}"
} }
@@ -10,6 +10,7 @@ import androidx.lifecycle.ViewModel
import net.helcel.cowspent.model.DBBill import net.helcel.cowspent.model.DBBill
import net.helcel.cowspent.model.DBMember import net.helcel.cowspent.model.DBMember
import net.helcel.cowspent.android.helper.DialogState import net.helcel.cowspent.android.helper.DialogState
import net.helcel.cowspent.android.helper.parseAmount
import net.helcel.cowspent.util.SupportUtil import net.helcel.cowspent.util.SupportUtil
import net.helcel.cowspent.util.evalMath import net.helcel.cowspent.util.evalMath
@@ -41,13 +42,8 @@ class EditBillViewModel : ViewModel() {
val amountAsDouble: Double val amountAsDouble: Double
get() { get() {
val amountStr = amount.replace(',', '.') return parseAmount(amount) ?: try {
return try { evalMath(amount.replace(',', '.'))
if (amountStr.matches("[0-9.]+".toRegex())) {
amountStr.toDouble()
} else {
evalMath(amountStr)
}
} catch (_: Exception) { } catch (_: Exception) {
0.0 0.0
} }
@@ -18,6 +18,7 @@ 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
import net.helcel.cowspent.android.helper.AlertDialog import net.helcel.cowspent.android.helper.AlertDialog
import net.helcel.cowspent.android.helper.formatAmount
import net.helcel.cowspent.model.DBCurrency import net.helcel.cowspent.model.DBCurrency
@Composable @Composable
@@ -108,7 +109,7 @@ fun ManageCurrenciesScreen(
horizontalArrangement = Arrangement.Center horizontalArrangement = Arrangement.Center
) { ) {
Text("1 ", style = MaterialTheme.typography.body1) Text("1 ", style = MaterialTheme.typography.body1)
Text(viewModel.mainCurrencyName.ifEmpty { "Base" }, fontWeight = FontWeight.Bold) Text(viewModel.mainCurrencyName.ifEmpty { "$" }, fontWeight = FontWeight.Bold)
Text(" = ", style = MaterialTheme.typography.h6) Text(" = ", style = MaterialTheme.typography.h6)
Text(viewModel.newCurrencyRate.ifEmpty { "0.0" }, fontWeight = FontWeight.Bold, color = MaterialTheme.colors.primary) Text(viewModel.newCurrencyRate.ifEmpty { "0.0" }, fontWeight = FontWeight.Bold, color = MaterialTheme.colors.primary)
Text(" ") Text(" ")
@@ -170,7 +171,7 @@ fun CurrencyRow(currency: DBCurrency, mainCurrencyName: String, onDelete: () ->
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f) color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
) )
Text( Text(
text = mainCurrencyName, text = mainCurrencyName.ifEmpty { "$" },
style = MaterialTheme.typography.body1, style = MaterialTheme.typography.body1,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
@@ -182,7 +183,7 @@ fun CurrencyRow(currency: DBCurrency, mainCurrencyName: String, onDelete: () ->
) )
Column { Column {
Text( Text(
text = currency.exchangeRate.toString(), text = formatAmount(currency.exchangeRate),
style = MaterialTheme.typography.body1, style = MaterialTheme.typography.body1,
fontWeight = FontWeight.ExtraBold fontWeight = FontWeight.ExtraBold
) )
@@ -1,20 +1,80 @@
package net.helcel.cowspent.android.helper package net.helcel.cowspent.android.helper
import net.helcel.cowspent.util.SupportUtil import java.text.NumberFormat
import java.util.Locale import java.util.Locale
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.round import kotlin.math.round
/**
* Formats amount using k/M notation (e.g., 1.5k for 1500).
*/
fun formatShortValue(value: Double): String { fun formatShortValue(value: Double): String {
val absValue = abs(value)
val sign = if (value < 0) "-" else ""
return when { return when {
value >= 1_000_000 -> String.format(Locale.ROOT, "%.1fM", value / 1_000_000).replace(".0", "") absValue >= 1_000_000 -> sign + formatAmount(absValue / 1_000_000) + "M"
value >= 1_000 -> String.format(Locale.ROOT, "%.1fk", value / 1_000).replace(".0", "") absValue >= 1_000 -> sign + formatAmount(absValue / 1_000) + "k"
else -> String.format(Locale.ROOT, "%.0f", value) else -> sign + formatAmount(absValue)
} }
} }
/**
* Formats balance with sign and locale-aware number formatting.
*/
fun formatBalance(balance: Double): String { fun formatBalance(balance: Double): String {
val rbalance = round(abs(balance) * 100.0) / 100.0 val rbalance = round(abs(balance) * 100.0) / 100.0
val balanceSign = if (balance > 0.01) "+" else if (balance < -0.01) "-" else "" val balanceSign = if (balance > 0.01) "+" else if (balance < -0.01) "-" else ""
return if (rbalance == 0.0) "" else "$balanceSign${SupportUtil.normalNumberFormat.format(rbalance)}" return if (rbalance == 0.0) "" else "$balanceSign${formatAmount(rbalance)}"
}
/**
* Formats amount using system locale or specified locale.
*/
fun formatAmount(value: Double, locale: Locale = Locale.getDefault()): String {
val formatter = NumberFormat.getNumberInstance(locale)
formatter.maximumFractionDigits = 2
formatter.minimumFractionDigits = 0
return formatter.format(value)
}
/**
* Parses amount from string robustly.
* Tries common separators and cleans up non-numeric characters (except separators).
*/
fun parseAmount(input: String?): Double? {
if (input.isNullOrBlank()) return null
// Remove non-numeric characters except for delimiters and minus sign
val cleaned = input.replace(Regex("[^0-9,.-]"), "").trim()
if (cleaned.isEmpty()) return null
// If there's both a comma and a dot, we assume the last one is the decimal separator
val lastDot = cleaned.lastIndexOf('.')
val lastComma = cleaned.lastIndexOf(',')
return if (lastDot != -1 && lastComma != -1) {
if (lastDot > lastComma) {
// Dot is decimal, comma is thousands
cleaned.replace(",", "").toDoubleOrNull()
} else {
// Comma is decimal, dot is thousands
cleaned.replace(".", "").replace(",", ".").toDoubleOrNull()
}
} else if (lastComma != -1) {
// Only comma. Could be decimal or thousands.
if (cleaned.count { it == ',' } == 1) {
cleaned.replace(',', '.').toDoubleOrNull()
} else {
// Multiple commas -> thousands
cleaned.replace(",", "").toDoubleOrNull()
}
} else {
// Only dots or no separators
if (cleaned.count { it == '.' } > 1) {
cleaned.replace(".", "").toDoubleOrNull()
} else {
cleaned.toDoubleOrNull()
}
}
} }
@@ -16,6 +16,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import net.helcel.cowspent.R import net.helcel.cowspent.R
import net.helcel.cowspent.android.helper.formatAmount
import net.helcel.cowspent.android.helper.MemberAvatar import net.helcel.cowspent.android.helper.MemberAvatar
import net.helcel.cowspent.model.DBBill import net.helcel.cowspent.model.DBBill
import net.helcel.cowspent.model.DBMember import net.helcel.cowspent.model.DBMember