5 Commits
Author SHA1 Message Date
sora dc469ee576 Archiving sync fix 2026-09-03 22:00:27 +02:00
sora e59680e710 Prefill setting 2026-09-03 22:00:27 +02:00
sora fff3490719 Default color to system 2026-09-03 22:00:27 +02:00
sora aedefc9010 Partial Sync 2026-09-03 22:00:27 +02:00
sora 492230faa2 Fix currency and added user weighting 2026-09-03 22:00:26 +02:00
12 changed files with 320 additions and 75 deletions
@@ -12,6 +12,7 @@ import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceManager
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -151,12 +152,25 @@ 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) val preferences = PreferenceManager.getDefaultSharedPreferences(applicationContext)
bill = DBBill( val fillFromLast = preferences.getBoolean(getString(R.string.pref_key_fill_new_bill_from_last), false)
0, 0, projectId, project?.lastPayerId ?: 0, 0.0, timeNowSeconds, val lastBill = if (fillFromLast) db.getLastBillOfProject(projectId) else null
"", DBBill.STATE_ADDED, DBBill.NON_REPEATED,
DBBill.PAYMODE_NONE, DBBill.CATEGORY_NONE, "", DBBill.PAYMODE_ID_NONE if (lastBill != null) {
) bill = DBBill(
0, 0, projectId, lastBill.payerId, 0.0, timeNowSeconds,
"", DBBill.STATE_ADDED, lastBill.repeat ?: DBBill.NON_REPEATED,
lastBill.paymentMode, lastBill.categoryId, "", lastBill.paymentModeId
)
bill.billOwers = lastBill.billOwers.map { DBBillOwer(0, 0, it.memberId) }
} else {
val project = db.getProject(projectId)
bill = DBBill(
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
)
}
} else { } else {
val btd = db.getBill(billIdToDuplicate)!! val btd = db.getBill(billIdToDuplicate)!!
bill = DBBill( bill = DBBill(
@@ -333,7 +347,7 @@ class EditBillActivity : AppCompatActivity() {
viewModel.owersSelection[id] == true && viewModel.parseAmountFromUi(amountStr) > 0 viewModel.owersSelection[id] == true && viewModel.parseAmountFromUi(amountStr) > 0
}.mapValues { }.mapValues {
val uiAmount = viewModel.parseAmountFromUi(it.value) val uiAmount = viewModel.parseAmountFromUi(it.value)
SupportUtil.round2(uiAmount / viewModel.selectedCurrencyRate) SupportUtil.round2(uiAmount * viewModel.selectedCurrencyRate)
} }
} else { // PERCENT } else { // PERCENT
val totalAmount = viewModel.amountAsDouble val totalAmount = viewModel.amountAsDouble
@@ -342,7 +356,7 @@ class EditBillActivity : AppCompatActivity() {
}.mapValues { }.mapValues {
val percent = viewModel.parseAmountFromUi(it.value) val percent = viewModel.parseAmountFromUi(it.value)
val uiAmount = SupportUtil.round2(totalAmount * percent / 100.0) val uiAmount = SupportUtil.round2(totalAmount * percent / 100.0)
SupportUtil.round2(uiAmount / viewModel.selectedCurrencyRate) SupportUtil.round2(uiAmount * viewModel.selectedCurrencyRate)
} }
} }
@@ -450,7 +450,10 @@ fun OwerSelectionSection(
size = 32.dp size = 32.dp
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text(member.name, modifier = Modifier.weight(1f)) val weightSuffix = if (viewModel.hasDifferentWeights && member.weight != 1.0) {
" (x${member.weight.toString().removeSuffix(".0")})"
} else ""
Text("${member.name}$weightSuffix", modifier = Modifier.weight(1f))
if (isSelected || viewModel.splitMode != SplitMode.EVEN) { if (isSelected || viewModel.splitMode != SplitMode.EVEN) {
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
@@ -671,19 +674,115 @@ fun BillAdditionalDetailsSection(
} }
@SuppressLint("ViewModelConstructorInComposable") @SuppressLint("ViewModelConstructorInComposable")
@Preview(showBackground = true) @Preview(showBackground = true, name = "Standard Split")
@Composable @Composable
fun EditBillScreenPreview() { fun EditBillScreenPreview() {
MaterialTheme { MaterialTheme {
EditBillScreen( EditBillScreen(
viewModel = EditBillViewModel().apply { viewModel = EditBillViewModel().apply {
what = "Pizza" what = "Pizza"
amount = "12.50" amount = "12.00"
mainCurrencyName = "EUR" mainCurrencyName = "EUR"
members = listOf( members = listOf(
DBMember(1, 0, 0, "Alice", true, 1.0, 0, null, null, null, null, null), DBMember(1, 0, 0, "Alice", true, 1.0, 0, null, null, null, null, null),
DBMember(2, 0, 0, "Bob", true, 1.0, 0, null, null, null, null, null) DBMember(2, 0, 0, "Bob", true, 1.0, 0, null, null, null, null, null)
) )
owersSelection[1] = true
owersSelection[2] = true
updateSplits()
},
categories = emptyList(),
paymentModes = emptyList(),
onSave = {},
onBack = {},
onDateClick = {},
onTimeClick = {},
onScan = {}
)
}
}
@SuppressLint("ViewModelConstructorInComposable")
@Preview(showBackground = true, name = "Weighted Split")
@Composable
fun EditBillScreenWeightedPreview() {
MaterialTheme {
EditBillScreen(
viewModel = EditBillViewModel().apply {
what = "Weighted Pizza"
amount = "60.00"
mainCurrencyName = "EUR"
members = listOf(
DBMember(1, 0, 0, "Alice", true, 2.0, 0, null, null, null, null, null),
DBMember(2, 0, 0, "Bob", true, 1.0, 0, null, null, null, null, null),
DBMember(3, 0, 0, "Charlie", true, 1.0, 0, null, null, null, null, null)
)
owersSelection[1] = true
owersSelection[2] = true
owersSelection[3] = true
updateSplits()
},
categories = emptyList(),
paymentModes = emptyList(),
onSave = {},
onBack = {},
onDateClick = {},
onTimeClick = {},
onScan = {}
)
}
}
@SuppressLint("ViewModelConstructorInComposable")
@Preview(showBackground = true, name = "Custom Split (#)")
@Composable
fun EditBillScreenCustomPreview() {
MaterialTheme {
EditBillScreen(
viewModel = EditBillViewModel().apply {
what = "Custom Split Pizza"
amount = "60.00"
mainCurrencyName = "EUR"
members = listOf(
DBMember(1, 0, 0, "Alice", true, 1.0, 0, null, null, null, null, null),
DBMember(2, 0, 0, "Bob", true, 1.0, 0, null, null, null, null, null)
)
splitMode = SplitMode.CUSTOM
owersSelection[1] = true
owersSelection[2] = true
owersCustomSplit[1] = "40.00"
owersCustomSplit[2] = "20.00"
},
categories = emptyList(),
paymentModes = emptyList(),
onSave = {},
onBack = {},
onDateClick = {},
onTimeClick = {},
onScan = {}
)
}
}
@SuppressLint("ViewModelConstructorInComposable")
@Preview(showBackground = true, name = "Percent Split (%)")
@Composable
fun EditBillScreenPercentPreview() {
MaterialTheme {
EditBillScreen(
viewModel = EditBillViewModel().apply {
what = "Percent Split Pizza"
amount = "100.00"
mainCurrencyName = "EUR"
members = listOf(
DBMember(1, 0, 0, "Alice", true, 1.0, 0, null, null, null, null, null),
DBMember(2, 0, 0, "Bob", true, 1.0, 0, null, null, null, null, null)
)
splitMode = SplitMode.PERCENT
owersSelection[1] = true
owersSelection[2] = true
owersPercentSplit[1] = "70"
owersPercentSplit[2] = "30"
}, },
categories = emptyList(), categories = emptyList(),
paymentModes = emptyList(), paymentModes = emptyList(),
@@ -61,18 +61,18 @@ class EditBillViewModel : ViewModel() {
val amountAsDouble: Double val amountAsDouble: Double
get() = parseAmountFromUi(amount) get() = parseAmountFromUi(amount)
fun getEvenSplit(): Double { val hasDifferentWeights: Boolean
val selectedOwersCount = owersSelection.count { it.value } get() = members.isNotEmpty() && members.any { it.weight != members[0].weight }
return if (selectedOwersCount > 0) SupportUtil.round2(amountAsDouble / selectedOwersCount) else 0.0
}
fun updateSplits() { fun updateSplits() {
if (splitMode == SplitMode.EVEN) { if (splitMode == SplitMode.EVEN) {
val even = getEvenSplit() val selectedMembers = members.filter { owersSelection[it.id] == true }
val evenStr = if (even == 0.0) "" else SupportUtil.round2(even).toString() val totalWeight = selectedMembers.sumOf { it.weight }
val uiAmount = amountAsDouble
members.forEach { m -> members.forEach { m ->
if (owersSelection[m.id] == true) { if (owersSelection[m.id] == true) {
owersCustomSplit[m.id] = evenStr val share = if (totalWeight > 0) (uiAmount * m.weight) / totalWeight else 0.0
owersCustomSplit[m.id] = if (share == 0.0) "" else SupportUtil.round2(share).toString()
} else { } else {
owersCustomSplit.remove(m.id) owersCustomSplit.remove(m.id)
} }
@@ -180,7 +180,7 @@ class EditBillViewModel : ViewModel() {
} }
fun getFinalAmount(): Double { fun getFinalAmount(): Double {
return SupportUtil.round2(amountAsDouble / selectedCurrencyRate) return SupportUtil.round2(amountAsDouble * selectedCurrencyRate)
} }
fun getFinalComment(): String { fun getFinalComment(): String {
@@ -252,10 +252,9 @@ class EditBillViewModel : ViewModel() {
if (selected) { if (selected) {
// If we have metadata, the custom splits from DB are also converted. // If we have metadata, the custom splits from DB are also converted.
// We should show them as "Original" if possible? // We should show them as "Original" if possible?
// Actually, if we use metadata, we should probably store original splits too, // Reverse calculation for display: UI Part = DB Part / Rate
// but for now let's just reverse the rate for display.
val dbPart = customSplits[member.id]!! val dbPart = customSplits[member.id]!!
val uiPart = if (selectedCurrencyRate != 1.0) dbPart * selectedCurrencyRate else dbPart val uiPart = if (selectedCurrencyRate != 0.0) dbPart / selectedCurrencyRate else dbPart
owersCustomSplit[member.id] = SupportUtil.round2(uiPart).toString() owersCustomSplit[member.id] = SupportUtil.round2(uiPart).toString()
} }
} }
@@ -263,18 +262,18 @@ class EditBillViewModel : ViewModel() {
// Even split logic // Even split logic
splitMode = SplitMode.EVEN splitMode = SplitMode.EVEN
val billOwerIds = bill.billOwersIds val billOwerIds = bill.billOwersIds
val selectedCount = billOwerIds.size val selectedMembers = members.filter { billOwerIds.contains(it.id) }
val totalWeight = selectedMembers.sumOf { it.weight }
// Use UI amount for even split calculation // Use UI amount for even split calculation
val uiAmount = amountAsDouble val uiAmount = amountAsDouble
val evenSplit = if (selectedCount > 0) uiAmount / selectedCount else 0.0
val evenSplitStr = if (evenSplit == 0.0) "" else SupportUtil.round2(evenSplit).toString()
for (member in members) { for (member in members) {
val selected = billOwerIds.contains(member.id) val selected = billOwerIds.contains(member.id)
owersSelection[member.id] = selected owersSelection[member.id] = selected
if (selected) { if (selected) {
owersCustomSplit[member.id] = evenSplitStr val share = if (totalWeight > 0) (uiAmount * member.weight) / totalWeight else 0.0
owersCustomSplit[member.id] = if (share == 0.0) "" else SupportUtil.round2(share).toString()
} }
} }
} }
@@ -114,7 +114,8 @@ class ManageCurrenciesActivity : AppCompatActivity() {
} }
private fun addOrUpdateCurrency() { private fun addOrUpdateCurrency() {
val exchangeRate = try { viewModel.newCurrencyRate.toDouble() } catch (_: Exception) { 0.0 } val uiRate = try { viewModel.newCurrencyRate.toDouble() } catch (_: Exception) { 0.0 }
val exchangeRate = if (uiRate != 0.0) 1.0 / uiRate else 0.0
val currencyName = viewModel.newCurrencyName val currencyName = viewModel.newCurrencyName
val editingId = viewModel.editingCurrencyId val editingId = viewModel.editingCurrencyId
@@ -142,7 +143,8 @@ class ManageCurrenciesActivity : AppCompatActivity() {
private fun startEditing(currency: DBCurrency) { private fun startEditing(currency: DBCurrency) {
viewModel.editingCurrencyId = currency.id viewModel.editingCurrencyId = currency.id
viewModel.newCurrencyName = currency.name ?: "" viewModel.newCurrencyName = currency.name ?: ""
viewModel.newCurrencyRate = currency.exchangeRate.toString() val uiRate = if (currency.exchangeRate != 0.0) 1.0 / currency.exchangeRate else 0.0
viewModel.newCurrencyRate = uiRate.toString()
} }
private fun cancelEditing() { private fun cancelEditing() {
@@ -304,7 +304,7 @@ fun CurrencyRow(
modifier = Modifier.padding(horizontal = 8.dp) modifier = Modifier.padding(horizontal = 8.dp)
) )
Text( Text(
text = formatAmount(currency.exchangeRate), text = formatAmount(if (currency.exchangeRate != 0.0) 1.0 / currency.exchangeRate else 0.0),
style = MaterialTheme.typography.body1, style = MaterialTheme.typography.body1,
fontWeight = FontWeight.ExtraBold, fontWeight = FontWeight.ExtraBold,
color = if (isEditing) MaterialTheme.colors.secondary else MaterialTheme.colors.onSurface color = if (isEditing) MaterialTheme.colors.secondary else MaterialTheme.colors.onSurface
@@ -761,7 +761,7 @@ class BillsListViewActivity :
val proj = withContext(Dispatchers.IO) { db.getProject(selectedProjectId) } val proj = withContext(Dispatchers.IO) { db.getProject(selectedProjectId) }
if (proj != null && !proj.isLocal) { if (proj != null && !proj.isLocal) {
db.cowspentServerSyncHelper.addCallbackPull(syncCallBack) db.cowspentServerSyncHelper.addCallbackPull(syncCallBack)
db.cowspentServerSyncHelper.scheduleSync(false, selectedProjectId) db.cowspentServerSyncHelper.scheduleSync(false, selectedProjectId, manual)
} else viewModel.isRefreshing = false } else viewModel.isRefreshing = false
} }
} else viewModel.isRefreshing = false } else viewModel.isRefreshing = false
@@ -54,6 +54,7 @@ import androidx.preference.PreferenceManager
import net.helcel.cowspent.R import net.helcel.cowspent.R
import net.helcel.cowspent.android.helper.ColorPicker import net.helcel.cowspent.android.helper.ColorPicker
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.persistence.CowspentServerSyncHelper
import net.helcel.cowspent.util.ColorUtils import net.helcel.cowspent.util.ColorUtils
import net.helcel.cowspent.util.Cowspent import net.helcel.cowspent.util.Cowspent
@@ -80,22 +81,28 @@ fun SettingsScreen(
val keyOfflineMode = stringResource(R.string.pref_key_offline_mode) val keyOfflineMode = stringResource(R.string.pref_key_offline_mode)
val keyShowArchived = stringResource(R.string.pref_key_show_archived) val keyShowArchived = stringResource(R.string.pref_key_show_archived)
val keyBetaFeatures = stringResource(R.string.pref_key_beta_features) val keyBetaFeatures = stringResource(R.string.pref_key_beta_features)
val keyFillNewBillFromLast = stringResource(R.string.pref_key_fill_new_bill_from_last)
val isNextcloudConfigured = CowspentServerSyncHelper.isNextcloudAccountConfigured(context)
// States for preferences // States for preferences
var nightMode by remember(keyNightMode) { var nightMode by remember(keyNightMode) {
mutableStateOf(sharedPreferences.getString(keyNightMode, "-1") ?: "-1") mutableStateOf(sharedPreferences.getString(keyNightMode, "-1") ?: "-1")
} }
var colorMode by remember(keyColorMode, keyUseServerColor, keyUseSystemColor) { var colorMode by remember(keyColorMode, keyUseServerColor, keyUseSystemColor, isNextcloudConfigured) {
mutableStateOf(sharedPreferences.getString(keyColorMode, null) ?: run { val savedMode = sharedPreferences.getString(keyColorMode, null)
val useServer = sharedPreferences.getBoolean(keyUseServerColor, true) val mode = savedMode ?: run {
val useServer = sharedPreferences.getBoolean(keyUseServerColor, false)
val useSystem = sharedPreferences.getBoolean(keyUseSystemColor, true) val useSystem = sharedPreferences.getBoolean(keyUseSystemColor, true)
when { when {
useServer -> "server" useServer && isNextcloudConfigured -> "server"
useSystem -> "system" useSystem -> "system"
else -> "manual" else -> "manual"
} }
}) }
// Fallback to system if server is selected but not configured
mutableStateOf(if (mode == "server" && !isNextcloudConfigured) "system" else mode)
} }
// Apply theme globally only when leaving settings to avoid flickering/restarts during selection // Apply theme globally only when leaving settings to avoid flickering/restarts during selection
@@ -117,6 +124,9 @@ fun SettingsScreen(
var betaFeatures by remember(keyBetaFeatures) { var betaFeatures by remember(keyBetaFeatures) {
mutableStateOf(sharedPreferences.getBoolean(keyBetaFeatures, false)) mutableStateOf(sharedPreferences.getBoolean(keyBetaFeatures, false))
} }
var fillNewBillFromLast by remember(keyFillNewBillFromLast) {
mutableStateOf(sharedPreferences.getBoolean(keyFillNewBillFromLast, false))
}
Scaffold( Scaffold(
topBar = { topBar = {
@@ -181,15 +191,19 @@ fun SettingsScreen(
} }
) )
val colorModeEntries = buildMap {
put("system", stringResource(R.string.pref_value_color_system))
if (isNextcloudConfigured) {
put("server", stringResource(R.string.pref_value_color_server))
}
put("manual", stringResource(R.string.pref_value_color_manual))
}
SettingsListPreference( SettingsListPreference(
title = stringResource(R.string.settings_color_mode), title = stringResource(R.string.settings_color_mode),
icon = Icons.Default.Palette, icon = Icons.Default.Palette,
value = colorMode, value = colorMode,
entries = mapOf( entries = colorModeEntries,
"system" to stringResource(R.string.pref_value_color_system),
"server" to stringResource(R.string.pref_value_color_server),
"manual" to stringResource(R.string.pref_value_color_manual)
),
onValueChange = { mode -> onValueChange = { mode ->
colorMode = mode colorMode = mode
sharedPreferences.edit { sharedPreferences.edit {
@@ -259,6 +273,21 @@ fun SettingsScreen(
} }
) )
if (betaFeatures) {
SettingsSwitchPreference(
title = stringResource(R.string.settings_fill_new_bill_from_last),
summary = stringResource(R.string.settings_fill_new_bill_from_last_summary),
icon = Icons.Default.Info,
checked = fillNewBillFromLast,
onCheckedChange = {
fillNewBillFromLast = it
sharedPreferences.edit {
putBoolean(keyFillNewBillFromLast, it)
}
}
)
}
SettingsPreference( SettingsPreference(
title = stringResource(R.string.title_about), title = stringResource(R.string.title_about),
icon = Icons.Default.Info, icon = Icons.Default.Info,
@@ -557,6 +557,15 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
return if (bills.isEmpty()) null else bills[0] return if (bills.isEmpty()) null else bills[0]
} }
fun getLastBillOfProject(projectId: Long): DBBill? {
val list = getBillsCustom(
"$key_projectid = ? AND $key_state != ?",
arrayOf(projectId.toString(), DBBill.STATE_DELETED.toString()),
"$key_id DESC LIMIT 1"
)
return if (list.isEmpty()) null else list[0]
}
@WorkerThread @WorkerThread
fun searchBills(query: CharSequence?, projectId: Long): List<DBBill> { fun searchBills(query: CharSequence?, projectId: Long): List<DBBill> {
val andWhere: MutableList<String> = ArrayList() val andWhere: MutableList<String> = ArrayList()
@@ -93,14 +93,14 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
callbacksPull.add(callback) callbacksPull.add(callback)
} }
fun scheduleSync(onlyLocalChanges: Boolean, projId: Long): SyncTask? { fun scheduleSync(onlyLocalChanges: Boolean, projId: Long, forceFullSync: Boolean = false): SyncTask? {
Log.d(TAG, "Sync requested (${if (onlyLocalChanges) "onlyLocalChanges" else "full"}; ${if (syncActive) "sync active" else "sync NOT active"}) ...") Log.d(TAG, "Sync requested (${if (onlyLocalChanges) "onlyLocalChanges" else "full"}; ${if (syncActive) "sync active" else "sync NOT active"}; forceFullSync=$forceFullSync) ...")
updateNetworkStatus() updateNetworkStatus()
if (isSyncPossible && (!syncActive || onlyLocalChanges)) { if (isSyncPossible && (!syncActive || onlyLocalChanges)) {
val project = dbHelper.getProject(projId) val project = dbHelper.getProject(projId)
if (project != null) { if (project != null) {
Log.d(TAG, "... starting now") Log.d(TAG, "... starting now")
val syncTask = SyncTask(onlyLocalChanges, project) val syncTask = SyncTask(onlyLocalChanges, project, forceFullSync)
syncTask.addCallbacks(callbacksPush) syncTask.addCallbacks(callbacksPush)
callbacksPush = ArrayList() callbacksPush = ArrayList()
if (!onlyLocalChanges) { if (!onlyLocalChanges) {
@@ -138,7 +138,7 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
} }
} }
inner class SyncTask(private val onlyLocalChanges: Boolean, private val project: DBProject) { inner class SyncTask(private val onlyLocalChanges: Boolean, private val project: DBProject, private val forceFullSync: Boolean = false) {
private val callbacks: MutableList<ICallback> = ArrayList() private val callbacks: MutableList<ICallback> = ArrayList()
private var nextcloudClient: NextcloudClient? = null private var nextcloudClient: NextcloudClient? = null
private var client: VersatileProjectSyncClient? = null private var client: VersatileProjectSyncClient? = null
@@ -726,23 +726,59 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
val paymentModesRemoteIdToId = dbPaymentModes.associate { it.remoteId to it.id }.toMutableMap() val paymentModesRemoteIdToId = dbPaymentModes.associate { it.remoteId to it.id }.toMutableMap()
dbPaymentModes.filter { it.remoteId < 0 }.forEach { paymentModesRemoteIdToId[it.remoteId] = it.id } dbPaymentModes.filter { it.remoteId < 0 }.forEach { paymentModesRemoteIdToId[it.remoteId] = it.id }
val billsResponse = client!!.getBills(project)
val isIHM = project.type == ProjectType.IHATEMONEY val isIHM = project.type == ProjectType.IHATEMONEY
val serverSyncTimestamp = if (isIHM) 0L else billsResponse.syncTimestamp var serverSyncTimestamp = project.lastSyncedTimestamp
val remoteBills: List<DBBill> = if (isIHM) { val remoteBills = mutableListOf<DBBill>()
billsResponse.getBillsIHM(project.id, memberRemoteIdToId, categoriesRemoteIdToId, paymentModesRemoteIdToId) val remoteAllBillIds = mutableListOf<Long>()
val localBills = dbHelper.getBillsOfProject(project.id)
val localBillsByRemoteId = localBills.associateBy { it.remoteId }
if (project.type == ProjectType.COSPEND && !forceFullSync && localBills.isNotEmpty()) {
Log.d(TAG, "Starting partial sync for project ${project.remoteId}")
var offset = 0
val limit = 50
var allMatched = false
while (!allMatched) {
val partialResponse = client!!.getBills(project, offset, limit, true, 0)
val partialRemoteBills = partialResponse.getBillsCospend(project.id, memberRemoteIdToId, categoriesRemoteIdToId, paymentModesRemoteIdToId)
if (partialRemoteBills.isEmpty()) break
remoteBills.addAll(partialRemoteBills)
var frameMatched = true
for (rb in partialRemoteBills) {
val lb = localBillsByRemoteId[rb.remoteId]
if (lb == null || hasChanged(lb, rb)) {
frameMatched = false
break
}
}
if (offset == 0 && partialResponse.syncTimestamp > 0) {
serverSyncTimestamp = partialResponse.syncTimestamp
}
if (frameMatched) {
allMatched = true
} else {
offset += limit
}
}
} else { } else {
billsResponse.getBillsCospend(project.id, memberRemoteIdToId, categoriesRemoteIdToId, paymentModesRemoteIdToId) Log.d(TAG, "Starting full sync for project ${project.remoteId}")
} val billsResponse = client!!.getBills(project)
val remoteAllBillIds: List<Long> = if (isIHM) { serverSyncTimestamp = if (isIHM) 0L else billsResponse.syncTimestamp
remoteBills.map { it.remoteId } if (isIHM) {
} else { remoteBills.addAll(billsResponse.getBillsIHM(project.id, memberRemoteIdToId, categoriesRemoteIdToId, paymentModesRemoteIdToId))
billsResponse.allBillIds remoteAllBillIds.addAll(remoteBills.map { it.remoteId })
} else {
remoteBills.addAll(billsResponse.getBillsCospend(project.id, memberRemoteIdToId, categoriesRemoteIdToId, paymentModesRemoteIdToId))
remoteAllBillIds.addAll(billsResponse.allBillIds)
}
} }
val remoteBillsByRemoteId = remoteBills.associateBy { it.remoteId } val remoteBillsByRemoteId = remoteBills.associateBy { it.remoteId }
val localBills = dbHelper.getBillsOfProject(project.id)
val localBillsByRemoteId = localBills.associateBy { it.remoteId }
for (remoteBill in remoteBills) { for (remoteBill in remoteBills) {
if (!localBillsByRemoteId.containsKey(remoteBill.remoteId)) { if (!localBillsByRemoteId.containsKey(remoteBill.remoteId)) {
@@ -783,7 +819,7 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
} }
} }
if (project.type == ProjectType.COSPEND || project.type == ProjectType.IHATEMONEY) { if (remoteAllBillIds.isNotEmpty() && (project.type == ProjectType.COSPEND || project.type == ProjectType.IHATEMONEY)) {
for (localBill in localBills) { for (localBill in localBills) {
if (!remoteAllBillIds.contains(localBill.remoteId)) { if (!remoteAllBillIds.contains(localBill.remoteId)) {
dbHelper.deleteBill(localBill.id) dbHelper.deleteBill(localBill.id)
@@ -792,7 +828,7 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
Log.d(TAG, "Delete local bill : $localBill") Log.d(TAG, "Delete local bill : $localBill")
} }
} }
} else { } else if (remoteAllBillIds.isNotEmpty()) {
for (localBill in localBills) { for (localBill in localBills) {
if (!remoteBillsByRemoteId.containsKey(localBill.remoteId)) { if (!remoteBillsByRemoteId.containsKey(localBill.remoteId)) {
dbHelper.deleteBill(localBill.id) dbHelper.deleteBill(localBill.id)
@@ -24,7 +24,7 @@ object ColorUtils {
val colorMode = if (prefs.contains(modeKey)) { val colorMode = if (prefs.contains(modeKey)) {
prefs.getString(modeKey, "system") prefs.getString(modeKey, "system")
} else { } else {
val useServer = prefs.getBoolean(context.getString(R.string.pref_key_use_server_color), true) val useServer = prefs.getBoolean(context.getString(R.string.pref_key_use_server_color), false)
val useSystem = prefs.getBoolean(context.getString(R.string.pref_key_use_system_color), true) val useSystem = prefs.getBoolean(context.getString(R.string.pref_key_use_system_color), true)
when { when {
useServer -> "server" useServer -> "server"
@@ -158,10 +158,18 @@ class VersatileProjectSyncClient(
paramKeys.add("password") paramKeys.add("password")
paramValues.add(newPassword) paramValues.add(newPassword)
} }
if (newEmail != null) {
paramKeys.add("contact_email")
paramValues.add(newEmail)
}
if (newMainCurrencyName != null) { if (newMainCurrencyName != null) {
paramKeys.add("currencyName") paramKeys.add("currencyName")
paramValues.add(newMainCurrencyName) paramValues.add(newMainCurrencyName)
} }
if (newArchivedTs != null) {
paramKeys.add("archived_ts")
paramValues.add(newArchivedTs.toString())
}
} }
if (canAccessProjectWithNCLogin(project)) { if (canAccessProjectWithNCLogin(project)) {
username = this.username username = this.username
@@ -679,22 +687,66 @@ class VersatileProjectSyncClient(
} }
@Throws(JSONException::class, IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class) @Throws(JSONException::class, IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class)
fun getBills(project: DBProject): ServerResponse.BillsResponse { fun getBills(
project: DBProject,
offset: Int? = null,
limit: Int? = null,
reverse: Boolean? = null,
deleted: Int? = null
): ServerResponse.BillsResponse {
var target: String var target: String
var username: String? var username: String?
var password: String? var password: String?
var bearerToken: String? var bearerToken: String?
var useOcsApiRequest: Boolean var useOcsApiRequest: Boolean
val paramKeys: MutableList<String> = ArrayList()
val paramValues: MutableList<String> = ArrayList()
if (ProjectType.COSPEND == project.type) { if (ProjectType.COSPEND == project.type) {
val tsLastSync = project.lastSyncedTimestamp val tsLastSync = project.lastSyncedTimestamp
if (offset == null) {
if (cospendVersionGT161) {
paramKeys.add("lastChanged")
} else {
paramKeys.add("lastchanged")
}
paramValues.add(tsLastSync.toString())
} else {
paramKeys.add("offset")
paramValues.add(offset.toString())
if (limit != null) {
paramKeys.add("limit")
paramValues.add(limit.toString())
}
if (reverse != null) {
paramKeys.add("reverse")
paramValues.add(reverse.toString())
}
if (deleted != null) {
paramKeys.add("deleted")
paramValues.add(deleted.toString())
}
}
if (canAccessProjectWithNCLogin(project)) { if (canAccessProjectWithNCLogin(project)) {
username = this.username username = this.username
password = this.password password = this.password
target = if (cospendVersionGT161)
project.getRequestBaseUrl(true) + "/api/v1/projects/" + project.remoteId + "/bills?lastChanged=" + tsLastSync
else
project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/bills?lastchanged=" + tsLastSync
useOcsApiRequest = cospendVersionGT161 useOcsApiRequest = cospendVersionGT161
val baseUrl = project.getRequestBaseUrl(useOcsApiRequest)
target = if (useOcsApiRequest)
"$baseUrl/api/v1/projects/${project.remoteId}/bills"
else
"$baseUrl/api-priv/projects/${project.remoteId}/bills"
if (paramKeys.isNotEmpty()) {
target += "?"
for (i in paramKeys.indices) {
if (i > 0) target += "&"
target += "${paramKeys[i]}=${paramValues[i]}"
}
}
return ServerResponse.BillsResponse( return ServerResponse.BillsResponse(
requestServer( requestServer(
target, METHOD_GET, null, null, target, METHOD_GET, null, null,
@@ -703,14 +755,6 @@ class VersatileProjectSyncClient(
useOcsApiRequest useOcsApiRequest
) )
} else if (canAccessProjectWithSSO(project)) { } else if (canAccessProjectWithSSO(project)) {
val paramKeys: MutableList<String> = ArrayList()
val paramValues: MutableList<String> = ArrayList()
if (cospendVersionGT161) {
paramKeys.add("lastChanged")
} else {
paramKeys.add("lastchanged")
}
paramValues.add(tsLastSync.toString())
return if (cospendVersionGT161) { return if (cospendVersionGT161) {
target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/bills" target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/bills"
ServerResponse.BillsResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_GET, paramKeys, paramValues, true), true) ServerResponse.BillsResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_GET, paramKeys, paramValues, true), true)
@@ -720,10 +764,20 @@ class VersatileProjectSyncClient(
} }
} else { } else {
useOcsApiRequest = cospendVersionGT161 useOcsApiRequest = cospendVersionGT161
target = if (cospendVersionGT161) val baseUrl = project.getRequestBaseUrl(useOcsApiRequest)
project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/bills?lastChanged=" + tsLastSync target = if (useOcsApiRequest)
"$baseUrl/api/v1/public/projects/${project.remoteId}/${getEncodedPassword(project.password)}/bills"
else else
project.getRequestBaseUrl(false) + "/apiv2/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/bills?lastchanged=" + tsLastSync "$baseUrl/apiv2/projects/${project.remoteId}/${getEncodedPassword(project.password)}/bills"
if (paramKeys.isNotEmpty()) {
target += "?"
for (i in paramKeys.indices) {
if (i > 0) target += "&"
target += "${paramKeys[i]}=${paramValues[i]}"
}
}
return ServerResponse.BillsResponse( return ServerResponse.BillsResponse(
requestServer( requestServer(
target, METHOD_GET, null, null, target, METHOD_GET, null, null,
+3
View File
@@ -137,6 +137,8 @@
<string name="settings_show_archived">Show archived projects</string> <string name="settings_show_archived">Show archived projects</string>
<string name="settings_beta_features">Beta Features</string> <string name="settings_beta_features">Beta Features</string>
<string name="settings_beta_features_summary">Enable experimental features. Use at your own risk.</string> <string name="settings_beta_features_summary">Enable experimental features. Use at your own risk.</string>
<string name="settings_fill_new_bill_from_last">Auto-fill from last bill</string>
<string name="settings_fill_new_bill_from_last_summary">Pre-fill payer, category, mode and owers from the last bill created in the project.</string>
<string name="settings_url_warn_http">WARNING: "http" is unsafe. Use "https".</string> <string name="settings_url_warn_http">WARNING: "http" is unsafe. Use "https".</string>
<string name="settings_colorpicker_title">Choose Color</string> <string name="settings_colorpicker_title">Choose Color</string>
@@ -158,6 +160,7 @@
<string name="pref_key_offline_mode" translatable="false">offlineMode</string> <string name="pref_key_offline_mode" translatable="false">offlineMode</string>
<string name="pref_key_show_archived" translatable="false">showArchived</string> <string name="pref_key_show_archived" translatable="false">showArchived</string>
<string name="pref_key_beta_features" translatable="false">betaFeatures</string> <string name="pref_key_beta_features" translatable="false">betaFeatures</string>
<string name="pref_key_fill_new_bill_from_last" translatable="false">fillNewBillFromLast</string>
<string name="pref_value_night_mode_no" translatable="false">1</string> <string name="pref_value_night_mode_no" translatable="false">1</string>
<string name="pref_value_night_mode_yes" translatable="false">2</string> <string name="pref_value_night_mode_yes" translatable="false">2</string>
<string name="pref_value_night_mode_system" translatable="false">-1</string> <string name="pref_value_night_mode_system" translatable="false">-1</string>