Compare commits
17
Commits
1.6
..
ce8053ff2c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce8053ff2c | ||
|
|
f5f4c083f0 | ||
|
|
6800fc6c41
|
||
|
|
642b3b9c68
|
||
|
|
291694eaf4
|
||
|
|
b518c2a8c2 | ||
|
|
5054525cfd | ||
|
|
47b74de720 | ||
|
|
86384dd826 | ||
|
|
625747389b | ||
|
|
bdc39d3c7e | ||
|
|
12b424679d | ||
|
|
3ba5ee4d78 | ||
|
|
c01399f45d
|
||
|
|
a9cd70ea07 | ||
|
|
206c756ad7 | ||
|
|
a66d046b3f |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.8 KiB |
@@ -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
@@ -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
|
||||
@@ -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
|
||||
|
||||
```
|
||||
|
||||
+2
-2
@@ -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'
|
||||
|
||||
|
||||
@@ -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,7 +129,12 @@ fun EditBillScreen(
|
||||
val errorOwers = stringResource(R.string.error_invalid_bill_owers)
|
||||
val errorInvalidForm = stringResource(R.string.error_generic)
|
||||
|
||||
FloatingActionButton(onClick = {
|
||||
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
|
||||
)
|
||||
@@ -141,8 +157,12 @@ fun EditBillScreen(
|
||||
modifier = Modifier
|
||||
.padding(padding)
|
||||
.imePadding()
|
||||
.padding(16.dp)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(16.dp)
|
||||
.verticalScroll(scrollState)
|
||||
) {
|
||||
BillBasicInfoSection(
|
||||
@@ -150,7 +170,10 @@ fun EditBillScreen(
|
||||
canEdit = canEdit,
|
||||
onDateClick = onDateClick,
|
||||
onTimeClick = onTimeClick,
|
||||
amountFocusRequester = focusRequester
|
||||
amountFocusRequester = focusRequester,
|
||||
amountSelection = amountSelection,
|
||||
onAmountSelectionChange = { amountSelection = it },
|
||||
onAmountFocusChange = { amountFocused = it }
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
@@ -178,6 +201,95 @@ fun EditBillScreen(
|
||||
|
||||
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.+-*/" }
|
||||
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)
|
||||
|
||||
+9
-1
@@ -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,8 +95,12 @@ fun ProjectSettlementDialogContent(
|
||||
}
|
||||
|
||||
val transactions = remember(selectedMemberId, membersBalance, membersSortedByName) {
|
||||
if (selectedMemberId == SETTLE_DIRECT) {
|
||||
settleBillsDirect(membersSortedByName, db.getBillsOfProject(proj.id))
|
||||
} else {
|
||||
settleBills(membersSortedByName, membersBalance, selectedMemberId)
|
||||
}
|
||||
}
|
||||
|
||||
ProjectSettlementUI(
|
||||
transactions = transactions,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user