10 Commits
Author SHA1 Message Date
soraefir 741e3cf861 Minor tweaks 2026-09-03 01:43:00 +02:00
soraefir 8c7dfd8568 Unit Test and Build Artifact 2026-09-03 00:49:11 +02:00
soraefir da9d2a2f74 Importer Fix 2026-09-03 00:01:08 +02:00
soraefir d654e5f0f3 Focus Flow 2026-09-02 23:58:03 +02:00
soraefir 1742da65df Add empty box at bottom of bill list 2026-09-02 23:28:00 +02:00
soraefir f515e5cd5e Focus amount and default payer 2026-09-02 23:22:54 +02:00
soraefir 4c2edc77ca Archiving Sync 2026-09-02 23:12:08 +02:00
soraefir e2d9088dea Formula Input & rounding & more 2026-09-02 00:43:39 +02:00
soraefir 0633dbe2ca Percent Split 2026-09-02 00:17:42 +02:00
soraefir 3586a16f5f Gradle Update 2026-09-01 23:47:55 +02:00
20 changed files with 354 additions and 230 deletions
+9
View File
@@ -49,12 +49,21 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
- name: Run unit tests
run: ./gradlew testDebugUnitTest
- name: Build APK
run: |
VERSION_CODE=$(git rev-list --count HEAD)
VERSION_NAME=$(git describe --tags --always)
./gradlew assembleSignedRelease -PVERSION_CODE=$VERSION_CODE -PVERSION_NAME=$VERSION_NAME
- name: Upload APK
uses: actions/upload-artifact@v4
with:
name: Cowspent-APK
path: app/build/outputs/apk/signedRelease/app-signedRelease.apk
- name: Release
uses: softprops/action-gh-release@v3
if: startsWith(github.ref, 'refs/tags/')
+43 -10
View File
@@ -1,7 +1,8 @@
plugins {
id 'com.android.application' version '9.2.1'
id 'org.jetbrains.kotlin.plugin.serialization' version '2.4.0'
id 'org.jetbrains.kotlin.plugin.compose' version '2.4.0'
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 'jacoco'
}
android {
@@ -40,6 +41,7 @@ android {
debuggable true
initWith(buildTypes.release)
signingConfig = signingConfigs.debug
enableUnitTestCoverage true
}
release {
minifyEnabled true
@@ -69,6 +71,12 @@ android {
compose = true
}
testOptions {
unitTests {
includeAndroidResources = true
}
}
kotlin {
jvmToolchain(21)
}
@@ -96,21 +104,21 @@ android {
}
dependencies {
implementation 'androidx.compose.foundation:foundation:1.11.4'
implementation 'androidx.compose.runtime:runtime:1.11.4'
implementation 'androidx.compose.foundation:foundation:1.12.0'
implementation 'androidx.compose.runtime:runtime:1.12.0'
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.5'
implementation 'androidx.preference:preference-ktx:1.2.1'
implementation 'androidx.work:work-runtime-ktx:2.11.2'
implementation 'com.google.zxing:core:3.5.4'
implementation 'androidx.camera:camera-camera2:1.6.1'
implementation 'androidx.camera:camera-lifecycle:1.6.1'
implementation 'androidx.camera:camera-view:1.6.1'
implementation 'androidx.camera:camera-camera2:1.6.2'
implementation 'androidx.camera:camera-lifecycle:1.6.2'
implementation 'androidx.camera:camera-view:1.6.2'
implementation 'com.github.nextcloud:Android-SingleSignOn:1.1.0'
implementation 'com.opencsv:opencsv:5.12.0'
implementation platform('androidx.compose:compose-bom:2026.06.01')
implementation platform('androidx.compose:compose-bom:2026.08.00')
implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.material:material'
implementation 'androidx.compose.material:material-icons-extended'
@@ -121,5 +129,30 @@ dependencies {
implementation 'androidx.security:security-crypto:1.1.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.11.0'
implementation "androidx.datastore:datastore-preferences:1.2.1"
implementation "com.google.crypto.tink:tink-android:1.22.0"
implementation "com.google.crypto.tink:tink-android:1.23.0"
testImplementation 'junit:junit:4.13.2'
testImplementation 'io.mockk:mockk:1.14.11'
testImplementation 'org.robolectric:robolectric:4.16.1'
testImplementation 'androidx.test:core:1.7.0'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0'
}
tasks.register('jacocoTestReport', JacocoReport) {
dependsOn 'testDebugUnitTest'
reports {
xml.required = true
html.required = true
}
def fileFilter = ['**/R.class', '**/R$*.class', '**/BuildConfig.*', '**/Manifest*.*', '**/*Test*.*']
def debugTree = fileTree(dir: "${project.layout.buildDirectory.asFile.get()}/intermediates/built_in_kotlinc/debug/compileDebugKotlin/classes", excludes: fileFilter)
def mainSrc = "${project.projectDir}/src/main/java"
sourceDirectories.from = files([mainSrc])
classDirectories.from = files([debugTree])
executionData.from = fileTree(dir: project.layout.buildDirectory.asFile.get(), includes: [
'jacoco/testDebugUnitTest.exec', 'outputs/unit_test_code_coverage/debugUnitTest/testDebugUnitTest.exec'
])
}
@@ -151,8 +151,9 @@ class EditBillActivity : AppCompatActivity() {
val billIdToDuplicate = intent.getLongExtra(PARAM_BILL_ID_TO_DUPLICATE, 0)
val timeNowSeconds = System.currentTimeMillis() / 1000
if (billIdToDuplicate == 0L) {
val project = db.getProject(projectId)
bill = DBBill(
0, 0, projectId, 0, 0.0, timeNowSeconds,
0, 0, projectId, project?.lastPayerId ?: 0, 0.0, timeNowSeconds,
"", DBBill.STATE_ADDED, DBBill.NON_REPEATED,
DBBill.PAYMODE_NONE, DBBill.CATEGORY_NONE, "", DBBill.PAYMODE_ID_NONE
)
@@ -324,16 +325,26 @@ class EditBillActivity : AppCompatActivity() {
private suspend fun saveBill(): Long = withContext(Dispatchers.IO) {
val groupedBillIds = intent.getLongArrayExtra(PARAM_GROUPED_BILL_IDS)
val isCustomSplit = viewModel.isCustomSplit
val splitMode = viewModel.splitMode
if (isCustomSplit) {
val splits: Map<Long, Double> = viewModel.owersCustomSplit.filter { (id, amountStr) ->
viewModel.owersSelection[id] == true && (amountStr.replace(',', '.').toDoubleOrNull()
?: 0.0) > 0
if (splitMode != SplitMode.EVEN) {
val splits: Map<Long, Double> = if (splitMode == SplitMode.CUSTOM) {
viewModel.owersCustomSplit.filter { (id, amountStr) ->
viewModel.owersSelection[id] == true && viewModel.parseAmountFromUi(amountStr) > 0
}.mapValues {
val uiAmount = it.value.replace(',', '.').toDoubleOrNull() ?: 0.0
val uiAmount = viewModel.parseAmountFromUi(it.value)
SupportUtil.round2(uiAmount / viewModel.selectedCurrencyRate)
}
} else { // PERCENT
val totalAmount = viewModel.amountAsDouble
viewModel.owersPercentSplit.filter { (id, percentStr) ->
viewModel.owersSelection[id] == true && viewModel.parseAmountFromUi(percentStr) > 0
}.mapValues {
val percent = viewModel.parseAmountFromUi(it.value)
val uiAmount = SupportUtil.round2(totalAmount * percent / 100.0)
SupportUtil.round2(uiAmount / viewModel.selectedCurrencyRate)
}
}
if (splits.isEmpty()) return@withContext 0L
@@ -405,16 +416,8 @@ class EditBillActivity : AppCompatActivity() {
val proj = db.getProject(bill.projectId)
if (proj != null) db.syncIfRemote(proj)
db.updateProject(
bill.projectId,
null,
null,
null,
viewModel.payerId,
null,
null,
null,
null,
null
projId = bill.projectId,
newLastPayerId = viewModel.payerId
)
return@withContext firstSavedId
@@ -458,16 +461,8 @@ class EditBillActivity : AppCompatActivity() {
newOwersIds.forEach { newBill.billOwers += DBBillOwer(0, 0, it) }
val newBillId = db.addBill(newBill)
db.updateProject(
bill.projectId,
null,
null,
null,
viewModel.payerId,
null,
null,
null,
null,
null
projId = bill.projectId,
newLastPayerId = viewModel.payerId
)
val proj = db.getProject(bill.projectId)
if (proj != null) db.syncIfRemote(proj)
@@ -1,11 +1,13 @@
package net.helcel.cowspent.android.bill_edit
import android.annotation.SuppressLint
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
@@ -16,12 +18,18 @@ import androidx.compose.material.icons.filled.*
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.scale
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
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.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
@@ -53,6 +61,13 @@ fun EditBillScreen(
) {
val canEdit = accessLevel == DBProject.ACCESS_LEVEL_UNKNOWN || accessLevel >= DBProject.ACCESS_LEVEL_PARTICIPANT
val context = LocalContext.current
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
if (viewModel.isNewBill) {
focusRequester.requestFocus()
}
}
StatefulAlertDialog(
state = viewModel.dialogState,
@@ -128,7 +143,8 @@ fun EditBillScreen(
viewModel = viewModel,
canEdit = canEdit,
onDateClick = onDateClick,
onTimeClick = onTimeClick
onTimeClick = onTimeClick,
amountFocusRequester = focusRequester
)
Spacer(modifier = Modifier.height(8.dp))
@@ -164,7 +180,8 @@ fun BillBasicInfoSection(
viewModel: EditBillViewModel,
canEdit: Boolean,
onDateClick: () -> Unit,
onTimeClick: () -> Unit
onTimeClick: () -> Unit,
amountFocusRequester: FocusRequester
) {
Text(
text = "GENERAL",
@@ -175,29 +192,20 @@ fun BillBasicInfoSection(
)
val context = LocalContext.current
val focusManager = LocalFocusManager.current
val currencyDialogTitle =
stringResource(R.string.currency_dialog_title, viewModel.mainCurrencyName)
OutlinedTextField(
value = viewModel.what,
onValueChange = { viewModel.what = it },
enabled = canEdit,
placeholder = { Text(stringResource(R.string.label_what)) },
modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.Default.Title, contentDescription = null) }
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = viewModel.amount,
onValueChange = {
viewModel.amount = it
onValueChange = { nv->
val filteredValue = nv.filter { it in "0123456789.+-*/" }
viewModel.amount = filteredValue
viewModel.updateSplits()
},
enabled = canEdit,
placeholder = { Text("0") },
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth().focusRequester(amountFocusRequester),
leadingIcon = {
val currencyToShow = viewModel.selectedCurrencyName.ifEmpty {
viewModel.mainCurrencyName.ifEmpty { "$" }
@@ -231,7 +239,21 @@ fun BillBasicInfoSection(
Icon(Icons.Default.SwapHoriz, contentDescription = "Change Currency")
}
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone, imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) })
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = viewModel.what,
onValueChange = { viewModel.what = it },
enabled = canEdit,
placeholder = { Text(stringResource(R.string.label_what)) },
modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.Default.Title, contentDescription = null) },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) })
)
Spacer(modifier = Modifier.height(8.dp))
@@ -357,35 +379,55 @@ fun OwerSelectionSection(
)
Spacer(Modifier.weight(1f))
if (viewModel.isCustomSplit) {
if (viewModel.splitMode != SplitMode.EVEN) {
val diff = viewModel.getDiffSplit()
if (abs(diff) > 0.01) {
val unit = if (viewModel.splitMode == SplitMode.PERCENT) "%" else ""
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)
}"
}$unit"
Text(
diffText,
color = MaterialTheme.colors.error,
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,
enabled = canEdit,
onCheckedChange = {
viewModel.isCustomSplit = !it
val splitOptions = listOf(SplitMode.EVEN, SplitMode.CUSTOM, SplitMode.PERCENT)
Row(
modifier = Modifier
.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()
}
.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Text(
text = when (mode) {
SplitMode.EVEN -> "="
SplitMode.CUSTOM -> "#"
SplitMode.PERCENT -> "%"
},
colors = SwitchDefaults.colors(
uncheckedThumbColor = MaterialTheme.colors.onSurface,
)
color = if (isSelected) MaterialTheme.colors.onPrimary else MaterialTheme.colors.onSurface,
fontSize = 14.sp,
fontWeight = FontWeight.Bold
)
}
}
}
}
viewModel.members.forEach { member ->
val isSelected = viewModel.owersSelection[member.id] ?: false
@@ -410,21 +452,34 @@ fun OwerSelectionSection(
Spacer(modifier = Modifier.width(8.dp))
Text(member.name, modifier = Modifier.weight(1f))
if (isSelected || viewModel.isCustomSplit) {
if (isSelected || viewModel.splitMode != SplitMode.EVEN) {
val focusManager = LocalFocusManager.current
val interactionSource = remember { MutableInteractionSource() }
val value = if (viewModel.splitMode == SplitMode.PERCENT) {
viewModel.owersPercentSplit[member.id] ?: ""
} else {
viewModel.owersCustomSplit[member.id] ?: ""
}
BasicTextField(
value = viewModel.owersCustomSplit[member.id] ?: "",
onValueChange = {
viewModel.owersCustomSplit[member.id] = it
viewModel.owersSelection[member.id] = (it != "")
value = value,
onValueChange = { nv ->
val filteredValue = nv.filter { it in "0123456789.+-*/" }
if (viewModel.splitMode == SplitMode.PERCENT) {
viewModel.owersPercentSplit[member.id] = filteredValue
} else {
viewModel.owersCustomSplit[member.id] = filteredValue
}
viewModel.owersSelection[member.id] = (filteredValue != "")
},
modifier = Modifier
.width(80.dp)
.height(46.dp),
interactionSource = interactionSource,
singleLine = true,
enabled = viewModel.isCustomSplit && canEdit,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
enabled = viewModel.splitMode != SplitMode.EVEN && canEdit,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) }),
cursorBrush = SolidColor(MaterialTheme.colors.onSurface),
textStyle = LocalTextStyle.current.copy(
textAlign = TextAlign.Right,
@@ -432,13 +487,13 @@ fun OwerSelectionSection(
),
) { innerTextField ->
TextFieldDefaults.OutlinedTextFieldDecorationBox(
value = viewModel.owersCustomSplit[member.id] ?: "",
value = value,
visualTransformation = VisualTransformation.None,
innerTextField = innerTextField,
singleLine = true,
enabled = viewModel.isCustomSplit && canEdit,
enabled = viewModel.splitMode != SplitMode.EVEN && canEdit,
interactionSource = interactionSource,
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 0.dp),
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp),
colors = TextFieldDefaults.textFieldColors(
cursorColor = MaterialTheme.colors.onSurface,
backgroundColor = Color.Transparent,
@@ -16,6 +16,10 @@ import net.helcel.cowspent.model.DBMember
import net.helcel.cowspent.util.SupportUtil
import net.helcel.cowspent.util.evalMath
enum class SplitMode {
EVEN, CUSTOM, PERCENT
}
class EditBillViewModel : ViewModel() {
var what by mutableStateOf("")
var amount by mutableStateOf("")
@@ -34,27 +38,36 @@ class EditBillViewModel : ViewModel() {
var members by mutableStateOf<List<DBMember>>(emptyList())
var owersSelection = mutableStateMapOf<Long, Boolean>()
var isCustomSplit by mutableStateOf(false)
var splitMode by mutableStateOf(SplitMode.EVEN)
var owersCustomSplit = mutableStateMapOf<Long, String>()
var owersPercentSplit = mutableStateMapOf<Long, String>()
var dialogState by mutableStateOf<DialogState?>(null)
fun parseAmountFromUi(input: String): Double {
if (input.isBlank()) return 0.0
val sanitized = input.replace(',', '.')
// If it contains any math operator, try evalMath first
if (sanitized.any { it in "+-*/" }) {
try {
val result = evalMath(sanitized)
if (result != 0.0) return SupportUtil.round2(result)
} catch (_: Exception) {}
}
val parsed = parseAmount(input) ?: 0.0
return SupportUtil.round2(parsed)
}
val amountAsDouble: Double
get() {
return parseAmount(amount) ?: try {
evalMath(amount.replace(',', '.'))
} catch (_: Exception) {
0.0
}
}
get() = parseAmountFromUi(amount)
fun getEvenSplit(): Double {
val selectedOwersCount = owersSelection.count { it.value }
return if (selectedOwersCount > 0) amountAsDouble / selectedOwersCount else 0.0
return if (selectedOwersCount > 0) SupportUtil.round2(amountAsDouble / selectedOwersCount) else 0.0
}
fun updateSplits() {
if (!isCustomSplit) {
if (splitMode == SplitMode.EVEN) {
val even = getEvenSplit()
val evenStr = if (even == 0.0) "" else SupportUtil.round2(even).toString()
members.forEach { m ->
@@ -64,18 +77,36 @@ class EditBillViewModel : ViewModel() {
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) {
owersSelection[id] = selected
if (isCustomSplit) {
if (splitMode != SplitMode.EVEN) {
if (selected) {
if (owersCustomSplit[id].isNullOrEmpty()) {
if (splitMode == SplitMode.CUSTOM && owersCustomSplit[id].isNullOrEmpty()) {
owersCustomSplit[id] = "0"
} else if (splitMode == SplitMode.PERCENT && owersPercentSplit[id].isNullOrEmpty()) {
owersPercentSplit[id] = "0"
}
} else {
owersCustomSplit.remove(id)
owersPercentSplit.remove(id)
}
} else {
updateSplits()
@@ -83,10 +114,17 @@ class EditBillViewModel : ViewModel() {
}
fun getDiffSplit(): Double {
return if (splitMode == SplitMode.PERCENT) {
val customTotal = owersPercentSplit.entries
.filter { owersSelection[it.key] == true }
.sumOf { parseAmountFromUi(it.value) }
SupportUtil.round2(100.0 - customTotal)
} else {
val customTotal = owersCustomSplit.entries
.filter { owersSelection[it.key] == true }
.sumOf { it.value.replace(',', '.').toDoubleOrNull() ?: 0.0 }
return amountAsDouble - customTotal
.sumOf { parseAmountFromUi(it.value) }
SupportUtil.round2(amountAsDouble - customTotal)
}
}
fun getOwersIds(): List<Long> {
@@ -204,9 +242,10 @@ class EditBillViewModel : ViewModel() {
owersSelection.clear()
owersCustomSplit.clear()
owersPercentSplit.clear()
if (customSplits != null) {
isCustomSplit = true
splitMode = SplitMode.CUSTOM
for (member in members) {
val selected = customSplits.containsKey(member.id)
owersSelection[member.id] = selected
@@ -222,6 +261,7 @@ class EditBillViewModel : ViewModel() {
}
} else {
// Even split logic
splitMode = SplitMode.EVEN
val billOwerIds = bill.billOwersIds
val selectedCount = billOwerIds.size
@@ -84,9 +84,8 @@ class ManageCurrenciesActivity : AppCompatActivity() {
lifecycleScope.launch {
withContext(Dispatchers.IO) {
db!!.updateProject(
selectedProjectID, null, null, null,
null, null, newMainCurrencyName,
null, null, null
projId = selectedProjectID,
newCurrencyName = newMainCurrencyName
)
val project = db!!.getProject(selectedProjectID)
if (project != null) {
@@ -94,7 +93,12 @@ class ManageCurrenciesActivity : AppCompatActivity() {
if (project.type == ProjectType.COSPEND) {
withContext(Dispatchers.Main) {
if (!db!!.cowspentServerSyncHelper
.editRemoteProject(selectedProjectID, project.name, null, null, newMainCurrencyName, editMainCurrencyCallBack)
.editRemoteProject(
projId = selectedProjectID,
newName = project.name,
newMainCurrencyName = newMainCurrencyName,
callback = editMainCurrencyCallBack
)
) {
showToast(this@ManageCurrenciesActivity, getString(R.string.remote_project_operation_no_network), Toast.LENGTH_LONG)
}
@@ -4,8 +4,10 @@ import android.annotation.SuppressLint
import android.content.Intent
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@@ -417,6 +419,9 @@ fun BillsListScreen(
is SectionItem -> SectionHeader(item.title)
}
}
item {
Spacer(modifier = Modifier.height(64.dp).fillMaxWidth())
}
}
}
}
@@ -88,6 +88,7 @@ object BillsListUtils {
shareIntent,
context.getString(R.string.title_settle)
)
chooserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(chooserIntent)
}
@@ -427,19 +427,26 @@ class BillsListViewActivity :
withContext(Dispatchers.IO) {
db.updateProject(
projId = projectId,
newName = null,
newEmail = null,
newPassword = null,
newLastPayerId = null,
newLastSyncedTimestamp = null,
newCurrencyName = null,
newDeletionDisabled = null,
newMyAccessLevel = null,
newBearerToken = null,
newArchivedTs = newArchivedTs
)
}
if (!proj.isLocal) {
db.cowspentServerSyncHelper.editRemoteProject(
projId = projectId,
newArchivedTs = newArchivedTs,
callback = object : ICallback {
override fun onFinish() {}
override fun onFinish(result: String, message: String) {
if (message.isNotEmpty()) {
showToast(this@BillsListViewActivity, getString(R.string.error_sync, message))
}
}
override fun onScheduled() {}
}
)
}
setupDrawerProjects()
refreshLists()
@@ -114,7 +114,7 @@ object ProjectImportHelper {
}
else -> 0
}
val payerName = if (columns.containsKey("payer_name")) line[columns["payer_name"]!!] else ""
val payerName = if (columns.containsKey("payer_name")) line[columns["payer_name"]!!].trim() else ""
val payerWeight = if (columns.containsKey("payer_weight")) line[columns["payer_weight"]!!].toDouble() else 1.0
val owersStr = if (columns.containsKey("owers")) line[columns["owers"]!!] else ""
val payerActive = columns.containsKey("payer_active") && line[columns["payer_active"]!!] == "1"
@@ -122,8 +122,10 @@ object ProjectImportHelper {
val pmId = if (columns.containsKey("paymentmodeid") && line[columns["paymentmodeid"]!!].isNotEmpty()) line[columns["paymentmodeid"]!!].toLong() else 0L
val pm = if (columns.containsKey("paymentmode")) line[columns["paymentmode"]!!] else null
if (payerName.isNotEmpty()) {
membersActive[payerName] = payerActive
membersWeight[payerName] = payerWeight
}
if (owersStr.trim().isEmpty()) {
onError(context.getString(R.string.import_error_owers, row))
@@ -132,10 +134,10 @@ object ProjectImportHelper {
if (what != "deleteMeIfYouWant") {
billRemoteIdToOwerStr[row.toLong()] = owersStr
val owersArray = owersStr.split(", ").filter { it.isNotEmpty() }
val owersArray = owersStr.split(",").map { it.trim() }.filter { it.isNotEmpty() }
for (ower in owersArray) {
if (!membersWeight.containsKey(ower.trim())) {
membersWeight[ower.trim()] = 1.0
if (!membersWeight.containsKey(ower)) {
membersWeight[ower] = 1.0
}
}
bills.add(DBBill(0, row.toLong(), 0, 0, amount, timestamp, what, DBBill.STATE_OK, "n", pm, catId, comment, pmId))
@@ -171,8 +173,8 @@ object ProjectImportHelper {
val localCatId = catRemoteToLocal[b.categoryId] ?: 0L
val localPmId = pmRemoteToLocal[b.paymentModeId] ?: 0L
val billId = db.addBill(DBBill(0, 0, pid, payerId, b.amount, b.timestamp, b.what, DBBill.STATE_OK, b.repeat, b.paymentMode, localCatId, b.comment, localPmId))
billRemoteIdToOwerStr[b.remoteId]?.split(", ")?.filter { it.isNotEmpty() }?.forEach { ower ->
memberNameToId[ower.trim()]?.let { owerId -> db.addBillower(billId, owerId) }
billRemoteIdToOwerStr[b.remoteId]?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }?.forEach { ower ->
memberNameToId[ower]?.let { owerId -> db.addBillower(billId, owerId) }
}
}
onSuccess(pid)
@@ -85,10 +85,11 @@ class EditProjectActivity : AppCompatActivity() {
lifecycleScope.launch {
withContext(Dispatchers.IO) {
db.updateProject(
project.id, newName, newEmail, targetPwd,
null, project.type, null,
null, null,
null, null
projId = project.id,
newName = newName,
newEmail = newEmail,
newPassword = targetPwd,
projectType = project.type
)
}
closeOnEdit(project.id)
@@ -103,18 +104,21 @@ class EditProjectActivity : AppCompatActivity() {
project.password = currentPwd
lifecycleScope.launch {
withContext(Dispatchers.IO) {
db.updateProject(project.id, null, null, currentPwd, null, project.type, null, null, null, null, null)
db.updateProject(
projId = project.id,
newPassword = currentPwd,
projectType = project.type
)
}
}
}
if (!db.cowspentServerSyncHelper.editRemoteProject(
project.id,
newName,
newEmail,
if (pwdChanged) newPwd else null,
null,
editCallBack
projId = project.id,
newName = newName,
newEmail = newEmail,
newPassword = if (pwdChanged) newPwd else null,
callback = editCallBack
)
) {
showToast(this, getString(R.string.remote_project_operation_no_network), Toast.LENGTH_LONG)
@@ -124,10 +128,9 @@ class EditProjectActivity : AppCompatActivity() {
lifecycleScope.launch {
withContext(Dispatchers.IO) {
db.updateProject(
project.id, null, null, currentPwd,
null, project.type, null,
null, null,
null, null
projId = project.id,
newPassword = currentPwd,
projectType = project.type
)
}
closeOnEdit(project.id)
@@ -271,12 +271,18 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
}
fun updateProject(
projId: Long, newName: String?, newEmail: String?,
newPassword: String?, newLastPayerId: Long?,
newLastSyncedTimestamp: Long?,
newCurrencyName: String?, newDeletionDisabled: Boolean?,
newMyAccessLevel: Int?, newBearerToken: String?,
newArchivedTs: Long? = null
projId: Long,
newName: String? = null,
newEmail: String? = null,
newPassword: String? = null,
newLastPayerId: Long? = null,
newLastSyncedTimestamp: Long? = null,
newCurrencyName: String? = null,
newDeletionDisabled: Boolean? = null,
newMyAccessLevel: Int? = null,
newBearerToken: String? = null,
newArchivedTs: Long? = null,
projectType: ProjectType? = null
) {
val db = writableDatabase
val values = ContentValues()
@@ -290,47 +296,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
if (newDeletionDisabled != null) values.put(key_deletionDisabled, if (newDeletionDisabled) 1 else 0)
if (newMyAccessLevel != null) values.put(key_myAccessLevel, newMyAccessLevel)
if (newArchivedTs != null) values.put(key_archived, newArchivedTs)
if (values.size() > 0) {
db.update(table_projects, values, "$key_id = ?", arrayOf(projId.toString()))
}
}
fun updateProject(
projId: Long, newName: String?, newEmail: String?,
newPassword: String?, newLastPayerId: Long?,
projectType: ProjectType, newLastSyncedTimestamp: Long?,
newCurrencyName: String?, newDeletionDisabled: Boolean?,
newMyAccessLevel: Int?, newBearerToken: String?,
newArchivedTs: Long? = null
) {
val db = writableDatabase
updateProject(
projId, newName, newEmail, newPassword, newLastPayerId, projectType,
newLastSyncedTimestamp, newCurrencyName, newDeletionDisabled, newMyAccessLevel,
newBearerToken, newArchivedTs, db
)
}
private fun updateProject(
projId: Long, newName: String?, newEmail: String?,
newPassword: String?, newLastPayerId: Long?,
projectType: ProjectType, newLastSyncedTimestamp: Long?,
newCurrencyName: String?, newDeletionDisabled: Boolean?,
newMyAccessLevel: Int?, newBearerToken: String?,
newArchivedTs: Long?, db: SQLiteDatabase
) {
val values = ContentValues()
if (newName != null) values.put(key_name, newName)
if (newEmail != null) values.put(key_email, newEmail)
if (newPassword != null) SecureStorage.savePasswordSync(context, "ProjectPassword_$projId", newPassword)
if (newBearerToken != null) values.put(key_bearer_token, newBearerToken)
if (newLastPayerId != null) values.put(key_lastPayerId, newLastPayerId)
if (newLastSyncedTimestamp != null) values.put(key_lastSyncTimestamp, newLastSyncedTimestamp)
if (newCurrencyName != null) values.put(key_currencyName, newCurrencyName)
if (newDeletionDisabled != null) values.put(key_deletionDisabled, if (newDeletionDisabled) 1 else 0)
if (newMyAccessLevel != null) values.put(key_myAccessLevel, newMyAccessLevel)
if (newArchivedTs != null) values.put(key_archived, newArchivedTs)
values.put(key_type, projectType.id)
if (projectType != null) values.put(key_type, projectType.id)
if (values.size() > 0) {
db.update(table_projects, values, "$key_id = ?", arrayOf(projId.toString()))
}
@@ -85,7 +85,8 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
val isSyncPossible: Boolean
get() {
updateNetworkStatus()
return networkConnected
val offlineMode = preferences.getBoolean(appContext.getString(R.string.pref_key_offline_mode), false)
return networkConnected && !offlineMode
}
fun addCallbackPull(callback: ICallback) {
@@ -817,10 +818,8 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
}
dbHelper.updateProject(
project.id, null, null,
null, null, serverSyncTimestamp,
null, null, null,
null
projId = project.id,
newLastSyncedTimestamp = serverSyncTimestamp
)
LoginStatus.OK
} catch (_: ServerResponse.NotModifiedException) {
@@ -935,11 +934,18 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
return isCospend && projUrl == accountUrl
}
fun editRemoteProject(projId: Long, newName: String?, newEmail: String?,
newPassword: String?, newMainCurrencyName: String?, callback: ICallback): Boolean {
fun editRemoteProject(
projId: Long,
newName: String? = null,
newEmail: String? = null,
newPassword: String? = null,
newMainCurrencyName: String? = null,
newArchivedTs: Long? = null,
callback: ICallback
): Boolean {
updateNetworkStatus()
if (isSyncPossible) {
EditRemoteProjectTask(projId, newName, newEmail, newPassword, newMainCurrencyName, callback).execute()
EditRemoteProjectTask(projId, newName, newEmail, newPassword, newMainCurrencyName, newArchivedTs, callback).execute()
return true
}
return false
@@ -951,6 +957,7 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
private val newEmail: String?,
private val newPassword: String?,
private val newMainCurrencyName: String?,
private val newArchivedTs: Long?,
private val callback: ICallback
) {
private val project: DBProject? = dbHelper.getProject(projId)
@@ -985,7 +992,7 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
var status = LoginStatus.OK
try {
val response = client!!.editRemoteProject(
project!!, newName, newEmail, newPassword, newMainCurrencyName
project!!, newName, newEmail, newPassword, newMainCurrencyName, newArchivedTs
)
if (BillsListViewActivity.DEBUG) {
Log.i(TAG, "RESPONSE edit remote project : ${response.stringContent}")
@@ -1025,8 +1032,11 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
}
} else {
dbHelper.updateProject(
project!!.id, newName, newEmail, newPassword,
null, null, null, null, null, null
projId = project!!.id,
newName = newName,
newEmail = newEmail,
newPassword = newPassword,
newArchivedTs = newArchivedTs
)
}
callback.onFinish(newName ?: "", errorString)
@@ -1205,7 +1215,7 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
}
}
private fun hasChanged(localBill: DBBill, remoteBill: DBBill): Boolean {
internal fun hasChanged(localBill: DBBill, remoteBill: DBBill): Boolean {
if (localBill.payerId == remoteBill.payerId &&
localBill.amount == remoteBill.amount &&
localBill.timestamp == remoteBill.timestamp &&
@@ -114,7 +114,7 @@ class VersatileProjectSyncClient(
@Throws(IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class)
fun editRemoteProject(
project: DBProject, newName: String?, newEmail: String?, newPassword: String?,
newMainCurrencyName: String?
newMainCurrencyName: String?, newArchivedTs: Long? = null
): ServerResponse.EditRemoteProjectResponse {
val paramKeys: MutableList<String> = ArrayList()
val paramValues: MutableList<String> = ArrayList()
@@ -130,6 +130,10 @@ class VersatileProjectSyncClient(
paramKeys.add("password")
paramValues.add(newPassword)
}
if (newArchivedTs != null) {
paramKeys.add("archived_ts")
paramValues.add(newArchivedTs.toString())
}
var target: String
var username: String? = null
@@ -5,9 +5,11 @@ fun evalMath(expression: String): Double {
var result = 0.0
var db: SQLiteDatabase? = null
try {
// Force floating point division by replacing / with * 1.0 /
val forcedRealExpr = expression.replace("/", "* 1.0 /")
// Opens a temporary, in-memory system database block
db = SQLiteDatabase.create(null)
val cursor = db.rawQuery("SELECT ($expression);", null)
val cursor = db.rawQuery("SELECT ($forcedRealExpr);", null)
if (cursor.moveToFirst()) {
result = cursor.getDouble(0)
}
Binary file not shown.
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
Vendored
+5 -8
View File
@@ -1,7 +1,7 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -20,7 +20,7 @@
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
# gradlew start up script for POSIX generated by Gradle.
#
# Important for running:
#
@@ -29,7 +29,7 @@
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
# ksh gradlew
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
@@ -57,7 +57,7 @@
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
@@ -114,7 +114,6 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
@@ -172,7 +171,6 @@ fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
@@ -212,8 +210,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
Vendored
+12 -24
View File
@@ -19,12 +19,12 @@
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem gradlew startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@@ -51,7 +51,7 @@ echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
"%COMSPEC%" /c exit 1
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
@@ -65,30 +65,18 @@ echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
@rem Execute gradlew
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%
+3
View File
@@ -6,6 +6,9 @@ pluginManagement {
maven { url = 'https://jitpack.io' }
}
}
plugins {
id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0'
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {