Author SHA1 Message Date
sora 405ff5c525 Warn if logout/delete would impact unsynced data 2026-09-10 01:58:45 +02:00
sora 49a275c53e Fix sync of archived 2026-09-10 01:30:25 +02:00
sora b7037d8074 Restore deleted 2026-09-10 01:29:37 +02:00
sora 98b7bcaaf7 webview cleanup 2026-09-10 01:28:19 +02:00
sora 50c82068a4 Login/Logout Improvements 2026-09-10 00:37:09 +02:00
sora a83601c0c0 sync trigger logic improvements 2026-09-10 00:35:43 +02:00
sora 5bc83e22ba Add local delete of archived projects 2026-09-10 00:34:02 +02:00
sora d6ee50bc34 Sync Flag lifecycle 2026-09-10 00:26:55 +02:00
sora 06f31571a3 Fix db crash on big projects 2026-09-10 00:26:16 +02:00
sora c70bb808c1 UI fix of button overlap in label lists 2026-09-09 10:23:37 +02:00
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
50 changed files with 1099 additions and 557 deletions
+1 -1
View File
@@ -119,7 +119,7 @@ android {
dependencies { dependencies {
implementation 'androidx.compose.foundation:foundation:1.12.0' implementation 'androidx.compose.foundation:foundation:1.12.0'
implementation 'androidx.compose.runtime:runtime:1.12.1' implementation 'androidx.compose.runtime:runtime:1.12.0'
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.5' coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.5'
implementation 'androidx.preference:preference-ktx:1.2.1' implementation 'androidx.preference:preference-ktx:1.2.1'
@@ -1,7 +1,6 @@
package net.helcel.cowspent.android.account package net.helcel.cowspent.android.account
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.SharedPreferences import android.content.SharedPreferences
import android.os.Build import android.os.Build
@@ -38,6 +37,8 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import net.helcel.cowspent.R import net.helcel.cowspent.R
import net.helcel.cowspent.android.main.MainConstants import net.helcel.cowspent.android.main.MainConstants
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.persistence.CowspentServerSyncHelper
import net.helcel.cowspent.theme.ThemeUtils import net.helcel.cowspent.theme.ThemeUtils
import net.helcel.cowspent.util.CospendClientUtil import net.helcel.cowspent.util.CospendClientUtil
import net.helcel.cowspent.util.CospendClientUtil.LoginStatus import net.helcel.cowspent.util.CospendClientUtil.LoginStatus
@@ -108,8 +109,7 @@ class AccountActivity : AppCompatActivity() {
viewModel.useSso = false viewModel.useSso = false
preferences.edit { putBoolean(SETTINGS_USE_SSO, false) } preferences.edit { putBoolean(SETTINGS_USE_SSO, false) }
} }
}, }
onLogout = { viewModel.logout() }
) )
} }
@@ -147,7 +147,7 @@ class AccountActivity : AppCompatActivity() {
allowFileAccess = false allowFileAccess = false
javaScriptEnabled = true javaScriptEnabled = true
domStorageEnabled = true domStorageEnabled = true
userAgentString = getWebLoginUserAgent(context) userAgentString = getWebLoginUserAgent()
} }
webViewClient = object : WebViewClient() { webViewClient = object : WebViewClient() {
@Deprecated("Deprecated in Java") @Deprecated("Deprecated in Java")
@@ -188,6 +188,12 @@ class AccountActivity : AppCompatActivity() {
} }
} }
private fun startInitialAccountSync() {
if (!CowspentServerSyncHelper.isNextcloudAccountConfigured(applicationContext)) return
CowspentSQLiteOpenHelper.getInstance(applicationContext)
.cowspentServerSyncHelper.runAccountProjectsSync()
}
@Deprecated("Deprecated in Java") @Deprecated("Deprecated in Java")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data) super.onActivityResult(requestCode, resultCode, data)
@@ -207,6 +213,8 @@ class AccountActivity : AppCompatActivity() {
viewModel.serverUrl = ssoAccount.url viewModel.serverUrl = ssoAccount.url
viewModel.username = ssoAccount.userId viewModel.username = ssoAccount.userId
startInitialAccountSync()
val resultData = Intent() val resultData = Intent()
resultData.putExtra(MainConstants.CREDENTIALS_CHANGED, CREDENTIALS_CHANGED) resultData.putExtra(MainConstants.CREDENTIALS_CHANGED, CREDENTIALS_CHANGED)
setResult(RESULT_OK, resultData) setResult(RESULT_OK, resultData)
@@ -234,13 +242,9 @@ class AccountActivity : AppCompatActivity() {
} }
} }
private fun getWebLoginUserAgent(context: Context): String { private fun getWebLoginUserAgent(): String {
val defaultUA = try { val manufacturer = Build.MANUFACTURER.replaceFirstChar { it.titlecase(Locale.ROOT) }
android.webkit.WebSettings.getDefaultUserAgent(context) return "$manufacturer ${Build.MODEL} (Cowspent/Android)"
} catch (_: Exception) {
Build.MANUFACTURER + " " + Build.MODEL
}
return "$defaultUA Cowspent/Android"
} }
private fun parseAndLoginFromWebView(dataString: String) { private fun parseAndLoginFromWebView(dataString: String) {
@@ -312,6 +316,8 @@ class AccountActivity : AppCompatActivity() {
remove(SETTINGS_KEY_LAST_MODIFIED) remove(SETTINGS_KEY_LAST_MODIFIED)
} }
startInitialAccountSync()
val data = Intent() val data = Intent()
data.putExtra(MainConstants.CREDENTIALS_CHANGED, CREDENTIALS_CHANGED) data.putExtra(MainConstants.CREDENTIALS_CHANGED, CREDENTIALS_CHANGED)
setResult(RESULT_OK, data) setResult(RESULT_OK, data)
@@ -14,6 +14,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
@@ -28,8 +29,7 @@ fun AccountScreen(
viewModel: AccountViewModel, viewModel: AccountViewModel,
onBack: () -> Unit, onBack: () -> Unit,
onConnect: () -> Unit, onConnect: () -> Unit,
onSsoClick: (Boolean) -> Unit, onSsoClick: (Boolean) -> Unit
onLogout: () -> Unit
) { ) {
AccountScreenContent( AccountScreenContent(
isLoggedIn = viewModel.isLoggedIn, isLoggedIn = viewModel.isLoggedIn,
@@ -51,7 +51,57 @@ fun AccountScreen(
onBack = onBack, onBack = onBack,
onConnect = onConnect, onConnect = onConnect,
onSsoClick = onSsoClick, onSsoClick = onSsoClick,
onLogout = onLogout onLogout = { viewModel.requestLogout() }
)
viewModel.logoutImpact?.let { impact ->
LogoutConfirmationDialog(
impact = impact,
onDismiss = { viewModel.cancelLogout() },
onConfirm = { viewModel.confirmLogout() }
)
}
}
@Composable
fun LogoutConfirmationDialog(
impact: AccountViewModel.LogoutImpact,
onDismiss: () -> Unit,
onConfirm: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.logout_confirm_title)) },
text = {
Column {
Text(
if (impact.projects > 0) {
pluralStringResource(
R.plurals.logout_confirm_projects, impact.projects, impact.projects
)
} else {
stringResource(R.string.logout_confirm_no_projects)
}
)
if (impact.unsyncedBills > 0) {
Spacer(Modifier.height(12.dp))
Text(
text = pluralStringResource(
R.plurals.warning_unsynced_bills,
impact.unsyncedBills,
impact.unsyncedBills
),
fontWeight = FontWeight.Bold
)
}
}
},
confirmButton = {
TextButton(onClick = onConfirm) { Text(stringResource(R.string.action_logout)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.simple_cancel)) }
}
) )
} }
@@ -2,9 +2,11 @@ package net.helcel.cowspent.android.account
import android.app.Application import android.app.Application
import android.content.SharedPreferences import android.content.SharedPreferences
import android.util.Log
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.annotation.VisibleForTesting
import androidx.core.content.edit import androidx.core.content.edit
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@@ -12,10 +14,17 @@ import androidx.preference.PreferenceManager
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import net.helcel.cowspent.model.DBProject
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.util.CospendClientUtil import net.helcel.cowspent.util.CospendClientUtil
import net.helcel.cowspent.util.SecureStorage import net.helcel.cowspent.util.SecureStorage
class AccountViewModel(application: Application) : AndroidViewModel(application) { class AccountViewModel(application: Application) : AndroidViewModel(application) {
private companion object {
const val TAG = "AccountViewModel"
}
private val preferences: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(application) private val preferences: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(application)
var useSso by mutableStateOf(preferences.getBoolean(AccountActivity.SETTINGS_USE_SSO, false)) var useSso by mutableStateOf(preferences.getBoolean(AccountActivity.SETTINGS_USE_SSO, false))
@@ -41,6 +50,9 @@ class AccountViewModel(application: Application) : AndroidViewModel(application)
var showWebView by mutableStateOf(false) var showWebView by mutableStateOf(false)
var isLoggedIn by mutableStateOf(false) var isLoggedIn by mutableStateOf(false)
/** Non-null while the logout confirmation is up, carrying what it would cost. */
var logoutImpact by mutableStateOf<LogoutImpact?>(null)
private set private set
var isValidatingLogin by mutableStateOf(false) var isValidatingLogin by mutableStateOf(false)
@@ -88,8 +100,63 @@ class AccountViewModel(application: Application) : AndroidViewModel(application)
} }
} }
private fun projectsTheAccountProvided(db: CowspentSQLiteOpenHelper): List<DBProject> {
val offered = db.accountProjects
val cospendPath = "/index.php/apps/cospend"
return db.projects.filter { project ->
offered.any {
it.remoteId == project.remoteId &&
project.serverUrl?.replace("/+$".toRegex(), "") ==
it.ncUrl.replace("/+$".toRegex(), "") + cospendPath
}
}
}
data class LogoutImpact(val projects: Int, val unsyncedBills: Int)
private fun measureLogoutImpact(): LogoutImpact {
val db = CowspentSQLiteOpenHelper.getInstance(getApplication())
val projects = projectsTheAccountProvided(db)
val unsynced = projects.sumOf { db.countUnsyncedBills(it.id) }
return LogoutImpact(projects.size, unsynced)
}
fun requestLogout() {
viewModelScope.launch {
logoutImpact = withContext(Dispatchers.IO) {
try {
measureLogoutImpact()
} catch (e: Exception) {
// Never let a failed count block signing out - just ask without the detail.
Log.e(TAG, "Could not measure what logging out would remove", e)
LogoutImpact(0, 0)
}
}
}
}
fun cancelLogout() {
logoutImpact = null
}
fun confirmLogout() {
logoutImpact = null
logout()
}
private fun forgetProjectsTheAccountProvided() {
try {
val db = CowspentSQLiteOpenHelper.getInstance(getApplication())
projectsTheAccountProvided(db).forEach { db.deleteProject(it.id) }
db.clearAccountProjects()
} catch (e: Exception) {
Log.e(TAG, "Could not remove the account's projects on logout", e)
}
}
@VisibleForTesting
fun logout() { fun logout() {
viewModelScope.launch { viewModelScope.launch {
withContext(Dispatchers.IO) { forgetProjectsTheAccountProvided() }
SecureStorage.removePassword(getApplication(), AccountActivity.SETTINGS_PASSWORD) SecureStorage.removePassword(getApplication(), AccountActivity.SETTINGS_PASSWORD)
} }
preferences.edit { preferences.edit {
@@ -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 ?: "",
@@ -219,6 +219,7 @@ fun CategoryList(
onDelete = { onDelete(category) } onDelete = { onDelete(category) }
) )
} }
item { LabelListBottomSpacer() }
} }
} }
@@ -238,9 +239,19 @@ fun PaymentModeList(
onDelete = { onDelete(pm) } onDelete = { onDelete(pm) }
) )
} }
item { LabelListBottomSpacer() }
} }
} }
/**
* Keeps the last row clear of the add button, which otherwise sits on top of its delete icon and
* swallows the tap - with nothing below to scroll to, that row simply cannot be deleted.
*/
@Composable
private fun LabelListBottomSpacer() {
Spacer(modifier = Modifier.height(64.dp).fillMaxWidth())
}
@Composable @Composable
fun LabelItem( fun LabelItem(
name: String, name: String,
@@ -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(
@@ -132,6 +132,10 @@ fun BillsListScreen(
onProjectAction(projectOptionsProjectId, 1) onProjectAction(projectOptionsProjectId, 1)
viewModel.showProjectOptionsDialogByProjectId = null viewModel.showProjectOptionsDialogByProjectId = null
}, },
onForgetProject = {
onProjectAction(projectOptionsProjectId, 9)
viewModel.showProjectOptionsDialogByProjectId = null
},
onManageMembers = { onManageMembers = {
onProjectAction(projectOptionsProjectId, 2) onProjectAction(projectOptionsProjectId, 2)
viewModel.showProjectOptionsDialogByProjectId = null viewModel.showProjectOptionsDialogByProjectId = null
@@ -165,7 +169,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 +338,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,
@@ -408,6 +412,8 @@ fun BillsListScreen(
.pullRefresh(pullRefreshState)) { .pullRefresh(pullRefreshState)) {
when { when {
viewModel.showNoProjects -> EmptyProjectsState(onAccountSwitcherClick, onAddProjectClick) viewModel.showNoProjects -> EmptyProjectsState(onAccountSwitcherClick, onAddProjectClick)
(viewModel.isRefreshing || viewModel.isLoadingBills) && viewModel.bills.isEmpty() ->
LoadingBillsState()
viewModel.showNoMembers -> EmptyMembersState() viewModel.showNoMembers -> EmptyMembersState()
viewModel.showNoBills -> EmptyBillsState() viewModel.showNoBills -> EmptyBillsState()
viewModel.bills.isEmpty() -> EmptyState() viewModel.bills.isEmpty() -> EmptyState()
@@ -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.
} }
} }
@@ -268,6 +270,7 @@ class BillsListViewActivity :
8 -> { 8 -> {
startActivity(net.helcel.cowspent.android.label.LabelManagementActivity.createIntent(this@BillsListViewActivity, pid)) startActivity(net.helcel.cowspent.android.label.LabelManagementActivity.createIntent(this@BillsListViewActivity, pid))
} }
9 -> onRemoveProjectClick(pid)
} }
}, },
onAccountSwitcherClick = { onAccountSwitcherClick = {
@@ -332,6 +335,7 @@ class BillsListViewActivity :
super.onResume() super.onResume()
val preferences = PreferenceManager.getDefaultSharedPreferences(applicationContext) val preferences = PreferenceManager.getDefaultSharedPreferences(applicationContext)
val selectedProjectId = preferences.getLong("selected_project", 0) val selectedProjectId = preferences.getLong("selected_project", 0)
setupDrawerProjects()
if (selectedProjectId != 0L) { if (selectedProjectId != 0L) {
refreshLists() refreshLists()
} }
@@ -339,6 +343,10 @@ class BillsListViewActivity :
synchronize(SyncTrigger.APP_OPEN) synchronize(SyncTrigger.APP_OPEN)
lifecycleScope.launch {
syncNewlyDiscoveredProjects(withContext(Dispatchers.IO) { db.projects })
}
registerBroadcastReceiver() registerBroadcastReceiver()
updateAvatarInDrawer(CowspentServerSyncHelper.isNextcloudAccountConfigured(this)) updateAvatarInDrawer(CowspentServerSyncHelper.isNextcloudAccountConfigured(this))
isActivityVisible = true isActivityVisible = true
@@ -375,6 +383,8 @@ class BillsListViewActivity :
fun onProjectClick(projectId: Long) { fun onProjectClick(projectId: Long) {
if (viewModel.selectedProjectId != projectId) { if (viewModel.selectedProjectId != projectId) {
viewModel.selectedMemberId = null viewModel.selectedMemberId = null
viewModel.bills = emptyList()
viewModel.isLoadingBills = true
} }
setSelectedProject(projectId) setSelectedProject(projectId)
navigationSelection = Category(null, null) navigationSelection = Category(null, null)
@@ -398,16 +408,34 @@ class BillsListViewActivity :
private fun onRemoveProjectClick(projectId: Long) { private fun onRemoveProjectClick(projectId: Long) {
if (projectId == 0L) return if (projectId == 0L) return
lifecycleScope.launch { lifecycleScope.launch {
val proj = withContext(Dispatchers.IO) { db.getProject(projectId) } ?: return@launch val (proj, unsyncedBills) = withContext(Dispatchers.IO) {
val p = db.getProject(projectId) ?: return@withContext null
p to db.countUnsyncedBills(projectId)
} ?: return@launch
val message = buildString {
if (!proj.isLocal) append(getString(R.string.dialog_confirm_remove_project_msg))
if (unsyncedBills > 0) {
if (isNotEmpty()) append("\n\n")
append(
resources.getQuantityString(
R.plurals.warning_unsynced_bills, unsyncedBills, unsyncedBills
)
)
}
}
viewModel.showDialog( viewModel.showDialog(
title = getString(R.string.title_confirm), title = getString(R.string.title_confirm),
message = if (!proj.isLocal) getString(R.string.dialog_confirm_remove_project_msg) else null, message = message.ifEmpty { null },
positiveText = getString(R.string.simple_yes), positiveText = getString(R.string.simple_yes),
onConfirm = { onConfirm = {
lifecycleScope.launch { lifecycleScope.launch {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
db.deleteProject(projectId) db.deleteProject(projectId)
// Otherwise the next account sync sees a project the account offers
// with no local row, and creates it again.
CowspentServerSyncHelper.forgetAccountProject(applicationContext, proj)
PreferenceManager.getDefaultSharedPreferences(applicationContext) PreferenceManager.getDefaultSharedPreferences(applicationContext)
.edit { remove(lastProjectSyncKey(projectId)) } .edit { remove(lastProjectSyncKey(projectId)) }
val dbProjects = db.projects val dbProjects = db.projects
@@ -619,6 +647,7 @@ class BillsListViewActivity :
val selectedProjectId = PreferenceManager.getDefaultSharedPreferences(applicationContext).getLong("selected_project", 0) val selectedProjectId = PreferenceManager.getDefaultSharedPreferences(applicationContext).getLong("selected_project", 0)
lifecycleScope.launch { lifecycleScope.launch {
try {
val (projId, projName) = withContext(Dispatchers.IO) { val (projId, projName) = withContext(Dispatchers.IO) {
if (selectedProjectId != 0L) { if (selectedProjectId != 0L) {
db.getProject(selectedProjectId)?.let { db.getProject(selectedProjectId)?.let {
@@ -690,6 +719,9 @@ class BillsListViewActivity :
viewModel.bills = ljItems viewModel.bills = ljItems
} }
} }
} finally {
viewModel.isLoadingBills = false
}
} }
} }
@@ -778,40 +810,68 @@ class BillsListViewActivity :
val selectedProjectId = preferences.getLong("selected_project", 0) val selectedProjectId = preferences.getLong("selected_project", 0)
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
// The account and all-projects refresh belongs to opening the app, throttled by the // The account tells the app which projects exist at all, so a project joined elsewhere only
// SyncOnOpen interval so that resuming within the interval does not repeat it. // shows up once it has been read. That happens on login, on opening the app - throttled by
val intervalMinutes = SyncSettings.intervalMinutes(applicationContext) // the interval so resuming does not repeat it - and on a manual refresh, which is an
// explicit ask and so is never throttled.
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 openPass = trigger == SyncTrigger.APP_OPEN &&
now - lastAccountSync > intervalMinutes * 60 * 1000L now - lastAccountSync > intervalMinutes * 60 * 1000L
val accountSyncDue = openPass || trigger == SyncTrigger.MANUAL
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 }
// 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) { if (accountSyncDue && CowspentServerSyncHelper.isNextcloudAccountConfigured(applicationContext)) {
if (CowspentServerSyncHelper.isNextcloudAccountConfigured(applicationContext)) {
db.cowspentServerSyncHelper.runAccountProjectsSync() db.cowspentServerSyncHelper.runAccountProjectsSync()
} }
remoteProjects.count {
val scheduled = db.cowspentServerSyncHelper.scheduleSync(false, it, false) != null val started = if (openPass) {
if (scheduled) markProjectSynced(preferences, it.id, now) // 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)
}
remoteProjects.filter { !it.isArchived }.count {
val full = neverSynced(it)
val scheduled = db.cowspentServerSyncHelper.scheduleSync(false, it, full) != null
if (scheduled) {
markProjectSynced(preferences, it.id, now)
if (full) markFullySynced(preferences, it.id, now)
}
scheduled scheduled
} }
} 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 && neverSynced(selectedProj)
val due = trigger == SyncTrigger.MANUAL || neverCompleted ||
now - lastSync > SELECTED_PROJECT_SYNC_INTERVAL_MS now - lastSync > SELECTED_PROJECT_SYNC_INTERVAL_MS
val fullSync = neverCompleted || (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
} }
@@ -822,7 +882,52 @@ class BillsListViewActivity :
} }
} }
private fun neverSynced(project: DBProject) =
project.type != ProjectType.IHATEMONEY && (project.lastSyncedTimestamp ?: 0L) == 0L
@VisibleForTesting
internal fun syncNewlyDiscoveredProjects(projects: List<DBProject>) {
if (!db.cowspentServerSyncHelper.isSyncPossible) return
val preferences = PreferenceManager.getDefaultSharedPreferences(applicationContext)
val now = System.currentTimeMillis()
projects
.filter { !it.isLocal && !it.isArchived && neverSynced(it) }
.forEach {
if (db.cowspentServerSyncHelper.scheduleSync(false, it, true) != null) {
markProjectSynced(preferences, it.id, now)
markFullySynced(preferences, it.id, now)
}
}
}
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) }
} }
@@ -897,6 +1002,7 @@ class BillsListViewActivity :
synchronize(SyncTrigger.PROJECT_OPEN) synchronize(SyncTrigger.PROJECT_OPEN)
} }
} }
syncNewlyDiscoveredProjects(dbProjects)
} }
} }
} }
@@ -20,6 +20,7 @@ class BillsListViewModel : ViewModel() {
var selectedMemberId by mutableStateOf<Long?>(null) var selectedMemberId by mutableStateOf<Long?>(null)
var bills by mutableStateOf<List<Item>>(emptyList()) var bills by mutableStateOf<List<Item>>(emptyList())
var isRefreshing by mutableStateOf(false) var isRefreshing by mutableStateOf(false)
var isLoadingBills by mutableStateOf(false)
var searchQuery by mutableStateOf("") var searchQuery by mutableStateOf("")
var title by mutableStateOf("") var title by mutableStateOf("")
var accountName by mutableStateOf("") var accountName by mutableStateOf("")
@@ -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) }
@@ -24,6 +24,7 @@ import net.helcel.cowspent.model.ProjectType
fun ProjectOptionsDialogContent( fun ProjectOptionsDialogContent(
onEditProject: () -> Unit, onEditProject: () -> Unit,
onRemoveProject: () -> Unit, onRemoveProject: () -> Unit,
onForgetProject: () -> Unit = {},
onManageMembers: () -> Unit, onManageMembers: () -> Unit,
onManageCurrencies: () -> Unit, onManageCurrencies: () -> Unit,
onManageLabels: () -> Unit, onManageLabels: () -> Unit,
@@ -36,7 +37,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,
@@ -73,6 +74,15 @@ fun ProjectOptionsDialogContent(
val archiveLabel = if (isArchived) stringResource(R.string.action_unarchive) else stringResource(R.string.action_archive) val archiveLabel = if (isArchived) stringResource(R.string.action_unarchive) else stringResource(R.string.action_archive)
val archiveIcon = if (isArchived) Icons.Default.Unarchive else Icons.Default.Archive val archiveIcon = if (isArchived) Icons.Default.Unarchive else Icons.Default.Archive
row1.add(ProjectOption(archiveLabel, archiveIcon, onRemoveProject)) row1.add(ProjectOption(archiveLabel, archiveIcon, onRemoveProject))
if (isArchived) {
row1.add(
ProjectOption(
stringResource(R.string.action_forget),
Icons.Default.Delete,
onForgetProject
)
)
}
} else { } else {
row1.add(ProjectOption(stringResource(R.string.action_delete), Icons.Default.Delete, onRemoveProject)) row1.add(ProjectOption(stringResource(R.string.action_delete), Icons.Default.Delete, onRemoveProject))
} }
@@ -80,7 +90,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 +193,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 +218,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 +242,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
) )
} }
} }
@@ -5,6 +5,7 @@ import android.os.Bundle
import androidx.activity.OnBackPressedCallback import androidx.activity.OnBackPressedCallback
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
@@ -15,8 +16,13 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.core.app.NavUtils import androidx.core.app.NavUtils
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import net.helcel.cowspent.R
import net.helcel.cowspent.android.about.AboutActivity import net.helcel.cowspent.android.about.AboutActivity
import net.helcel.cowspent.android.account.AccountActivity import net.helcel.cowspent.android.account.AccountActivity
import net.helcel.cowspent.android.helper.showToast
import net.helcel.cowspent.model.DBAccountProject
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.persistence.CowspentServerSyncHelper
import net.helcel.cowspent.theme.ThemeUtils import net.helcel.cowspent.theme.ThemeUtils
import net.helcel.cowspent.util.ColorUtils import net.helcel.cowspent.util.ColorUtils
@@ -25,6 +31,25 @@ import net.helcel.cowspent.util.ColorUtils
*/ */
class PreferencesActivity : AppCompatActivity() { class PreferencesActivity : AppCompatActivity() {
private val accountLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == RESULT_OK) {
setResult(RESULT_OK, result.data)
}
}
private fun restoreDeletedProjects(projects: List<DBAccountProject>) {
if (projects.isEmpty()) return
val syncHelper = CowspentSQLiteOpenHelper.getInstance(applicationContext).cowspentServerSyncHelper
syncHelper.restoreAccountProjects(projects)
if (CowspentServerSyncHelper.isNextcloudAccountConfigured(applicationContext)) {
syncHelper.runAccountProjectsSync()
}
setResult(RESULT_OK)
showToast(this, getString(R.string.settings_restore_deleted_projects_done))
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge() enableEdgeToEdge()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -59,13 +84,14 @@ class PreferencesActivity : AppCompatActivity() {
SettingsScreen( SettingsScreen(
onBack = { NavUtils.navigateUpFromSameTask(this) }, onBack = { NavUtils.navigateUpFromSameTask(this) },
onAccountSettingsClick = { onAccountSettingsClick = {
startActivity(Intent(this, AccountActivity::class.java)) accountLauncher.launch(Intent(this, AccountActivity::class.java))
}, },
onAboutClick = { onAboutClick = {
startActivity(Intent(this, AboutActivity::class.java)) startActivity(Intent(this, AboutActivity::class.java))
}, },
onColorSelected = { appColor = it }, onColorSelected = { appColor = it },
onNightModeChanged = { nightMode = it } onNightModeChanged = { nightMode = it },
onRestoreDeletedProjects = { restoreDeletedProjects(it) }
) )
} }
} }
@@ -21,6 +21,7 @@ import androidx.compose.material.MaterialTheme
import androidx.compose.material.RadioButton import androidx.compose.material.RadioButton
import androidx.compose.material.Scaffold import androidx.compose.material.Scaffold
import androidx.compose.material.Slider import androidx.compose.material.Slider
import androidx.compose.material.Checkbox
import androidx.compose.material.Switch import androidx.compose.material.Switch
import androidx.compose.material.SwitchDefaults import androidx.compose.material.SwitchDefaults
import androidx.compose.material.Text import androidx.compose.material.Text
@@ -34,11 +35,14 @@ import androidx.compose.material.icons.filled.Brightness2
import androidx.compose.material.icons.filled.Group import androidx.compose.material.icons.filled.Group
import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.Restore
import androidx.compose.material.icons.filled.Sync import androidx.compose.material.icons.filled.Sync
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -47,6 +51,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
@@ -56,6 +61,9 @@ import androidx.core.content.edit
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import net.helcel.cowspent.R import net.helcel.cowspent.R
import net.helcel.cowspent.android.helper.ColorPicker import net.helcel.cowspent.android.helper.ColorPicker
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import net.helcel.cowspent.model.DBAccountProject
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.persistence.CowspentServerSyncHelper import net.helcel.cowspent.persistence.CowspentServerSyncHelper
import net.helcel.cowspent.util.ColorUtils import net.helcel.cowspent.util.ColorUtils
@@ -68,7 +76,8 @@ fun SettingsScreen(
onAccountSettingsClick: () -> Unit, onAccountSettingsClick: () -> Unit,
onAboutClick: () -> Unit, onAboutClick: () -> Unit,
onColorSelected: (Int) -> Unit, onColorSelected: (Int) -> Unit,
onNightModeChanged: (String) -> Unit = {} onNightModeChanged: (String) -> Unit = {},
onRestoreDeletedProjects: (List<DBAccountProject>) -> Unit = {}
) { ) {
val context = LocalContext.current val context = LocalContext.current
val sharedPreferences = remember { PreferenceManager.getDefaultSharedPreferences(context) } val sharedPreferences = remember { PreferenceManager.getDefaultSharedPreferences(context) }
@@ -84,14 +93,24 @@ 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)
val isNextcloudConfigured = CowspentServerSyncHelper.isNextcloudAccountConfigured(context) val isNextcloudConfigured = CowspentServerSyncHelper.isNextcloudAccountConfigured(context)
var deletedProjects by remember { mutableStateOf(emptyList<DBAccountProject>()) }
var showRestoreDialog by remember { mutableStateOf(false) }
// Off the main thread: this reads the account projects table.
LaunchedEffect(Unit) {
deletedProjects = withContext(Dispatchers.IO) {
CowspentSQLiteOpenHelper.getInstance(context).cowspentServerSyncHelper.deletedAccountProjects()
}
}
// States for preferences // States for preferences
var nightMode by remember(keyNightMode) { var nightMode by remember(keyNightMode) {
mutableStateOf(sharedPreferences.getString(keyNightMode, "-1") ?: "-1") mutableStateOf(sharedPreferences.getString(keyNightMode, "-1") ?: "-1")
@@ -128,8 +147,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 +157,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 +192,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,
@@ -279,23 +276,71 @@ fun SettingsScreen(
onClick = onAccountSettingsClick onClick = onAccountSettingsClick
) )
// Nothing held back means nothing to undo, so the row is not offered at all.
if (deletedProjects.isNotEmpty()) {
SettingsPreference(
title = stringResource(R.string.settings_restore_deleted_projects),
summary = pluralStringResource(
R.plurals.settings_restore_deleted_projects_summary,
deletedProjects.size,
deletedProjects.size
),
icon = Icons.Default.Restore,
onClick = { showRestoreDialog = true }
)
}
if (showRestoreDialog) {
RestoreDeletedProjectsDialog(
projects = deletedProjects,
onDismiss = { showRestoreDialog = false },
onConfirm = { chosen ->
showRestoreDialog = false
onRestoreDeletedProjects(chosen)
deletedProjects = deletedProjects - chosen.toSet()
}
)
}
// Other // Other
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 +367,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)
} }
} }
} }
@@ -349,6 +394,60 @@ fun SettingsScreen(
} }
} }
@Composable
fun RestoreDeletedProjectsDialog(
projects: List<DBAccountProject>,
onDismiss: () -> Unit,
onConfirm: (List<DBAccountProject>) -> Unit
) {
val selected = remember(projects) { mutableStateListOf<DBAccountProject>().apply { addAll(projects) } }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.settings_restore_deleted_projects)) },
text = {
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
projects.forEach { project ->
val isSelected = project in selected
Row(
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = isSelected,
onClick = {
if (isSelected) selected.remove(project) else selected.add(project)
}
)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(checked = isSelected, onCheckedChange = null)
Spacer(Modifier.width(16.dp))
Column {
Text(
text = project.name.ifEmpty { project.remoteId },
style = MaterialTheme.typography.subtitle1
)
Text(text = project.ncUrl, style = MaterialTheme.typography.caption)
}
}
}
}
},
confirmButton = {
TextButton(
onClick = { onConfirm(selected.toList()) },
enabled = selected.isNotEmpty()
) {
Text(stringResource(R.string.settings_restore_deleted_projects_confirm))
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.simple_cancel)) }
}
)
}
@Composable @Composable
fun SettingsCategory(title: String) { fun SettingsCategory(title: String) {
Text( Text(
@@ -4,6 +4,7 @@ import android.annotation.SuppressLint
import android.content.ContentValues import android.content.ContentValues
import android.content.Context import android.content.Context
import android.database.Cursor import android.database.Cursor
import android.database.DatabaseUtils
import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper import android.database.sqlite.SQLiteOpenHelper
import android.text.TextUtils import android.text.TextUtils
@@ -238,13 +239,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
@WorkerThread @WorkerThread
private fun getProjectsCustom(selection: String, selectionArgs: Array<String>, orderBy: String?, db: SQLiteDatabase): List<DBProject> { private fun getProjectsCustom(selection: String, selectionArgs: Array<String>, orderBy: String?, db: SQLiteDatabase): List<DBProject> {
val cursor = db.query(table_projects, columnsProjects, selection, selectionArgs, null, null, orderBy) return queryAll(db, table_projects, columnsProjects, selection, selectionArgs, orderBy, ::getProjectFromCursor)
val projects: MutableList<DBProject> = ArrayList()
while (cursor.moveToNext()) {
projects.add(getProjectFromCursor(cursor))
}
cursor.close()
return projects
} }
@SuppressLint("Range") @SuppressLint("Range")
@@ -305,11 +300,17 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
fun deleteProject(id: Long) { fun deleteProject(id: Long) {
val db = writableDatabase val db = writableDatabase
for (b in getBillsOfProject(id)) { val projectId = arrayOf(id.toString())
deleteBill(b.id) inTransaction {
db.delete(
table_billowers,
"$key_billId IN (SELECT $key_id FROM $table_bills WHERE $key_projectid = ?)",
projectId
)
db.delete(table_bills, "$key_projectid = ?", projectId)
db.delete(table_members, "$key_projectid = ?", projectId)
db.delete(table_projects, "$key_id = ?", projectId)
} }
db.delete(table_members, "$key_projectid = ?", arrayOf(id.toString()))
db.delete(table_projects, "$key_id = ?", arrayOf(id.toString()))
SecureStorage.removePasswordSync(context, "ProjectPassword_$id") SecureStorage.removePasswordSync(context, "ProjectPassword_$id")
} }
@@ -413,14 +414,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
@WorkerThread @WorkerThread
private fun getMembersCustom(selection: String, selectionArgs: Array<String>, orderBy: String?): List<DBMember> { private fun getMembersCustom(selection: String, selectionArgs: Array<String>, orderBy: String?): List<DBMember> {
val db = readableDatabase return queryAll(readableDatabase, table_members, columnsMembers, selection, selectionArgs, orderBy, ::getMemberFromCursor)
val cursor = db.query(table_members, columnsMembers, selection, selectionArgs, null, null, orderBy)
val members: MutableList<DBMember> = ArrayList()
while (cursor.moveToNext()) {
members.add(getMemberFromCursor(cursor))
}
cursor.close()
return members
} }
@SuppressLint("Range") @SuppressLint("Range")
@@ -448,6 +442,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()
@@ -541,6 +555,14 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
return getBillsCustom("$key_projectid = ?", arrayOf(projId.toString()), "$key_timestamp ASC") return getBillsCustom("$key_projectid = ?", arrayOf(projId.toString()), "$key_timestamp ASC")
} }
@WorkerThread
fun countUnsyncedBills(projId: Long): Int = DatabaseUtils.queryNumEntries(
readableDatabase,
table_bills,
"$key_projectid = ? AND $key_state != ?",
arrayOf(projId.toString(), DBBill.STATE_OK.toString())
).toInt()
fun getBillsOfProjectWithState(projId: Long, state: Int): List<DBBill> { fun getBillsOfProjectWithState(projId: Long, state: Int): List<DBBill> {
return getBillsCustom( return getBillsCustom(
"$key_projectid = ? AND $key_state = ?", "$key_projectid = ? AND $key_state = ?",
@@ -624,15 +646,20 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
@WorkerThread @WorkerThread
private fun getBillsCustom(selection: String, selectionArgs: Array<String>, orderBy: String?): List<DBBill> { private fun getBillsCustom(selection: String, selectionArgs: Array<String>, orderBy: String?): List<DBBill> {
val db = readableDatabase val db = readableDatabase
val cursor = db.query(table_bills, columnsBills, selection, selectionArgs, null, null, orderBy) return db.transactionally {
val bills: MutableList<DBBill> = ArrayList() val bills = queryAll(db, table_bills, columnsBills, selection, selectionArgs, orderBy, ::getBillFromCursor)
while (cursor.moveToNext()) { if (bills.isNotEmpty()) {
val bill = getBillFromCursor(cursor) val owersByBill = queryAll(
bill.billOwers = getBillowersOfBill(bill.id) db, table_billowers, columnsBillowers,
bills.add(bill) "$key_billId IN (SELECT $key_id FROM $table_bills WHERE $selection)",
selectionArgs, null, ::getBillOwerFromCursor
).groupBy { it.billId }
for (bill in bills) {
bill.billOwers = owersByBill[bill.id].orEmpty()
}
}
bills
} }
cursor.close()
return bills
} }
@SuppressLint("Range") @SuppressLint("Range")
@@ -680,14 +707,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
@WorkerThread @WorkerThread
private fun getBillOwersCustom(selection: String, selectionArgs: Array<String>, orderBy: String?): List<DBBillOwer> { private fun getBillOwersCustom(selection: String, selectionArgs: Array<String>, orderBy: String?): List<DBBillOwer> {
val db = readableDatabase return queryAll(readableDatabase, table_billowers, columnsBillowers, selection, selectionArgs, orderBy, ::getBillOwerFromCursor)
val cursor = db.query(table_billowers, columnsBillowers, selection, selectionArgs, null, null, orderBy)
val billOwers: MutableList<DBBillOwer> = ArrayList()
while (cursor.moveToNext()) {
billOwers.add(getBillOwerFromCursor(cursor))
}
cursor.close()
return billOwers
} }
@SuppressLint("Range") @SuppressLint("Range")
@@ -754,14 +774,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
@WorkerThread @WorkerThread
private fun getCategoriesCustom(selection: String, selectionArgs: Array<String>, orderBy: String?): List<DBCategory> { private fun getCategoriesCustom(selection: String, selectionArgs: Array<String>, orderBy: String?): List<DBCategory> {
val db = readableDatabase return queryAll(readableDatabase, table_categories, columnsCategories, selection, selectionArgs, orderBy, ::getCategoryFromCursor)
val cursor = db.query(table_categories, columnsCategories, selection, selectionArgs, null, null, orderBy)
val categories: MutableList<DBCategory> = ArrayList()
while (cursor.moveToNext()) {
categories.add(getCategoryFromCursor(cursor))
}
cursor.close()
return categories
} }
@SuppressLint("Range") @SuppressLint("Range")
@@ -870,13 +883,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
@WorkerThread @WorkerThread
private fun getPaymentModesCustom(selection: String, selectionArgs: Array<String>, orderBy: String?, db: SQLiteDatabase): List<DBPaymentMode> { private fun getPaymentModesCustom(selection: String, selectionArgs: Array<String>, orderBy: String?, db: SQLiteDatabase): List<DBPaymentMode> {
val cursor = db.query(table_payment_modes, columnsPaymentModes, selection, selectionArgs, null, null, orderBy) return queryAll(db, table_payment_modes, columnsPaymentModes, selection, selectionArgs, orderBy, ::getPaymentModeFromCursor)
val paymentModes: MutableList<DBPaymentMode> = ArrayList()
while (cursor.moveToNext()) {
paymentModes.add(getPaymentModeFromCursor(cursor))
}
cursor.close()
return paymentModes
} }
@SuppressLint("Range") @SuppressLint("Range")
@@ -986,13 +993,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
@WorkerThread @WorkerThread
private fun getCurrenciesCustom(selection: String, selectionArgs: Array<String>, orderBy: String?, db: SQLiteDatabase): List<DBCurrency> { private fun getCurrenciesCustom(selection: String, selectionArgs: Array<String>, orderBy: String?, db: SQLiteDatabase): List<DBCurrency> {
val cursor = db.query(table_currencies, columnsCurrencies, selection, selectionArgs, null, null, orderBy) return queryAll(db, table_currencies, columnsCurrencies, selection, selectionArgs, orderBy, ::getCurrencyFromCursor)
val currencies: MutableList<DBCurrency> = ArrayList()
while (cursor.moveToNext()) {
currencies.add(getCurrencyFromCursor(cursor))
}
cursor.close()
return currencies
} }
@SuppressLint("Range") @SuppressLint("Range")
@@ -1040,6 +1041,36 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
// --- Common Helpers --- // --- Common Helpers ---
@WorkerThread
private fun <T> queryAll(
db: SQLiteDatabase,
table: String,
columns: Array<String>,
selection: String?,
selectionArgs: Array<String>?,
orderBy: String?,
fromCursor: (Cursor) -> T
): List<T> = db.transactionally {
val items: MutableList<T> = ArrayList()
db.query(table, columns, selection, selectionArgs, null, null, orderBy).use { cursor ->
while (cursor.moveToNext()) {
items.add(fromCursor(cursor))
}
}
items
}
private fun <T> SQLiteDatabase.transactionally(block: () -> T): T {
beginTransaction()
try {
val result = block()
setTransactionSuccessful()
return result
} finally {
endTransaction()
}
}
fun syncIfRemote(proj: DBProject) { fun syncIfRemote(proj: DBProject) {
if (!proj.isLocal) { if (!proj.isLocal) {
val preferences = PreferenceManager.getDefaultSharedPreferences(context) val preferences = PreferenceManager.getDefaultSharedPreferences(context)
@@ -1112,13 +1143,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
@WorkerThread @WorkerThread
private fun getAccountProjectsCustom(selection: String, selectionArgs: Array<String>, orderBy: String?, db: SQLiteDatabase): List<DBAccountProject> { private fun getAccountProjectsCustom(selection: String, selectionArgs: Array<String>, orderBy: String?, db: SQLiteDatabase): List<DBAccountProject> {
val cursor = db.query(table_account_projects, columnsAccountProjects, selection, selectionArgs, null, null, orderBy) return queryAll(db, table_account_projects, columnsAccountProjects, selection, selectionArgs, orderBy, ::getAccountProjectFromCursor)
val accountProjects: MutableList<DBAccountProject> = ArrayList()
while (cursor.moveToNext()) {
accountProjects.add(getAccountProjectFromCursor(cursor))
}
cursor.close()
return accountProjects
} }
@SuppressLint("Range") @SuppressLint("Range")
@@ -6,6 +6,7 @@ import android.content.SharedPreferences
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.util.Log import android.util.Log
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import androidx.annotation.WorkerThread
import androidx.core.content.edit import androidx.core.content.edit
import androidx.core.graphics.toColorInt import androidx.core.graphics.toColorInt
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
@@ -14,6 +15,7 @@ import com.nextcloud.android.sso.api.NextcloudAPI
import com.nextcloud.android.sso.exceptions.NextcloudHttpRequestFailedException import com.nextcloud.android.sso.exceptions.NextcloudHttpRequestFailedException
import com.nextcloud.android.sso.exceptions.TokenMismatchException import com.nextcloud.android.sso.exceptions.TokenMismatchException
import com.nextcloud.android.sso.helper.SingleAccountHelper import com.nextcloud.android.sso.helper.SingleAccountHelper
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -26,6 +28,7 @@ import net.helcel.cowspent.R
import net.helcel.cowspent.android.account.AccountActivity import net.helcel.cowspent.android.account.AccountActivity
import net.helcel.cowspent.android.main.BillsListViewActivity import net.helcel.cowspent.android.main.BillsListViewActivity
import net.helcel.cowspent.android.main.MainConstants import net.helcel.cowspent.android.main.MainConstants
import net.helcel.cowspent.model.DBAccountProject
import net.helcel.cowspent.model.DBBill import net.helcel.cowspent.model.DBBill
import net.helcel.cowspent.model.DBMember import net.helcel.cowspent.model.DBMember
import net.helcel.cowspent.model.DBProject import net.helcel.cowspent.model.DBProject
@@ -50,7 +53,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 +103,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 +114,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 +147,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) {
@@ -180,14 +179,22 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
} }
fun execute(): SyncTask { fun execute(): SyncTask {
deferred = scope.async {
syncActive = true syncActive = true
deferred = scope.async {
try {
val status = withContext(Dispatchers.IO) { val status = withContext(Dispatchers.IO) {
doWork() doWork()
} }
onPostExecute(status) onPostExecute(status)
syncActive = false
status status
} catch (e: CancellationException) {
syncActive = false
throw e
} catch (e: Exception) {
Log.e(TAG, "Sync failed for ${project.remoteId}", e)
syncActive = false
LoginStatus.CONNECTION_FAILED
}
} }
return this return this
} }
@@ -197,12 +204,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 +241,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 +290,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) {
@@ -272,12 +304,8 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
null, null, null, null, null null, null, null, null, null
) )
} }
} catch (e: IOException) { } catch (e: Exception) {
if (e.message == "{\"message\": \"Internal Server Error\"}") { Log.e(TAG, "EDIT MEMBER FAILED for ${mToEdit.name}", e)
Log.d(TAG, "EDIT MEMBER FAILED : it does not exist remotely")
} else {
throw e
}
} }
} }
@@ -549,32 +577,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 +857,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 +883,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 +893,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 +933,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 +965,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 +1038,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++
@@ -979,6 +1055,8 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
for (localMember in dbHelper.getMembersOfProject(project.id, null)) { for (localMember in dbHelper.getMembersOfProject(project.id, null)) {
if (remoteMembersByRemoteId.containsKey(localMember.remoteId)) continue if (remoteMembersByRemoteId.containsKey(localMember.remoteId)) continue
if (localMember.state != DBBill.STATE_OK) continue
// A member still named by a bill cannot be removed without orphaning it. // A member still named by a bill cannot be removed without orphaning it.
if (dbHelper.getBillsOfMember(localMember.id).isEmpty() && if (dbHelper.getBillsOfMember(localMember.id).isEmpty() &&
dbHelper.getBillowersOfMember(localMember.id).isEmpty() dbHelper.getBillowersOfMember(localMember.id).isEmpty()
@@ -1399,6 +1477,21 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
} }
} }
@WorkerThread
fun deletedAccountProjects(): List<DBAccountProject> {
val forgotten = forgottenAccountProjects(preferences)
if (forgotten.isEmpty()) return emptyList()
return dbHelper.accountProjects.filter { accountProjectKey(it.remoteId, it.ncUrl) in forgotten }
}
fun restoreAccountProjects(projects: List<DBAccountProject>) {
if (projects.isEmpty()) return
val restored = projects.map { accountProjectKey(it.remoteId, it.ncUrl) }.toSet()
preferences.edit {
putStringSet(FORGOTTEN_ACCOUNT_PROJECTS, forgottenAccountProjects(preferences) - restored)
}
}
fun runAccountProjectsSync() { fun runAccountProjectsSync() {
Log.d(TAG, "Account projects sync requested; ${if (syncAccountProjectsActive) "sync active" else "sync NOT active"}) ...") Log.d(TAG, "Account projects sync requested; ${if (syncAccountProjectsActive) "sync active" else "sync NOT active"}) ...")
updateNetworkStatus() updateNetworkStatus()
@@ -1445,12 +1538,19 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
fun execute(): SyncAccountProjectsTask { fun execute(): SyncAccountProjectsTask {
scope.launch { scope.launch {
syncAccountProjectsActive = true syncAccountProjectsActive = true
try {
val status = withContext(Dispatchers.IO) { val status = withContext(Dispatchers.IO) {
doWork() doWork()
} }
onPostExecute(status) onPostExecute(status)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.e(TAG, "Account projects sync failed", e)
} finally {
syncAccountProjectsActive = false syncAccountProjectsActive = false
} }
}
return this return this
} }
@@ -1478,20 +1578,25 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
val response = client.getAccountProjects(useOcsApi) val response = client.getAccountProjects(useOcsApi)
val remoteAccountProjects = response.getAccountProjects(url) val remoteAccountProjects = response.getAccountProjects(url)
val forgotten = forgottenAccountProjects(preferences)
dbHelper.clearAccountProjects() dbHelper.clearAccountProjects()
for (remoteAccountProject in remoteAccountProjects) { for (remoteAccountProject in remoteAccountProjects) {
dbHelper.addAccountProject(remoteAccountProject) dbHelper.addAccountProject(remoteAccountProject)
Log.v(TAG, "received account project $remoteAccountProject") Log.v(TAG, "received account project $remoteAccountProject")
val existingProj = localProjects.find { val existingProj = localProjects.find {
it.remoteId == remoteAccountProject.remoteId && it.remoteId == remoteAccountProject.remoteId &&
it.serverUrl?.replace("/+$".toRegex(), "") == remoteAccountProject.ncUrl.replace("/+$".toRegex(), "") + "/index.php/apps/cospend" it.serverUrl?.replace("/+$".toRegex(), "") == remoteAccountProject.ncUrl.replace("/+$".toRegex(), "") + COSPEND_PATH
} }
if (existingProj == null) { if (existingProj == null) {
if (accountProjectKey(remoteAccountProject.remoteId, remoteAccountProject.ncUrl) in forgotten) {
Log.d(TAG, "skipping ${remoteAccountProject.remoteId}, deleted on this device")
continue
}
val newProj = DBProject(0, val newProj = DBProject(0,
remoteAccountProject.remoteId, remoteAccountProject.remoteId,
"", "",
remoteAccountProject.name, remoteAccountProject.name,
remoteAccountProject.ncUrl.replace("/+$".toRegex(), "") + "/index.php/apps/cospend", remoteAccountProject.ncUrl.replace("/+$".toRegex(), "") + COSPEND_PATH,
"", "",
null, null,
ProjectType.COSPEND, ProjectType.COSPEND,
@@ -1801,6 +1906,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()
@@ -1824,6 +1932,34 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
preferences.getBoolean(AccountActivity.SETTINGS_USE_SSO, false) preferences.getBoolean(AccountActivity.SETTINGS_USE_SSO, false)
} }
/** The path a Cospend project's URL carries on top of its Nextcloud server URL. */
const val COSPEND_PATH = "/index.php/apps/cospend"
private const val FORGOTTEN_ACCOUNT_PROJECTS = "forgottenAccountProjects"
private fun trimSlashes(url: String) = url.replace("/+$".toRegex(), "")
/** Names one project an account offers, by server and remote id, with no local row needed. */
private fun accountProjectKey(remoteId: String, ncUrl: String) = "${trimSlashes(ncUrl)}|$remoteId"
/** The same key for a stored project, or null when it is not one an account can offer. */
private fun accountProjectKey(project: DBProject): String? {
val url = trimSlashes(project.serverUrl.orEmpty())
if (!url.endsWith(COSPEND_PATH)) return null
return accountProjectKey(project.remoteId, url.removeSuffix(COSPEND_PATH))
}
private fun forgottenAccountProjects(preferences: SharedPreferences): Set<String> =
preferences.getStringSet(FORGOTTEN_ACCOUNT_PROJECTS, emptySet()).orEmpty()
fun forgetAccountProject(context: Context, project: DBProject) {
val key = accountProjectKey(project) ?: return
val preferences = PreferenceManager.getDefaultSharedPreferences(context)
preferences.edit {
putStringSet(FORGOTTEN_ACCOUNT_PROJECTS, forgottenAccountProjects(preferences) + key)
}
}
fun getNextcloudAccountServerUrl(context: Context): String { fun getNextcloudAccountServerUrl(context: Context): String {
val preferences = PreferenceManager.getDefaultSharedPreferences(context) val preferences = PreferenceManager.getDefaultSharedPreferences(context)
return if (preferences.getBoolean(AccountActivity.SETTINGS_USE_SSO, false)) { return if (preferences.getBoolean(AccountActivity.SETTINGS_USE_SSO, false)) {
@@ -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>
+29 -8
View File
@@ -17,6 +17,7 @@
<string name="simple_back">Back</string> <string name="simple_back">Back</string>
<string name="action_archive">Archive</string> <string name="action_archive">Archive</string>
<string name="action_unarchive">Unarchive</string> <string name="action_unarchive">Unarchive</string>
<string name="action_forget">Delete locally</string>
<string name="action_export">Export</string> <string name="action_export">Export</string>
<string name="action_stats">Stats</string> <string name="action_stats">Stats</string>
<string name="action_settle">Settle</string> <string name="action_settle">Settle</string>
@@ -24,6 +25,8 @@
<string name="action_settings">Settings</string> <string name="action_settings">Settings</string>
<string name="action_label_bills">Label missing categories</string> <string name="action_label_bills">Label missing categories</string>
<string name="action_logout">Logout</string> <string name="action_logout">Logout</string>
<string name="logout_confirm_title">Log out?</string>
<string name="logout_confirm_no_projects">You can sign back in at any time.</string>
<string name="action_connect">Connect</string> <string name="action_connect">Connect</string>
<string name="action_discard">Discard</string> <string name="action_discard">Discard</string>
<string name="action_members">Members</string> <string name="action_members">Members</string>
@@ -136,20 +139,23 @@
<string name="settings_night_mode">Theme</string> <string name="settings_night_mode">Theme</string>
<string name="settings_offline_mode">Offline mode</string> <string name="settings_offline_mode">Offline mode</string>
<string name="settings_offline_mode_summary">Only sync manually.</string> <string name="settings_offline_mode_summary">Only sync manually.</string>
<string name="settings_restore_deleted_projects">Restore deleted projects</string>
<string name="settings_restore_deleted_projects_done">Restoring from the account</string>
<string name="settings_restore_deleted_projects_confirm">Restore</string>
<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 +176,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>
@@ -290,4 +296,19 @@
<string name="share_intent_title">Project %1$s</string> <string name="share_intent_title">Project %1$s</string>
<string name="share_chooser_title">Share %1$s</string> <string name="share_chooser_title">Share %1$s</string>
<plurals name="settings_restore_deleted_projects_summary">
<item quantity="one">%d project the account offers is kept off this device.</item>
<item quantity="other">%d projects the account offers are kept off this device.</item>
</plurals>
<plurals name="logout_confirm_projects">
<item quantity="one">%d project from this account will be removed from this device. It comes back when you sign in again.</item>
<item quantity="other">%d projects from this account will be removed from this device. They come back when you sign in again.</item>
</plurals>
<plurals name="warning_unsynced_bills">
<item quantity="one">%d bill has not reached the server yet and will be lost.</item>
<item quantity="other">%d bills have not reached the server yet and will be lost.</item>
</plurals>
</resources> </resources>