Author SHA1 Message Date
Renovate Bot 99d031551d Update plugin org.jetbrains.kotlin.plugin.serialization to v2.4.21 2026-10-09 02:01:54 +00:00
sora c2da40ba4d Update .github/workflows/build.yml 2026-10-03 19:38:47 +02:00
sora 6e45507b22 Merge pull request 'Update plugin org.jetbrains.kotlin.plugin.serialization to v2.4.20' (#15) from renovate/org.jetbrains.kotlin.plugin.serialization-2.x into main
Reviewed-on: #15
2026-10-03 19:33:49 +02:00
sora 99d05680c5 Merge pull request 'Update dependency androidx.compose.runtime:runtime to v1.12.1' (#17) from renovate/androidx.compose.runtime-runtime-1.x into main
Reviewed-on: #17
2026-10-03 19:33:35 +02:00
Renovate Bot d89d99ceff Update plugin org.jetbrains.kotlin.plugin.serialization to v2.4.20 2026-10-01 02:01:46 +00:00
Renovate Bot 40be1b79f2 Update dependency androidx.compose.runtime:runtime to v1.12.1 2026-10-01 02:01:44 +00:00
sora e807d6fa66 Merge pull request 'Update dependency androidx.work:work-runtime-ktx to v2.12.0' (#20) from renovate/androidx.work-work-runtime-ktx-2.x into main
Reviewed-on: #20
2026-09-30 23:03:02 +02:00
sora 8ae8df6ef8 Merge pull request 'Update dependency org.robolectric:robolectric to v4.17' (#19) from renovate/org.robolectric-robolectric-4.x into main
Reviewed-on: #19
2026-09-30 23:02:53 +02:00
sora 791f3572b6 Merge pull request 'Update dependency androidx.compose:compose-bom to v2026.09.00' (#18) from renovate/androidx.compose-compose-bom-2026.x into main
Reviewed-on: #18
2026-09-30 23:02:34 +02:00
sora ce8053ff2c Merge pull request 'Update dependency androidx.compose.foundation:foundation to v1.12.1' (#16) from renovate/androidx.compose.foundation-foundation-1.x into main
Reviewed-on: #16
2026-09-30 23:02:15 +02:00
sora f5f4c083f0 Merge pull request 'Update plugin org.jetbrains.kotlin.plugin.compose to v2.4.20' (#14) from renovate/org.jetbrains.kotlin.plugin.compose-2.x into main
Reviewed-on: #14
2026-09-30 23:01:39 +02:00
sora 6800fc6c41 Direct Settlement 2026-09-30 22:51:10 +02:00
sora 642b3b9c68 Fix keyboard (and add calculator bar) 2026-09-30 22:41:10 +02:00
sora 291694eaf4 Allow Comma values 2026-09-30 22:18:25 +02:00
Renovate Bot 1f56757d30 Update dependency androidx.work:work-runtime-ktx to v2.12.0 2026-09-24 02:02:58 +00:00
Renovate Bot 73dc64a688 Update dependency org.robolectric:robolectric to v4.17 2026-09-11 02:01:48 +00:00
Renovate Bot 3b22037496 Update dependency androidx.compose:compose-bom to v2026.09.00 2026-09-11 02:01:46 +00:00
Renovate Bot 206c756ad7 Update dependency androidx.compose.foundation:foundation to v1.12.1 2026-09-10 02:01:21 +00:00
Renovate Bot a66d046b3f Update plugin org.jetbrains.kotlin.plugin.compose to v2.4.20 2026-09-08 02:01:15 +00:00
11 changed files with 236 additions and 61 deletions
+2 -2
View File
@@ -93,11 +93,11 @@ jobs:
- name: Upload to Google Play - name: Upload to Google Play
uses: r0adkll/upload-google-play@v1 uses: r0adkll/upload-google-play@v1
if: startsWith(github.ref, 'refs/tags/') # if: startsWith(github.ref, 'refs/tags/')
with: with:
serviceAccountJsonPlainText: ${{ secrets.SERVICE_ACCOUNT_JSON }} serviceAccountJsonPlainText: ${{ secrets.SERVICE_ACCOUNT_JSON }}
packageName: net.helcel.cowspent packageName: net.helcel.cowspent
releaseFiles: app/build/outputs/bundle/signedRelease/app-signedRelease.aab releaseFiles: app/build/outputs/bundle/signedRelease/app-signedRelease.aab
tracks: ${{ startsWith(github.ref, 'refs/tags/') && 'production' || 'alpha' }} tracks: ${{ startsWith(github.ref, 'refs/tags/') && 'production' || 'internal' }}
status: completed status: completed
whatsNewDirectory: tmp/whatsnew whatsNewDirectory: tmp/whatsnew
+8 -8
View File
@@ -1,7 +1,7 @@
plugins { plugins {
id 'com.android.application' version '9.3.2' id 'com.android.application' version '9.3.2'
id 'org.jetbrains.kotlin.plugin.serialization' version '2.4.10' id 'org.jetbrains.kotlin.plugin.serialization' version '2.4.21'
id 'org.jetbrains.kotlin.plugin.compose' version '2.4.10' id 'org.jetbrains.kotlin.plugin.compose' version '2.4.20'
id 'jacoco' id 'jacoco'
} }
@@ -118,12 +118,12 @@ android {
} }
dependencies { dependencies {
implementation 'androidx.compose.foundation:foundation:1.12.0' implementation 'androidx.compose.foundation:foundation:1.12.1'
implementation 'androidx.compose.runtime:runtime:1.12.0' implementation 'androidx.compose.runtime:runtime:1.12.1'
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.5' coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.5'
implementation 'androidx.preference:preference-ktx:1.2.1' implementation 'androidx.preference:preference-ktx:1.2.1'
implementation 'androidx.work:work-runtime-ktx:2.11.2' implementation 'androidx.work:work-runtime-ktx:2.12.0'
implementation 'com.google.zxing:core:3.5.4' implementation 'com.google.zxing:core:3.5.4'
implementation 'androidx.camera:camera-camera2:1.6.2' implementation 'androidx.camera:camera-camera2:1.6.2'
@@ -132,7 +132,7 @@ dependencies {
implementation 'com.github.nextcloud:Android-SingleSignOn:1.3.4' implementation 'com.github.nextcloud:Android-SingleSignOn:1.3.4'
implementation 'com.opencsv:opencsv:5.12.0' implementation 'com.opencsv:opencsv:5.12.0'
implementation platform('androidx.compose:compose-bom:2026.08.00') implementation platform('androidx.compose:compose-bom:2026.09.00')
implementation 'androidx.compose.ui:ui' implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.material:material' implementation 'androidx.compose.material:material'
implementation 'androidx.compose.material:material-icons-extended' implementation 'androidx.compose.material:material-icons-extended'
@@ -147,12 +147,12 @@ dependencies {
testImplementation 'junit:junit:4.13.2' testImplementation 'junit:junit:4.13.2'
testImplementation 'io.mockk:mockk:1.14.11' testImplementation 'io.mockk:mockk:1.14.11'
testImplementation 'org.robolectric:robolectric:4.16.1' testImplementation 'org.robolectric:robolectric:4.17'
testImplementation 'androidx.test:core:1.7.0' testImplementation 'androidx.test:core:1.7.0'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0' testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0'
testImplementation 'androidx.compose.ui:ui-test-junit4' testImplementation 'androidx.compose.ui:ui-test-junit4'
androidTestImplementation platform('androidx.compose:compose-bom:2026.08.00') androidTestImplementation platform('androidx.compose:compose-bom:2026.09.00')
androidTestImplementation 'androidx.test.ext:junit:1.3.0' androidTestImplementation 'androidx.test.ext:junit:1.3.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0' androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0'
androidTestImplementation 'androidx.compose.ui:ui-test-junit4' androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
@@ -1,11 +1,14 @@
package net.helcel.cowspent.android.bill_edit package net.helcel.cowspent.android.bill_edit
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.os.Build
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
@@ -19,18 +22,24 @@ 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.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.draw.scale import androidx.compose.ui.draw.scale
import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
@@ -62,6 +71,8 @@ fun EditBillScreen(
val canEdit = accessLevel == DBProject.ACCESS_LEVEL_UNKNOWN || accessLevel >= DBProject.ACCESS_LEVEL_PARTICIPANT val canEdit = accessLevel == DBProject.ACCESS_LEVEL_UNKNOWN || accessLevel >= DBProject.ACCESS_LEVEL_PARTICIPANT
val context = LocalContext.current val context = LocalContext.current
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
var amountFocused by remember { mutableStateOf(false) }
var amountSelection by remember { mutableStateOf(TextRange(viewModel.amount.length)) }
// The activity reads the project off the main thread and only then reports whether this is a // The activity reads the project off the main thread and only then reports whether this is a
// new bill, so the flag arrives after the first composition. Keying on it rather than on Unit // new bill, so the flag arrives after the first composition. Keying on it rather than on Unit
@@ -118,16 +129,21 @@ fun EditBillScreen(
val errorOwers = stringResource(R.string.error_invalid_bill_owers) val errorOwers = stringResource(R.string.error_invalid_bill_owers)
val errorInvalidForm = stringResource(R.string.error_generic) val errorInvalidForm = stringResource(R.string.error_generic)
FloatingActionButton(onClick = { FloatingActionButton(
val validationError = viewModel.getValidationError( // Keep the button clear of the operator row pinned above the keyboard.
errorWhat, errorDate, errorPayer, errorOwers, errorInvalidForm modifier = Modifier.padding(
) bottom = if (amountFocused) MathOperatorRowHeight else 0.dp
if (validationError == null) { ),
onSave() onClick = {
} else { val validationError = viewModel.getValidationError(
showToast(context, validationError) errorWhat, errorDate, errorPayer, errorOwers, errorInvalidForm
} )
}) { if (validationError == null) {
onSave()
} else {
showToast(context, validationError)
}
}) {
Icon( Icon(
Icons.Default.Done, Icons.Default.Done,
contentDescription = stringResource(R.string.action_save) contentDescription = stringResource(R.string.action_save)
@@ -141,42 +157,138 @@ fun EditBillScreen(
modifier = Modifier modifier = Modifier
.padding(padding) .padding(padding)
.imePadding() .imePadding()
.padding(16.dp)
.fillMaxSize() .fillMaxSize()
.verticalScroll(scrollState)
) { ) {
BillBasicInfoSection( Column(
viewModel = viewModel, modifier = Modifier
canEdit = canEdit, .weight(1f)
onDateClick = onDateClick, .padding(16.dp)
onTimeClick = onTimeClick, .verticalScroll(scrollState)
amountFocusRequester = focusRequester ) {
) BillBasicInfoSection(
viewModel = viewModel,
canEdit = canEdit,
onDateClick = onDateClick,
onTimeClick = onTimeClick,
amountFocusRequester = focusRequester,
amountSelection = amountSelection,
onAmountSelectionChange = { amountSelection = it },
onAmountFocusChange = { amountFocused = it }
)
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
PayerSection( PayerSection(
viewModel = viewModel, viewModel = viewModel,
canEdit = canEdit canEdit = canEdit
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
OwerSelectionSection( OwerSelectionSection(
viewModel = viewModel, viewModel = viewModel,
canEdit = canEdit canEdit = canEdit
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
BillAdditionalDetailsSection( BillAdditionalDetailsSection(
viewModel = viewModel, viewModel = viewModel,
categories = categories, categories = categories,
paymentModes = paymentModes, paymentModes = paymentModes,
canEdit = canEdit canEdit = canEdit
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
}
if (amountFocused && canEdit) {
MathOperatorRow(onInsert = { op ->
val text = viewModel.amount
val start = amountSelection.min.coerceIn(0, text.length)
val end = amountSelection.max.coerceIn(0, text.length)
viewModel.amount = text.replaceRange(start, end, op)
amountSelection = TextRange(start + op.length)
viewModel.updateSplits()
})
}
}
}
}
private val MathOperatorRowHeight = 54.dp
private data class KeyboardColors(val tray: Color, val edge: Color, val key: Color, val label: Color)
@Composable
private fun keyboardColors(): KeyboardColors {
val dark = isSystemInDarkTheme()
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val (tray, edge) = when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE -> if (dark) {
colorResource(android.R.color.system_surface_container_dark) to
colorResource(android.R.color.system_surface_container_high_dark)
} else {
colorResource(android.R.color.system_surface_container_light) to
colorResource(android.R.color.system_surface_container_high_light)
}
else -> if (dark) {
colorResource(android.R.color.system_neutral1_900) to
colorResource(android.R.color.system_neutral1_800)
} else {
colorResource(android.R.color.system_neutral1_50) to
colorResource(android.R.color.system_neutral1_100)
}
}
if (dark) KeyboardColors(
tray = tray,
edge = edge,
key = colorResource(android.R.color.system_accent2_700),
label = colorResource(android.R.color.system_neutral1_50)
) else KeyboardColors(
tray = tray,
edge = edge,
key = colorResource(android.R.color.system_accent2_100),
label = colorResource(android.R.color.system_neutral1_900)
)
} else {
if (dark) KeyboardColors(Color(0xFF1B1B1F), Color(0xFF2B2B2F), Color(0xFF43474E), Color(0xFFE3E2E6))
else KeyboardColors(Color(0xFFEEEDF6), Color(0xFFE7E7F0), Color(0xFFDDE2F9), Color(0xFF1B1B1F))
}
}
/** Operator keys shown above the numeric keypad, which has no room for them itself. */
@Composable
fun MathOperatorRow(onInsert: (String) -> Unit) {
val colors = keyboardColors()
Surface(color = colors.tray) {
// Flat pill-shaped keys, styled like the keyboard's own function keys (e.g. its minus).
val keyShape = RoundedCornerShape(percent = 50)
Row(
modifier = Modifier
.fillMaxWidth()
.height(MathOperatorRowHeight)
.drawBehind {
// A thin edge in the same tone the keyboard uses for its own top border.
drawLine(colors.edge, Offset(0f, 0f), Offset(size.width, 0f), 1.dp.toPx())
}
.padding(horizontal = 3.dp, vertical = 5.dp)
) {
listOf("(" to "(", ")" to ")", "+" to "+", "−" to "-", "×" to "*", "÷" to "/").forEach { (label, op) ->
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.padding(horizontal = 3.dp)
.clip(keyShape)
.background(colors.key)
.clickable { onInsert(op) },
contentAlignment = Alignment.Center
) {
Text(label, fontSize = 24.sp, color = colors.label)
}
}
} }
} }
} }
@@ -187,7 +299,10 @@ fun BillBasicInfoSection(
canEdit: Boolean, canEdit: Boolean,
onDateClick: () -> Unit, onDateClick: () -> Unit,
onTimeClick: () -> Unit, onTimeClick: () -> Unit,
amountFocusRequester: FocusRequester amountFocusRequester: FocusRequester,
amountSelection: TextRange = TextRange(viewModel.amount.length),
onAmountSelectionChange: (TextRange) -> Unit = {},
onAmountFocusChange: (Boolean) -> Unit = {}
) { ) {
Text( Text(
text = "GENERAL", text = "GENERAL",
@@ -203,15 +318,29 @@ fun BillBasicInfoSection(
stringResource(R.string.currency_dialog_title, viewModel.mainCurrencyName) stringResource(R.string.currency_dialog_title, viewModel.mainCurrencyName)
OutlinedTextField( OutlinedTextField(
value = viewModel.amount, value = TextFieldValue(
text = viewModel.amount,
selection = TextRange(
amountSelection.start.coerceIn(0, viewModel.amount.length),
amountSelection.end.coerceIn(0, viewModel.amount.length)
)
),
onValueChange = { nv -> onValueChange = { nv ->
val filteredValue = nv.filter { it in "0123456789.+-*/" } val filteredValue = nv.text.filter { it in "0123456789.,+-*/()" }
viewModel.amount = filteredValue onAmountSelectionChange(
viewModel.updateSplits() if (filteredValue == nv.text) nv.selection else TextRange(filteredValue.length)
)
if (filteredValue != viewModel.amount) {
viewModel.amount = filteredValue
viewModel.updateSplits()
}
}, },
enabled = canEdit, enabled = canEdit,
placeholder = { Text("0") }, placeholder = { Text("0") },
modifier = Modifier.fillMaxWidth().focusRequester(amountFocusRequester), modifier = Modifier
.fillMaxWidth()
.focusRequester(amountFocusRequester)
.onFocusChanged { onAmountFocusChange(it.isFocused) },
leadingIcon = { leadingIcon = {
val currencyToShow = viewModel.selectedCurrencyName.ifEmpty { val currencyToShow = viewModel.selectedCurrencyName.ifEmpty {
viewModel.mainCurrencyName.ifEmpty { "$" } viewModel.mainCurrencyName.ifEmpty { "$" }
@@ -245,7 +374,7 @@ fun BillBasicInfoSection(
Icon(Icons.Default.SwapHoriz, contentDescription = "Change Currency") Icon(Icons.Default.SwapHoriz, contentDescription = "Change Currency")
} }
}, },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone, imeAction = ImeAction.Next), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal, imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) }) keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) })
) )
@@ -473,7 +602,7 @@ fun OwerSelectionSection(
BasicTextField( BasicTextField(
value = value, value = value,
onValueChange = { nv -> onValueChange = { nv ->
val filteredValue = nv.filter { it in "0123456789.+-*/" } val filteredValue = nv.filter { it in "0123456789,.+-*/" }
if (viewModel.splitMode == SplitMode.PERCENT) { if (viewModel.splitMode == SplitMode.PERCENT) {
viewModel.owersPercentSplit[member.id] = filteredValue viewModel.owersPercentSplit[member.id] = filteredValue
} else { } else {
@@ -48,7 +48,7 @@ class EditBillViewModel : ViewModel() {
if (input.isBlank()) return 0.0 if (input.isBlank()) return 0.0
val sanitized = input.replace(',', '.') val sanitized = input.replace(',', '.')
// If it contains any math operator, try evalMath first // If it contains any math operator, try evalMath first
if (sanitized.any { it in "+-*/" }) { if (sanitized.any { it in "+-*/()" }) {
try { try {
val result = evalMath(sanitized) val result = evalMath(sanitized)
if (result != 0.0) return SupportUtil.round2(result) if (result != 0.0) return SupportUtil.round2(result)
@@ -48,8 +48,10 @@ import net.helcel.cowspent.model.Transaction
import net.helcel.cowspent.model.UserItem import net.helcel.cowspent.model.UserItem
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.util.SupportUtil import net.helcel.cowspent.util.SupportUtil
import net.helcel.cowspent.util.SupportUtil.SETTLE_DIRECT
import net.helcel.cowspent.util.SupportUtil.SETTLE_OPTIMAL import net.helcel.cowspent.util.SupportUtil.SETTLE_OPTIMAL
import net.helcel.cowspent.util.SupportUtil.settleBills import net.helcel.cowspent.util.SupportUtil.settleBills
import net.helcel.cowspent.util.SupportUtil.settleBillsDirect
@Composable @Composable
fun ProjectSettlementDialogContent( fun ProjectSettlementDialogContent(
@@ -60,10 +62,12 @@ fun ProjectSettlementDialogContent(
onDismiss: () -> Unit onDismiss: () -> Unit
) { ) {
val centerNoneStr = stringResource(R.string.center_none) val centerNoneStr = stringResource(R.string.center_none)
val centerDirectStr = stringResource(R.string.center_none_direct)
val memberList = remember(proj.id) { db.getMembersOfProject(proj.id, null) } val memberList = remember(proj.id) { db.getMembersOfProject(proj.id, null) }
val userList = remember(memberList, centerNoneStr) { val userList = remember(memberList, centerNoneStr, centerDirectStr) {
buildList { buildList {
add(UserItem(SETTLE_OPTIMAL, centerNoneStr)) add(UserItem(SETTLE_OPTIMAL, centerNoneStr))
add(UserItem(SETTLE_DIRECT, centerDirectStr))
addAll(memberList.map { UserItem(it.id, it.name) }) addAll(memberList.map { UserItem(it.id, it.name) })
} }
} }
@@ -91,7 +95,11 @@ fun ProjectSettlementDialogContent(
} }
val transactions = remember(selectedMemberId, membersBalance, membersSortedByName) { val transactions = remember(selectedMemberId, membersBalance, membersSortedByName) {
settleBills(membersSortedByName, membersBalance, selectedMemberId) if (selectedMemberId == SETTLE_DIRECT) {
settleBillsDirect(membersSortedByName, db.getBillsOfProject(proj.id))
} else {
settleBills(membersSortedByName, membersBalance, selectedMemberId)
}
} }
ProjectSettlementUI( ProjectSettlementUI(
@@ -141,7 +141,7 @@ object SupportUtil {
membersBalance[b.payerId] = balPayer + amount membersBalance[b.payerId] = balPayer + amount
val paid = membersPaid[b.payerId] ?: 0.0 val paid = membersPaid[b.payerId] ?: 0.0
membersPaid[b.payerId] = paid + amount membersPaid[b.payerId] = paid + amount
var nbOwerShares = 0.0 var nbOwerShares = 0.0
for (bo in b.billOwers) { for (bo in b.billOwers) {
nbOwerShares += membersWeight[bo.memberId] ?: 0.0 nbOwerShares += membersWeight[bo.memberId] ?: 0.0
@@ -167,6 +167,39 @@ object SupportUtil {
} }
const val SETTLE_OPTIMAL: Long = 0 const val SETTLE_OPTIMAL: Long = 0
const val SETTLE_DIRECT: Long = -1
@JvmStatic
fun settleBillsDirect(members: List<DBMember>, bills: List<DBBill>): List<Transaction> {
val membersWeight = members.associate { it.id to it.weight }
// owed[debtor][creditor] = total the debtor owes the creditor over all bills
val owed = HashMap<Long, HashMap<Long, Double>>()
for (b in bills) {
if (b.state == DBBill.STATE_DELETED) continue
val nbOwerShares = b.billOwers.sumOf { membersWeight[it.memberId] ?: 0.0 }
if (nbOwerShares <= 0.0) continue
for (bo in b.billOwers) {
if (bo.memberId == b.payerId) continue
val share = b.amount / nbOwerShares * (membersWeight[bo.memberId] ?: 0.0)
val row = owed.getOrPut(bo.memberId) { HashMap() }
row[b.payerId] = (row[b.payerId] ?: 0.0) + share
}
}
val results: MutableList<Transaction> = ArrayList()
for (debtor in members) {
for (creditor in members) {
if (debtor.id == creditor.id) continue
val net = (owed[debtor.id]?.get(creditor.id) ?: 0.0) -
(owed[creditor.id]?.get(debtor.id) ?: 0.0)
if (round2(net) > 0.0) {
results.add(Transaction(debtor.id, creditor.id, round2(net)))
}
}
}
return results
}
@JvmStatic @JvmStatic
fun settleBills( fun settleBills(
@@ -233,11 +266,11 @@ object SupportUtil {
if (cd1.balance < cd2.balance) 1 else -1 if (cd1.balance < cd2.balance) 1 else -1
} }
} }
for (c in crediters) { for (c in crediters) {
Log.e(SupportUtil::class.java.simpleName, "* " + c.memberId + " : " + c.balance) Log.e(SupportUtil::class.java.simpleName, "* " + c.memberId + " : " + c.balance)
} }
debiters.sortWith { cd2, cd1 -> debiters.sortWith { cd2, cd1 ->
if (cd1.balance == cd2.balance) { if (cd1.balance == cd2.balance) {
0 0
@@ -209,6 +209,7 @@
<!-- Settlement --> <!-- Settlement -->
<string name="center_none">Keine (optimal)</string> <string name="center_none">Keine (optimal)</string>
<string name="center_none_direct">Keine (direkt)</string>
<string name="settle_who">Wer zahlt</string> <string name="settle_who">Wer zahlt</string>
<string name="settle_to_whom">An wen</string> <string name="settle_to_whom">An wen</string>
<string name="settle_how_much">Betrag</string> <string name="settle_how_much">Betrag</string>
@@ -210,6 +210,7 @@
<!-- Settlement --> <!-- Settlement -->
<string name="center_none">Ninguno (óptimo)</string> <string name="center_none">Ninguno (óptimo)</string>
<string name="center_none_direct">Ninguno (directo)</string>
<string name="settle_who">Quién paga</string> <string name="settle_who">Quién paga</string>
<string name="settle_to_whom">A quién</string> <string name="settle_to_whom">A quién</string>
<string name="settle_how_much">Importe</string> <string name="settle_how_much">Importe</string>
@@ -213,6 +213,7 @@
<!-- Settlement --> <!-- Settlement -->
<string name="center_none">Aucun (optimal)</string> <string name="center_none">Aucun (optimal)</string>
<string name="center_none_direct">Aucun (direct)</string>
<string name="settle_who">Qui paie</string> <string name="settle_who">Qui paie</string>
<string name="settle_to_whom">À qui</string> <string name="settle_to_whom">À qui</string>
<string name="settle_how_much">Montant</string> <string name="settle_how_much">Montant</string>
@@ -209,6 +209,7 @@
<!-- Settlement --> <!-- Settlement -->
<string name="center_none">Нет (оптимально)</string> <string name="center_none">Нет (оптимально)</string>
<string name="center_none_direct">Нет (напрямую)</string>
<string name="settle_who">Кто платит</string> <string name="settle_who">Кто платит</string>
<string name="settle_to_whom">Кому</string> <string name="settle_to_whom">Кому</string>
<string name="settle_how_much">Сумма</string> <string name="settle_how_much">Сумма</string>
+1
View File
@@ -247,6 +247,7 @@
<!-- Settlement --> <!-- Settlement -->
<string name="center_none">None (Optimal)</string> <string name="center_none">None (Optimal)</string>
<string name="center_none_direct">None (Direct)</string>
<string name="settle_who">Who pays</string> <string name="settle_who">Who pays</string>
<string name="settle_to_whom">To whom</string> <string name="settle_to_whom">To whom</string>
<string name="settle_how_much">Amount</string> <string name="settle_how_much">Amount</string>