Percent Split

This commit is contained in:
soraefir
2026-09-02 00:17:42 +02:00
parent 3586a16f5f
commit 0633dbe2ca
3 changed files with 117 additions and 41 deletions
@@ -324,16 +324,28 @@ class EditBillActivity : AppCompatActivity() {
private suspend fun saveBill(): Long = withContext(Dispatchers.IO) { private suspend fun saveBill(): Long = withContext(Dispatchers.IO) {
val groupedBillIds = intent.getLongArrayExtra(PARAM_GROUPED_BILL_IDS) val groupedBillIds = intent.getLongArrayExtra(PARAM_GROUPED_BILL_IDS)
val isCustomSplit = viewModel.isCustomSplit val splitMode = viewModel.splitMode
if (isCustomSplit) { if (splitMode != SplitMode.EVEN) {
val splits: Map<Long, Double> = viewModel.owersCustomSplit.filter { (id, amountStr) -> val splits: Map<Long, Double> = if (splitMode == SplitMode.CUSTOM) {
viewModel.owersCustomSplit.filter { (id, amountStr) ->
viewModel.owersSelection[id] == true && (amountStr.replace(',', '.').toDoubleOrNull() viewModel.owersSelection[id] == true && (amountStr.replace(',', '.').toDoubleOrNull()
?: 0.0) > 0 ?: 0.0) > 0
}.mapValues { }.mapValues {
val uiAmount = it.value.replace(',', '.').toDoubleOrNull() ?: 0.0 val uiAmount = it.value.replace(',', '.').toDoubleOrNull() ?: 0.0
SupportUtil.round2(uiAmount / viewModel.selectedCurrencyRate) SupportUtil.round2(uiAmount / viewModel.selectedCurrencyRate)
} }
} else { // PERCENT
val totalAmount = viewModel.amountAsDouble
viewModel.owersPercentSplit.filter { (id, percentStr) ->
viewModel.owersSelection[id] == true && (percentStr.replace(',', '.').toDoubleOrNull()
?: 0.0) > 0
}.mapValues {
val percent = it.value.replace(',', '.').toDoubleOrNull() ?: 0.0
val uiAmount = totalAmount * percent / 100.0
SupportUtil.round2(uiAmount / viewModel.selectedCurrencyRate)
}
}
if (splits.isEmpty()) return@withContext 0L if (splits.isEmpty()) return@withContext 0L
@@ -1,6 +1,7 @@
package net.helcel.cowspent.android.bill_edit package net.helcel.cowspent.android.bill_edit
import android.annotation.SuppressLint import android.annotation.SuppressLint
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
@@ -16,6 +17,7 @@ import androidx.compose.material.icons.filled.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
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.clip
import androidx.compose.ui.draw.scale import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
@@ -357,35 +359,55 @@ fun OwerSelectionSection(
) )
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
if (viewModel.isCustomSplit) { if (viewModel.splitMode != SplitMode.EVEN) {
val diff = viewModel.getDiffSplit() val diff = viewModel.getDiffSplit()
if (abs(diff) > 0.01) { if (abs(diff) > 0.01) {
val unit = if (viewModel.splitMode == SplitMode.PERCENT) "%" else ""
val diffText = val diffText =
if (diff > 0) "Missing: ${SupportUtil.normalNumberFormat.format(diff)}" else "Excess: ${ if (diff > 0) "Missing: ${SupportUtil.normalNumberFormat.format(diff)}$unit" else "Excess: ${
SupportUtil.normalNumberFormat.format(-diff) SupportUtil.normalNumberFormat.format(-diff)
}" }$unit"
Text( Text(
diffText, diffText,
color = MaterialTheme.colors.error, color = MaterialTheme.colors.error,
fontSize = 12.sp, fontSize = 12.sp,
modifier = Modifier.padding(horizontal = 16.dp) modifier = Modifier.padding(horizontal = 4.dp)
) )
} }
} else {
Text("Even Split", fontSize = 12.sp)
} }
Switch(
checked = !viewModel.isCustomSplit, val splitOptions = listOf(SplitMode.EVEN, SplitMode.CUSTOM, SplitMode.PERCENT)
enabled = canEdit, Row(
onCheckedChange = { modifier = Modifier
viewModel.isCustomSplit = !it .padding(start = 8.dp)
.background(MaterialTheme.colors.onSurface.copy(alpha = 0.05f), MaterialTheme.shapes.small)
) {
splitOptions.forEach { mode ->
val isSelected = viewModel.splitMode == mode
Box(
modifier = Modifier
.clip(MaterialTheme.shapes.small)
.background(if (isSelected) MaterialTheme.colors.primary else Color.Transparent)
.clickable(enabled = canEdit) {
viewModel.splitMode = mode
viewModel.updateSplits() viewModel.updateSplits()
}
.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Text(
text = when (mode) {
SplitMode.EVEN -> "="
SplitMode.CUSTOM -> "#"
SplitMode.PERCENT -> "%"
}, },
colors = SwitchDefaults.colors( color = if (isSelected) MaterialTheme.colors.onPrimary else MaterialTheme.colors.onSurface,
uncheckedThumbColor = MaterialTheme.colors.onSurface, fontSize = 14.sp,
) fontWeight = FontWeight.Bold
) )
} }
}
}
}
viewModel.members.forEach { member -> viewModel.members.forEach { member ->
val isSelected = viewModel.owersSelection[member.id] ?: false val isSelected = viewModel.owersSelection[member.id] ?: false
@@ -410,12 +432,22 @@ fun OwerSelectionSection(
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text(member.name, modifier = Modifier.weight(1f)) Text(member.name, modifier = Modifier.weight(1f))
if (isSelected || viewModel.isCustomSplit) { if (isSelected || viewModel.splitMode != SplitMode.EVEN) {
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val value = if (viewModel.splitMode == SplitMode.PERCENT) {
viewModel.owersPercentSplit[member.id] ?: ""
} else {
viewModel.owersCustomSplit[member.id] ?: ""
}
BasicTextField( BasicTextField(
value = viewModel.owersCustomSplit[member.id] ?: "", value = value,
onValueChange = { onValueChange = {
if (viewModel.splitMode == SplitMode.PERCENT) {
viewModel.owersPercentSplit[member.id] = it
} else {
viewModel.owersCustomSplit[member.id] = it viewModel.owersCustomSplit[member.id] = it
}
viewModel.owersSelection[member.id] = (it != "") viewModel.owersSelection[member.id] = (it != "")
}, },
modifier = Modifier modifier = Modifier
@@ -423,7 +455,7 @@ fun OwerSelectionSection(
.height(46.dp), .height(46.dp),
interactionSource = interactionSource, interactionSource = interactionSource,
singleLine = true, singleLine = true,
enabled = viewModel.isCustomSplit && canEdit, enabled = viewModel.splitMode != SplitMode.EVEN && canEdit,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
cursorBrush = SolidColor(MaterialTheme.colors.onSurface), cursorBrush = SolidColor(MaterialTheme.colors.onSurface),
textStyle = LocalTextStyle.current.copy( textStyle = LocalTextStyle.current.copy(
@@ -432,13 +464,13 @@ fun OwerSelectionSection(
), ),
) { innerTextField -> ) { innerTextField ->
TextFieldDefaults.OutlinedTextFieldDecorationBox( TextFieldDefaults.OutlinedTextFieldDecorationBox(
value = viewModel.owersCustomSplit[member.id] ?: "", value = value,
visualTransformation = VisualTransformation.None, visualTransformation = VisualTransformation.None,
innerTextField = innerTextField, innerTextField = innerTextField,
singleLine = true, singleLine = true,
enabled = viewModel.isCustomSplit && canEdit, enabled = viewModel.splitMode != SplitMode.EVEN && canEdit,
interactionSource = interactionSource, interactionSource = interactionSource,
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 0.dp), contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp),
colors = TextFieldDefaults.textFieldColors( colors = TextFieldDefaults.textFieldColors(
cursorColor = MaterialTheme.colors.onSurface, cursorColor = MaterialTheme.colors.onSurface,
backgroundColor = Color.Transparent, backgroundColor = Color.Transparent,
@@ -16,6 +16,10 @@ import net.helcel.cowspent.model.DBMember
import net.helcel.cowspent.util.SupportUtil import net.helcel.cowspent.util.SupportUtil
import net.helcel.cowspent.util.evalMath import net.helcel.cowspent.util.evalMath
enum class SplitMode {
EVEN, CUSTOM, PERCENT
}
class EditBillViewModel : ViewModel() { class EditBillViewModel : ViewModel() {
var what by mutableStateOf("") var what by mutableStateOf("")
var amount by mutableStateOf("") var amount by mutableStateOf("")
@@ -34,8 +38,9 @@ class EditBillViewModel : ViewModel() {
var members by mutableStateOf<List<DBMember>>(emptyList()) var members by mutableStateOf<List<DBMember>>(emptyList())
var owersSelection = mutableStateMapOf<Long, Boolean>() var owersSelection = mutableStateMapOf<Long, Boolean>()
var isCustomSplit by mutableStateOf(false) var splitMode by mutableStateOf(SplitMode.EVEN)
var owersCustomSplit = mutableStateMapOf<Long, String>() var owersCustomSplit = mutableStateMapOf<Long, String>()
var owersPercentSplit = mutableStateMapOf<Long, String>()
var dialogState by mutableStateOf<DialogState?>(null) var dialogState by mutableStateOf<DialogState?>(null)
@@ -54,7 +59,7 @@ class EditBillViewModel : ViewModel() {
} }
fun updateSplits() { fun updateSplits() {
if (!isCustomSplit) { if (splitMode == SplitMode.EVEN) {
val even = getEvenSplit() val even = getEvenSplit()
val evenStr = if (even == 0.0) "" else SupportUtil.round2(even).toString() val evenStr = if (even == 0.0) "" else SupportUtil.round2(even).toString()
members.forEach { m -> members.forEach { m ->
@@ -64,18 +69,36 @@ class EditBillViewModel : ViewModel() {
owersCustomSplit.remove(m.id) owersCustomSplit.remove(m.id)
} }
} }
} else if (splitMode == SplitMode.PERCENT) {
val selectedCount = owersSelection.count { it.value }
if (selectedCount > 0) {
val evenPercent = 100.0 / selectedCount
val evenPercentStr = SupportUtil.round2(evenPercent).toString()
members.forEach { m ->
if (owersSelection[m.id] == true) {
if (owersPercentSplit[m.id].isNullOrEmpty()) {
owersPercentSplit[m.id] = evenPercentStr
}
} else {
owersPercentSplit.remove(m.id)
}
}
}
} }
} }
fun toggleMember(id: Long, selected: Boolean) { fun toggleMember(id: Long, selected: Boolean) {
owersSelection[id] = selected owersSelection[id] = selected
if (isCustomSplit) { if (splitMode != SplitMode.EVEN) {
if (selected) { if (selected) {
if (owersCustomSplit[id].isNullOrEmpty()) { if (splitMode == SplitMode.CUSTOM && owersCustomSplit[id].isNullOrEmpty()) {
owersCustomSplit[id] = "0" owersCustomSplit[id] = "0"
} else if (splitMode == SplitMode.PERCENT && owersPercentSplit[id].isNullOrEmpty()) {
owersPercentSplit[id] = "0"
} }
} else { } else {
owersCustomSplit.remove(id) owersCustomSplit.remove(id)
owersPercentSplit.remove(id)
} }
} else { } else {
updateSplits() updateSplits()
@@ -83,10 +106,17 @@ class EditBillViewModel : ViewModel() {
} }
fun getDiffSplit(): Double { fun getDiffSplit(): Double {
return if (splitMode == SplitMode.PERCENT) {
val customTotal = owersPercentSplit.entries
.filter { owersSelection[it.key] == true }
.sumOf { it.value.replace(',', '.').toDoubleOrNull() ?: 0.0 }
100.0 - customTotal
} else {
val customTotal = owersCustomSplit.entries val customTotal = owersCustomSplit.entries
.filter { owersSelection[it.key] == true } .filter { owersSelection[it.key] == true }
.sumOf { it.value.replace(',', '.').toDoubleOrNull() ?: 0.0 } .sumOf { it.value.replace(',', '.').toDoubleOrNull() ?: 0.0 }
return amountAsDouble - customTotal amountAsDouble - customTotal
}
} }
fun getOwersIds(): List<Long> { fun getOwersIds(): List<Long> {
@@ -204,9 +234,10 @@ class EditBillViewModel : ViewModel() {
owersSelection.clear() owersSelection.clear()
owersCustomSplit.clear() owersCustomSplit.clear()
owersPercentSplit.clear()
if (customSplits != null) { if (customSplits != null) {
isCustomSplit = true splitMode = SplitMode.CUSTOM
for (member in members) { for (member in members) {
val selected = customSplits.containsKey(member.id) val selected = customSplits.containsKey(member.id)
owersSelection[member.id] = selected owersSelection[member.id] = selected
@@ -222,6 +253,7 @@ class EditBillViewModel : ViewModel() {
} }
} else { } else {
// Even split logic // Even split logic
splitMode = SplitMode.EVEN
val billOwerIds = bill.billOwersIds val billOwerIds = bill.billOwersIds
val selectedCount = billOwerIds.size val selectedCount = billOwerIds.size