9 Commits
Author SHA1 Message Date
sora d2d713c616 Autofocus race condition with UI 2026-09-09 09:20:47 +02:00
sora d7a51a797f Settings wording 2026-09-09 09:02:06 +02:00
sora de5f9b09a1 PartialSync avoids Members and Project sync 2026-09-09 08:56:05 +02:00
sora 2ec5c00df1 Import/Export Improvements 2026-09-09 08:53:41 +02:00
sora 561d3a4ed8 Avoid deletion of non-synced bills 2026-09-09 00:36:20 +02:00
sora d836be88c6 Sync improvements 2026-09-09 00:33:54 +02:00
sora b179edf086 UI Feedback 2026-09-09 00:28:53 +02:00
sora dde1042701 Batching 2026-09-09 00:27:13 +02:00
sora a67ce253f9 Removed unused callback 2026-09-06 19:55:31 +02:00
43 changed files with 521 additions and 382 deletions
@@ -63,11 +63,17 @@ fun EditBillScreen(
val context = LocalContext.current val context = LocalContext.current
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { // The activity reads the project off the main thread and only then reports whether this is a
if (viewModel.isNewBill) { // new bill, so the flag arrives after the first composition. Keying on it rather than on Unit
// is what makes the effect run at all.
LaunchedEffect(viewModel.isNewBill) {
if (!viewModel.isNewBill) return@LaunchedEffect
// The field has to be laid out before it can take focus, which is not yet true on the
// pass that composed it. Waiting for the next frame makes this hold whether the flag was
// already set or arrived later.
withFrameNanos { }
focusRequester.requestFocus() focusRequester.requestFocus()
} }
}
StatefulAlertDialog( StatefulAlertDialog(
state = viewModel.dialogState, state = viewModel.dialogState,
@@ -252,8 +258,8 @@ fun BillBasicInfoSection(
placeholder = { Text(stringResource(R.string.label_what)) }, placeholder = { Text(stringResource(R.string.label_what)) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.Default.Title, contentDescription = null) }, leadingIcon = { Icon(Icons.Default.Title, contentDescription = null) },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) }) keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() })
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
@@ -528,7 +534,10 @@ fun BillAdditionalDetailsSection(
val context = LocalContext.current val context = LocalContext.current
var categoryExpanded by remember { mutableStateOf(false) } var categoryExpanded by remember { mutableStateOf(false) }
val selectedCategory = val selectedCategory =
categories.find { it.id == viewModel.categoryId } ?: CategoryUtils.getCategoryById(context, viewModel.categoryId) categories.find { it.id == viewModel.categoryId } ?: CategoryUtils.getCategoryById(
context,
viewModel.categoryId
)
EditableExposedDropdownMenu( EditableExposedDropdownMenu(
value = selectedCategory?.name ?: "", value = selectedCategory?.name ?: "",
@@ -582,7 +591,10 @@ fun BillAdditionalDetailsSection(
var pmExpanded by remember { mutableStateOf(false) } var pmExpanded by remember { mutableStateOf(false) }
val selectedPm = val selectedPm =
paymentModes.find { it.id == viewModel.paymentModeId } ?: CategoryUtils.getPaymentModeById(context, viewModel.paymentModeId) paymentModes.find { it.id == viewModel.paymentModeId } ?: CategoryUtils.getPaymentModeById(
context,
viewModel.paymentModeId
)
EditableExposedDropdownMenu( EditableExposedDropdownMenu(
value = selectedPm?.name ?: "", value = selectedPm?.name ?: "",
@@ -3,6 +3,9 @@ package net.helcel.cowspent.android.main
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.CircularProgressIndicator
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.Row import androidx.compose.foundation.layout.Row
@@ -69,13 +72,43 @@ fun EmptyProjectsState(onConfigureNextcloud: () -> Unit, onAddManually: () -> Un
} }
} }
/**
* A full-height, centred state that a pull to refresh can still act on.
*
* PullRefresh reads the gesture from a nested scroll source, and a plain Column offers none - so
* on an empty list, which is exactly when a refresh is wanted most, the pull never fires. A
* single-item LazyColumn has nowhere to scroll but does provide that source.
*/
@Composable
private fun RefreshableFullScreenState(content: @Composable ColumnScope.() -> Unit) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
item {
Column(
modifier = Modifier.fillParentMaxSize().padding(16.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
content = content
)
}
}
}
@Composable
fun LoadingBillsState() {
RefreshableFullScreenState {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.error_loading),
style = MaterialTheme.typography.subtitle1,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
)
}
}
@Composable @Composable
fun EmptyMembersState() { fun EmptyMembersState() {
Column( RefreshableFullScreenState {
modifier = Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text( Text(
text = stringResource(R.string.error_no_members).uppercase(), text = stringResource(R.string.error_no_members).uppercase(),
style = MaterialTheme.typography.subtitle1, style = MaterialTheme.typography.subtitle1,
@@ -94,11 +127,7 @@ fun EmptyMembersState() {
@Composable @Composable
fun EmptyBillsState() { fun EmptyBillsState() {
Column( RefreshableFullScreenState {
modifier = Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text( Text(
text = stringResource(R.string.error_no_bills).uppercase(), text = stringResource(R.string.error_no_bills).uppercase(),
style = MaterialTheme.typography.subtitle1, style = MaterialTheme.typography.subtitle1,
@@ -203,11 +232,7 @@ fun SectionHeader(title: String) {
@Composable @Composable
fun EmptyState() { fun EmptyState() {
Column( RefreshableFullScreenState {
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text( Text(
text = stringResource(R.string.error_no_bills).uppercase(), text = stringResource(R.string.error_no_bills).uppercase(),
style = MaterialTheme.typography.subtitle1, style = MaterialTheme.typography.subtitle1,
@@ -105,9 +105,9 @@ fun BillsListScreen(
val context = LocalContext.current val context = LocalContext.current
val sharedPreferences = remember { PreferenceManager.getDefaultSharedPreferences(context) } val sharedPreferences = remember { PreferenceManager.getDefaultSharedPreferences(context) }
val showArchived = sharedPreferences.getBoolean(stringResource(R.string.pref_key_show_archived), false) val showArchived = sharedPreferences.getBoolean(stringResource(R.string.pref_key_show_archived), false)
val keyBetaFeatures = stringResource(R.string.pref_key_beta_features) val keyExtraFeatures = stringResource(R.string.pref_key_extra_features)
var showBetaFeatures by remember(keyBetaFeatures) { var showExtraFeatures by remember(keyExtraFeatures) {
mutableStateOf(sharedPreferences.getBoolean(keyBetaFeatures, false)) mutableStateOf(sharedPreferences.getBoolean(keyExtraFeatures, false))
} }
StatefulAlertDialog( StatefulAlertDialog(
@@ -165,7 +165,7 @@ fun BillsListScreen(
projectType = proj?.type ?: ProjectType.LOCAL, projectType = proj?.type ?: ProjectType.LOCAL,
accessLevel = proj?.myAccessLevel ?: DBProject.ACCESS_LEVEL_ADMIN, accessLevel = proj?.myAccessLevel ?: DBProject.ACCESS_LEVEL_ADMIN,
isShareable = proj?.isShareable() ?: true, isShareable = proj?.isShareable() ?: true,
showBetaFeatures = showBetaFeatures showExtraFeatures = showExtraFeatures
) )
} }
} }
@@ -334,7 +334,7 @@ fun BillsListScreen(
}, },
actions = { actions = {
if (!isSearchExpanded) { if (!isSearchExpanded) {
if (showBetaFeatures && viewModel.hasUnlabeledBills) { if (showExtraFeatures && viewModel.hasUnlabeledBills) {
IconButton(onClick = onLabelBillsClick) { IconButton(onClick = onLabelBillsClick) {
Icon( Icon(
Icons.Default.Category, Icons.Default.Category,
@@ -409,6 +409,10 @@ fun BillsListScreen(
when { when {
viewModel.showNoProjects -> EmptyProjectsState(onAccountSwitcherClick, onAddProjectClick) viewModel.showNoProjects -> EmptyProjectsState(onAccountSwitcherClick, onAddProjectClick)
viewModel.showNoMembers -> EmptyMembersState() viewModel.showNoMembers -> EmptyMembersState()
// A large project takes a long time on its first sync. Until that finishes there
// is nothing stored for it yet, and reporting that as "no bills" tells the user
// the project is empty when it is still downloading.
viewModel.isRefreshing && viewModel.bills.isEmpty() -> LoadingBillsState()
viewModel.showNoBills -> EmptyBillsState() viewModel.showNoBills -> EmptyBillsState()
viewModel.bills.isEmpty() -> EmptyState() viewModel.bills.isEmpty() -> EmptyState()
else -> { else -> {
@@ -94,14 +94,16 @@ class BillsListViewActivity :
private val syncCallBack = object : ICallback { private val syncCallBack = object : ICallback {
override fun onFinish() { override fun onFinish() {
mActionMode?.finish() mActionMode?.finish()
refreshLists()
viewModel.isRefreshing = false viewModel.isRefreshing = false
refreshLists()
} }
override fun onFinish(result: String, message: String) {} override fun onFinish(result: String, message: String) {}
override fun onScheduled() { override fun onScheduled() {
viewModel.isRefreshing = false // Being queued is not being done. Clearing the indicator here stops the spinner while a
// large project is still downloading; synchronize() already releases it when nothing
// actually started.
} }
} }
@@ -780,7 +782,7 @@ class BillsListViewActivity :
// The account and all-projects refresh belongs to opening the app, throttled by the // The account and all-projects refresh belongs to opening the app, throttled by the
// SyncOnOpen interval so that resuming within the interval does not repeat it. // SyncOnOpen interval so that resuming within the interval does not repeat it.
val intervalMinutes = SyncSettings.intervalMinutes(applicationContext) val intervalMinutes = SyncSettings.OPEN_SYNC_INTERVAL_MINUTES
val lastAccountSync = preferences.getLong(getString(R.string.pref_key_last_account_sync_timestamp), 0L) val lastAccountSync = preferences.getLong(getString(R.string.pref_key_last_account_sync_timestamp), 0L)
val accountSyncDue = trigger == SyncTrigger.APP_OPEN && val accountSyncDue = trigger == SyncTrigger.APP_OPEN &&
now - lastAccountSync > intervalMinutes * 60 * 1000L now - lastAccountSync > intervalMinutes * 60 * 1000L
@@ -788,11 +790,23 @@ class BillsListViewActivity :
lifecycleScope.launch { lifecycleScope.launch {
val remoteProjects = withContext(Dispatchers.IO) { db.projects } val remoteProjects = withContext(Dispatchers.IO) { db.projects }
.filter { !it.isLocal && !it.isArchived } .filter { !it.isLocal && !it.isArchived }
// Only the first one scheduled actually starts - the rest queue behind it - and it
// takes the screen's callback with it. So the selected project goes first: otherwise
// the spinner follows a project the user is not looking at, stops when that one
// finishes, and theirs is reported as empty while it is still waiting its turn.
.sortedByDescending { it.id == selectedProjectId }
viewModel.isRefreshing = true viewModel.isRefreshing = true
db.cowspentServerSyncHelper.addCallbackPull(syncCallBack) db.cowspentServerSyncHelper.addCallbackPull(syncCallBack)
val started = if (accountSyncDue) { val started = if (accountSyncDue) {
// The pass is throttled by this stamp, so it has to be written here rather than
// left to the account sync: that only runs when an account is configured and only
// records itself on success, so without one the stamp stayed at zero and the pass
// repeated on every single resume, re-scheduling every project each time.
preferences.edit {
putLong(getString(R.string.pref_key_last_account_sync_timestamp), now)
}
if (CowspentServerSyncHelper.isNextcloudAccountConfigured(applicationContext)) { if (CowspentServerSyncHelper.isNextcloudAccountConfigured(applicationContext)) {
db.cowspentServerSyncHelper.runAccountProjectsSync() db.cowspentServerSyncHelper.runAccountProjectsSync()
} }
@@ -804,14 +818,24 @@ class BillsListViewActivity :
} else { } else {
val selectedProj = remoteProjects.find { it.id == selectedProjectId } val selectedProj = remoteProjects.find { it.id == selectedProjectId }
val lastSync = preferences.getLong(lastProjectSyncKey(selectedProjectId), 0L) val lastSync = preferences.getLong(lastProjectSyncKey(selectedProjectId), 0L)
val due = trigger == SyncTrigger.MANUAL || // The pull writes the project cursor only after it has applied everything, so a cursor
// still at zero means no sync ever finished and nothing is stored locally.
// Throttling that strands the user on an empty list with no spinner - which is what
// the back gesture does to a large project, since it destroys the activity while
// the sync is still running. IHateMoney never advances the cursor, so the interval
// stays in charge there.
val neverCompleted = selectedProj != null &&
selectedProj.type != ProjectType.IHATEMONEY &&
(selectedProj.lastSyncedTimestamp ?: 0L) == 0L
val due = trigger == SyncTrigger.MANUAL || neverCompleted ||
now - lastSync > SELECTED_PROJECT_SYNC_INTERVAL_MS now - lastSync > SELECTED_PROJECT_SYNC_INTERVAL_MS
val fullSync = trigger == SyncTrigger.MANUAL &&
!partialRefreshPreferred(preferences, selectedProjectId, now)
if (selectedProj != null && due && if (selectedProj != null && due &&
db.cowspentServerSyncHelper.scheduleSync( db.cowspentServerSyncHelper.scheduleSync(false, selectedProj, fullSync) != null
false, selectedProj, trigger == SyncTrigger.MANUAL
) != null
) { ) {
markProjectSynced(preferences, selectedProj.id, now) markProjectSynced(preferences, selectedProj.id, now)
if (fullSync) markFullySynced(preferences, selectedProj.id, now)
1 1
} else 0 } else 0
} }
@@ -823,6 +847,33 @@ class BillsListViewActivity :
} }
private fun lastProjectSyncKey(projectId: Long) = "lastProjectSyncTimestamp_$projectId" private fun lastProjectSyncKey(projectId: Long) = "lastProjectSyncTimestamp_$projectId"
private fun lastFullSyncKey(projectId: Long) = "lastFullSyncTimestamp_$projectId"
private fun markFullySynced(preferences: SharedPreferences, projectId: Long, at: Long) {
preferences.edit { putLong(lastFullSyncKey(projectId), at) }
}
/**
* Whether a pull to refresh should settle for the cheaper paged walk.
*
* A refresh by hand normally re-downloads the whole project, which is what makes it a usable
* repair when the local copy looks wrong. On a large project that is expensive to repeat, so
* with beta features on a refresh that follows a recent full one walks the recent pages
* instead - the common case of pulling twice in a row costs a page rather than the project.
*/
private fun partialRefreshPreferred(
preferences: SharedPreferences,
projectId: Long,
now: Long
): Boolean {
if (!preferences.getBoolean(getString(R.string.pref_key_extra_features), false)) return false
// "Always" means what it says: every manual refresh is a full sync.
val delayMinutes = SyncSettings.fullSyncDelayMinutes(applicationContext)
if (delayMinutes == 0) return false
val lastFull = preferences.getLong(lastFullSyncKey(projectId), 0L)
if (lastFull == 0L) return false
return now - lastFull < delayMinutes * 60 * 1000L
}
private fun markProjectSynced(preferences: SharedPreferences, projectId: Long, at: Long) { private fun markProjectSynced(preferences: SharedPreferences, projectId: Long, at: Long) {
preferences.edit { putLong(lastProjectSyncKey(projectId), at) } preferences.edit { putLong(lastProjectSyncKey(projectId), at) }
} }
@@ -11,11 +11,30 @@ import net.helcel.cowspent.R
import net.helcel.cowspent.model.* import net.helcel.cowspent.model.*
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import java.io.InputStreamReader import java.io.InputStreamReader
import java.net.URLDecoder
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
object ProjectImportHelper { object ProjectImportHelper {
private fun optional(line: Array<String>, columns: Map<String, Int>, name: String): String =
columns[name]?.takeIf { it < line.size }?.let { line[it] } ?: ""
/** Parses the `#rrggbb` member colour Cospend writes in its members section. */
private fun parseHexColor(value: String): Triple<Int, Int, Int>? {
val hex = value.trim().removePrefix("#")
if (hex.length != 6) return null
return try {
Triple(
hex.substring(0, 2).toInt(16),
hex.substring(2, 4).toInt(16),
hex.substring(4, 6).toInt(16)
)
} catch (_: NumberFormatException) {
null
}
}
@SuppressLint("Range") @SuppressLint("Range")
fun getFileName(contentResolver: ContentResolver, uri: Uri): String { fun getFileName(contentResolver: ContentResolver, uri: Uri): String {
var result: String? = null var result: String? = null
@@ -59,6 +78,7 @@ object ProjectImportHelper {
val bills = mutableListOf<DBBill>() val bills = mutableListOf<DBBill>()
val membersActive = mutableMapOf<String, Boolean>() val membersActive = mutableMapOf<String, Boolean>()
val membersWeight = mutableMapOf<String, Double>() val membersWeight = mutableMapOf<String, Double>()
val membersColor = mutableMapOf<String, Triple<Int, Int, Int>>()
val billRemoteIdToPayerName = mutableMapOf<Long, String>() val billRemoteIdToPayerName = mutableMapOf<Long, String>()
val billRemoteIdToOwerStr = mutableMapOf<Long, String>() val billRemoteIdToOwerStr = mutableMapOf<Long, String>()
@@ -76,7 +96,10 @@ object ProjectImportHelper {
currentSection = when { currentSection = when {
columns.containsKey("what") && columns.containsKey("amount") -> "bills" columns.containsKey("what") && columns.containsKey("amount") -> "bills"
columns.containsKey("name") && columns.containsKey("weight") &&
columns.containsKey("active") -> "members"
columns.containsKey("categoryid") && columns.containsKey("categoryname") -> "categories" columns.containsKey("categoryid") && columns.containsKey("categoryname") -> "categories"
columns.containsKey("paymentmodeid") && columns.containsKey("paymentmodename") -> "paymentmodes"
columns.containsKey("exchange_rate") && columns.containsKey("currencyname") -> "currencies" columns.containsKey("exchange_rate") && columns.containsKey("currencyname") -> "currencies"
else -> { else -> {
onError(context.getString(R.string.import_error_header, row)) onError(context.getString(R.string.import_error_header, row))
@@ -86,11 +109,19 @@ object ProjectImportHelper {
} else { } else {
previousLineEmpty = false previousLineEmpty = false
when (currentSection) { when (currentSection) {
"members" -> {
val name = line[columns["name"]!!].trim()
if (name.isNotEmpty()) {
membersWeight[name] = optional(line, columns, "weight").toDoubleOrNull() ?: 1.0
membersActive[name] = optional(line, columns, "active") != "0"
parseHexColor(optional(line, columns, "color"))?.let { membersColor[name] = it }
}
}
"categories" -> { "categories" -> {
categories.add(DBCategory(0, line[columns["categoryid"]!!].toLong(), 0, line[columns["categoryname"]!!], line[columns["icon"]!!], line[columns["color"]!!])) categories.add(DBCategory(0, line[columns["categoryid"]!!].toLong(), 0, line[columns["categoryname"]!!], optional(line, columns, "icon"), optional(line, columns, "color")))
} }
"paymentmodes" -> { "paymentmodes" -> {
paymentModes.add(DBPaymentMode(0, line[columns["categoryid"]!!].toLong(), 0, line[columns["categoryname"]!!], line[columns["icon"]!!], line[columns["color"]!!])) paymentModes.add(DBPaymentMode(0, line[columns["paymentmodeid"]!!].toLong(), 0, line[columns["paymentmodename"]!!], optional(line, columns, "icon"), optional(line, columns, "color")))
} }
"currencies" -> { "currencies" -> {
val name = line[columns["currencyname"]!!] val name = line[columns["currencyname"]!!]
@@ -100,7 +131,22 @@ object ProjectImportHelper {
} }
"bills" -> { "bills" -> {
val what = if (columns.containsKey("what")) line[columns["what"]!!] else "" val what = if (columns.containsKey("what")) line[columns["what"]!!] else ""
val comment = if (columns.containsKey("comment")) line[columns["comment"]!!] else "" // Cospend url-encodes bill comments on export and marks trashed
// bills with a "deleted" column no other dialect has, so that column
// doubles as the marker for which comment encoding to expect.
val cospendDialect = columns.containsKey("deleted")
val comment = if (columns.containsKey("comment")) {
val raw = line[columns["comment"]!!]
if (cospendDialect) {
try {
URLDecoder.decode(raw, "UTF-8")
} catch (_: Exception) {
raw
}
} else raw
} else ""
val deleted = cospendDialect &&
line[columns["deleted"]!!].trim().let { it.isNotEmpty() && it != "0" }
val amount = if (columns.containsKey("amount")) line[columns["amount"]!!].toDouble() else 0.0 val amount = if (columns.containsKey("amount")) line[columns["amount"]!!].toDouble() else 0.0
val timestamp: Long = when { val timestamp: Long = when {
columns.containsKey("timestamp") -> line[columns["timestamp"]!!].toLong() columns.containsKey("timestamp") -> line[columns["timestamp"]!!].toLong()
@@ -121,6 +167,11 @@ object ProjectImportHelper {
val catId = if (columns.containsKey("categoryid") && line[columns["categoryid"]!!].isNotEmpty()) line[columns["categoryid"]!!].toLong() else 0L val catId = if (columns.containsKey("categoryid") && line[columns["categoryid"]!!].isNotEmpty()) line[columns["categoryid"]!!].toLong() else 0L
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
// MoneyBuster's export only carries the legacy one-letter payment
// mode. Everything downstream keys off paymentModeId, so translate
// it back the same way the sync parser does.
val effectivePmId =
if (pmId != 0L) pmId else DBBill.oldPmIdToNew[pm] ?: DBBill.PAYMODE_ID_NONE
if (payerName.isNotEmpty()) { if (payerName.isNotEmpty()) {
membersActive[payerName] = payerActive membersActive[payerName] = payerActive
@@ -132,7 +183,7 @@ object ProjectImportHelper {
return return
} }
if (what != "deleteMeIfYouWant") { if (what != "deleteMeIfYouWant" && !deleted) {
billRemoteIdToOwerStr[row.toLong()] = owersStr billRemoteIdToOwerStr[row.toLong()] = owersStr
val owersArray = owersStr.split(",").map { it.trim() }.filter { it.isNotEmpty() } val owersArray = owersStr.split(",").map { it.trim() }.filter { it.isNotEmpty() }
for (ower in owersArray) { for (ower in owersArray) {
@@ -140,7 +191,7 @@ object ProjectImportHelper {
membersWeight[ower] = 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, effectivePmId))
billRemoteIdToPayerName[row.toLong()] = payerName billRemoteIdToPayerName[row.toLong()] = payerName
} }
} }
@@ -168,13 +219,20 @@ object ProjectImportHelper {
currencies.forEach { db.addCurrency(DBCurrency(0, 0, pid, it.name, it.exchangeRate, DBBill.STATE_OK)) } currencies.forEach { db.addCurrency(DBCurrency(0, 0, pid, it.name, it.exchangeRate, DBBill.STATE_OK)) }
membersWeight.keys.forEach { mName -> membersWeight.keys.forEach { mName ->
memberNameToId[mName] = db.addMember(DBMember(0, 0, pid, mName, membersActive[mName] ?: true, membersWeight[mName] ?: 1.0, DBBill.STATE_OK, null, null, null, null, null)) val c = membersColor[mName]
memberNameToId[mName] = db.addMember(DBMember(0, 0, pid, mName, membersActive[mName] ?: true, membersWeight[mName] ?: 1.0, DBBill.STATE_OK, c?.first, c?.second, c?.third, null, null))
} }
bills.forEach { b -> bills.forEach { b ->
val payerId = memberNameToId[billRemoteIdToPayerName[b.remoteId]] ?: 0L val payerId = memberNameToId[billRemoteIdToPayerName[b.remoteId]] ?: 0L
val localCatId = catRemoteToLocal[b.categoryId] ?: 0L // Only custom labels are listed in the categories/paymentmodes sections. Built-in
val localPmId = pmRemoteToLocal[b.paymentModeId] ?: 0L // ones are referenced by their (negative) constant, which the UI resolves on its
// own, so those have to be kept rather than reset to "none". Unmapped positive
// ids are dropped instead, as they would collide with local ids.
val localCatId = catRemoteToLocal[b.categoryId]
?: b.categoryId.takeIf { it < 0 } ?: 0L
val localPmId = pmRemoteToLocal[b.paymentModeId]
?: b.paymentModeId.takeIf { it < 0 } ?: 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(",")?.map { it.trim() }?.filter { it.isNotEmpty() }?.forEach { ower -> billRemoteIdToOwerStr[b.remoteId]?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }?.forEach { ower ->
memberNameToId[ower]?.let { owerId -> db.addBillower(billId, owerId) } memberNameToId[ower]?.let { owerId -> db.addBillower(billId, owerId) }
@@ -36,7 +36,7 @@ fun ProjectOptionsDialogContent(
projectType: ProjectType = ProjectType.LOCAL, projectType: ProjectType = ProjectType.LOCAL,
accessLevel: Int = DBProject.ACCESS_LEVEL_ADMIN, accessLevel: Int = DBProject.ACCESS_LEVEL_ADMIN,
isShareable: Boolean = true, isShareable: Boolean = true,
showBetaFeatures: Boolean = false showExtraFeatures: Boolean = false
) { ) {
Surface( Surface(
shape = MaterialTheme.shapes.large, shape = MaterialTheme.shapes.large,
@@ -80,7 +80,7 @@ fun ProjectOptionsDialogContent(
// Row 2: Manage Member, Manage Labels, Manage Currencies // Row 2: Manage Member, Manage Labels, Manage Currencies
if (!isArchived && isMaintainer) { if (!isArchived && isMaintainer) {
row2.add(ProjectOption(stringResource(R.string.action_members), Icons.Default.Group, onManageMembers)) row2.add(ProjectOption(stringResource(R.string.action_members), Icons.Default.Group, onManageMembers))
if (showBetaFeatures && (projectType == ProjectType.LOCAL || projectType == ProjectType.COSPEND)) { if (showExtraFeatures && (projectType == ProjectType.LOCAL || projectType == ProjectType.COSPEND)) {
row2.add(ProjectOption(stringResource(R.string.action_labels), Icons.AutoMirrored.Filled.Label, onManageLabels)) row2.add(ProjectOption(stringResource(R.string.action_labels), Icons.AutoMirrored.Filled.Label, onManageLabels))
} }
row2.add(ProjectOption(stringResource(R.string.action_currencies), Icons.Default.MonetizationOn, onManageCurrencies)) row2.add(ProjectOption(stringResource(R.string.action_currencies), Icons.Default.MonetizationOn, onManageCurrencies))
@@ -183,7 +183,7 @@ fun ProjectOptionsDialogPreview() {
projectType = ProjectType.COSPEND, projectType = ProjectType.COSPEND,
accessLevel = DBProject.ACCESS_LEVEL_ADMIN, accessLevel = DBProject.ACCESS_LEVEL_ADMIN,
isShareable = true, isShareable = true,
showBetaFeatures = true showExtraFeatures = true
) )
} }
} }
@@ -208,7 +208,7 @@ fun ProjectOptionsDialogPreview2() {
projectType = ProjectType.COSPEND, projectType = ProjectType.COSPEND,
accessLevel = DBProject.ACCESS_LEVEL_ADMIN, accessLevel = DBProject.ACCESS_LEVEL_ADMIN,
isShareable = true, isShareable = true,
showBetaFeatures = true showExtraFeatures = true
) )
} }
} }
@@ -232,7 +232,7 @@ fun ProjectOptionsDialogPreview3() {
projectType = ProjectType.LOCAL, projectType = ProjectType.LOCAL,
accessLevel = DBProject.ACCESS_LEVEL_ADMIN, accessLevel = DBProject.ACCESS_LEVEL_ADMIN,
isShareable = true, isShareable = true,
showBetaFeatures = true showExtraFeatures = true
) )
} }
} }
@@ -84,9 +84,9 @@ fun SettingsScreen(
val keyColor = stringResource(R.string.pref_key_color) val keyColor = stringResource(R.string.pref_key_color)
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 keyExtraFeatures = stringResource(R.string.pref_key_extra_features)
val keyStatsIncludeDeactivated = stringResource(R.string.pref_key_stats_include_deactivated) val keyStatsIncludeDeactivated = stringResource(R.string.pref_key_stats_include_deactivated)
val keyAutoSyncOnOpen = stringResource(R.string.pref_key_auto_sync_on_open) val keyFullSyncDelay = stringResource(R.string.pref_key_full_sync_delay)
val keyFillNewBillFromLast = stringResource(R.string.pref_key_fill_new_bill_from_last) val keyFillNewBillFromLast = stringResource(R.string.pref_key_fill_new_bill_from_last)
val keyLastAccountSync = stringResource(R.string.pref_key_last_account_sync_timestamp) val keyLastAccountSync = stringResource(R.string.pref_key_last_account_sync_timestamp)
@@ -128,8 +128,8 @@ fun SettingsScreen(
var showArchived by remember(keyShowArchived) { var showArchived by remember(keyShowArchived) {
mutableStateOf(sharedPreferences.getBoolean(keyShowArchived, false)) mutableStateOf(sharedPreferences.getBoolean(keyShowArchived, false))
} }
var betaFeatures by remember(keyBetaFeatures) { var extraFeatures by remember(keyExtraFeatures) {
mutableStateOf(sharedPreferences.getBoolean(keyBetaFeatures, false)) mutableStateOf(sharedPreferences.getBoolean(keyExtraFeatures, false))
} }
var fillNewBillFromLast by remember(keyFillNewBillFromLast) { var fillNewBillFromLast by remember(keyFillNewBillFromLast) {
mutableStateOf(sharedPreferences.getBoolean(keyFillNewBillFromLast, false)) mutableStateOf(sharedPreferences.getBoolean(keyFillNewBillFromLast, false))
@@ -138,16 +138,16 @@ fun SettingsScreen(
mutableStateOf(sharedPreferences.getBoolean(keyStatsIncludeDeactivated, false)) mutableStateOf(sharedPreferences.getBoolean(keyStatsIncludeDeactivated, false))
} }
val syncIntervals = SyncSettings.INTERVAL_CHOICES_MINUTES val fullSyncDelays = SyncSettings.FULL_SYNC_DELAY_CHOICES_MINUTES
val syncIntervalLabels = listOf( val fullSyncDelayLabels = listOf(
stringResource(R.string.pref_value_sync_1m), stringResource(R.string.pref_value_sync_always),
stringResource(R.string.pref_value_sync_10m),
stringResource(R.string.pref_value_sync_1h), stringResource(R.string.pref_value_sync_1h),
stringResource(R.string.pref_value_sync_1d) stringResource(R.string.pref_value_sync_1d),
stringResource(R.string.pref_value_sync_1w)
) )
var syncInterval by remember(keyAutoSyncOnOpen) { var fullSyncDelay by remember(keyFullSyncDelay) {
mutableIntStateOf(SyncSettings.intervalMinutes(context)) mutableIntStateOf(SyncSettings.fullSyncDelayMinutes(context))
} }
Scaffold( Scaffold(
@@ -173,28 +173,6 @@ fun SettingsScreen(
// Appearance // Appearance
SettingsCategory(stringResource(R.string.settings_appearance)) SettingsCategory(stringResource(R.string.settings_appearance))
SettingsSwitchPreference(
title = stringResource(R.string.settings_show_archived),
icon = Icons.Default.Archive,
checked = showArchived,
onCheckedChange = {
showArchived = it
sharedPreferences.edit {
putBoolean(keyShowArchived, it)
if (!it) {
val selectedProjectId = sharedPreferences.getLong("selected_project", 0)
if (selectedProjectId != 0L) {
val db = CowspentSQLiteOpenHelper.getInstance(context)
val project = db.getProject(selectedProjectId)
if (project?.isArchived == true) {
putLong("selected_project", 0)
}
}
}
}
}
)
SettingsListPreference( SettingsListPreference(
title = stringResource(R.string.settings_night_mode), title = stringResource(R.string.settings_night_mode),
icon = Icons.Default.Brightness2, icon = Icons.Default.Brightness2,
@@ -283,19 +261,41 @@ fun SettingsScreen(
SettingsCategory(stringResource(R.string.settings_other)) SettingsCategory(stringResource(R.string.settings_other))
SettingsSwitchPreference( SettingsSwitchPreference(
title = stringResource(R.string.settings_beta_features), title = stringResource(R.string.settings_extra_features),
summary = stringResource(R.string.settings_beta_features_summary), summary = stringResource(R.string.settings_extra_features_summary),
icon = Icons.Default.Info, icon = Icons.Default.Info,
checked = betaFeatures, checked = extraFeatures,
onCheckedChange = { onCheckedChange = {
betaFeatures = it extraFeatures = it
sharedPreferences.edit { sharedPreferences.edit {
putBoolean(keyBetaFeatures, it) putBoolean(keyExtraFeatures, it)
}
}
)
if (extraFeatures) {
SettingsSwitchPreference(
title = stringResource(R.string.settings_show_archived),
icon = Icons.Default.Archive,
checked = showArchived,
onCheckedChange = {
showArchived = it
sharedPreferences.edit {
putBoolean(keyShowArchived, it)
if (!it) {
val selectedProjectId = sharedPreferences.getLong("selected_project", 0)
if (selectedProjectId != 0L) {
val db = CowspentSQLiteOpenHelper.getInstance(context)
val project = db.getProject(selectedProjectId)
if (project?.isArchived == true) {
putLong("selected_project", 0)
}
}
}
} }
} }
) )
if (betaFeatures) {
SettingsSwitchPreference( SettingsSwitchPreference(
title = stringResource(R.string.settings_fill_new_bill_from_last), title = stringResource(R.string.settings_fill_new_bill_from_last),
summary = stringResource(R.string.settings_fill_new_bill_from_last_summary), summary = stringResource(R.string.settings_fill_new_bill_from_last_summary),
@@ -322,18 +322,18 @@ fun SettingsScreen(
) )
SettingsSliderPreference( SettingsSliderPreference(
title = stringResource(R.string.settings_auto_sync_on_open), title = stringResource(R.string.settings_full_sync_delay),
summary = stringResource(R.string.settings_auto_sync_on_open_summary), summary = stringResource(R.string.settings_full_sync_delay_summary),
icon = Icons.Default.Sync, icon = Icons.Default.Sync,
value = syncInterval, value = fullSyncDelay,
values = syncIntervals, values = fullSyncDelays,
labels = syncIntervalLabels, labels = fullSyncDelayLabels,
onValueChange = { newInterval -> onValueChange = { newDelay ->
// Slider reports every drag delta, not just the snapped steps. // Slider reports every drag delta, not just the snapped steps.
if (newInterval != syncInterval) { if (newDelay != fullSyncDelay) {
syncInterval = newInterval fullSyncDelay = newDelay
sharedPreferences.edit { sharedPreferences.edit {
putInt(keyAutoSyncOnOpen, newInterval) putInt(keyFullSyncDelay, newDelay)
} }
} }
} }
@@ -448,6 +448,26 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
// --- Bills logic --- // --- Bills logic ---
/**
* Runs [block] as one database transaction.
*
* Every write here commits on its own otherwise, which for a sync means a committed
* transaction per bill and another per ower - the dominant cost of a large project's first
* sync. Callers batch in chunks rather than wrapping everything: a sync interrupted halfway
* then keeps the chunks it already committed instead of rolling the lot back.
*/
fun <T> inTransaction(block: () -> T): T {
val db = writableDatabase
db.beginTransaction()
try {
val result = block()
db.setTransactionSuccessful()
return result
} finally {
db.endTransaction()
}
}
fun addBill(b: DBBill): Long { fun addBill(b: DBBill): Long {
val db = writableDatabase val db = writableDatabase
val values = ContentValues() val values = ContentValues()
@@ -627,11 +647,25 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
val cursor = db.query(table_bills, columnsBills, selection, selectionArgs, null, null, orderBy) val cursor = db.query(table_bills, columnsBills, selection, selectionArgs, null, null, orderBy)
val bills: MutableList<DBBill> = ArrayList() val bills: MutableList<DBBill> = ArrayList()
while (cursor.moveToNext()) { while (cursor.moveToNext()) {
val bill = getBillFromCursor(cursor) bills.add(getBillFromCursor(cursor))
bill.billOwers = getBillowersOfBill(bill.id)
bills.add(bill)
} }
cursor.close() cursor.close()
if (bills.isEmpty()) return bills
// Every ower of the matched bills in one query, keyed back to its bill.
//
// Fetching them per bill meant a project with thousands of bills issued thousands of
// queries on every list refresh - and that refresh runs on resume, on switching project,
// and after each sync. The bill ids are re-selected as a subquery rather than bound as
// arguments, which would blow past SQLite's limit on bound variables for a large project.
val owersByBill = getBillOwersCustom(
"$key_billId IN (SELECT $key_id FROM $table_bills WHERE $selection)",
selectionArgs,
null
).groupBy { it.billId }
for (bill in bills) {
bill.billOwers = owersByBill[bill.id].orEmpty()
}
return bills return bills
} }
@@ -50,7 +50,6 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
private var syncActive = false private var syncActive = false
private var syncAccountProjectsActive = false private var syncAccountProjectsActive = false
private var callbacksPush: MutableList<ICallback> = ArrayList()
private var callbacksPull: MutableList<ICallback> = ArrayList() private var callbacksPull: MutableList<ICallback> = ArrayList()
init { init {
@@ -101,8 +100,6 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
if (project != null) { if (project != null) {
Log.d(TAG, "... starting now") Log.d(TAG, "... starting now")
val syncTask = SyncTask(onlyLocalChanges, project, forceFullSync) val syncTask = SyncTask(onlyLocalChanges, project, forceFullSync)
syncTask.addCallbacks(callbacksPush)
callbacksPush = ArrayList()
if (!onlyLocalChanges) { if (!onlyLocalChanges) {
syncTask.addCallbacks(callbacksPull) syncTask.addCallbacks(callbacksPull)
callbacksPull = ArrayList() callbacksPull = ArrayList()
@@ -114,14 +111,8 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
} else if (!onlyLocalChanges) { } else if (!onlyLocalChanges) {
Log.d(TAG, "... scheduled") Log.d(TAG, "... scheduled")
projectIdsToSync.add(projId) projectIdsToSync.add(projId)
for (callback in callbacksPush) {
callback.onScheduled()
}
} else { } else {
Log.d(TAG, "... do nothing") Log.d(TAG, "... do nothing")
for (callback in callbacksPush) {
callback.onScheduled()
}
} }
return null return null
} }
@@ -153,7 +144,12 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
* walk it is empty, and nothing may be deleted locally on the strength of it. * walk it is empty, and nothing may be deleted locally on the strength of it.
*/ */
val allIds: List<Long>, val allIds: List<Long>,
val syncTimestamp: Long? val syncTimestamp: Long?,
/**
* A bill referenced a member, category or payment mode this device does not know. A
* partial sync skips re-reading the project, so this is what tells it to go back for it.
*/
val hasUnresolvedReferences: Boolean = false
) )
inner class SyncTask(private val onlyLocalChanges: Boolean, private val project: DBProject, private val forceFullSync: Boolean = false) { inner class SyncTask(private val onlyLocalChanges: Boolean, private val project: DBProject, private val forceFullSync: Boolean = false) {
@@ -197,12 +193,19 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
if (project.type == ProjectType.COSPEND) { if (project.type == ProjectType.COSPEND) {
nextcloudClient = createNextcloudClient() nextcloudClient = createNextcloudClient()
if (nextcloudClient != null) { if (nextcloudClient != null) {
// The server's version only moves when the server itself is upgraded, and
// asking costs a whole round trip before any project data moves. Remember it
// per server, and re-read it whenever a full sync is asked for.
version = if (forceFullSync) null else cachedCospendVersion()
if (version == null) {
try { try {
val response = nextcloudClient!!.getCapabilities(project) val response = nextcloudClient!!.getCapabilities(project)
version = response.cospendVersion version = response.cospendVersion
rememberCospendVersion(version)
} catch (e: Exception) { } catch (e: Exception) {
Log.i(TAG, "Failed to get cospend version when syncing: $e") Log.i(TAG, "Failed to get cospend version when syncing: $e")
} }
}
} else if (preferences.getBoolean(AccountActivity.SETTINGS_USE_SSO, false)) { } else if (preferences.getBoolean(AccountActivity.SETTINGS_USE_SSO, false)) {
return LoginStatus.SSO_TOKEN_MISMATCH return LoginStatus.SSO_TOKEN_MISMATCH
} }
@@ -227,15 +230,32 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
deferred?.await() ?: LoginStatus.CONNECTION_FAILED deferred?.await() ?: LoginStatus.CONNECTION_FAILED
} }
/** Keyed by server rather than by project, since several projects can share one. */
private fun versionKey() = "cospend_version_" + (project.serverUrl ?: "")
private fun cachedCospendVersion(): String? =
preferences.getString(versionKey(), null)
private fun rememberCospendVersion(version: String?) {
// A failed lookup is not an answer, and caching it would pin the wrong API dialect
// until the next forced sync.
if (version.isNullOrEmpty()) return
preferences.edit { putString(versionKey(), version) }
}
private fun pushLocalChanges(): LoginStatus { private fun pushLocalChanges(): LoginStatus {
Log.d(TAG, "PUSH LOCAL CHANGES") Log.d(TAG, "PUSH LOCAL CHANGES")
return try { return try {
// The remote member list is only needed to match members waiting to be created,
// and pullRemoteChanges fetches them again as part of the project anyway, so it
// is not worth a round trip of its own when there is nothing to add.
val membersToAdd = dbHelper.getMembersOfProjectWithState(project.id, DBBill.STATE_ADDED)
if (membersToAdd.isNotEmpty()) {
val membersResponse = client!!.getMembers(project) val membersResponse = client!!.getMembers(project)
val remoteMembers = membersResponse.getMembers(project.id) val remoteMembers = membersResponse.getMembers(project.id)
val remoteMembersNames = remoteMembers.map { it.name } val remoteMembersNames = remoteMembers.map { it.name }
val membersToAdd = dbHelper.getMembersOfProjectWithState(project.id, DBBill.STATE_ADDED)
for (mToAdd in membersToAdd) { for (mToAdd in membersToAdd) {
val searchIndex = remoteMembersNames.indexOf(mToAdd.name) val searchIndex = remoteMembersNames.indexOf(mToAdd.name)
if (searchIndex != -1) { if (searchIndex != -1) {
@@ -259,6 +279,7 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
} }
} }
} }
}
val membersToEdit = dbHelper.getMembersOfProjectWithState(project.id, DBBill.STATE_EDITED) val membersToEdit = dbHelper.getMembersOfProjectWithState(project.id, DBBill.STATE_EDITED)
for (mToEdit in membersToEdit) { for (mToEdit in membersToEdit) {
@@ -549,32 +570,53 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
} }
/** /**
* Brings the local copy of the project in line with the server: its own row, then each of * Reads the project's own row and each of its collections. One request, and everything it
* its collections, then its bills. Each step is applied as it goes, so a failure part way * carries is applied as it goes, so a failure part way through leaves the earlier steps
* through leaves the earlier steps written - the next sync picks up where this one stopped. * written. Returns the members it saw, keyed by remote id.
*/ */
private fun pullRemoteChanges(): LoginStatus { private fun pullProject(): Map<Long, DBMember> {
Log.d(TAG, "pullRemoteChanges($project)")
return try {
val projResponse = client!!.getProject(project, 0, null) val projResponse = client!!.getProject(project, 0, null)
updateLocalProject(projResponse) updateLocalProject(projResponse)
syncPaymentModes(projResponse) syncPaymentModes(projResponse)
syncCategories(projResponse) syncCategories(projResponse)
syncCurrencies(projResponse) syncCurrencies(projResponse)
val remoteMembersByRemoteId = syncMembers(projResponse) return syncMembers(projResponse)
}
/**
* Brings the local copy of the project in line with the server: its own row, then each of
* its collections, then its bills. Each step is applied as it goes, so a failure part way
* through leaves the earlier steps written - the next sync picks up where this one stopped.
*
* A partial sync is meant to cost one request. A project's members and labels change far
* less often than its bills, so it skips reading them and goes straight for the bills; it
* only goes back for the project when a bill turns out to name something this device has
* never seen. What that trades away is noticing a rename or a deletion that no bill
* refers to - those land on the next full sync.
*/
private fun pullRemoteChanges(): LoginStatus {
Log.d(TAG, "pullRemoteChanges($project)")
return try {
val localBills = dbHelper.getBillsOfProject(project.id)
val localBillsByRemoteId = localBills.associateBy { it.remoteId }
val partial = !forceFullSync && localBillsByRemoteId.isNotEmpty()
var remoteMembersByRemoteId: Map<Long, DBMember>? = null
if (!partial) remoteMembersByRemoteId = pullProject()
// Bills arrive with the server's ids for their member, category and payment mode, // Bills arrive with the server's ids for their member, category and payment mode,
// so the maps have to be built after those collections are in place. // so the maps have to be built after those collections are in place.
val idMaps = buildRemoteIdMaps() var pulled = fetchRemoteBills(buildRemoteIdMaps(), localBillsByRemoteId)
val localBills = dbHelper.getBillsOfProject(project.id) if (pulled.hasUnresolvedReferences && remoteMembersByRemoteId == null) {
val localBillsByRemoteId = localBills.associateBy { it.remoteId } Log.d(TAG, "Bill referenced an unknown member or label; re-reading the project")
val pulled = fetchRemoteBills(idMaps, localBillsByRemoteId) remoteMembersByRemoteId = pullProject()
pulled = fetchRemoteBills(buildRemoteIdMaps(), localBillsByRemoteId)
}
applyRemoteBills(pulled.bills, localBillsByRemoteId) applyRemoteBills(pulled.bills, localBillsByRemoteId)
deleteVanishedBills(pulled, localBills) deleteVanishedBills(pulled, localBills)
deleteVanishedMembers(remoteMembersByRemoteId) remoteMembersByRemoteId?.let { deleteVanishedMembers(it) }
dbHelper.updateProject( dbHelper.updateProject(
projId = project.id, projId = project.id,
@@ -808,7 +850,9 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
if (usePagedWalk) { if (usePagedWalk) {
walkBillPages(idMaps, localBillsByRemoteId)?.let { return it } walkBillPages(idMaps, localBillsByRemoteId)?.let { return it }
} }
return fetchAllBills(idMaps) // A project with nothing stored has no cursor worth sending, so it asks for everything.
val since = if (forceFullSync || localBillsByRemoteId.isEmpty()) 0L else null
return fetchAllBills(idMaps, since)
} }
/** /**
@@ -832,6 +876,7 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
var syncTimestamp = project.lastSyncedTimestamp var syncTimestamp = project.lastSyncedTimestamp
var offset = 0 var offset = 0
var previousPageIds: List<Long>? = null var previousPageIds: List<Long>? = null
var unresolved = false
// Counted across pages, not restarted at each one: a run that begins near the end of // Counted across pages, not restarted at each one: a run that begins near the end of
// a page still finishes on the next. // a page still finishes on the next.
var unchangedRun = 0 var unchangedRun = 0
@@ -841,6 +886,7 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
val page = response.getBillsCospend( val page = response.getBillsCospend(
project.id, idMaps.members, idMaps.categories, idMaps.paymentModes project.id, idMaps.members, idMaps.categories, idMaps.paymentModes
) )
if (response.hasUnresolvedReferences) unresolved = true
if (page.isEmpty()) break if (page.isEmpty()) break
if (page.first().timestamp < page.last().timestamp) { if (page.first().timestamp < page.last().timestamp) {
@@ -880,24 +926,31 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
} }
// A walk reports no allIds, so it never causes a local deletion. // A walk reports no allIds, so it never causes a local deletion.
return RemoteBills(bills, emptyList(), syncTimestamp) return RemoteBills(bills, emptyList(), syncTimestamp, unresolved)
} }
private fun fetchAllBills(idMaps: RemoteIdMaps): RemoteBills { /**
Log.d(TAG, "Starting full sync for project ${project.remoteId}") * One request for the whole collection. [since] 0 asks for everything; the project cursor
val response = client!!.getBills(project) * asks only for what changed. Either way the response carries the full id list, which is
* what lets vanished bills be removed locally.
*/
private fun fetchAllBills(idMaps: RemoteIdMaps, since: Long?): RemoteBills {
Log.d(TAG, "Fetching bills for ${project.remoteId} (since=${since ?: project.lastSyncedTimestamp})")
val response = client!!.getBills(project, since = since)
return if (project.type == ProjectType.IHATEMONEY) { return if (project.type == ProjectType.IHATEMONEY) {
val bills = response.getBillsIHM( val bills = response.getBillsIHM(
project.id, idMaps.members, idMaps.categories, idMaps.paymentModes project.id, idMaps.members, idMaps.categories, idMaps.paymentModes
) )
RemoteBills(bills, bills.map { it.remoteId }, 0L) RemoteBills(bills, bills.map { it.remoteId }, 0L, response.hasUnresolvedReferences)
} else { } else {
RemoteBills( val bills = response.getBillsCospend(
response.getBillsCospend(
project.id, idMaps.members, idMaps.categories, idMaps.paymentModes project.id, idMaps.members, idMaps.categories, idMaps.paymentModes
), )
RemoteBills(
bills,
response.allBillIds, response.allBillIds,
response.syncTimestamp response.syncTimestamp,
response.hasUnresolvedReferences
) )
} }
} }
@@ -905,6 +958,18 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
private fun applyRemoteBills( private fun applyRemoteBills(
remoteBills: List<DBBill>, remoteBills: List<DBBill>,
localBillsByRemoteId: Map<Long, DBBill> localBillsByRemoteId: Map<Long, DBBill>
) {
// Committed in chunks: one transaction per chunk turns a per-row commit into one write
// for the whole batch, while still leaving finished chunks on disk if the sync is cut
// short - a project part-way through is far better than one that rolled back.
remoteBills.chunked(BILL_APPLY_CHUNK).forEach { chunk ->
dbHelper.inTransaction { applyBillChunk(chunk, localBillsByRemoteId) }
}
}
private fun applyBillChunk(
remoteBills: List<DBBill>,
localBillsByRemoteId: Map<Long, DBBill>
) { ) {
for (remoteBill in remoteBills) { for (remoteBill in remoteBills) {
val localBill = localBillsByRemoteId[remoteBill.remoteId] val localBill = localBillsByRemoteId[remoteBill.remoteId]
@@ -966,6 +1031,10 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
} }
for (localBill in localBills) { for (localBill in localBills) {
// A bill still waiting to be pushed has no remote id yet, so it is absent from the
// server list for the ordinary reason that the server has never seen it. Deleting
// it here would throw away a bill the user added while the sync was running.
if (localBill.state != DBBill.STATE_OK) continue
if (localBill.remoteId !in stillRemote) { if (localBill.remoteId !in stillRemote) {
dbHelper.deleteBill(localBill.id) dbHelper.deleteBill(localBill.id)
nbPulledDeletedBills++ nbPulledDeletedBills++
@@ -1801,6 +1870,9 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
*/ */
private const val UNCHANGED_RUN_TO_SETTLE = 25 private const val UNCHANGED_RUN_TO_SETTLE = 25
/** Bills written per transaction while applying a pull. */
private const val BILL_APPLY_CHUNK = 500
private var instance: CowspentServerSyncHelper? = null private var instance: CowspentServerSyncHelper? = null
private val projectIdsToSync: MutableList<Long> = ArrayList() private val projectIdsToSync: MutableList<Long> = ArrayList()
@@ -5,6 +5,9 @@ import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
object ExportUtil { object ExportUtil {
/** Quotes a field the way RFC4180 (and opencsv, and Cospend) expect. */
private fun q(value: String?): String = "\"" + (value ?: "").replace("\"", "\"\"") + "\""
@JvmStatic @JvmStatic
fun createExportContent(db: CowspentSQLiteOpenHelper, projectId: Long): String { fun createExportContent(db: CowspentSQLiteOpenHelper, projectId: Long): String {
var fileContent = "" var fileContent = ""
@@ -18,8 +21,8 @@ object ExportUtil {
} }
val bills = db.getBillsOfProject(projectId).toMutableList() val bills = db.getBillsOfProject(projectId).toMutableList()
// write header // write header.
fileContent += "what,amount,date,timestamp,payer_name,payer_weight,payer_active,owers,repeat,categoryid,paymentmode\n" fileContent += "what,amount,date,timestamp,payer_name,payer_weight,payer_active,owers,repeat,categoryid,paymentmode,paymentmodeid,comment\n"
// write members // write members
for (m in members) { for (m in members) {
@@ -41,15 +44,11 @@ object ExportUtil {
val payerName = payer.name val payerName = payer.name
val payerWeight = payer.weight val payerWeight = payer.weight
val payerActive = if (payer.isActivated) 1 else 0 val payerActive = if (payer.isActivated) 1 else 0
val billOwers = b.billOwers val owersTxt = b.billOwers.mapNotNull { membersById[it.memberId]?.name }.joinToString(",")
var owersTxt = "" fileContent += "${q(b.what)},${b.amount},${b.date},${b.timestamp},${q(payerName)}," +
for (bo in billOwers) { "$payerWeight,$payerActive,${q(owersTxt)},${b.repeat ?: DBBill.NON_REPEATED}," +
owersTxt += membersById[bo.memberId]?.name + "," "${b.categoryId},${b.paymentMode ?: DBBill.PAYMODE_NONE},${b.paymentModeId}," +
} "${q(b.comment)}\n"
owersTxt = owersTxt.replace(",$".toRegex(), "")
fileContent += "\"${b.what}\",${b.amount},${b.date},${b.timestamp},\"$payerName\"," +
"$payerWeight,$payerActive,\"$owersTxt\",${b.repeat},${b.categoryId}," +
"${b.paymentMode}\n"
} }
// write categories // write categories
@@ -57,7 +56,16 @@ object ExportUtil {
if (cats.isNotEmpty()) { if (cats.isNotEmpty()) {
fileContent += "\ncategoryname,categoryid,icon,color\n" fileContent += "\ncategoryname,categoryid,icon,color\n"
for (cat in cats) { for (cat in cats) {
fileContent += "\"${cat.name}\",${cat.id},\"${cat.icon}\",\"${cat.color}\"\n" fileContent += "${q(cat.name)},${cat.id},${q(cat.icon)},${q(cat.color)}\n"
}
}
// write payment modes
val pms = db.getPaymentModes(projectId)
if (pms.isNotEmpty()) {
fileContent += "\npaymentmodename,paymentmodeid,icon,color\n"
for (pm in pms) {
fileContent += "${q(pm.name)},${pm.id},${q(pm.icon)},${q(pm.color)}\n"
} }
} }
@@ -67,9 +75,9 @@ object ExportUtil {
project.currencyName!!.isNotEmpty() && project.currencyName != "null" project.currencyName!!.isNotEmpty() && project.currencyName != "null"
) { ) {
fileContent += "\ncurrencyname,exchange_rate\n" fileContent += "\ncurrencyname,exchange_rate\n"
fileContent += "\"${project.currencyName}\",1\n" fileContent += "${q(project.currencyName)},1\n"
for (cur in curs) { for (cur in curs) {
fileContent += "\"${cur.name}\",${cur.exchangeRate}\n" fileContent += "${q(cur.name)},${cur.exchangeRate}\n"
} }
} }
@@ -28,6 +28,33 @@ open class ServerResponse(
) { ) {
private val sdf = SimpleDateFormat("yyyy-MM-dd", Locale.ROOT) private val sdf = SimpleDateFormat("yyyy-MM-dd", Locale.ROOT)
/**
* Set while parsing bills when one referenced a member, category or payment mode that none of
* the supplied maps knew about. A partial sync does not re-read the project, so this is how it
* learns that its copy of those collections has gone stale and has to be refreshed.
*/
var hasUnresolvedReferences = false
private set
/** Members are only ever addressed by the server's own id, so an unknown one is stale state. */
private fun resolveMember(map: Map<Long, Long>, remoteId: Long): Long {
map[remoteId]?.let { return it }
if (remoteId != 0L) hasUnresolvedReferences = true
return 0L
}
/**
* Built-in labels are negative constants with no row of their own on a remote project, so they
* never appear in these maps and are carried through as-is. An unknown *positive* id is a real
* gap - it is dropped rather than kept, as it would collide with a local id.
*/
private fun resolveLabel(map: Map<Long, Long>, remoteId: Long): Long {
map[remoteId]?.let { return it }
if (remoteId < 0) return remoteId
if (remoteId > 0) hasUnresolvedReferences = true
return 0L
}
class NotModifiedException : IOException() class NotModifiedException : IOException()
protected val content: String protected val content: String
@@ -744,10 +771,10 @@ open class ServerResponse(
} }
if (!json.isNull("payer_id")) { if (!json.isNull("payer_id")) {
payerRemoteId = json.getLong("payer_id") payerRemoteId = json.getLong("payer_id")
payerId = memberRemoteIdToId[payerRemoteId] ?: 0 payerId = resolveMember(memberRemoteIdToId, payerRemoteId)
} else if (!json.isNull("payer")) { } else if (!json.isNull("payer")) {
payerRemoteId = json.getLong("payer") payerRemoteId = json.getLong("payer")
payerId = memberRemoteIdToId[payerRemoteId] ?: 0 payerId = resolveMember(memberRemoteIdToId, payerRemoteId)
} }
if (!json.isNull("amount")) { if (!json.isNull("amount")) {
amount = json.getDouble("amount") amount = json.getDouble("amount")
@@ -794,8 +821,8 @@ open class ServerResponse(
paymentModeRemoteId = DBBill.oldPmIdToNew[paymentMode] ?: DBBill.PAYMODE_ID_NONE paymentModeRemoteId = DBBill.oldPmIdToNew[paymentMode] ?: DBBill.PAYMODE_ID_NONE
} }
val categoryId = catRemoteIdToId[categoryRemoteId] ?: 0L val categoryId = resolveLabel(catRemoteIdToId, categoryRemoteId)
val paymentModeId = pmRemoteIdToId[paymentModeRemoteId] ?: 0L val paymentModeId = resolveLabel(pmRemoteIdToId, paymentModeRemoteId)
val bill = DBBill( val bill = DBBill(
0, remoteId, projId, payerId, amount, timestamp, what, 0, remoteId, projId, payerId, amount, timestamp, what,
@@ -822,7 +849,7 @@ open class ServerResponse(
} else { } else {
jsonOs.getLong(i) jsonOs.getLong(i)
} }
val memberLocalId = memberRemoteIdToId[memberRemoteId] ?: 0 val memberLocalId = resolveMember(memberRemoteIdToId, memberRemoteId)
billOwers.add(DBBillOwer(0, 0, memberLocalId)) billOwers.add(DBBillOwer(0, 0, memberLocalId))
} }
} }
@@ -5,29 +5,45 @@ import androidx.preference.PreferenceManager
import net.helcel.cowspent.R import net.helcel.cowspent.R
/** /**
* The SyncOnOpen preference: how often opening the app refreshes the account and every project. * The two independent delays that govern syncing.
* *
* The choices and the default live here rather than in the settings screen so that the screen and * They used to be one setting, which meant the app-open throttle and the full-sync delay could
* the sync trigger cannot disagree about what is in effect. * not be tuned apart even though they answer different questions. The choices and defaults live
* here rather than in the settings screen so the screen and the sync triggers cannot disagree
* about what is in effect.
*/ */
object SyncSettings { object SyncSettings {
/** Steps offered by the slider, in minutes. */ /**
val INTERVAL_CHOICES_MINUTES = listOf(1, 10, 60, 1440) * How long opening the app waits before refreshing the account and every project again, so
* that resuming within the interval does not repeat the work. Not user-facing: it governs
const val DEFAULT_INTERVAL_MINUTES = 10 * background catch-up, not anything the user asked for.
*/
const val OPEN_SYNC_INTERVAL_MINUTES = 10
/** /**
* The configured interval in minutes. A stored value that is not one of the offered steps — * Steps offered for the full sync delay, in minutes. 0 means every manual refresh is a full
* from a restored backup, or a build that changed the steps — falls back to the default * sync; anything else lets a refresh inside the window settle for fetching just the changes.
* rather than being displayed as the first step while a different value drives the sync.
*/ */
fun intervalMinutes(context: Context): Int { val FULL_SYNC_DELAY_CHOICES_MINUTES = listOf(0, 60, 1440, 10080)
/**
* Only consulted once Extra Features is on - with it off every pull-to-refresh is a full
* sync, whatever is stored here.
*/
const val DEFAULT_FULL_SYNC_DELAY_MINUTES = 1440
/**
* The configured delay in minutes. A stored value that is not one of the offered steps - from
* a restored backup, or a build that changed the steps - falls back to the default rather
* than being displayed as one step while a different value drives the sync.
*/
fun fullSyncDelayMinutes(context: Context): Int {
val prefs = PreferenceManager.getDefaultSharedPreferences(context) val prefs = PreferenceManager.getDefaultSharedPreferences(context)
val stored = prefs.getInt( val stored = prefs.getInt(
context.getString(R.string.pref_key_auto_sync_on_open), context.getString(R.string.pref_key_full_sync_delay),
DEFAULT_INTERVAL_MINUTES DEFAULT_FULL_SYNC_DELAY_MINUTES
) )
return if (stored in INTERVAL_CHOICES_MINUTES) stored else DEFAULT_INTERVAL_MINUTES return if (stored in FULL_SYNC_DELAY_CHOICES_MINUTES) stored else DEFAULT_FULL_SYNC_DELAY_MINUTES
} }
} }
@@ -689,12 +689,18 @@ class VersatileProjectSyncClient(
} }
@Throws(JSONException::class, IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class) @Throws(JSONException::class, IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class)
/**
* [since] overrides the project cursor sent as lastChanged. Passing 0 asks the server for the
* whole project rather than the changes since the last sync; omitting it keeps the cursor, so
* the server returns only what has moved.
*/
fun getBills( fun getBills(
project: DBProject, project: DBProject,
offset: Int? = null, offset: Int? = null,
limit: Int? = null, limit: Int? = null,
reverse: Boolean? = null, reverse: Boolean? = null,
deleted: Int? = null deleted: Int? = null,
since: Long? = null
): ServerResponse.BillsResponse { ): ServerResponse.BillsResponse {
var target: String var target: String
var username: String? var username: String?
@@ -706,7 +712,7 @@ class VersatileProjectSyncClient(
val paramValues: MutableList<String> = ArrayList() val paramValues: MutableList<String> = ArrayList()
if (ProjectType.COSPEND == project.type) { if (ProjectType.COSPEND == project.type) {
val tsLastSync = project.lastSyncedTimestamp val tsLastSync = since ?: project.lastSyncedTimestamp
if (offset == null) { if (offset == null) {
if (cospendVersionGT161) { if (cospendVersionGT161) {
paramKeys.add("lastChanged") paramKeys.add("lastChanged")
@@ -144,14 +144,8 @@
<string name="settings_color_custom">Custom color</string> <string name="settings_color_custom">Custom color</string>
<string name="settings_color_mode">Color Selection</string> <string name="settings_color_mode">Color Selection</string>
<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_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">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_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_auto_sync_on_open">Sync interval</string>
<string name="settings_auto_sync_on_open_summary">How often to refresh the account and all projects when opening the app.</string>
<string name="pref_value_sync_1m">1 minute</string>
<string name="pref_value_sync_10m">10 minutes</string>
<string name="pref_value_sync_1h">1 hour</string> <string name="pref_value_sync_1h">1 hour</string>
<string name="pref_value_sync_1d">1 day</string> <string name="pref_value_sync_1d">1 day</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>
@@ -144,14 +144,8 @@
<string name="settings_color_custom">Custom color</string> <string name="settings_color_custom">Custom color</string>
<string name="settings_color_mode">Color Selection</string> <string name="settings_color_mode">Color Selection</string>
<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_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">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_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_auto_sync_on_open">Sync interval</string>
<string name="settings_auto_sync_on_open_summary">How often to refresh the account and all projects when opening the app.</string>
<string name="pref_value_sync_1m">1 minute</string>
<string name="pref_value_sync_10m">10 minutes</string>
<string name="pref_value_sync_1h">1 hour</string> <string name="pref_value_sync_1h">1 hour</string>
<string name="pref_value_sync_1d">1 day</string> <string name="pref_value_sync_1d">1 day</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>
@@ -144,14 +144,8 @@
<string name="settings_color_custom">Custom color</string> <string name="settings_color_custom">Custom color</string>
<string name="settings_color_mode">Color Selection</string> <string name="settings_color_mode">Color Selection</string>
<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_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">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_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_auto_sync_on_open">Sync interval</string>
<string name="settings_auto_sync_on_open_summary">How often to refresh the account and all projects when opening the app.</string>
<string name="pref_value_sync_1m">1 minute</string>
<string name="pref_value_sync_10m">10 minutes</string>
<string name="pref_value_sync_1h">1 hour</string> <string name="pref_value_sync_1h">1 hour</string>
<string name="pref_value_sync_1d">1 day</string> <string name="pref_value_sync_1d">1 day</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>
@@ -144,14 +144,8 @@
<string name="settings_color_custom">Custom color</string> <string name="settings_color_custom">Custom color</string>
<string name="settings_color_mode">Color Selection</string> <string name="settings_color_mode">Color Selection</string>
<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_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">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_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_auto_sync_on_open">Sync interval</string>
<string name="settings_auto_sync_on_open_summary">How often to refresh the account and all projects when opening the app.</string>
<string name="pref_value_sync_1m">1 minute</string>
<string name="pref_value_sync_10m">10 minutes</string>
<string name="pref_value_sync_1h">1 hour</string> <string name="pref_value_sync_1h">1 hour</string>
<string name="pref_value_sync_1d">1 day</string> <string name="pref_value_sync_1d">1 day</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>
@@ -144,14 +144,8 @@
<string name="settings_color_custom">Custom color</string> <string name="settings_color_custom">Custom color</string>
<string name="settings_color_mode">Color Selection</string> <string name="settings_color_mode">Color Selection</string>
<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_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">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_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_auto_sync_on_open">Sync interval</string>
<string name="settings_auto_sync_on_open_summary">How often to refresh the account and all projects when opening the app.</string>
<string name="pref_value_sync_1m">1 minute</string>
<string name="pref_value_sync_10m">10 minutes</string>
<string name="pref_value_sync_1h">1 hour</string> <string name="pref_value_sync_1h">1 hour</string>
<string name="pref_value_sync_1d">1 day</string> <string name="pref_value_sync_1d">1 day</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>
@@ -138,14 +138,8 @@
<string name="settings_color_custom">Eigene Farbe</string> <string name="settings_color_custom">Eigene Farbe</string>
<string name="settings_color_mode">Farbauswahl</string> <string name="settings_color_mode">Farbauswahl</string>
<string name="settings_show_archived">Archivierte Projekte anzeigen</string> <string name="settings_show_archived">Archivierte Projekte anzeigen</string>
<string name="settings_beta_features">Beta-Funktionen</string>
<string name="settings_beta_features_summary">Experimentelle Funktionen aktivieren. Benutzung auf eigene Gefahr.</string>
<string name="settings_fill_new_bill_from_last">Aus letzter Rechnung vorausfüllen</string> <string name="settings_fill_new_bill_from_last">Aus letzter Rechnung vorausfüllen</string>
<string name="settings_fill_new_bill_from_last_summary">Zahler, Kategorie, Zahlungsart und Beteiligte aus der zuletzt erstellten Rechnung des Projekts übernehmen.</string> <string name="settings_fill_new_bill_from_last_summary">Zahler, Kategorie, Zahlungsart und Beteiligte aus der zuletzt erstellten Rechnung des Projekts übernehmen.</string>
<string name="settings_auto_sync_on_open">Synchronisierungsintervall</string>
<string name="settings_auto_sync_on_open_summary">Wie oft Konto und alle Projekte beim Öffnen der App aktualisiert werden.</string>
<string name="pref_value_sync_1m">1 Minute</string>
<string name="pref_value_sync_10m">10 Minuten</string>
<string name="pref_value_sync_1h">1 Stunde</string> <string name="pref_value_sync_1h">1 Stunde</string>
<string name="pref_value_sync_1d">1 Tag</string> <string name="pref_value_sync_1d">1 Tag</string>
<string name="settings_url_warn_http">WARNUNG: \"http\" ist unsicher. Verwende \"https\".</string> <string name="settings_url_warn_http">WARNUNG: \"http\" ist unsicher. Verwende \"https\".</string>
@@ -144,14 +144,8 @@
<string name="settings_color_custom">Custom color</string> <string name="settings_color_custom">Custom color</string>
<string name="settings_color_mode">Color Selection</string> <string name="settings_color_mode">Color Selection</string>
<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_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">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_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_auto_sync_on_open">Sync interval</string>
<string name="settings_auto_sync_on_open_summary">How often to refresh the account and all projects when opening the app.</string>
<string name="pref_value_sync_1m">1 minute</string>
<string name="pref_value_sync_10m">10 minutes</string>
<string name="pref_value_sync_1h">1 hour</string> <string name="pref_value_sync_1h">1 hour</string>
<string name="pref_value_sync_1d">1 day</string> <string name="pref_value_sync_1d">1 day</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>
@@ -137,14 +137,8 @@
<string name="settings_color_custom">Color personalizado</string> <string name="settings_color_custom">Color personalizado</string>
<string name="settings_color_mode">Selección de color</string> <string name="settings_color_mode">Selección de color</string>
<string name="settings_show_archived">Mostrar los proyectos archivados</string> <string name="settings_show_archived">Mostrar los proyectos archivados</string>
<string name="settings_beta_features">Funciones beta</string>
<string name="settings_beta_features_summary">Activar las funciones experimentales. Úsalas bajo tu propia responsabilidad.</string>
<string name="settings_fill_new_bill_from_last">Rellenar desde la última factura</string> <string name="settings_fill_new_bill_from_last">Rellenar desde la última factura</string>
<string name="settings_fill_new_bill_from_last_summary">Reutilizar el pagador, la categoría, el modo y los participantes de la última factura creada en el proyecto.</string> <string name="settings_fill_new_bill_from_last_summary">Reutilizar el pagador, la categoría, el modo y los participantes de la última factura creada en el proyecto.</string>
<string name="settings_auto_sync_on_open">Intervalo de sincronización</string>
<string name="settings_auto_sync_on_open_summary">Con qué frecuencia se actualizan la cuenta y todos los proyectos al abrir la aplicación.</string>
<string name="pref_value_sync_1m">1 minuto</string>
<string name="pref_value_sync_10m">10 minutos</string>
<string name="pref_value_sync_1h">1 hora</string> <string name="pref_value_sync_1h">1 hora</string>
<string name="pref_value_sync_1d">1 día</string> <string name="pref_value_sync_1d">1 día</string>
<string name="settings_url_warn_http">ADVERTENCIA: \"http\" no es seguro. Usa \"https\".</string> <string name="settings_url_warn_http">ADVERTENCIA: \"http\" no es seguro. Usa \"https\".</string>
@@ -144,14 +144,8 @@
<string name="settings_color_custom">Custom color</string> <string name="settings_color_custom">Custom color</string>
<string name="settings_color_mode">Color Selection</string> <string name="settings_color_mode">Color Selection</string>
<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_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">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_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_auto_sync_on_open">Sync interval</string>
<string name="settings_auto_sync_on_open_summary">How often to refresh the account and all projects when opening the app.</string>
<string name="pref_value_sync_1m">1 minute</string>
<string name="pref_value_sync_10m">10 minutes</string>
<string name="pref_value_sync_1h">1 hour</string> <string name="pref_value_sync_1h">1 hour</string>
<string name="pref_value_sync_1d">1 day</string> <string name="pref_value_sync_1d">1 day</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>
@@ -138,14 +138,8 @@
<string name="settings_color_custom">Couleur personnalisée</string> <string name="settings_color_custom">Couleur personnalisée</string>
<string name="settings_color_mode">Sélection de la couleur</string> <string name="settings_color_mode">Sélection de la couleur</string>
<string name="settings_show_archived">Afficher les projets archivés</string> <string name="settings_show_archived">Afficher les projets archivés</string>
<string name="settings_beta_features">Fonctionnalités bêta</string>
<string name="settings_beta_features_summary">Activer les fonctionnalités expérimentales. À utiliser à vos risques et périls.</string>
<string name="settings_fill_new_bill_from_last">Pré-remplir depuis la dernière facture</string> <string name="settings_fill_new_bill_from_last">Pré-remplir depuis la dernière facture</string>
<string name="settings_fill_new_bill_from_last_summary">Reprendre le payeur, la catégorie, le mode et les participants de la dernière facture créée dans le projet.</string> <string name="settings_fill_new_bill_from_last_summary">Reprendre le payeur, la catégorie, le mode et les participants de la dernière facture créée dans le projet.</string>
<string name="settings_auto_sync_on_open">Intervalle de synchronisation</string>
<string name="settings_auto_sync_on_open_summary">Fréquence de rafraîchissement du compte et de tous les projets à l\'ouverture de l\'application.</string>
<string name="pref_value_sync_1m">1 minute</string>
<string name="pref_value_sync_10m">10 minutes</string>
<string name="pref_value_sync_1h">1 heure</string> <string name="pref_value_sync_1h">1 heure</string>
<string name="pref_value_sync_1d">1 jour</string> <string name="pref_value_sync_1d">1 jour</string>
<string name="settings_url_warn_http">AVERTISSEMENT : \"http\" n\'est pas sûr. Utilisez \"https\".</string> <string name="settings_url_warn_http">AVERTISSEMENT : \"http\" n\'est pas sûr. Utilisez \"https\".</string>
@@ -144,14 +144,8 @@
<string name="settings_color_custom">Custom color</string> <string name="settings_color_custom">Custom color</string>
<string name="settings_color_mode">Color Selection</string> <string name="settings_color_mode">Color Selection</string>
<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_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">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_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_auto_sync_on_open">Sync interval</string>
<string name="settings_auto_sync_on_open_summary">How often to refresh the account and all projects when opening the app.</string>
<string name="pref_value_sync_1m">1 minute</string>
<string name="pref_value_sync_10m">10 minutes</string>
<string name="pref_value_sync_1h">1 hour</string> <string name="pref_value_sync_1h">1 hour</string>
<string name="pref_value_sync_1d">1 day</string> <string name="pref_value_sync_1d">1 day</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>
@@ -144,14 +144,8 @@
<string name="settings_color_custom">Custom color</string> <string name="settings_color_custom">Custom color</string>
<string name="settings_color_mode">Color Selection</string> <string name="settings_color_mode">Color Selection</string>
<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_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">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_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_auto_sync_on_open">Sync interval</string>
<string name="settings_auto_sync_on_open_summary">How often to refresh the account and all projects when opening the app.</string>
<string name="pref_value_sync_1m">1 minute</string>
<string name="pref_value_sync_10m">10 minutes</string>
<string name="pref_value_sync_1h">1 hour</string> <string name="pref_value_sync_1h">1 hour</string>
<string name="pref_value_sync_1d">1 day</string> <string name="pref_value_sync_1d">1 day</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>
@@ -144,14 +144,8 @@
<string name="settings_color_custom">Custom color</string> <string name="settings_color_custom">Custom color</string>
<string name="settings_color_mode">Color Selection</string> <string name="settings_color_mode">Color Selection</string>
<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_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">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_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_auto_sync_on_open">Sync interval</string>
<string name="settings_auto_sync_on_open_summary">How often to refresh the account and all projects when opening the app.</string>
<string name="pref_value_sync_1m">1 minute</string>
<string name="pref_value_sync_10m">10 minutes</string>
<string name="pref_value_sync_1h">1 hour</string> <string name="pref_value_sync_1h">1 hour</string>
<string name="pref_value_sync_1d">1 day</string> <string name="pref_value_sync_1d">1 day</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>
@@ -144,14 +144,8 @@
<string name="settings_color_custom">Custom color</string> <string name="settings_color_custom">Custom color</string>
<string name="settings_color_mode">Color Selection</string> <string name="settings_color_mode">Color Selection</string>
<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_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">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_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_auto_sync_on_open">Sync interval</string>
<string name="settings_auto_sync_on_open_summary">How often to refresh the account and all projects when opening the app.</string>
<string name="pref_value_sync_1m">1 minute</string>
<string name="pref_value_sync_10m">10 minutes</string>
<string name="pref_value_sync_1h">1 hour</string> <string name="pref_value_sync_1h">1 hour</string>
<string name="pref_value_sync_1d">1 day</string> <string name="pref_value_sync_1d">1 day</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>
@@ -144,14 +144,8 @@
<string name="settings_color_custom">Custom color</string> <string name="settings_color_custom">Custom color</string>
<string name="settings_color_mode">Color Selection</string> <string name="settings_color_mode">Color Selection</string>
<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_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">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_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_auto_sync_on_open">Sync interval</string>
<string name="settings_auto_sync_on_open_summary">How often to refresh the account and all projects when opening the app.</string>
<string name="pref_value_sync_1m">1 minute</string>
<string name="pref_value_sync_10m">10 minutes</string>
<string name="pref_value_sync_1h">1 hour</string> <string name="pref_value_sync_1h">1 hour</string>
<string name="pref_value_sync_1d">1 day</string> <string name="pref_value_sync_1d">1 day</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>
@@ -144,14 +144,8 @@
<string name="settings_color_custom">Custom color</string> <string name="settings_color_custom">Custom color</string>
<string name="settings_color_mode">Color Selection</string> <string name="settings_color_mode">Color Selection</string>
<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_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">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_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_auto_sync_on_open">Sync interval</string>
<string name="settings_auto_sync_on_open_summary">How often to refresh the account and all projects when opening the app.</string>
<string name="pref_value_sync_1m">1 minute</string>
<string name="pref_value_sync_10m">10 minutes</string>
<string name="pref_value_sync_1h">1 hour</string> <string name="pref_value_sync_1h">1 hour</string>
<string name="pref_value_sync_1d">1 day</string> <string name="pref_value_sync_1d">1 day</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>
@@ -144,14 +144,8 @@
<string name="settings_color_custom">Custom color</string> <string name="settings_color_custom">Custom color</string>
<string name="settings_color_mode">Color Selection</string> <string name="settings_color_mode">Color Selection</string>
<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_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">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_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_auto_sync_on_open">Sync interval</string>
<string name="settings_auto_sync_on_open_summary">How often to refresh the account and all projects when opening the app.</string>
<string name="pref_value_sync_1m">1 minute</string>
<string name="pref_value_sync_10m">10 minutes</string>
<string name="pref_value_sync_1h">1 hour</string> <string name="pref_value_sync_1h">1 hour</string>
<string name="pref_value_sync_1d">1 day</string> <string name="pref_value_sync_1d">1 day</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>
@@ -144,14 +144,8 @@
<string name="settings_color_custom">Custom color</string> <string name="settings_color_custom">Custom color</string>
<string name="settings_color_mode">Color Selection</string> <string name="settings_color_mode">Color Selection</string>
<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_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">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_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_auto_sync_on_open">Sync interval</string>
<string name="settings_auto_sync_on_open_summary">How often to refresh the account and all projects when opening the app.</string>
<string name="pref_value_sync_1m">1 minute</string>
<string name="pref_value_sync_10m">10 minutes</string>
<string name="pref_value_sync_1h">1 hour</string> <string name="pref_value_sync_1h">1 hour</string>
<string name="pref_value_sync_1d">1 day</string> <string name="pref_value_sync_1d">1 day</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>
@@ -144,14 +144,8 @@
<string name="settings_color_custom">Custom color</string> <string name="settings_color_custom">Custom color</string>
<string name="settings_color_mode">Color Selection</string> <string name="settings_color_mode">Color Selection</string>
<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_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">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_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_auto_sync_on_open">Sync interval</string>
<string name="settings_auto_sync_on_open_summary">How often to refresh the account and all projects when opening the app.</string>
<string name="pref_value_sync_1m">1 minute</string>
<string name="pref_value_sync_10m">10 minutes</string>
<string name="pref_value_sync_1h">1 hour</string> <string name="pref_value_sync_1h">1 hour</string>
<string name="pref_value_sync_1d">1 day</string> <string name="pref_value_sync_1d">1 day</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>
@@ -144,14 +144,8 @@
<string name="settings_color_custom">Custom color</string> <string name="settings_color_custom">Custom color</string>
<string name="settings_color_mode">Color Selection</string> <string name="settings_color_mode">Color Selection</string>
<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_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">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_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_auto_sync_on_open">Sync interval</string>
<string name="settings_auto_sync_on_open_summary">How often to refresh the account and all projects when opening the app.</string>
<string name="pref_value_sync_1m">1 minute</string>
<string name="pref_value_sync_10m">10 minutes</string>
<string name="pref_value_sync_1h">1 hour</string> <string name="pref_value_sync_1h">1 hour</string>
<string name="pref_value_sync_1d">1 day</string> <string name="pref_value_sync_1d">1 day</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>
@@ -144,14 +144,8 @@
<string name="settings_color_custom">Custom color</string> <string name="settings_color_custom">Custom color</string>
<string name="settings_color_mode">Color Selection</string> <string name="settings_color_mode">Color Selection</string>
<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_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">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_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_auto_sync_on_open">Sync interval</string>
<string name="settings_auto_sync_on_open_summary">How often to refresh the account and all projects when opening the app.</string>
<string name="pref_value_sync_1m">1 minute</string>
<string name="pref_value_sync_10m">10 minutes</string>
<string name="pref_value_sync_1h">1 hour</string> <string name="pref_value_sync_1h">1 hour</string>
<string name="pref_value_sync_1d">1 day</string> <string name="pref_value_sync_1d">1 day</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>
@@ -144,14 +144,8 @@
<string name="settings_color_custom">Custom color</string> <string name="settings_color_custom">Custom color</string>
<string name="settings_color_mode">Color Selection</string> <string name="settings_color_mode">Color Selection</string>
<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_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">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_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_auto_sync_on_open">Sync interval</string>
<string name="settings_auto_sync_on_open_summary">How often to refresh the account and all projects when opening the app.</string>
<string name="pref_value_sync_1m">1 minute</string>
<string name="pref_value_sync_10m">10 minutes</string>
<string name="pref_value_sync_1h">1 hour</string> <string name="pref_value_sync_1h">1 hour</string>
<string name="pref_value_sync_1d">1 day</string> <string name="pref_value_sync_1d">1 day</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>
@@ -144,14 +144,8 @@
<string name="settings_color_custom">Custom color</string> <string name="settings_color_custom">Custom color</string>
<string name="settings_color_mode">Color Selection</string> <string name="settings_color_mode">Color Selection</string>
<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_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">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_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_auto_sync_on_open">Sync interval</string>
<string name="settings_auto_sync_on_open_summary">How often to refresh the account and all projects when opening the app.</string>
<string name="pref_value_sync_1m">1 minute</string>
<string name="pref_value_sync_10m">10 minutes</string>
<string name="pref_value_sync_1h">1 hour</string> <string name="pref_value_sync_1h">1 hour</string>
<string name="pref_value_sync_1d">1 day</string> <string name="pref_value_sync_1d">1 day</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>
@@ -144,14 +144,8 @@
<string name="settings_color_custom">Custom color</string> <string name="settings_color_custom">Custom color</string>
<string name="settings_color_mode">Color Selection</string> <string name="settings_color_mode">Color Selection</string>
<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_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">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_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_auto_sync_on_open">Sync interval</string>
<string name="settings_auto_sync_on_open_summary">How often to refresh the account and all projects when opening the app.</string>
<string name="pref_value_sync_1m">1 minute</string>
<string name="pref_value_sync_10m">10 minutes</string>
<string name="pref_value_sync_1h">1 hour</string> <string name="pref_value_sync_1h">1 hour</string>
<string name="pref_value_sync_1d">1 day</string> <string name="pref_value_sync_1d">1 day</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>
@@ -144,14 +144,8 @@
<string name="settings_color_custom">Custom color</string> <string name="settings_color_custom">Custom color</string>
<string name="settings_color_mode">Color Selection</string> <string name="settings_color_mode">Color Selection</string>
<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_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">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_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_auto_sync_on_open">Sync interval</string>
<string name="settings_auto_sync_on_open_summary">How often to refresh the account and all projects when opening the app.</string>
<string name="pref_value_sync_1m">1 minute</string>
<string name="pref_value_sync_10m">10 minutes</string>
<string name="pref_value_sync_1h">1 hour</string> <string name="pref_value_sync_1h">1 hour</string>
<string name="pref_value_sync_1d">1 day</string> <string name="pref_value_sync_1d">1 day</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>
@@ -144,14 +144,8 @@
<string name="settings_color_custom">Custom color</string> <string name="settings_color_custom">Custom color</string>
<string name="settings_color_mode">Color Selection</string> <string name="settings_color_mode">Color Selection</string>
<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_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">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_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_auto_sync_on_open">Sync interval</string>
<string name="settings_auto_sync_on_open_summary">How often to refresh the account and all projects when opening the app.</string>
<string name="pref_value_sync_1m">1 minute</string>
<string name="pref_value_sync_10m">10 minutes</string>
<string name="pref_value_sync_1h">1 hour</string> <string name="pref_value_sync_1h">1 hour</string>
<string name="pref_value_sync_1d">1 day</string> <string name="pref_value_sync_1d">1 day</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>
@@ -144,14 +144,8 @@
<string name="settings_color_custom">Custom color</string> <string name="settings_color_custom">Custom color</string>
<string name="settings_color_mode">Color Selection</string> <string name="settings_color_mode">Color Selection</string>
<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_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">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_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_auto_sync_on_open">Sync interval</string>
<string name="settings_auto_sync_on_open_summary">How often to refresh the account and all projects when opening the app.</string>
<string name="pref_value_sync_1m">1 minute</string>
<string name="pref_value_sync_10m">10 minutes</string>
<string name="pref_value_sync_1h">1 hour</string> <string name="pref_value_sync_1h">1 hour</string>
<string name="pref_value_sync_1d">1 day</string> <string name="pref_value_sync_1d">1 day</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>
@@ -144,14 +144,8 @@
<string name="settings_color_custom">Custom color</string> <string name="settings_color_custom">Custom color</string>
<string name="settings_color_mode">Color Selection</string> <string name="settings_color_mode">Color Selection</string>
<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_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">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_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_auto_sync_on_open">Sync interval</string>
<string name="settings_auto_sync_on_open_summary">How often to refresh the account and all projects when opening the app.</string>
<string name="pref_value_sync_1m">1 minute</string>
<string name="pref_value_sync_10m">10 minutes</string>
<string name="pref_value_sync_1h">1 hour</string> <string name="pref_value_sync_1h">1 hour</string>
<string name="pref_value_sync_1d">1 day</string> <string name="pref_value_sync_1d">1 day</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>
@@ -144,14 +144,8 @@
<string name="settings_color_custom">Custom color</string> <string name="settings_color_custom">Custom color</string>
<string name="settings_color_mode">Color Selection</string> <string name="settings_color_mode">Color Selection</string>
<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_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">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_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_auto_sync_on_open">Sync interval</string>
<string name="settings_auto_sync_on_open_summary">How often to refresh the account and all projects when opening the app.</string>
<string name="pref_value_sync_1m">1 minute</string>
<string name="pref_value_sync_10m">10 minutes</string>
<string name="pref_value_sync_1h">1 hour</string> <string name="pref_value_sync_1h">1 hour</string>
<string name="pref_value_sync_1d">1 day</string> <string name="pref_value_sync_1d">1 day</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>
+8 -8
View File
@@ -139,17 +139,17 @@
<string name="settings_color_custom">Custom color</string> <string name="settings_color_custom">Custom color</string>
<string name="settings_color_mode">Color Selection</string> <string name="settings_color_mode">Color Selection</string>
<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_extra_features">Extra Features</string>
<string name="settings_beta_features_summary">Enable experimental features. Use at your own risk.</string> <string name="settings_extra_features_summary">Experimental and advanced settings. 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">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_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_stats_include_deactivated">Include deactivated members in stats</string> <string name="settings_stats_include_deactivated">Include deactivated members in stats</string>
<string name="settings_auto_sync_on_open">Sync interval</string> <string name="settings_full_sync_delay">Full sync delay</string>
<string name="settings_auto_sync_on_open_summary">How often to refresh the account and all projects when opening the app.</string> <string name="settings_full_sync_delay_summary">How long a pull-to-refresh waits between two full syncs. In between, it only fetches what changed, which is much faster.</string>
<string name="pref_value_sync_1m">1 minute</string> <string name="pref_value_sync_always">Always</string>
<string name="pref_value_sync_10m">10 minutes</string>
<string name="pref_value_sync_1h">1 hour</string> <string name="pref_value_sync_1h">1 hour</string>
<string name="pref_value_sync_1d">1 day</string> <string name="pref_value_sync_1d">1 day</string>
<string name="pref_value_sync_1w">1 week</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>
@@ -170,9 +170,9 @@
<string name="pref_key_color_mode" translatable="false">colorMode</string> <string name="pref_key_color_mode" translatable="false">colorMode</string>
<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_extra_features" translatable="false">betaFeatures</string>
<string name="pref_key_stats_include_deactivated" translatable="false">statsIncludeDeactivated</string> <string name="pref_key_stats_include_deactivated" translatable="false">statsIncludeDeactivated</string>
<string name="pref_key_auto_sync_on_open" translatable="false">autoSyncOnOpen</string> <string name="pref_key_full_sync_delay" translatable="false">fullSyncDelayMinutes</string>
<string name="pref_key_last_account_sync_timestamp" translatable="false">lastAccountSyncTimestamp</string> <string name="pref_key_last_account_sync_timestamp" translatable="false">lastAccountSyncTimestamp</string>
<string name="pref_key_fill_new_bill_from_last" translatable="false">fillNewBillFromLast</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>