19 Commits
Author SHA1 Message Date
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
sora b518c2a8c2 Update .github/workflows/build.yml 2026-09-20 09:00:12 +02:00
sora 5054525cfd Update README.md 2026-09-19 18:11:05 +02:00
sora 47b74de720 Upload files to ".github/images" 2026-09-19 17:59:41 +02:00
sora 86384dd826 Upload files to ".github/images" 2026-09-19 17:50:35 +02:00
sora 625747389b Update .github/workflows/build.yml 2026-09-19 12:20:29 +02:00
sora bdc39d3c7e Update PRIVACY.md 2026-09-19 10:27:15 +02:00
sora 12b424679d Update .github/workflows/build.yml 2026-09-18 23:40:59 +02:00
sora 3ba5ee4d78 Add app/src/main/assets/adi-registration.properties 2026-09-18 12:20:29 +02:00
sora c01399f45d moved signing key to the end 2026-09-16 23:57:15 +02:00
gtcrowdandGitHub a9cd70ea07 Update README.md to include the signing key
For security and also to have a source of true for AppVerifierBG and https://github.com/privacyguides/verified-apps/
2026-09-11 10:24:33 +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
16 changed files with 340 additions and 69 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

+19 -1
View File
@@ -34,6 +34,11 @@ jobs:
gpg -d --passphrase "${{ secrets.RELEASE_KEYSTORE_PASSWORD }}" --batch keystore.asc > app/keystore.properties
gpg -d --passphrase "${{ secrets.RELEASE_KEYSTORE_PASSWORD }}" --batch key.asc > app/key.jks
- name: Generate Dynamic Release Notes
run: |
mkdir -p tmp/whatsnew
echo "Various improvements for you to experience..." > tmp/whatsnew/whatsnew-en-US
- name: create and checkout branch
if: github.event_name == 'pull_request'
env:
@@ -62,7 +67,9 @@ jobs:
else
VERSION_NAME="${VERSION_BASE}"
fi
./gradlew assembleSignedRelease -PVERSION_CODE=$VERSION_CODE -PVERSION_NAME=$VERSION_NAME
./gradlew bundleSignedRelease assembleSignedRelease -PVERSION_CODE=$VERSION_CODE -PVERSION_NAME=$VERSION_NAME
# ./gradlew assembleSignedRelease -PVERSION_CODE=$VERSION_CODE -PVERSION_NAME=$VERSION_NAME
# ./gradlew bundleSignedRelease -PVERSION_CODE=$VERSION_CODE -PVERSION_NAME=$VERSION_NAME
- name: Upload APK
uses: actions/upload-artifact@v7
@@ -83,3 +90,14 @@ jobs:
else
echo "true"
fi
- name: Upload to Google Play
uses: r0adkll/upload-google-play@v1
if: startsWith(github.ref, 'refs/tags/')
with:
serviceAccountJsonPlainText: ${{ secrets.SERVICE_ACCOUNT_JSON }}
packageName: net.helcel.cowspent
releaseFiles: app/build/outputs/bundle/signedRelease/app-signedRelease.aab
tracks: ${{ startsWith(github.ref, 'refs/tags/') && 'production' || 'alpha' }}
status: completed
whatsNewDirectory: tmp/whatsnew
+70 -8
View File
@@ -1,12 +1,74 @@
# Cowspent Privacy Policy
# Privacy Policy for Cowspent
Cowspent does not collect any personal data.
This is a Free/Libre software so you can verify no data is collected
by reading the source code or asking someone to do it.
**App:** Cowspent (`net.helcel.cowspent`)
**Developer:** Helcel
**Effective date:** 19 September 2026
Members and bills are synchronized with
the Cospend server of your choice.
## What we collect
CAMERA permission is used to read QR-codes.
Nothing. We operate no servers and receive no data from the app. Cowspent
contains no analytics, advertising, or tracking libraries.
GET\_ACCOUNTS permission is used by Single-Sign-On library to access accounts from Nextcloud Files app.
## Data you enter and where it goes
Cowspent stores your projects, members, bills, categories, and app settings on
your device.
If you connect the app to a Cospend or IHateMoney server, this data is
synchronised with **the server you choose**: either your own or one operated by
a third party. That server's operator controls that copy of your data and their
privacy policy applies to it. We are not involved in that transfer and cannot
access it.
Your server address, username, and password or access token are stored on your
device and sent only to the server you configured, over the connection it
provides. If you sign in through the Nextcloud Files app, authentication is
handled by that app's Single Sign-On and we never see your password.
## Sharing projects
Project share links and QR codes are generated by your server. Anyone who
obtains a link or code may be able to access the project, so share them only
with people you trust.
## Permissions
- `INTERNET`, `ACCESS_NETWORK_STATE` — synchronising with your chosen server.
- `CAMERA` — scanning QR codes to import a project. Images are processed on the
device, never stored or transmitted.
- `GET_ACCOUNTS` — used by the Nextcloud Single Sign-On library to list accounts
from the Nextcloud Files app.
## Retention and deletion
Data on your device remains until you delete it. Clearing the app's data in
Android Settings, or uninstalling Cowspent, removes the local copy and the
stored credentials. Data already synchronised to a server must be deleted on
that server, by you or its administrator.
## Children
Cowspent is suitable for all ages and collects no data from any user, including
children.
## Security
Credentials and project data are held in the app's private storage, protected by
the Android sandbox. Use an HTTPS server address so that synchronisation is
encrypted in transit.
## Open source
Cowspent is released under the GNU General Public License v3.0. The full source
is available at https://github.com/helcel-net/cowspent, so these statements can
be independently verified.
## Changes
Any change affecting privacy will be published in this document before or with
the release that introduces it.
## Contact
Email: net-helcel+privacy@gmail.com
Issues: https://github.com/helcel-net/cowspent/issues
+19 -4
View File
@@ -12,8 +12,6 @@ Which is originally a fork of [PhoneTrack-Android](https://gitlab.com/eneiluj/ph
Which is itself a fork of [Nextcloud Notes for Android](https://github.com/stefan-niedermann/nextcloud-notes).
Many thanks to their developers :heart: !
</p>
<a href="https://ko-fi.com/I2I615VP5M"><img src="https://ko-fi.com/img/githubbutton_sm.svg" alt="ko-fi"></a>
@@ -24,6 +22,7 @@ Many thanks to their developers :heart: !
<a href="https://github.com/helcel-net/cowspent/actions/workflows/build.yml">
<img src="https://github.com/helcel-net/cowspent/actions/workflows/build.yml/badge.svg?branch=main" alt="Build Status">
</a>
</div>
## 🌄 Screenshots
@@ -57,18 +56,27 @@ Extra Features:
- Support for Cospend archived-status (hide archived projects by default)
- Modern UI and code with Kotlin and Jetpack-Compose
## 📳 Installation
<div style="display: flex; justify-content: center; align-items: center; flex-direction: row;">
<a href="https://apt.izzysoft.de/fdroid/index/apk/net.helcel.cowspent">
<img width="200" height="80" alt="Izzy Download" src=".github/images/izzy.png">
<img width="200" height="84" alt="Izzy Download" src=".github/images/izzy.png">
</a>
<a href="https://github.com/helcel-net/cowspent/releases/latest">
<img width="200" height="84" alt="APK Download" src=".github/images/apk.png">
</a>
<a future-href="https://f-droid.org/en/packages/net.helcel.cowspent">
<img width="200" height="84" alt="Fdroid Download" src=".github/images/fdroid.png">
</a>
<a href="https://play.google.com/store/apps/details?id=net.helcel.cowspent">
<img width="200" height="84" alt="GooglePlay Download" src=".github/images/playstore.png">
</a>
</div>
Note:
- Fdroid: requested, see https://gitlab.com/fdroid/rfp/-/work_items/4412
- PlayStore: alpha test restriction by Google. To access, join https://groups.google.com/g/helcel-android-test
## ⚙️ Permissions
- `CAMERA`: recommended for scanning barcodes from camera
@@ -88,6 +96,13 @@ Cowspent is a user-driven project. We welcome any contribution, big or small.
Thanks to all contributors, the developers of our dependencies, and our users.
## Signinig key
```
net.helcel.cowspent
D3:EE:68:A8:5A:7F:35:A1:14:D2:74:B4:50:AA:CE:8C:ED:68:EA:C8:1A:A2:22:E2:7F:F5:0E:98:4C:C6:29
```
## 📝 License
```
+4 -4
View File
@@ -1,7 +1,7 @@
plugins {
id 'com.android.application' version '9.3.2'
id 'org.jetbrains.kotlin.plugin.serialization' version '2.4.10'
id 'org.jetbrains.kotlin.plugin.compose' version '2.4.10'
id 'org.jetbrains.kotlin.plugin.compose' version '2.4.20'
id 'jacoco'
}
@@ -118,7 +118,7 @@ android {
}
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'
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.5'
@@ -132,7 +132,7 @@ dependencies {
implementation 'com.github.nextcloud:Android-SingleSignOn:1.3.4'
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.material:material'
implementation 'androidx.compose.material:material-icons-extended'
@@ -152,7 +152,7 @@ dependencies {
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0'
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.espresso:espresso-core:3.7.0'
androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
@@ -0,0 +1 @@
CUE6FCSBR6JIAAAAAAAAAAAAAA
@@ -1,11 +1,14 @@
package net.helcel.cowspent.android.bill_edit
import android.annotation.SuppressLint
import android.os.Build
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
@@ -19,18 +22,24 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.focus.FocusDirection
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.SolidColor
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
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.TextFieldValue
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
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 context = LocalContext.current
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
// 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 errorInvalidForm = stringResource(R.string.error_generic)
FloatingActionButton(onClick = {
val validationError = viewModel.getValidationError(
errorWhat, errorDate, errorPayer, errorOwers, errorInvalidForm
)
if (validationError == null) {
onSave()
} else {
showToast(context, validationError)
}
}) {
FloatingActionButton(
// Keep the button clear of the operator row pinned above the keyboard.
modifier = Modifier.padding(
bottom = if (amountFocused) MathOperatorRowHeight else 0.dp
),
onClick = {
val validationError = viewModel.getValidationError(
errorWhat, errorDate, errorPayer, errorOwers, errorInvalidForm
)
if (validationError == null) {
onSave()
} else {
showToast(context, validationError)
}
}) {
Icon(
Icons.Default.Done,
contentDescription = stringResource(R.string.action_save)
@@ -141,42 +157,138 @@ fun EditBillScreen(
modifier = Modifier
.padding(padding)
.imePadding()
.padding(16.dp)
.fillMaxSize()
.verticalScroll(scrollState)
) {
BillBasicInfoSection(
viewModel = viewModel,
canEdit = canEdit,
onDateClick = onDateClick,
onTimeClick = onTimeClick,
amountFocusRequester = focusRequester
)
Column(
modifier = Modifier
.weight(1f)
.padding(16.dp)
.verticalScroll(scrollState)
) {
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(
viewModel = viewModel,
canEdit = canEdit
)
PayerSection(
viewModel = viewModel,
canEdit = canEdit
)
Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(8.dp))
OwerSelectionSection(
viewModel = viewModel,
canEdit = canEdit
)
OwerSelectionSection(
viewModel = viewModel,
canEdit = canEdit
)
Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(8.dp))
BillAdditionalDetailsSection(
viewModel = viewModel,
categories = categories,
paymentModes = paymentModes,
canEdit = canEdit
)
BillAdditionalDetailsSection(
viewModel = viewModel,
categories = categories,
paymentModes = paymentModes,
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,
onDateClick: () -> Unit,
onTimeClick: () -> Unit,
amountFocusRequester: FocusRequester
amountFocusRequester: FocusRequester,
amountSelection: TextRange = TextRange(viewModel.amount.length),
onAmountSelectionChange: (TextRange) -> Unit = {},
onAmountFocusChange: (Boolean) -> Unit = {}
) {
Text(
text = "GENERAL",
@@ -203,15 +318,29 @@ fun BillBasicInfoSection(
stringResource(R.string.currency_dialog_title, viewModel.mainCurrencyName)
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 ->
val filteredValue = nv.filter { it in "0123456789.+-*/" }
viewModel.amount = filteredValue
viewModel.updateSplits()
val filteredValue = nv.text.filter { it in "0123456789.,+-*/()" }
onAmountSelectionChange(
if (filteredValue == nv.text) nv.selection else TextRange(filteredValue.length)
)
if (filteredValue != viewModel.amount) {
viewModel.amount = filteredValue
viewModel.updateSplits()
}
},
enabled = canEdit,
placeholder = { Text("0") },
modifier = Modifier.fillMaxWidth().focusRequester(amountFocusRequester),
modifier = Modifier
.fillMaxWidth()
.focusRequester(amountFocusRequester)
.onFocusChanged { onAmountFocusChange(it.isFocused) },
leadingIcon = {
val currencyToShow = viewModel.selectedCurrencyName.ifEmpty {
viewModel.mainCurrencyName.ifEmpty { "$" }
@@ -245,7 +374,7 @@ fun BillBasicInfoSection(
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) })
)
@@ -473,7 +602,7 @@ fun OwerSelectionSection(
BasicTextField(
value = value,
onValueChange = { nv ->
val filteredValue = nv.filter { it in "0123456789.+-*/" }
val filteredValue = nv.filter { it in "0123456789,.+-*/" }
if (viewModel.splitMode == SplitMode.PERCENT) {
viewModel.owersPercentSplit[member.id] = filteredValue
} else {
@@ -48,7 +48,7 @@ class EditBillViewModel : ViewModel() {
if (input.isBlank()) return 0.0
val sanitized = input.replace(',', '.')
// If it contains any math operator, try evalMath first
if (sanitized.any { it in "+-*/" }) {
if (sanitized.any { it in "+-*/()" }) {
try {
val result = evalMath(sanitized)
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.persistence.CowspentSQLiteOpenHelper
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.settleBills
import net.helcel.cowspent.util.SupportUtil.settleBillsDirect
@Composable
fun ProjectSettlementDialogContent(
@@ -60,10 +62,12 @@ fun ProjectSettlementDialogContent(
onDismiss: () -> Unit
) {
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 userList = remember(memberList, centerNoneStr) {
val userList = remember(memberList, centerNoneStr, centerDirectStr) {
buildList {
add(UserItem(SETTLE_OPTIMAL, centerNoneStr))
add(UserItem(SETTLE_DIRECT, centerDirectStr))
addAll(memberList.map { UserItem(it.id, it.name) })
}
}
@@ -91,7 +95,11 @@ fun ProjectSettlementDialogContent(
}
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(
@@ -167,6 +167,39 @@ object SupportUtil {
}
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
fun settleBills(
@@ -209,6 +209,7 @@
<!-- Settlement -->
<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_to_whom">An wen</string>
<string name="settle_how_much">Betrag</string>
@@ -210,6 +210,7 @@
<!-- Settlement -->
<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_to_whom">A quién</string>
<string name="settle_how_much">Importe</string>
@@ -213,6 +213,7 @@
<!-- Settlement -->
<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_to_whom">À qui</string>
<string name="settle_how_much">Montant</string>
@@ -209,6 +209,7 @@
<!-- Settlement -->
<string name="center_none">Нет (оптимально)</string>
<string name="center_none_direct">Нет (напрямую)</string>
<string name="settle_who">Кто платит</string>
<string name="settle_to_whom">Кому</string>
<string name="settle_how_much">Сумма</string>
+1
View File
@@ -247,6 +247,7 @@
<!-- Settlement -->
<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_to_whom">To whom</string>
<string name="settle_how_much">Amount</string>