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 - name: Setup Gradle
uses: gradle/actions/setup-gradle@v6 uses: gradle/actions/setup-gradle@v6
- name: Run unit tests
run: ./gradlew testDebugUnitTest
- name: Build APK - name: Build APK
run: | run: |
VERSION_CODE=$(git rev-list --count HEAD) VERSION_CODE=$(git rev-list --count HEAD)
VERSION_NAME=$(git describe --tags --always) VERSION_NAME=$(git describe --tags --always)
./gradlew assembleSignedRelease -PVERSION_CODE=$VERSION_CODE -PVERSION_NAME=$VERSION_NAME ./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 - name: Release
uses: softprops/action-gh-release@v3 uses: softprops/action-gh-release@v3
if: startsWith(github.ref, 'refs/tags/') if: startsWith(github.ref, 'refs/tags/')
+43 -10
View File
@@ -1,7 +1,8 @@
plugins { plugins {
id 'com.android.application' version '9.2.1' id 'com.android.application' version '9.3.2'
id 'org.jetbrains.kotlin.plugin.serialization' version '2.4.0' id 'org.jetbrains.kotlin.plugin.serialization' version '2.4.10'
id 'org.jetbrains.kotlin.plugin.compose' version '2.4.0' id 'org.jetbrains.kotlin.plugin.compose' version '2.4.10'
id 'jacoco'
} }
android { android {
@@ -40,6 +41,7 @@ android {
debuggable true debuggable true
initWith(buildTypes.release) initWith(buildTypes.release)
signingConfig = signingConfigs.debug signingConfig = signingConfigs.debug
enableUnitTestCoverage true
} }
release { release {
minifyEnabled true minifyEnabled true
@@ -69,6 +71,12 @@ android {
compose = true compose = true
} }
testOptions {
unitTests {
includeAndroidResources = true
}
}
kotlin { kotlin {
jvmToolchain(21) jvmToolchain(21)
} }
@@ -96,21 +104,21 @@ android {
} }
dependencies { dependencies {
implementation 'androidx.compose.foundation:foundation:1.11.4' implementation 'androidx.compose.foundation:foundation:1.12.0'
implementation 'androidx.compose.runtime:runtime:1.11.4' implementation 'androidx.compose.runtime:runtime:1.12.0'
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.11.2'
implementation 'com.google.zxing:core:3.5.4' implementation 'com.google.zxing:core:3.5.4'
implementation 'androidx.camera:camera-camera2:1.6.1' implementation 'androidx.camera:camera-camera2:1.6.2'
implementation 'androidx.camera:camera-lifecycle:1.6.1' implementation 'androidx.camera:camera-lifecycle:1.6.2'
implementation 'androidx.camera:camera-view:1.6.1' implementation 'androidx.camera:camera-view:1.6.2'
implementation 'com.github.nextcloud:Android-SingleSignOn:1.1.0' implementation 'com.github.nextcloud:Android-SingleSignOn:1.1.0'
implementation 'com.opencsv:opencsv:5.12.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.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'
@@ -121,5 +129,30 @@ dependencies {
implementation 'androidx.security:security-crypto:1.1.0' implementation 'androidx.security:security-crypto:1.1.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.11.0' implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.11.0'
implementation "androidx.datastore:datastore-preferences:1.2.1" 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 billIdToDuplicate = intent.getLongExtra(PARAM_BILL_ID_TO_DUPLICATE, 0)
val timeNowSeconds = System.currentTimeMillis() / 1000 val timeNowSeconds = System.currentTimeMillis() / 1000
if (billIdToDuplicate == 0L) { if (billIdToDuplicate == 0L) {
val project = db.getProject(projectId)
bill = DBBill( 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.STATE_ADDED, DBBill.NON_REPEATED,
DBBill.PAYMODE_NONE, DBBill.CATEGORY_NONE, "", DBBill.PAYMODE_ID_NONE 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) { private suspend fun saveBill(): Long = withContext(Dispatchers.IO) {
val groupedBillIds = intent.getLongArrayExtra(PARAM_GROUPED_BILL_IDS) val groupedBillIds = intent.getLongArrayExtra(PARAM_GROUPED_BILL_IDS)
val isCustomSplit = viewModel.isCustomSplit val splitMode = viewModel.splitMode
if (isCustomSplit) { if (splitMode != SplitMode.EVEN) {
val splits: Map<Long, Double> = viewModel.owersCustomSplit.filter { (id, amountStr) -> val splits: Map<Long, Double> = if (splitMode == SplitMode.CUSTOM) {
viewModel.owersSelection[id] == true && (amountStr.replace(',', '.').toDoubleOrNull() viewModel.owersCustomSplit.filter { (id, amountStr) ->
?: 0.0) > 0 viewModel.owersSelection[id] == true && viewModel.parseAmountFromUi(amountStr) > 0
}.mapValues { }.mapValues {
val uiAmount = it.value.replace(',', '.').toDoubleOrNull() ?: 0.0 val uiAmount = viewModel.parseAmountFromUi(it.value)
SupportUtil.round2(uiAmount / viewModel.selectedCurrencyRate) SupportUtil.round2(uiAmount / viewModel.selectedCurrencyRate)
} }
} else { // PERCENT
val totalAmount = viewModel.amountAsDouble
viewModel.owersPercentSplit.filter { (id, percentStr) ->
viewModel.owersSelection[id] == true && 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 if (splits.isEmpty()) return@withContext 0L
@@ -405,16 +416,8 @@ class EditBillActivity : AppCompatActivity() {
val proj = db.getProject(bill.projectId) val proj = db.getProject(bill.projectId)
if (proj != null) db.syncIfRemote(proj) if (proj != null) db.syncIfRemote(proj)
db.updateProject( db.updateProject(
bill.projectId, projId = bill.projectId,
null, newLastPayerId = viewModel.payerId
null,
null,
viewModel.payerId,
null,
null,
null,
null,
null
) )
return@withContext firstSavedId return@withContext firstSavedId
@@ -458,16 +461,8 @@ class EditBillActivity : AppCompatActivity() {
newOwersIds.forEach { newBill.billOwers += DBBillOwer(0, 0, it) } newOwersIds.forEach { newBill.billOwers += DBBillOwer(0, 0, it) }
val newBillId = db.addBill(newBill) val newBillId = db.addBill(newBill)
db.updateProject( db.updateProject(
bill.projectId, projId = bill.projectId,
null, newLastPayerId = viewModel.payerId
null,
null,
viewModel.payerId,
null,
null,
null,
null,
null
) )
val proj = db.getProject(bill.projectId) val proj = db.getProject(bill.projectId)
if (proj != null) db.syncIfRemote(proj) if (proj != null) db.syncIfRemote(proj)
@@ -1,11 +1,13 @@
package net.helcel.cowspent.android.bill_edit package net.helcel.cowspent.android.bill_edit
import android.annotation.SuppressLint import android.annotation.SuppressLint
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.* import androidx.compose.material.*
@@ -16,12 +18,18 @@ import androidx.compose.material.icons.filled.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale import androidx.compose.ui.draw.scale
import androidx.compose.ui.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.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.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.KeyboardType import androidx.compose.ui.text.input.KeyboardType
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
@@ -53,6 +61,13 @@ 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() }
LaunchedEffect(Unit) {
if (viewModel.isNewBill) {
focusRequester.requestFocus()
}
}
StatefulAlertDialog( StatefulAlertDialog(
state = viewModel.dialogState, state = viewModel.dialogState,
@@ -128,7 +143,8 @@ fun EditBillScreen(
viewModel = viewModel, viewModel = viewModel,
canEdit = canEdit, canEdit = canEdit,
onDateClick = onDateClick, onDateClick = onDateClick,
onTimeClick = onTimeClick onTimeClick = onTimeClick,
amountFocusRequester = focusRequester
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
@@ -164,7 +180,8 @@ fun BillBasicInfoSection(
viewModel: EditBillViewModel, viewModel: EditBillViewModel,
canEdit: Boolean, canEdit: Boolean,
onDateClick: () -> Unit, onDateClick: () -> Unit,
onTimeClick: () -> Unit onTimeClick: () -> Unit,
amountFocusRequester: FocusRequester
) { ) {
Text( Text(
text = "GENERAL", text = "GENERAL",
@@ -175,29 +192,20 @@ fun BillBasicInfoSection(
) )
val context = LocalContext.current val context = LocalContext.current
val focusManager = LocalFocusManager.current
val currencyDialogTitle = val currencyDialogTitle =
stringResource(R.string.currency_dialog_title, viewModel.mainCurrencyName) 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( OutlinedTextField(
value = viewModel.amount, value = viewModel.amount,
onValueChange = { onValueChange = { nv->
viewModel.amount = it val filteredValue = nv.filter { it in "0123456789.+-*/" }
viewModel.amount = filteredValue
viewModel.updateSplits() viewModel.updateSplits()
}, },
enabled = canEdit, enabled = canEdit,
placeholder = { Text("0") }, placeholder = { Text("0") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth().focusRequester(amountFocusRequester),
leadingIcon = { leadingIcon = {
val currencyToShow = viewModel.selectedCurrencyName.ifEmpty { val currencyToShow = viewModel.selectedCurrencyName.ifEmpty {
viewModel.mainCurrencyName.ifEmpty { "$" } viewModel.mainCurrencyName.ifEmpty { "$" }
@@ -231,7 +239,21 @@ fun BillBasicInfoSection(
Icon(Icons.Default.SwapHoriz, contentDescription = "Change Currency") 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)) Spacer(modifier = Modifier.height(8.dp))
@@ -357,35 +379,55 @@ fun OwerSelectionSection(
) )
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
if (viewModel.isCustomSplit) { if (viewModel.splitMode != SplitMode.EVEN) {
val diff = viewModel.getDiffSplit() val diff = viewModel.getDiffSplit()
if (abs(diff) > 0.01) { if (abs(diff) > 0.01) {
val unit = if (viewModel.splitMode == SplitMode.PERCENT) "%" else ""
val diffText = val diffText =
if (diff > 0) "Missing: ${SupportUtil.normalNumberFormat.format(diff)}" else "Excess: ${ if (diff > 0) "Missing: ${SupportUtil.normalNumberFormat.format(diff)}$unit" else "Excess: ${
SupportUtil.normalNumberFormat.format(-diff) SupportUtil.normalNumberFormat.format(-diff)
}" }$unit"
Text( Text(
diffText, diffText,
color = MaterialTheme.colors.error, color = MaterialTheme.colors.error,
fontSize = 12.sp, fontSize = 12.sp,
modifier = Modifier.padding(horizontal = 16.dp) modifier = Modifier.padding(horizontal = 4.dp)
) )
} }
} else {
Text("Even Split", fontSize = 12.sp)
} }
Switch(
checked = !viewModel.isCustomSplit, val splitOptions = listOf(SplitMode.EVEN, SplitMode.CUSTOM, SplitMode.PERCENT)
enabled = canEdit, Row(
onCheckedChange = { modifier = Modifier
viewModel.isCustomSplit = !it .padding(start = 8.dp)
.background(MaterialTheme.colors.onSurface.copy(alpha = 0.05f), MaterialTheme.shapes.small)
) {
splitOptions.forEach { mode ->
val isSelected = viewModel.splitMode == mode
Box(
modifier = Modifier
.clip(MaterialTheme.shapes.small)
.background(if (isSelected) MaterialTheme.colors.primary else Color.Transparent)
.clickable(enabled = canEdit) {
viewModel.splitMode = mode
viewModel.updateSplits() viewModel.updateSplits()
}
.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Text(
text = when (mode) {
SplitMode.EVEN -> "="
SplitMode.CUSTOM -> "#"
SplitMode.PERCENT -> "%"
}, },
colors = SwitchDefaults.colors( color = if (isSelected) MaterialTheme.colors.onPrimary else MaterialTheme.colors.onSurface,
uncheckedThumbColor = MaterialTheme.colors.onSurface, fontSize = 14.sp,
) fontWeight = FontWeight.Bold
) )
} }
}
}
}
viewModel.members.forEach { member -> viewModel.members.forEach { member ->
val isSelected = viewModel.owersSelection[member.id] ?: false val isSelected = viewModel.owersSelection[member.id] ?: false
@@ -410,21 +452,34 @@ fun OwerSelectionSection(
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text(member.name, modifier = Modifier.weight(1f)) Text(member.name, modifier = Modifier.weight(1f))
if (isSelected || viewModel.isCustomSplit) { if (isSelected || viewModel.splitMode != SplitMode.EVEN) {
val focusManager = LocalFocusManager.current
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val value = if (viewModel.splitMode == SplitMode.PERCENT) {
viewModel.owersPercentSplit[member.id] ?: ""
} else {
viewModel.owersCustomSplit[member.id] ?: ""
}
BasicTextField( BasicTextField(
value = viewModel.owersCustomSplit[member.id] ?: "", value = value,
onValueChange = { onValueChange = { nv ->
viewModel.owersCustomSplit[member.id] = it val filteredValue = nv.filter { it in "0123456789.+-*/" }
viewModel.owersSelection[member.id] = (it != "") if (viewModel.splitMode == SplitMode.PERCENT) {
viewModel.owersPercentSplit[member.id] = filteredValue
} else {
viewModel.owersCustomSplit[member.id] = filteredValue
}
viewModel.owersSelection[member.id] = (filteredValue != "")
}, },
modifier = Modifier modifier = Modifier
.width(80.dp) .width(80.dp)
.height(46.dp), .height(46.dp),
interactionSource = interactionSource, interactionSource = interactionSource,
singleLine = true, singleLine = true,
enabled = viewModel.isCustomSplit && canEdit, enabled = viewModel.splitMode != SplitMode.EVEN && canEdit,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) }),
cursorBrush = SolidColor(MaterialTheme.colors.onSurface), cursorBrush = SolidColor(MaterialTheme.colors.onSurface),
textStyle = LocalTextStyle.current.copy( textStyle = LocalTextStyle.current.copy(
textAlign = TextAlign.Right, textAlign = TextAlign.Right,
@@ -432,13 +487,13 @@ fun OwerSelectionSection(
), ),
) { innerTextField -> ) { innerTextField ->
TextFieldDefaults.OutlinedTextFieldDecorationBox( TextFieldDefaults.OutlinedTextFieldDecorationBox(
value = viewModel.owersCustomSplit[member.id] ?: "", value = value,
visualTransformation = VisualTransformation.None, visualTransformation = VisualTransformation.None,
innerTextField = innerTextField, innerTextField = innerTextField,
singleLine = true, singleLine = true,
enabled = viewModel.isCustomSplit && canEdit, enabled = viewModel.splitMode != SplitMode.EVEN && canEdit,
interactionSource = interactionSource, interactionSource = interactionSource,
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 0.dp), contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp),
colors = TextFieldDefaults.textFieldColors( colors = TextFieldDefaults.textFieldColors(
cursorColor = MaterialTheme.colors.onSurface, cursorColor = MaterialTheme.colors.onSurface,
backgroundColor = Color.Transparent, backgroundColor = Color.Transparent,
@@ -16,6 +16,10 @@ import net.helcel.cowspent.model.DBMember
import net.helcel.cowspent.util.SupportUtil import net.helcel.cowspent.util.SupportUtil
import net.helcel.cowspent.util.evalMath import net.helcel.cowspent.util.evalMath
enum class SplitMode {
EVEN, CUSTOM, PERCENT
}
class EditBillViewModel : ViewModel() { class EditBillViewModel : ViewModel() {
var what by mutableStateOf("") var what by mutableStateOf("")
var amount by mutableStateOf("") var amount by mutableStateOf("")
@@ -34,27 +38,36 @@ class EditBillViewModel : ViewModel() {
var members by mutableStateOf<List<DBMember>>(emptyList()) var members by mutableStateOf<List<DBMember>>(emptyList())
var owersSelection = mutableStateMapOf<Long, Boolean>() var owersSelection = mutableStateMapOf<Long, Boolean>()
var isCustomSplit by mutableStateOf(false) var splitMode by mutableStateOf(SplitMode.EVEN)
var owersCustomSplit = mutableStateMapOf<Long, String>() var owersCustomSplit = mutableStateMapOf<Long, String>()
var owersPercentSplit = mutableStateMapOf<Long, String>()
var dialogState by mutableStateOf<DialogState?>(null) var dialogState by mutableStateOf<DialogState?>(null)
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 val amountAsDouble: Double
get() { get() = parseAmountFromUi(amount)
return parseAmount(amount) ?: try {
evalMath(amount.replace(',', '.'))
} catch (_: Exception) {
0.0
}
}
fun getEvenSplit(): Double { fun getEvenSplit(): Double {
val selectedOwersCount = owersSelection.count { it.value } 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() { fun updateSplits() {
if (!isCustomSplit) { if (splitMode == SplitMode.EVEN) {
val even = getEvenSplit() val even = getEvenSplit()
val evenStr = if (even == 0.0) "" else SupportUtil.round2(even).toString() val evenStr = if (even == 0.0) "" else SupportUtil.round2(even).toString()
members.forEach { m -> members.forEach { m ->
@@ -64,18 +77,36 @@ class EditBillViewModel : ViewModel() {
owersCustomSplit.remove(m.id) owersCustomSplit.remove(m.id)
} }
} }
} else if (splitMode == SplitMode.PERCENT) {
val selectedCount = owersSelection.count { it.value }
if (selectedCount > 0) {
val evenPercent = 100.0 / selectedCount
val evenPercentStr = SupportUtil.round2(evenPercent).toString()
members.forEach { m ->
if (owersSelection[m.id] == true) {
if (owersPercentSplit[m.id].isNullOrEmpty()) {
owersPercentSplit[m.id] = evenPercentStr
}
} else {
owersPercentSplit.remove(m.id)
}
}
}
} }
} }
fun toggleMember(id: Long, selected: Boolean) { fun toggleMember(id: Long, selected: Boolean) {
owersSelection[id] = selected owersSelection[id] = selected
if (isCustomSplit) { if (splitMode != SplitMode.EVEN) {
if (selected) { if (selected) {
if (owersCustomSplit[id].isNullOrEmpty()) { if (splitMode == SplitMode.CUSTOM && owersCustomSplit[id].isNullOrEmpty()) {
owersCustomSplit[id] = "0" owersCustomSplit[id] = "0"
} else if (splitMode == SplitMode.PERCENT && owersPercentSplit[id].isNullOrEmpty()) {
owersPercentSplit[id] = "0"
} }
} else { } else {
owersCustomSplit.remove(id) owersCustomSplit.remove(id)
owersPercentSplit.remove(id)
} }
} else { } else {
updateSplits() updateSplits()
@@ -83,10 +114,17 @@ class EditBillViewModel : ViewModel() {
} }
fun getDiffSplit(): Double { fun getDiffSplit(): Double {
return if (splitMode == SplitMode.PERCENT) {
val customTotal = owersPercentSplit.entries
.filter { owersSelection[it.key] == true }
.sumOf { parseAmountFromUi(it.value) }
SupportUtil.round2(100.0 - customTotal)
} else {
val customTotal = owersCustomSplit.entries val customTotal = owersCustomSplit.entries
.filter { owersSelection[it.key] == true } .filter { owersSelection[it.key] == true }
.sumOf { it.value.replace(',', '.').toDoubleOrNull() ?: 0.0 } .sumOf { parseAmountFromUi(it.value) }
return amountAsDouble - customTotal SupportUtil.round2(amountAsDouble - customTotal)
}
} }
fun getOwersIds(): List<Long> { fun getOwersIds(): List<Long> {
@@ -204,9 +242,10 @@ class EditBillViewModel : ViewModel() {
owersSelection.clear() owersSelection.clear()
owersCustomSplit.clear() owersCustomSplit.clear()
owersPercentSplit.clear()
if (customSplits != null) { if (customSplits != null) {
isCustomSplit = true splitMode = SplitMode.CUSTOM
for (member in members) { for (member in members) {
val selected = customSplits.containsKey(member.id) val selected = customSplits.containsKey(member.id)
owersSelection[member.id] = selected owersSelection[member.id] = selected
@@ -222,6 +261,7 @@ class EditBillViewModel : ViewModel() {
} }
} else { } else {
// Even split logic // Even split logic
splitMode = SplitMode.EVEN
val billOwerIds = bill.billOwersIds val billOwerIds = bill.billOwersIds
val selectedCount = billOwerIds.size val selectedCount = billOwerIds.size
@@ -84,9 +84,8 @@ class ManageCurrenciesActivity : AppCompatActivity() {
lifecycleScope.launch { lifecycleScope.launch {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
db!!.updateProject( db!!.updateProject(
selectedProjectID, null, null, null, projId = selectedProjectID,
null, null, newMainCurrencyName, newCurrencyName = newMainCurrencyName
null, null, null
) )
val project = db!!.getProject(selectedProjectID) val project = db!!.getProject(selectedProjectID)
if (project != null) { if (project != null) {
@@ -94,7 +93,12 @@ class ManageCurrenciesActivity : AppCompatActivity() {
if (project.type == ProjectType.COSPEND) { if (project.type == ProjectType.COSPEND) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (!db!!.cowspentServerSyncHelper 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) 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 android.content.Intent
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
@@ -417,6 +419,9 @@ fun BillsListScreen(
is SectionItem -> SectionHeader(item.title) is SectionItem -> SectionHeader(item.title)
} }
} }
item {
Spacer(modifier = Modifier.height(64.dp).fillMaxWidth())
}
} }
} }
} }
@@ -88,6 +88,7 @@ object BillsListUtils {
shareIntent, shareIntent,
context.getString(R.string.title_settle) context.getString(R.string.title_settle)
) )
chooserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(chooserIntent) context.startActivity(chooserIntent)
} }
@@ -427,19 +427,26 @@ class BillsListViewActivity :
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
db.updateProject( db.updateProject(
projId = projectId, projId = projectId,
newName = null,
newEmail = null,
newPassword = null,
newLastPayerId = null,
newLastSyncedTimestamp = null,
newCurrencyName = null,
newDeletionDisabled = null,
newMyAccessLevel = null,
newBearerToken = null,
newArchivedTs = newArchivedTs 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() setupDrawerProjects()
refreshLists() refreshLists()
@@ -114,7 +114,7 @@ object ProjectImportHelper {
} }
else -> 0 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 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 owersStr = if (columns.containsKey("owers")) line[columns["owers"]!!] else ""
val payerActive = columns.containsKey("payer_active") && line[columns["payer_active"]!!] == "1" 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 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 val pm = if (columns.containsKey("paymentmode")) line[columns["paymentmode"]!!] else null
if (payerName.isNotEmpty()) {
membersActive[payerName] = payerActive membersActive[payerName] = payerActive
membersWeight[payerName] = payerWeight membersWeight[payerName] = payerWeight
}
if (owersStr.trim().isEmpty()) { if (owersStr.trim().isEmpty()) {
onError(context.getString(R.string.import_error_owers, row)) onError(context.getString(R.string.import_error_owers, row))
@@ -132,10 +134,10 @@ object ProjectImportHelper {
if (what != "deleteMeIfYouWant") { if (what != "deleteMeIfYouWant") {
billRemoteIdToOwerStr[row.toLong()] = owersStr 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) { for (ower in owersArray) {
if (!membersWeight.containsKey(ower.trim())) { if (!membersWeight.containsKey(ower)) {
membersWeight[ower.trim()] = 1.0 membersWeight[ower] = 1.0
} }
} }
bills.add(DBBill(0, row.toLong(), 0, 0, amount, timestamp, what, DBBill.STATE_OK, "n", pm, catId, comment, pmId)) 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 localCatId = catRemoteToLocal[b.categoryId] ?: 0L
val localPmId = pmRemoteToLocal[b.paymentModeId] ?: 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)) 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 -> billRemoteIdToOwerStr[b.remoteId]?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }?.forEach { ower ->
memberNameToId[ower.trim()]?.let { owerId -> db.addBillower(billId, owerId) } memberNameToId[ower]?.let { owerId -> db.addBillower(billId, owerId) }
} }
} }
onSuccess(pid) onSuccess(pid)
@@ -85,10 +85,11 @@ class EditProjectActivity : AppCompatActivity() {
lifecycleScope.launch { lifecycleScope.launch {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
db.updateProject( db.updateProject(
project.id, newName, newEmail, targetPwd, projId = project.id,
null, project.type, null, newName = newName,
null, null, newEmail = newEmail,
null, null newPassword = targetPwd,
projectType = project.type
) )
} }
closeOnEdit(project.id) closeOnEdit(project.id)
@@ -103,18 +104,21 @@ class EditProjectActivity : AppCompatActivity() {
project.password = currentPwd project.password = currentPwd
lifecycleScope.launch { lifecycleScope.launch {
withContext(Dispatchers.IO) { 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( if (!db.cowspentServerSyncHelper.editRemoteProject(
project.id, projId = project.id,
newName, newName = newName,
newEmail, newEmail = newEmail,
if (pwdChanged) newPwd else null, newPassword = if (pwdChanged) newPwd else null,
null, callback = editCallBack
editCallBack
) )
) { ) {
showToast(this, getString(R.string.remote_project_operation_no_network), Toast.LENGTH_LONG) showToast(this, getString(R.string.remote_project_operation_no_network), Toast.LENGTH_LONG)
@@ -124,10 +128,9 @@ class EditProjectActivity : AppCompatActivity() {
lifecycleScope.launch { lifecycleScope.launch {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
db.updateProject( db.updateProject(
project.id, null, null, currentPwd, projId = project.id,
null, project.type, null, newPassword = currentPwd,
null, null, projectType = project.type
null, null
) )
} }
closeOnEdit(project.id) closeOnEdit(project.id)
@@ -271,12 +271,18 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
} }
fun updateProject( fun updateProject(
projId: Long, newName: String?, newEmail: String?, projId: Long,
newPassword: String?, newLastPayerId: Long?, newName: String? = null,
newLastSyncedTimestamp: Long?, newEmail: String? = null,
newCurrencyName: String?, newDeletionDisabled: Boolean?, newPassword: String? = null,
newMyAccessLevel: Int?, newBearerToken: String?, newLastPayerId: Long? = null,
newArchivedTs: 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 db = writableDatabase
val values = ContentValues() 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 (newDeletionDisabled != null) values.put(key_deletionDisabled, if (newDeletionDisabled) 1 else 0)
if (newMyAccessLevel != null) values.put(key_myAccessLevel, newMyAccessLevel) if (newMyAccessLevel != null) values.put(key_myAccessLevel, newMyAccessLevel)
if (newArchivedTs != null) values.put(key_archived, newArchivedTs) if (newArchivedTs != null) values.put(key_archived, newArchivedTs)
if (values.size() > 0) { if (projectType != null) values.put(key_type, projectType.id)
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 (values.size() > 0) { if (values.size() > 0) {
db.update(table_projects, values, "$key_id = ?", arrayOf(projId.toString())) 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 val isSyncPossible: Boolean
get() { get() {
updateNetworkStatus() updateNetworkStatus()
return networkConnected val offlineMode = preferences.getBoolean(appContext.getString(R.string.pref_key_offline_mode), false)
return networkConnected && !offlineMode
} }
fun addCallbackPull(callback: ICallback) { fun addCallbackPull(callback: ICallback) {
@@ -817,10 +818,8 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
} }
dbHelper.updateProject( dbHelper.updateProject(
project.id, null, null, projId = project.id,
null, null, serverSyncTimestamp, newLastSyncedTimestamp = serverSyncTimestamp
null, null, null,
null
) )
LoginStatus.OK LoginStatus.OK
} catch (_: ServerResponse.NotModifiedException) { } catch (_: ServerResponse.NotModifiedException) {
@@ -935,11 +934,18 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
return isCospend && projUrl == accountUrl return isCospend && projUrl == accountUrl
} }
fun editRemoteProject(projId: Long, newName: String?, newEmail: String?, fun editRemoteProject(
newPassword: String?, newMainCurrencyName: String?, callback: ICallback): Boolean { projId: Long,
newName: String? = null,
newEmail: String? = null,
newPassword: String? = null,
newMainCurrencyName: String? = null,
newArchivedTs: Long? = null,
callback: ICallback
): Boolean {
updateNetworkStatus() updateNetworkStatus()
if (isSyncPossible) { if (isSyncPossible) {
EditRemoteProjectTask(projId, newName, newEmail, newPassword, newMainCurrencyName, callback).execute() EditRemoteProjectTask(projId, newName, newEmail, newPassword, newMainCurrencyName, newArchivedTs, callback).execute()
return true return true
} }
return false return false
@@ -951,6 +957,7 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
private val newEmail: String?, private val newEmail: String?,
private val newPassword: String?, private val newPassword: String?,
private val newMainCurrencyName: String?, private val newMainCurrencyName: String?,
private val newArchivedTs: Long?,
private val callback: ICallback private val callback: ICallback
) { ) {
private val project: DBProject? = dbHelper.getProject(projId) private val project: DBProject? = dbHelper.getProject(projId)
@@ -985,7 +992,7 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
var status = LoginStatus.OK var status = LoginStatus.OK
try { try {
val response = client!!.editRemoteProject( val response = client!!.editRemoteProject(
project!!, newName, newEmail, newPassword, newMainCurrencyName project!!, newName, newEmail, newPassword, newMainCurrencyName, newArchivedTs
) )
if (BillsListViewActivity.DEBUG) { if (BillsListViewActivity.DEBUG) {
Log.i(TAG, "RESPONSE edit remote project : ${response.stringContent}") Log.i(TAG, "RESPONSE edit remote project : ${response.stringContent}")
@@ -1025,8 +1032,11 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
} }
} else { } else {
dbHelper.updateProject( dbHelper.updateProject(
project!!.id, newName, newEmail, newPassword, projId = project!!.id,
null, null, null, null, null, null newName = newName,
newEmail = newEmail,
newPassword = newPassword,
newArchivedTs = newArchivedTs
) )
} }
callback.onFinish(newName ?: "", errorString) 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 && if (localBill.payerId == remoteBill.payerId &&
localBill.amount == remoteBill.amount && localBill.amount == remoteBill.amount &&
localBill.timestamp == remoteBill.timestamp && localBill.timestamp == remoteBill.timestamp &&
@@ -114,7 +114,7 @@ class VersatileProjectSyncClient(
@Throws(IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class) @Throws(IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class)
fun editRemoteProject( fun editRemoteProject(
project: DBProject, newName: String?, newEmail: String?, newPassword: String?, project: DBProject, newName: String?, newEmail: String?, newPassword: String?,
newMainCurrencyName: String? newMainCurrencyName: String?, newArchivedTs: Long? = null
): ServerResponse.EditRemoteProjectResponse { ): ServerResponse.EditRemoteProjectResponse {
val paramKeys: MutableList<String> = ArrayList() val paramKeys: MutableList<String> = ArrayList()
val paramValues: MutableList<String> = ArrayList() val paramValues: MutableList<String> = ArrayList()
@@ -130,6 +130,10 @@ class VersatileProjectSyncClient(
paramKeys.add("password") paramKeys.add("password")
paramValues.add(newPassword) paramValues.add(newPassword)
} }
if (newArchivedTs != null) {
paramKeys.add("archived_ts")
paramValues.add(newArchivedTs.toString())
}
var target: String var target: String
var username: String? = null var username: String? = null
@@ -5,9 +5,11 @@ fun evalMath(expression: String): Double {
var result = 0.0 var result = 0.0
var db: SQLiteDatabase? = null var db: SQLiteDatabase? = null
try { try {
// Force floating point division by replacing / with * 1.0 /
val forcedRealExpr = expression.replace("/", "* 1.0 /")
// Opens a temporary, in-memory system database block // Opens a temporary, in-memory system database block
db = SQLiteDatabase.create(null) db = SQLiteDatabase.create(null)
val cursor = db.rawQuery("SELECT ($expression);", null) val cursor = db.rawQuery("SELECT ($forcedRealExpr);", null)
if (cursor.moveToFirst()) { if (cursor.moveToFirst()) {
result = cursor.getDouble(0) result = cursor.getDouble(0)
} }
Binary file not shown.
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists 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 networkTimeout=10000
retries=0 retries=0
retryBackOffMs=500 retryBackOffMs=500
Vendored
+5 -8
View File
@@ -1,7 +1,7 @@
#!/bin/sh #!/bin/sh
# #
# Copyright © 2015-2021 the original authors. # Copyright © 2015 the original authors.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with 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: # Important for running:
# #
@@ -29,7 +29,7 @@
# bash, then to run this script, type that shell name before the whole # bash, then to run this script, type that shell name before the whole
# command line, like: # command line, like:
# #
# ksh Gradle # ksh gradlew
# #
# Busybox and similar reduced shells will NOT work, because this script # Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features: # requires all of these POSIX shell features:
@@ -57,7 +57,7 @@
# Darwin, MinGW, and NonStop. # Darwin, MinGW, and NonStop.
# #
# (3) This script is generated from the Groovy template # (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. # within the Gradle project.
# #
# You can find Gradle at https://github.com/gradle/gradle/. # You can find Gradle at https://github.com/gradle/gradle/.
@@ -114,7 +114,6 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;; NONSTOP* ) nonstop=true ;;
esac esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM. # 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 # For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" ) JAVACMD=$( cygpath --unix "$JAVACMD" )
@@ -212,8 +210,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
set -- \ set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \ "-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
org.gradle.wrapper.GradleWrapperMain \
"$@" "$@"
# Stop when "xargs" is not available. # Stop when "xargs" is not available.
Vendored
+12 -24
View File
@@ -19,12 +19,12 @@
@if "%DEBUG%"=="" @echo off @if "%DEBUG%"=="" @echo off
@rem ########################################################################## @rem ##########################################################################
@rem @rem
@rem Gradle startup script for Windows @rem gradlew startup script for Windows
@rem @rem
@rem ########################################################################## @rem ##########################################################################
@rem Set local scope for the variables with windows NT shell @rem Set local scope for the variables, and ensure extensions are enabled
if "%OS%"=="Windows_NT" setlocal setlocal EnableExtensions
set DIRNAME=%~dp0 set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=. 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 Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2 echo location of your Java installation. 1>&2
goto fail "%COMSPEC%" /c exit 1
:findJavaFromJavaHome :findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=% 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 Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2 echo location of your Java installation. 1>&2
goto fail "%COMSPEC%" /c exit 1
:execute :execute
@rem Setup the command line @rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle @rem Execute gradlew
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* @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 :exitWithErrorLevel
@rem End local scope for the variables with windows NT shell @rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
if %ERRORLEVEL% equ 0 goto mainEnd "%COMSPEC%" /c exit %ERRORLEVEL%
: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
+3
View File
@@ -6,6 +6,9 @@ pluginManagement {
maven { url = 'https://jitpack.io' } maven { url = 'https://jitpack.io' }
} }
} }
plugins {
id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0'
}
dependencyResolutionManagement { dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories { repositories {