4 Commits
Author SHA1 Message Date
sora 691bc82d52 Bill Sync 2026-09-06 02:57:01 +02:00
sora 8564f64576 Project Cleanup/Refactor 2026-09-06 02:56:32 +02:00
sora c6be8eda6b Manage Currencies 2026-09-06 02:56:02 +02:00
sora 7904aabbc3 Accessibility 2026-09-06 02:55:11 +02:00
18 changed files with 355 additions and 155 deletions
+15
View File
@@ -76,6 +76,18 @@ android {
testOptions { testOptions {
unitTests { unitTests {
includeAndroidResources = true includeAndroidResources = true
all {
maxParallelForks = 2
forkEvery = 50
maxHeapSize = "1024m"
// Robolectric loads classes through its own sandbox classloader, which leaves them
// without a code-source location. Without this JaCoCo skips them entirely and the
// report only sees the handful of plain JVM classes.
jacoco {
includeNoLocationClasses = true
excludes = ["jdk.internal.*"]
}
}
} }
} }
@@ -138,10 +150,13 @@ dependencies {
testImplementation 'org.robolectric:robolectric:4.16.1' testImplementation 'org.robolectric:robolectric:4.16.1'
testImplementation 'androidx.test:core:1.7.0' testImplementation 'androidx.test:core:1.7.0'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0' testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0'
testImplementation 'androidx.compose.ui:ui-test-junit4'
androidTestImplementation platform('androidx.compose:compose-bom:2026.08.00')
androidTestImplementation 'androidx.test.ext:junit:1.3.0' androidTestImplementation 'androidx.test.ext:junit:1.3.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0' androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0'
androidTestImplementation 'androidx.compose.ui:ui-test-junit4' androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
androidTestImplementation 'io.mockk:mockk-android:1.14.11'
debugImplementation 'androidx.compose.ui:ui-test-manifest' debugImplementation 'androidx.compose.ui:ui-test-manifest'
} }
@@ -48,7 +48,7 @@ fun AboutScreen(
title = { Text(stringResource(R.string.title_about)) }, title = { Text(stringResource(R.string.title_about)) },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
} }
}, },
backgroundColor = MaterialTheme.colors.primary, backgroundColor = MaterialTheme.colors.primary,
@@ -331,7 +331,7 @@ class EditBillActivity : AppCompatActivity() {
bill.amount == viewModel.getFinalAmount() && bill.amount == viewModel.getFinalAmount() &&
bill.payerId == viewModel.payerId && bill.payerId == viewModel.payerId &&
bill.comment == viewModel.getFinalComment() && bill.comment == viewModel.getFinalComment() &&
bill.repeat == viewModel.repeat && (bill.repeat ?: DBBill.NON_REPEATED) == viewModel.repeat &&
bill.categoryId == viewModel.categoryId && bill.categoryId == viewModel.categoryId &&
bill.paymentModeId == viewModel.paymentModeId && bill.paymentModeId == viewModel.paymentModeId &&
!owersChanged) !owersChanged)
@@ -15,6 +15,7 @@ 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.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
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.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@@ -223,7 +224,10 @@ fun BillSummaryCard(bill: DBBill, members: List<DBMember>, remainingCount: Int)
fun CategoryButton(icon: String, name: String, onClick: () -> Unit) { fun CategoryButton(icon: String, name: String, onClick: () -> Unit) {
OutlinedButton( OutlinedButton(
onClick = onClick, onClick = onClick,
modifier = Modifier.fillMaxWidth().height(60.dp), modifier = Modifier
.fillMaxWidth()
.height(60.dp)
.testTag("CategoryButton_$name"),
contentPadding = PaddingValues(2.dp) contentPadding = PaddingValues(2.dp)
) { ) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) { Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
@@ -43,6 +43,7 @@ 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.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
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
@@ -218,7 +219,7 @@ fun ManageCurrenciesScreen(
) { ) {
Icon( Icon(
imageVector = if (isEditing) Icons.Default.Done else Icons.Default.Add, imageVector = if (isEditing) Icons.Default.Done else Icons.Default.Add,
contentDescription = null, contentDescription = if (isEditing) "Done" else "Add",
tint = if (viewModel.isAddEnabled()) { tint = if (viewModel.isAddEnabled()) {
if (isEditing) MaterialTheme.colors.secondary else MaterialTheme.colors.primary if (isEditing) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
} else MaterialTheme.colors.onSurface.copy(alpha = 0.2f) } else MaterialTheme.colors.onSurface.copy(alpha = 0.2f)
@@ -276,7 +277,9 @@ fun CurrencyRow(
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
elevation = if (isEditing) 4.dp else 1.dp, elevation = if (isEditing) 4.dp else 1.dp,
border = if (isEditing) BorderStroke(1.dp, MaterialTheme.colors.secondary.copy(alpha = 0.5f)) else null, border = if (isEditing) BorderStroke(1.dp, MaterialTheme.colors.secondary.copy(alpha = 0.5f)) else null,
modifier = Modifier.fillMaxWidth() modifier = Modifier
.fillMaxWidth()
.testTag("CurrencyRow_${currency.name}")
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
@@ -322,7 +325,7 @@ fun CurrencyRow(
IconButton(onClick = onDelete) { IconButton(onClick = onDelete) {
Icon( Icon(
imageVector = Icons.Default.Delete, imageVector = Icons.Default.Delete,
contentDescription = null, contentDescription = "Delete",
tint = MaterialTheme.colors.error.copy(alpha = 0.7f), tint = MaterialTheme.colors.error.copy(alpha = 0.7f),
modifier = Modifier.size(20.dp) modifier = Modifier.size(20.dp)
) )
@@ -107,7 +107,7 @@ fun LabelManagementScreenContent(
showEditDialog = true showEditDialog = true
} }
}) { }) {
Icon(Icons.Default.Add, contentDescription = null) Icon(Icons.Default.Add, contentDescription = "Add Label")
} }
} }
) { padding -> ) { padding ->
@@ -293,7 +293,7 @@ fun BillsListScreen(
isSearchExpanded = false isSearchExpanded = false
viewModel.searchQuery = "" viewModel.searchQuery = ""
}) { }) {
Icon(Icons.Default.Close, contentDescription = null, tint = MaterialTheme.colors.onPrimary) Icon(Icons.Default.Close, contentDescription = stringResource(R.string.action_clear_search), tint = MaterialTheme.colors.onPrimary)
} }
} }
) )
@@ -322,13 +322,13 @@ fun BillsListScreen(
isSearchExpanded = false isSearchExpanded = false
viewModel.searchQuery = "" viewModel.searchQuery = ""
}) { }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_close_search))
} }
} else { } else {
IconButton(onClick = { IconButton(onClick = {
scope.launch { scaffoldState.drawerState.open() } scope.launch { scaffoldState.drawerState.open() }
}) { }) {
Icon(Icons.Default.Menu, contentDescription = null) Icon(Icons.Default.Menu, contentDescription = stringResource(R.string.action_open_menu))
} }
} }
}, },
@@ -343,7 +343,10 @@ fun BillsListScreen(
} }
} }
IconButton(onClick = { isSearchExpanded = true }) { IconButton(onClick = { isSearchExpanded = true }) {
Icon(Icons.Default.Search, contentDescription = null) Icon(
Icons.Default.Search,
contentDescription = stringResource(R.string.action_search)
)
} }
} }
}, },
@@ -18,6 +18,7 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.annotation.VisibleForTesting
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -53,7 +54,9 @@ import net.helcel.cowspent.util.CospendClientUtil
import net.helcel.cowspent.util.ExportUtil import net.helcel.cowspent.util.ExportUtil
import net.helcel.cowspent.util.ICallback import net.helcel.cowspent.util.ICallback
import net.helcel.cowspent.util.IRefreshBillsListCallback import net.helcel.cowspent.util.IRefreshBillsListCallback
import net.helcel.cowspent.util.SyncSettings
import net.helcel.cowspent.util.SupportUtil import net.helcel.cowspent.util.SupportUtil
import net.helcel.cowspent.util.VersatileProjectSyncClient
import java.io.IOException import java.io.IOException
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Calendar import java.util.Calendar
@@ -70,6 +73,9 @@ class BillsListViewActivity :
private val TAG = BillsListViewActivity::class.java.simpleName private val TAG = BillsListViewActivity::class.java.simpleName
// Opening a project only re-syncs it if it has not synced within this window.
private const val SELECTED_PROJECT_SYNC_INTERVAL_MS = 60 * 1000L
private const val SAVED_STATE_NAVIGATION_SELECTION = "navigationSelection" private const val SAVED_STATE_NAVIGATION_SELECTION = "navigationSelection"
private const val SAVED_STATE_NAVIGATION_OPEN = "navigationOpen" private const val SAVED_STATE_NAVIGATION_OPEN = "navigationOpen"
@@ -276,7 +282,7 @@ class BillsListViewActivity :
labelBillsLauncher.launch(LabelBillsActivity.createIntent(this, selectedProjectId)) labelBillsLauncher.launch(LabelBillsActivity.createIntent(this, selectedProjectId))
} }
}, },
onRefresh = { synchronize(true) } onRefresh = { synchronize(SyncTrigger.MANUAL) }
) )
} }
} }
@@ -330,10 +336,7 @@ class BillsListViewActivity :
} }
viewModel.isRefreshing = false viewModel.isRefreshing = false
if (db.cowspentServerSyncHelper.isSyncPossible) { synchronize(SyncTrigger.APP_OPEN)
db.cowspentServerSyncHelper.addCallbackPull(syncCallBack)
synchronize()
}
registerBroadcastReceiver() registerBroadcastReceiver()
updateAvatarInDrawer(CowspentServerSyncHelper.isNextcloudAccountConfigured(this)) updateAvatarInDrawer(CowspentServerSyncHelper.isNextcloudAccountConfigured(this))
@@ -347,6 +350,9 @@ class BillsListViewActivity :
} catch (_: RuntimeException) { } catch (_: RuntimeException) {
if (DEBUG) Log.d(TAG, "RECEIVER PROBLEM, let's ignore it...") if (DEBUG) Log.d(TAG, "RECEIVER PROBLEM, let's ignore it...")
} }
// The helper outlives the activity, and it only drains its pull callbacks when a task
// starts; drop ours so a paused activity is not retained by it.
db.cowspentServerSyncHelper.removeCallbackPull(syncCallBack)
isActivityVisible = false isActivityVisible = false
} }
@@ -373,7 +379,7 @@ class BillsListViewActivity :
navigationSelection = Category(null, null) navigationSelection = Category(null, null)
refreshLists(true) refreshLists(true)
synchronize() synchronize(SyncTrigger.PROJECT_OPEN)
} }
fun onManageProjectClick(projectId: Long) { fun onManageProjectClick(projectId: Long) {
@@ -406,7 +412,7 @@ class BillsListViewActivity :
} }
setupDrawerProjects() setupDrawerProjects()
refreshLists() refreshLists()
synchronize() synchronize(SyncTrigger.PROJECT_OPEN)
val projectNameString = proj.name.ifEmpty { proj.remoteId } val projectNameString = proj.name.ifEmpty { proj.remoteId }
showToast(this@BillsListViewActivity, getString(R.string.remove_project_confirmation, projectNameString)) showToast(this@BillsListViewActivity, getString(R.string.remove_project_confirmation, projectNameString))
} }
@@ -421,20 +427,25 @@ class BillsListViewActivity :
lifecycleScope.launch { lifecycleScope.launch {
val proj = withContext(Dispatchers.IO) { db.getProject(projectId) } ?: return@launch val proj = withContext(Dispatchers.IO) { db.getProject(projectId) } ?: return@launch
val isArchiving = !proj.isArchived val isArchiving = !proj.isArchived
val newArchivedTs = if (isArchiving) System.currentTimeMillis() / 1000 else 0L val localArchivedTs = if (isArchiving) System.currentTimeMillis() / 1000 else 0L
val remoteArchivedTs = if (isArchiving) {
localArchivedTs
} else {
VersatileProjectSyncClient.REMOTE_ARCHIVED_TS_UNSET
}
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
db.updateProject( db.updateProject(
projId = projectId, projId = projectId,
newArchivedTs = newArchivedTs newArchivedTs = localArchivedTs
) )
} }
if (!proj.isLocal) { if (!proj.isLocal) {
db.cowspentServerSyncHelper.editRemoteProject( db.cowspentServerSyncHelper.editRemoteProject(
projId = projectId, projId = projectId,
newArchivedTs = newArchivedTs, newArchivedTs = remoteArchivedTs,
callback = object : ICallback { callback = object : ICallback {
override fun onFinish() {} override fun onFinish() {}
override fun onFinish(result: String, message: String) { override fun onFinish(result: String, message: String) {
@@ -746,42 +757,60 @@ class BillsListViewActivity :
} }
} }
private fun synchronize(manual: Boolean = false) { /**
* What prompted a sync. Only [APP_OPEN] may refresh the account and every project, and only
* then when the SyncOnOpen interval has elapsed; the other triggers touch the selected
* project alone.
*/
@VisibleForTesting
internal enum class SyncTrigger { APP_OPEN, PROJECT_OPEN, MANUAL }
@VisibleForTesting
internal fun synchronize(trigger: SyncTrigger) {
val preferences = PreferenceManager.getDefaultSharedPreferences(applicationContext) val preferences = PreferenceManager.getDefaultSharedPreferences(applicationContext)
val offlineMode = preferences.getBoolean(getString(R.string.pref_key_offline_mode), false) // isSyncPossible is networkConnected && !offlineMode, so offline mode is already covered
if (offlineMode && !manual) { // here - including for a manual refresh, which cannot currently override it.
return if (!db.cowspentServerSyncHelper.isSyncPossible) return
}
val selectedProjectId = preferences.getLong("selected_project", 0)
val now = System.currentTimeMillis()
// The account and all-projects refresh belongs to opening the app, throttled by the
// SyncOnOpen interval so that resuming within the interval does not repeat it.
val intervalMinutes = SyncSettings.intervalMinutes(applicationContext)
val lastAccountSync = preferences.getLong(getString(R.string.pref_key_last_account_sync_timestamp), 0L)
val accountSyncDue = trigger == SyncTrigger.APP_OPEN &&
now - lastAccountSync > intervalMinutes * 60 * 1000L
lifecycleScope.launch {
val remoteProjects = withContext(Dispatchers.IO) { db.projects }
.filter { !it.isLocal && !it.isArchived }
if (db.cowspentServerSyncHelper.isSyncPossible) {
viewModel.isRefreshing = true viewModel.isRefreshing = true
val selectedProjectId = preferences.getLong("selected_project", 0) db.cowspentServerSyncHelper.addCallbackPull(syncCallBack)
lifecycleScope.launch {
val projects = withContext(Dispatchers.IO) { db.projects }
val remoteProjects = projects.filter { !it.isLocal }
if (remoteProjects.isNotEmpty()) {
// Sync selected project first to prioritize it (it starts immediately or becomes top of stack)
val selectedProj = remoteProjects.find { it.id == selectedProjectId }
if (selectedProj != null) {
db.cowspentServerSyncHelper.addCallbackPull(syncCallBack)
db.cowspentServerSyncHelper.scheduleSync(false, selectedProjectId, manual)
} else if (manual) {
viewModel.isRefreshing = false
}
// remoteProjects.filter { it.id != selectedProjectId }.forEach {
// db.cowspentServerSyncHelper.scheduleSync(false, it.id, manual)
// }
} else {
viewModel.isRefreshing = false
}
val started = if (accountSyncDue) {
if (CowspentServerSyncHelper.isNextcloudAccountConfigured(applicationContext)) { if (CowspentServerSyncHelper.isNextcloudAccountConfigured(applicationContext)) {
db.cowspentServerSyncHelper.runAccountProjectsSync() db.cowspentServerSyncHelper.runAccountProjectsSync()
} }
remoteProjects.count {
db.cowspentServerSyncHelper.scheduleSync(false, it, false) != null
}
} else {
val selectedProj = remoteProjects.find { it.id == selectedProjectId }
val lastSync = (selectedProj?.lastSyncedTimestamp ?: 0L) * 1000L
val due = trigger == SyncTrigger.MANUAL ||
now - lastSync > SELECTED_PROJECT_SYNC_INTERVAL_MS
if (selectedProj != null && due &&
db.cowspentServerSyncHelper.scheduleSync(
false, selectedProj, trigger == SyncTrigger.MANUAL
) != null
) 1 else 0
} }
// Only a task that actually started reports back through syncCallBack, so clear
// the indicator here when none did - nothing else would.
if (started == 0) viewModel.isRefreshing = false
} }
} }
@@ -824,7 +853,7 @@ class BillsListViewActivity :
refreshLists() refreshLists()
} }
MainConstants.BROADCAST_SYNC_PROJECT -> { MainConstants.BROADCAST_SYNC_PROJECT -> {
synchronize() synchronize(SyncTrigger.PROJECT_OPEN)
} }
MainConstants.BROADCAST_NETWORK_AVAILABLE -> { MainConstants.BROADCAST_NETWORK_AVAILABLE -> {
} }
@@ -852,7 +881,7 @@ class BillsListViewActivity :
refreshLists() refreshLists()
if (db.cowspentServerSyncHelper.isSyncPossible) { if (db.cowspentServerSyncHelper.isSyncPossible) {
db.cowspentServerSyncHelper.addCallbackPull(syncCallBack) db.cowspentServerSyncHelper.addCallbackPull(syncCallBack)
synchronize() synchronize(SyncTrigger.PROJECT_OPEN)
} }
} }
} }
@@ -3,8 +3,8 @@ package net.helcel.cowspent.android.project.edit
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.widget.Toast import android.widget.Toast
import androidx.activity.enableEdgeToEdge
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
@@ -13,12 +13,11 @@ 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.helper.showToast import net.helcel.cowspent.android.helper.showToast
import net.helcel.cowspent.android.main.MainConstants
import net.helcel.cowspent.model.DBProject import net.helcel.cowspent.model.DBProject
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.theme.ThemeUtils import net.helcel.cowspent.theme.ThemeUtils
import net.helcel.cowspent.util.ICallback import net.helcel.cowspent.util.ICallback
import net.helcel.cowspent.util.SupportUtil
import net.helcel.cowspent.android.main.MainConstants
class EditProjectActivity : AppCompatActivity() { class EditProjectActivity : AppCompatActivity() {
@@ -56,29 +55,33 @@ class EditProjectActivity : AppCompatActivity() {
} }
private fun onSave() { private fun onSave() {
when (viewModel.validate()) {
EditProjectViewModel.ValidationError.EMPTY_NAME -> {
showToast(this, getString(R.string.error_invalid_project_name), Toast.LENGTH_LONG)
return
}
EditProjectViewModel.ValidationError.INVALID_EMAIL -> {
showToast(this, getString(R.string.error_invalid_email), Toast.LENGTH_LONG)
return
}
null -> Unit
}
val changes = viewModel.changesFrom(project)
if (!changes.any) {
showToast(this, getString(R.string.project_edition_no_change), Toast.LENGTH_LONG)
return
}
val currentPwd = viewModel.password val currentPwd = viewModel.password
val newPwd = viewModel.newPassword val newPwd = viewModel.newPassword
val newName = viewModel.name val newName = viewModel.name
val newEmail = viewModel.email val newEmail = viewModel.email
if (newName.isEmpty()) { val nameChanged = changes.name
showToast(this, getString(R.string.error_invalid_project_name), Toast.LENGTH_LONG) val emailChanged = changes.email
return val pwdChanged = changes.newPassword
} val currentPwdChanged = changes.currentPassword
if (newEmail.isNotEmpty() && !SupportUtil.isValidEmail(newEmail)) {
showToast(this, getString(R.string.error_invalid_email), Toast.LENGTH_LONG)
return
}
val nameChanged = newName != project.name
val emailChanged = newEmail != project.email
val pwdChanged = newPwd != project.password
val currentPwdChanged = currentPwd != project.password
if (!nameChanged && !emailChanged && !pwdChanged && !currentPwdChanged) {
showToast(this, getString(R.string.project_edition_no_change), Toast.LENGTH_LONG)
return
}
if (project.isLocal) { if (project.isLocal) {
val targetPwd = if (pwdChanged) newPwd else currentPwd val targetPwd = if (pwdChanged) newPwd else currentPwd
@@ -7,6 +7,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import net.helcel.cowspent.android.helper.DialogState import net.helcel.cowspent.android.helper.DialogState
import net.helcel.cowspent.model.DBProject import net.helcel.cowspent.model.DBProject
import net.helcel.cowspent.util.SupportUtil
class EditProjectViewModel : ViewModel() { class EditProjectViewModel : ViewModel() {
var name by mutableStateOf("") var name by mutableStateOf("")
@@ -17,6 +18,31 @@ class EditProjectViewModel : ViewModel() {
var dialogState by mutableStateOf<DialogState?>(null) var dialogState by mutableStateOf<DialogState?>(null)
enum class ValidationError { EMPTY_NAME, INVALID_EMAIL }
fun validate(): ValidationError? = when {
name.isBlank() -> ValidationError.EMPTY_NAME
email.isNotEmpty() && !SupportUtil.isValidEmail(email) -> ValidationError.INVALID_EMAIL
else -> null
}
data class Changes(
val name: Boolean,
val email: Boolean,
val newPassword: Boolean,
val currentPassword: Boolean
) {
val any: Boolean get() = name || email || newPassword || currentPassword
}
fun changesFrom(project: DBProject) = Changes(
name = name != project.name,
// initFromProject maps a null or "null" email to "", so treat those as unchanged
email = email != project.email && !(email.isEmpty() && project.email == null),
newPassword = newPassword != project.password,
currentPassword = password != project.password
)
fun showDialog( fun showDialog(
title: String? = null, title: String? = null,
message: String? = null, message: String? = null,
@@ -12,6 +12,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
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.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
@@ -100,6 +101,7 @@ fun MemberEditDialogContent(
.fillMaxWidth() .fillMaxWidth()
.clickable { isActivated = !isActivated } .clickable { isActivated = !isActivated }
.padding(vertical = 8.dp) .padding(vertical = 8.dp)
.testTag("member_activated_row")
) { ) {
Icon( Icon(
imageVector = Icons.Default.Block, imageVector = Icons.Default.Block,
@@ -20,6 +20,7 @@ import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme 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.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
@@ -49,6 +50,7 @@ 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
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlin.math.roundToInt
import androidx.core.content.edit import androidx.core.content.edit
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import net.helcel.cowspent.R import net.helcel.cowspent.R
@@ -57,6 +59,7 @@ 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
import net.helcel.cowspent.util.Cowspent import net.helcel.cowspent.util.Cowspent
import net.helcel.cowspent.util.SyncSettings
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
@@ -81,7 +84,9 @@ fun SettingsScreen(
val keyOfflineMode = stringResource(R.string.pref_key_offline_mode) val keyOfflineMode = stringResource(R.string.pref_key_offline_mode)
val keyShowArchived = stringResource(R.string.pref_key_show_archived) val keyShowArchived = stringResource(R.string.pref_key_show_archived)
val keyBetaFeatures = stringResource(R.string.pref_key_beta_features) val keyBetaFeatures = stringResource(R.string.pref_key_beta_features)
val keyAutoSyncOnOpen = stringResource(R.string.pref_key_auto_sync_on_open)
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 isNextcloudConfigured = CowspentServerSyncHelper.isNextcloudAccountConfigured(context) val isNextcloudConfigured = CowspentServerSyncHelper.isNextcloudAccountConfigured(context)
@@ -127,6 +132,18 @@ fun SettingsScreen(
var fillNewBillFromLast by remember(keyFillNewBillFromLast) { var fillNewBillFromLast by remember(keyFillNewBillFromLast) {
mutableStateOf(sharedPreferences.getBoolean(keyFillNewBillFromLast, false)) mutableStateOf(sharedPreferences.getBoolean(keyFillNewBillFromLast, false))
} }
val syncIntervals = SyncSettings.INTERVAL_CHOICES_MINUTES
val syncIntervalLabels = listOf(
stringResource(R.string.pref_value_sync_1m),
stringResource(R.string.pref_value_sync_10m),
stringResource(R.string.pref_value_sync_1h),
stringResource(R.string.pref_value_sync_1d)
)
var syncInterval by remember(keyAutoSyncOnOpen) {
mutableIntStateOf(SyncSettings.intervalMinutes(context))
}
Scaffold( Scaffold(
topBar = { topBar = {
@@ -134,7 +151,7 @@ fun SettingsScreen(
title = { Text(stringResource(R.string.action_settings)) }, title = { Text(stringResource(R.string.action_settings)) },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
} }
}, },
backgroundColor = MaterialTheme.colors.primary, backgroundColor = MaterialTheme.colors.primary,
@@ -286,6 +303,24 @@ fun SettingsScreen(
} }
} }
) )
SettingsSliderPreference(
title = stringResource(R.string.settings_auto_sync_on_open),
summary = stringResource(R.string.settings_auto_sync_on_open_summary),
icon = Icons.Default.Sync,
value = syncInterval,
values = syncIntervals,
labels = syncIntervalLabels,
onValueChange = { newInterval ->
// Slider reports every drag delta, not just the snapped steps.
if (newInterval != syncInterval) {
syncInterval = newInterval
sharedPreferences.edit {
putInt(keyAutoSyncOnOpen, newInterval)
}
}
}
)
} }
SettingsPreference( SettingsPreference(
@@ -401,13 +436,7 @@ fun SettingsListPreference(
), ),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
RadioButton( RadioButton(selected = (key == value), onClick = null)
selected = (key == value),
onClick = {
onValueChange(key)
showDialog = false
}
)
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text(text = label) Text(text = label)
} }
@@ -470,6 +499,47 @@ fun SettingsColorPreference(
} }
} }
@Composable
fun SettingsSliderPreference(
title: String,
summary: String? = null,
icon: Any? = null,
value: Int,
values: List<Int>,
labels: List<String>,
onValueChange: (Int) -> Unit
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp, 8.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
SettingsIcon(icon)
Spacer(modifier = Modifier.width(32.dp))
Column(modifier = Modifier.weight(1f)) {
Text(text = title, style = MaterialTheme.typography.subtitle1)
if (summary != null) {
Text(text = summary, style = MaterialTheme.typography.caption)
}
}
}
val currentIndex = values.indexOf(value).coerceAtLeast(0)
val steps = (values.size - 2).coerceAtLeast(0)
Column(modifier = Modifier.padding(start = 56.dp, top = 8.dp)) {
Slider(
value = currentIndex.toFloat(),
onValueChange = { onValueChange(values[it.roundToInt()]) },
valueRange = 0f..(values.size - 1).toFloat(),
steps = steps
)
Text(text = labels[currentIndex], style = MaterialTheme.typography.caption, fontWeight = FontWeight.Bold, color = MaterialTheme.colors.primary)
}
}
}
@Composable @Composable
fun SettingsIcon(icon: Any?) { fun SettingsIcon(icon: Any?) {
Box(modifier = Modifier.size(24.dp), contentAlignment = Alignment.Center) { Box(modifier = Modifier.size(24.dp), contentAlignment = Alignment.Center) {
@@ -3,8 +3,8 @@ package net.helcel.cowspent.android.statistics
import android.app.DatePickerDialog import android.app.DatePickerDialog
import android.content.Context import android.content.Context
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.verticalScroll
import androidx.compose.material.* import androidx.compose.material.*
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
@@ -285,9 +285,18 @@ fun ProjectStatisticsTable(
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
LazyColumn(modifier = Modifier.weight(1f)) { Column(
items(stats.memberStats) { m -> modifier = Modifier
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp, horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically) { .weight(1f)
.verticalScroll(rememberScrollState())
) {
stats.memberStats.forEach { m ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(m.name, modifier = Modifier.weight(2f), color = MaterialTheme.colors.onSurface, fontWeight = FontWeight.Medium) Text(m.name, modifier = Modifier.weight(2f), color = MaterialTheme.colors.onSurface, fontWeight = FontWeight.Medium)
Text( Text(
@@ -391,8 +391,8 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
fun getActivatedMembersOfProject(projId: Long): List<DBMember> { fun getActivatedMembersOfProject(projId: Long): List<DBMember> {
return getMembersCustom( return getMembersCustom(
"$key_projectid = ? AND $key_activated = 1", "$key_projectid = ? AND $key_activated = 1 AND $key_state != ?",
arrayOf(projId.toString()), arrayOf(projId.toString(), DBBill.STATE_DELETED.toString()),
"$key_name ASC" "$key_name ASC"
) )
} }
@@ -571,70 +571,54 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
fun searchBills(query: CharSequence?, projectId: Long): List<DBBill> { fun searchBills(query: CharSequence?, projectId: Long): List<DBBill> {
val andWhere: MutableList<String> = ArrayList() val andWhere: MutableList<String> = ArrayList()
val args: MutableList<String> = ArrayList() val args: MutableList<String> = ArrayList()
andWhere.add("($key_projectid = $projectId)") andWhere.add("($key_projectid = ?)")
args.add(projectId.toString())
andWhere.add("($key_state != ${DBBill.STATE_DELETED})") andWhere.add("($key_state != ${DBBill.STATE_DELETED})")
if (query != null) {
args.add("%$query%") val terms = query?.toString()?.split("\\s+".toRegex())?.filter { it.isNotEmpty() }.orEmpty()
var whereStr = "($key_what LIKE ?" if (terms.isNotEmpty()) {
if (SupportUtil.isDouble(query.toString())) { val memberIdsByName = getMembersOfProject(projectId, null)
whereStr += " OR ($key_amount <= (? + 10) AND $key_amount >= (? - 10))" .associateBy({ it.name.lowercase(Locale.ROOT) }, { it.id })
args.add(query.toString()) // Every clause appends its arguments as it is built, so args stay in the same order
args.add(query.toString()) // as the placeholders they bind to.
for (term in terms) {
andWhere.add(memberClause(term, memberIdsByName, args) ?: textClause(term, args))
} }
val members = getMembersOfProject(projectId, null)
val memberNames: MutableList<String> = ArrayList()
val memberIds: MutableList<Long> = ArrayList()
for (m in members) {
memberNames.add(m.name.lowercase(Locale.ROOT))
memberIds.add(m.id)
}
val queryStr = query.toString()
val words = queryStr.split("\\s+".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
var nameSql = ""
for (word in words) {
if (word.startsWith("+")) {
val nameQuery = word.replace("^\\+".toRegex(), "")
val memberIndex = memberNames.indexOf(nameQuery.lowercase(Locale.ROOT))
if (memberIndex != -1) {
val searchMemberId = memberIds[memberIndex]
nameSql += "($key_payer_id=?) AND "
args.add(searchMemberId.toString())
}
}
if (word.startsWith("-")) {
val nameQuery = word.replace("^-".toRegex(), "")
val memberIndex = memberNames.indexOf(nameQuery.lowercase(Locale.ROOT))
if (memberIndex != -1) {
val searchMemberId = memberIds[memberIndex]
val joinOwer = "select $table_bills.$key_id from $table_bills inner join $table_billowers " +
"where $key_member_id=? and $table_bills.$key_id=$table_billowers.$key_billId"
nameSql += "($key_id IN ($joinOwer)) AND "
args.add(searchMemberId.toString())
}
}
if (word.startsWith("@")) {
val nameQuery = word.replace("^@".toRegex(), "")
val memberIndex = memberNames.indexOf(nameQuery.lowercase(Locale.ROOT))
if (memberIndex != -1) {
val searchMemberId = memberIds[memberIndex]
nameSql += "( ($key_payer_id=?) OR "
args.add(searchMemberId.toString())
val joinOwer = "select $table_bills.$key_id from $table_bills inner join $table_billowers " +
"where $key_member_id=? and $table_bills.$key_id=$table_billowers.$key_billId"
nameSql += "($key_id IN ($joinOwer)) ) AND "
args.add(searchMemberId.toString())
}
}
}
if (nameSql != "") {
nameSql = nameSql.replace(" AND $".toRegex(), "")
whereStr += " OR ($nameSql)"
}
whereStr += ")"
andWhere.add(whereStr)
} }
val order = "$key_timestamp DESC" return getBillsCustom(TextUtils.join(" AND ", andWhere), args.toTypedArray(), "$key_timestamp DESC")
return getBillsCustom(TextUtils.join(" AND ", andWhere), args.toTypedArray(), order) }
/** Clause for a `+name`/`-name`/`@name` term, or null when the term names no member. */
private fun memberClause(term: String, memberIdsByName: Map<String, Long>, args: MutableList<String>): String? {
val prefix = term.first()
if (prefix != '+' && prefix != '-' && prefix != '@') return null
val memberId = memberIdsByName[term.substring(1).lowercase(Locale.ROOT)] ?: return null
val owedByMember = "SELECT $table_bills.$key_id FROM $table_bills INNER JOIN $table_billowers " +
"WHERE $key_member_id = ? AND $table_bills.$key_id = $table_billowers.$key_billId"
args.add(memberId.toString())
return when (prefix) {
'+' -> "($key_payer_id = ?)"
'-' -> "($key_id IN ($owedByMember))"
else -> {
args.add(memberId.toString())
"(($key_payer_id = ?) OR ($key_id IN ($owedByMember)))"
}
}
}
/** Clause matching a free text term against the description, the comment and the amount. */
private fun textClause(term: String, args: MutableList<String>): String {
val needle = "%" + term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
args.add(needle)
args.add(needle)
var clause = "(($key_what LIKE ? ESCAPE '\\') OR ($key_comment LIKE ? ESCAPE '\\')"
if (SupportUtil.isDouble(term)) {
clause += " OR ($key_amount <= (? + $AMOUNT_SEARCH_TOLERANCE) AND " +
"$key_amount >= (? - $AMOUNT_SEARCH_TOLERANCE))"
args.add(term)
args.add(term)
}
return "$clause)"
} }
@WorkerThread @WorkerThread
@@ -1241,6 +1225,9 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
) )
private const val default_order = "$key_id DESC" private const val default_order = "$key_id DESC"
/** Half width of the window a numeric search term matches, in project currency. */
private const val AMOUNT_SEARCH_TOLERANCE = 10
@Volatile @Volatile
private var instance: CowspentSQLiteOpenHelper? = null private var instance: CowspentSQLiteOpenHelper? = null
@@ -91,14 +91,38 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
} }
fun addCallbackPull(callback: ICallback) { fun addCallbackPull(callback: ICallback) {
callbacksPull.add(callback) // Callers register on every resume but the list is only drained when a task actually
// starts, so refuse duplicates rather than letting the same callback pile up.
if (!callbacksPull.contains(callback)) {
callbacksPull.add(callback)
}
} }
fun scheduleSync(onlyLocalChanges: Boolean, projId: Long, forceFullSync: Boolean = false): SyncTask? { fun removeCallbackPull(callback: ICallback) {
callbacksPull.remove(callback)
}
fun scheduleSync(onlyLocalChanges: Boolean, projId: Long, forceFullSync: Boolean = false): SyncTask? =
scheduleSync(onlyLocalChanges, projId, forceFullSync) { dbHelper.getProject(projId) }
/**
* Overload for callers that already hold the project. Resolving one by id costs a query plus
* a blocking DataStore read and an AEAD decrypt in getProjectFromCursor, which adds up when
* scheduling a sync for every project at app open.
*/
fun scheduleSync(onlyLocalChanges: Boolean, project: DBProject, forceFullSync: Boolean = false): SyncTask? =
scheduleSync(onlyLocalChanges, project.id, forceFullSync) { project }
private fun scheduleSync(
onlyLocalChanges: Boolean,
projId: Long,
forceFullSync: Boolean,
resolveProject: () -> DBProject?
): SyncTask? {
Log.d(TAG, "Sync requested (${if (onlyLocalChanges) "onlyLocalChanges" else "full"}; ${if (syncActive) "sync active" else "sync NOT active"}; forceFullSync=$forceFullSync) ...") Log.d(TAG, "Sync requested (${if (onlyLocalChanges) "onlyLocalChanges" else "full"}; ${if (syncActive) "sync active" else "sync NOT active"}; forceFullSync=$forceFullSync) ...")
updateNetworkStatus() updateNetworkStatus()
if (isSyncPossible && (!syncActive || onlyLocalChanges)) { if (isSyncPossible && (!syncActive || onlyLocalChanges)) {
val project = dbHelper.getProject(projId) val project = resolveProject()
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)
@@ -1031,12 +1055,17 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
// Pass current project values if the new ones are null to ensure a complete project object is sent to the server // Pass current project values if the new ones are null to ensure a complete project object is sent to the server
val currentProj = project!! val currentProj = project!!
val finalName = (newName ?: currentProj.name).let { if (it.isBlank() || it == "null") currentProj.remoteId else it } val finalName = (newName ?: currentProj.name).let { if (it.isBlank() || it == "null") currentProj.remoteId else it }
// Stay null when the project has no main currency, so the PUT omits currencyName
// instead of silently setting the server-side currency.
val finalCurrency = (newMainCurrencyName ?: currentProj.currencyName)
?.takeUnless { it.isBlank() || it == "null" }
val response = client!!.editRemoteProject( val response = client!!.editRemoteProject(
currentProj, currentProj,
finalName, finalName,
newEmail ?: currentProj.email, newEmail ?: currentProj.email,
newPassword, newPassword,
newMainCurrencyName ?: currentProj.currencyName, finalCurrency,
newArchivedTs newArchivedTs
) )
if (BillsListViewActivity.DEBUG) { if (BillsListViewActivity.DEBUG) {
@@ -1427,6 +1456,11 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
} }
private fun onPostExecute(status: LoginStatus) { private fun onPostExecute(status: LoginStatus) {
if (status == LoginStatus.OK) {
preferences.edit {
putLong(appContext.getString(R.string.pref_key_last_account_sync_timestamp), System.currentTimeMillis())
}
}
if (status != LoginStatus.OK) { if (status != LoginStatus.OK) {
var errorString = appContext.getString(R.string.error_sync, appContext.getString(status.str)) + "\n\n" var errorString = appContext.getString(R.string.error_sync, appContext.getString(status.str)) + "\n\n"
for (errorMessage in errorMessages) { for (errorMessage in errorMessages) {
@@ -164,6 +164,7 @@ class VersatileProjectSyncClient(
} }
if (newMainCurrencyName != null) { if (newMainCurrencyName != null) {
paramKeys.add("currencyName") paramKeys.add("currencyName")
paramValues.add(newMainCurrencyName)
} }
if (newArchivedTs != null) { if (newArchivedTs != null) {
paramKeys.add("archivedTs") paramKeys.add("archivedTs")
@@ -1527,5 +1528,8 @@ class VersatileProjectSyncClient(
const val METHOD_POST = "POST" const val METHOD_POST = "POST"
const val METHOD_PUT = "PUT" const val METHOD_PUT = "PUT"
const val METHOD_DELETE = "DELETE" const val METHOD_DELETE = "DELETE"
const val REMOTE_ARCHIVED_TS_NOW = 0L
const val REMOTE_ARCHIVED_TS_UNSET = -1L
} }
} }
+11
View File
@@ -10,6 +10,9 @@
<string name="action_edit">Edit</string> <string name="action_edit">Edit</string>
<string name="action_share">Share</string> <string name="action_share">Share</string>
<string name="action_search">Search</string> <string name="action_search">Search</string>
<string name="action_open_menu">Open menu</string>
<string name="action_close_search">Close search</string>
<string name="action_clear_search">Clear search</string>
<string name="action_delete">Delete</string> <string name="action_delete">Delete</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>
@@ -139,6 +142,12 @@
<string name="settings_beta_features_summary">Enable experimental features. Use at your own risk.</string> <string name="settings_beta_features_summary">Enable experimental features. Use at your own risk.</string>
<string name="settings_fill_new_bill_from_last">Auto-fill from last bill</string> <string name="settings_fill_new_bill_from_last">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_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>
<string name="settings_colorpicker_title">Choose Color</string> <string name="settings_colorpicker_title">Choose Color</string>
@@ -160,6 +169,8 @@
<string name="pref_key_offline_mode" translatable="false">offlineMode</string> <string name="pref_key_offline_mode" translatable="false">offlineMode</string>
<string name="pref_key_show_archived" translatable="false">showArchived</string> <string name="pref_key_show_archived" translatable="false">showArchived</string>
<string name="pref_key_beta_features" translatable="false">betaFeatures</string> <string name="pref_key_beta_features" translatable="false">betaFeatures</string>
<string name="pref_key_auto_sync_on_open" translatable="false">autoSyncOnOpen</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>
<string name="pref_value_night_mode_yes" translatable="false">2</string> <string name="pref_value_night_mode_yes" translatable="false">2</string>
+1 -1
View File
@@ -19,7 +19,7 @@ org.gradle.dependency.verification.console=verbose
# When configured, Gradle will run in incubating parallel mode. # When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit # This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true org.gradle.parallel=true
android.useAndroidX=true android.useAndroidX=true
android.nonTransitiveRClass=false android.nonTransitiveRClass=false
org.gradle.warning.mode=all org.gradle.warning.mode=all