5 Commits
Author SHA1 Message Date
sora a4ec8b914e Sync improvements 2026-09-04 02:52:56 +02:00
sora 67ecbbc53c Sync improvements 2026-09-04 02:41:15 +02:00
sora 2fca158549 Refactor 2026-09-04 02:17:56 +02:00
sora 7b03955595 Sync improvements 2026-09-03 23:48:41 +02:00
sora dc469ee576 Archiving sync fix 2026-09-03 22:00:27 +02:00
18 changed files with 154 additions and 354 deletions
-15
View File
@@ -76,18 +76,6 @@ android {
testOptions {
unitTests {
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.*"]
}
}
}
}
@@ -150,13 +138,10 @@ dependencies {
testImplementation 'org.robolectric:robolectric:4.16.1'
testImplementation 'androidx.test:core:1.7.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.espresso:espresso-core:3.7.0'
androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
androidTestImplementation 'io.mockk:mockk-android:1.14.11'
debugImplementation 'androidx.compose.ui:ui-test-manifest'
}
@@ -48,7 +48,7 @@ fun AboutScreen(
title = { Text(stringResource(R.string.title_about)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
},
backgroundColor = MaterialTheme.colors.primary,
@@ -331,7 +331,7 @@ class EditBillActivity : AppCompatActivity() {
bill.amount == viewModel.getFinalAmount() &&
bill.payerId == viewModel.payerId &&
bill.comment == viewModel.getFinalComment() &&
(bill.repeat ?: DBBill.NON_REPEATED) == viewModel.repeat &&
bill.repeat == viewModel.repeat &&
bill.categoryId == viewModel.categoryId &&
bill.paymentModeId == viewModel.paymentModeId &&
!owersChanged)
@@ -15,7 +15,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@@ -224,10 +223,7 @@ fun BillSummaryCard(bill: DBBill, members: List<DBMember>, remainingCount: Int)
fun CategoryButton(icon: String, name: String, onClick: () -> Unit) {
OutlinedButton(
onClick = onClick,
modifier = Modifier
.fillMaxWidth()
.height(60.dp)
.testTag("CategoryButton_$name"),
modifier = Modifier.fillMaxWidth().height(60.dp),
contentPadding = PaddingValues(2.dp)
) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
@@ -43,7 +43,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
@@ -219,7 +218,7 @@ fun ManageCurrenciesScreen(
) {
Icon(
imageVector = if (isEditing) Icons.Default.Done else Icons.Default.Add,
contentDescription = if (isEditing) "Done" else "Add",
contentDescription = null,
tint = if (viewModel.isAddEnabled()) {
if (isEditing) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
} else MaterialTheme.colors.onSurface.copy(alpha = 0.2f)
@@ -277,9 +276,7 @@ fun CurrencyRow(
shape = RoundedCornerShape(12.dp),
elevation = if (isEditing) 4.dp else 1.dp,
border = if (isEditing) BorderStroke(1.dp, MaterialTheme.colors.secondary.copy(alpha = 0.5f)) else null,
modifier = Modifier
.fillMaxWidth()
.testTag("CurrencyRow_${currency.name}")
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
@@ -325,7 +322,7 @@ fun CurrencyRow(
IconButton(onClick = onDelete) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Delete",
contentDescription = null,
tint = MaterialTheme.colors.error.copy(alpha = 0.7f),
modifier = Modifier.size(20.dp)
)
@@ -107,7 +107,7 @@ fun LabelManagementScreenContent(
showEditDialog = true
}
}) {
Icon(Icons.Default.Add, contentDescription = "Add Label")
Icon(Icons.Default.Add, contentDescription = null)
}
}
) { padding ->
@@ -293,7 +293,7 @@ fun BillsListScreen(
isSearchExpanded = false
viewModel.searchQuery = ""
}) {
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.action_clear_search), tint = MaterialTheme.colors.onPrimary)
Icon(Icons.Default.Close, contentDescription = null, tint = MaterialTheme.colors.onPrimary)
}
}
)
@@ -322,13 +322,13 @@ fun BillsListScreen(
isSearchExpanded = false
viewModel.searchQuery = ""
}) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_close_search))
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
} else {
IconButton(onClick = {
scope.launch { scaffoldState.drawerState.open() }
}) {
Icon(Icons.Default.Menu, contentDescription = stringResource(R.string.action_open_menu))
Icon(Icons.Default.Menu, contentDescription = null)
}
}
},
@@ -343,10 +343,7 @@ fun BillsListScreen(
}
}
IconButton(onClick = { isSearchExpanded = true }) {
Icon(
Icons.Default.Search,
contentDescription = stringResource(R.string.action_search)
)
Icon(Icons.Default.Search, contentDescription = null)
}
}
},
@@ -18,7 +18,6 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.annotation.VisibleForTesting
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.compose.material.icons.Icons
@@ -54,9 +53,7 @@ import net.helcel.cowspent.util.CospendClientUtil
import net.helcel.cowspent.util.ExportUtil
import net.helcel.cowspent.util.ICallback
import net.helcel.cowspent.util.IRefreshBillsListCallback
import net.helcel.cowspent.util.SyncSettings
import net.helcel.cowspent.util.SupportUtil
import net.helcel.cowspent.util.VersatileProjectSyncClient
import java.io.IOException
import java.text.SimpleDateFormat
import java.util.Calendar
@@ -73,9 +70,6 @@ class BillsListViewActivity :
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_OPEN = "navigationOpen"
@@ -282,7 +276,7 @@ class BillsListViewActivity :
labelBillsLauncher.launch(LabelBillsActivity.createIntent(this, selectedProjectId))
}
},
onRefresh = { synchronize(SyncTrigger.MANUAL) }
onRefresh = { synchronize(true) }
)
}
}
@@ -336,7 +330,10 @@ class BillsListViewActivity :
}
viewModel.isRefreshing = false
synchronize(SyncTrigger.APP_OPEN)
if (db.cowspentServerSyncHelper.isSyncPossible) {
db.cowspentServerSyncHelper.addCallbackPull(syncCallBack)
synchronize()
}
registerBroadcastReceiver()
updateAvatarInDrawer(CowspentServerSyncHelper.isNextcloudAccountConfigured(this))
@@ -350,9 +347,6 @@ class BillsListViewActivity :
} catch (_: RuntimeException) {
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
}
@@ -379,7 +373,7 @@ class BillsListViewActivity :
navigationSelection = Category(null, null)
refreshLists(true)
synchronize(SyncTrigger.PROJECT_OPEN)
synchronize()
}
fun onManageProjectClick(projectId: Long) {
@@ -412,7 +406,7 @@ class BillsListViewActivity :
}
setupDrawerProjects()
refreshLists()
synchronize(SyncTrigger.PROJECT_OPEN)
synchronize()
val projectNameString = proj.name.ifEmpty { proj.remoteId }
showToast(this@BillsListViewActivity, getString(R.string.remove_project_confirmation, projectNameString))
}
@@ -428,24 +422,19 @@ class BillsListViewActivity :
val proj = withContext(Dispatchers.IO) { db.getProject(projectId) } ?: return@launch
val isArchiving = !proj.isArchived
val localArchivedTs = if (isArchiving) System.currentTimeMillis() / 1000 else 0L
val remoteArchivedTs = if (isArchiving) {
localArchivedTs
} else {
VersatileProjectSyncClient.REMOTE_ARCHIVED_TS_UNSET
}
val newArchivedTs = if (isArchiving) System.currentTimeMillis() / 1000 else 0L
withContext(Dispatchers.IO) {
db.updateProject(
projId = projectId,
newArchivedTs = localArchivedTs
newArchivedTs = newArchivedTs
)
}
if (!proj.isLocal) {
db.cowspentServerSyncHelper.editRemoteProject(
projId = projectId,
newArchivedTs = remoteArchivedTs,
newArchivedTs = newArchivedTs,
callback = object : ICallback {
override fun onFinish() {}
override fun onFinish(result: String, message: String) {
@@ -757,60 +746,42 @@ class BillsListViewActivity :
}
}
/**
* 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) {
private fun synchronize(manual: Boolean = false) {
val preferences = PreferenceManager.getDefaultSharedPreferences(applicationContext)
// isSyncPossible is networkConnected && !offlineMode, so offline mode is already covered
// here - including for a manual refresh, which cannot currently override it.
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 }
val offlineMode = preferences.getBoolean(getString(R.string.pref_key_offline_mode), false)
if (offlineMode && !manual) {
return
}
if (db.cowspentServerSyncHelper.isSyncPossible) {
viewModel.isRefreshing = true
db.cowspentServerSyncHelper.addCallbackPull(syncCallBack)
val selectedProjectId = preferences.getLong("selected_project", 0)
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)) {
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
}
}
@@ -853,7 +824,7 @@ class BillsListViewActivity :
refreshLists()
}
MainConstants.BROADCAST_SYNC_PROJECT -> {
synchronize(SyncTrigger.PROJECT_OPEN)
synchronize()
}
MainConstants.BROADCAST_NETWORK_AVAILABLE -> {
}
@@ -881,7 +852,7 @@ class BillsListViewActivity :
refreshLists()
if (db.cowspentServerSyncHelper.isSyncPossible) {
db.cowspentServerSyncHelper.addCallbackPull(syncCallBack)
synchronize(SyncTrigger.PROJECT_OPEN)
synchronize()
}
}
}
@@ -3,8 +3,8 @@ package net.helcel.cowspent.android.project.edit
import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
@@ -13,11 +13,12 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.helcel.cowspent.R
import net.helcel.cowspent.android.helper.showToast
import net.helcel.cowspent.android.main.MainConstants
import net.helcel.cowspent.model.DBProject
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.theme.ThemeUtils
import net.helcel.cowspent.util.ICallback
import net.helcel.cowspent.util.SupportUtil
import net.helcel.cowspent.android.main.MainConstants
class EditProjectActivity : AppCompatActivity() {
@@ -55,33 +56,29 @@ class EditProjectActivity : AppCompatActivity() {
}
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 newPwd = viewModel.newPassword
val newName = viewModel.name
val newEmail = viewModel.email
val nameChanged = changes.name
val emailChanged = changes.email
val pwdChanged = changes.newPassword
val currentPwdChanged = changes.currentPassword
if (newName.isEmpty()) {
showToast(this, getString(R.string.error_invalid_project_name), Toast.LENGTH_LONG)
return
}
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) {
val targetPwd = if (pwdChanged) newPwd else currentPwd
@@ -7,7 +7,6 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.lifecycle.ViewModel
import net.helcel.cowspent.android.helper.DialogState
import net.helcel.cowspent.model.DBProject
import net.helcel.cowspent.util.SupportUtil
class EditProjectViewModel : ViewModel() {
var name by mutableStateOf("")
@@ -18,31 +17,6 @@ class EditProjectViewModel : ViewModel() {
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(
title: String? = null,
message: String? = null,
@@ -12,7 +12,6 @@ import androidx.compose.material.icons.Icons
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
@@ -101,7 +100,6 @@ fun MemberEditDialogContent(
.fillMaxWidth()
.clickable { isActivated = !isActivated }
.padding(vertical = 8.dp)
.testTag("member_activated_row")
) {
Icon(
imageVector = Icons.Default.Block,
@@ -20,7 +20,6 @@ import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.RadioButton
import androidx.compose.material.Scaffold
import androidx.compose.material.Slider
import androidx.compose.material.Switch
import androidx.compose.material.SwitchDefaults
import androidx.compose.material.Text
@@ -50,7 +49,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kotlin.math.roundToInt
import androidx.core.content.edit
import androidx.preference.PreferenceManager
import net.helcel.cowspent.R
@@ -59,7 +57,6 @@ import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.persistence.CowspentServerSyncHelper
import net.helcel.cowspent.util.ColorUtils
import net.helcel.cowspent.util.Cowspent
import net.helcel.cowspent.util.SyncSettings
@Composable
fun SettingsScreen(
@@ -84,9 +81,7 @@ fun SettingsScreen(
val keyOfflineMode = stringResource(R.string.pref_key_offline_mode)
val keyShowArchived = stringResource(R.string.pref_key_show_archived)
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 keyLastAccountSync = stringResource(R.string.pref_key_last_account_sync_timestamp)
val isNextcloudConfigured = CowspentServerSyncHelper.isNextcloudAccountConfigured(context)
@@ -133,25 +128,13 @@ fun SettingsScreen(
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(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.action_settings)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
},
backgroundColor = MaterialTheme.colors.primary,
@@ -303,24 +286,6 @@ 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(
@@ -436,7 +401,13 @@ fun SettingsListPreference(
),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(selected = (key == value), onClick = null)
RadioButton(
selected = (key == value),
onClick = {
onValueChange(key)
showDialog = false
}
)
Spacer(Modifier.width(8.dp))
Text(text = label)
}
@@ -499,47 +470,6 @@ 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
fun SettingsIcon(icon: Any?) {
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.content.Context
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
@@ -285,18 +285,9 @@ fun ProjectStatisticsTable(
Spacer(modifier = Modifier.height(4.dp))
Column(
modifier = Modifier
.weight(1f)
.verticalScroll(rememberScrollState())
) {
stats.memberStats.forEach { m ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
LazyColumn(modifier = Modifier.weight(1f)) {
items(stats.memberStats) { 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(
@@ -391,8 +391,8 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
fun getActivatedMembersOfProject(projId: Long): List<DBMember> {
return getMembersCustom(
"$key_projectid = ? AND $key_activated = 1 AND $key_state != ?",
arrayOf(projId.toString(), DBBill.STATE_DELETED.toString()),
"$key_projectid = ? AND $key_activated = 1",
arrayOf(projId.toString()),
"$key_name ASC"
)
}
@@ -571,54 +571,70 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
fun searchBills(query: CharSequence?, projectId: Long): List<DBBill> {
val andWhere: MutableList<String> = ArrayList()
val args: MutableList<String> = ArrayList()
andWhere.add("($key_projectid = ?)")
args.add(projectId.toString())
andWhere.add("($key_projectid = $projectId)")
andWhere.add("($key_state != ${DBBill.STATE_DELETED})")
val terms = query?.toString()?.split("\\s+".toRegex())?.filter { it.isNotEmpty() }.orEmpty()
if (terms.isNotEmpty()) {
val memberIdsByName = getMembersOfProject(projectId, null)
.associateBy({ it.name.lowercase(Locale.ROOT) }, { it.id })
// Every clause appends its arguments as it is built, so args stay in the same order
// as the placeholders they bind to.
for (term in terms) {
andWhere.add(memberClause(term, memberIdsByName, args) ?: textClause(term, args))
if (query != null) {
args.add("%$query%")
var whereStr = "($key_what LIKE ?"
if (SupportUtil.isDouble(query.toString())) {
whereStr += " OR ($key_amount <= (? + 10) AND $key_amount >= (? - 10))"
args.add(query.toString())
args.add(query.toString())
}
}
return getBillsCustom(TextUtils.join(" AND ", andWhere), args.toTypedArray(), "$key_timestamp DESC")
}
/** 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)))"
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)
}
}
/** 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)"
val order = "$key_timestamp DESC"
return getBillsCustom(TextUtils.join(" AND ", andWhere), args.toTypedArray(), order)
}
@WorkerThread
@@ -1225,9 +1241,6 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
)
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
private var instance: CowspentSQLiteOpenHelper? = null
@@ -91,38 +91,14 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
}
fun addCallbackPull(callback: ICallback) {
// 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)
}
callbacksPull.add(callback)
}
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? {
fun scheduleSync(onlyLocalChanges: Boolean, projId: Long, forceFullSync: Boolean = false): SyncTask? {
Log.d(TAG, "Sync requested (${if (onlyLocalChanges) "onlyLocalChanges" else "full"}; ${if (syncActive) "sync active" else "sync NOT active"}; forceFullSync=$forceFullSync) ...")
updateNetworkStatus()
if (isSyncPossible && (!syncActive || onlyLocalChanges)) {
val project = resolveProject()
val project = dbHelper.getProject(projId)
if (project != null) {
Log.d(TAG, "... starting now")
val syncTask = SyncTask(onlyLocalChanges, project, forceFullSync)
@@ -1055,17 +1031,12 @@ 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
val currentProj = project!!
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(
currentProj,
finalName,
newEmail ?: currentProj.email,
newPassword,
finalCurrency,
newMainCurrencyName ?: currentProj.currencyName,
newArchivedTs
)
if (BillsListViewActivity.DEBUG) {
@@ -1456,11 +1427,6 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
}
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) {
var errorString = appContext.getString(R.string.error_sync, appContext.getString(status.str)) + "\n\n"
for (errorMessage in errorMessages) {
@@ -164,7 +164,6 @@ class VersatileProjectSyncClient(
}
if (newMainCurrencyName != null) {
paramKeys.add("currencyName")
paramValues.add(newMainCurrencyName)
}
if (newArchivedTs != null) {
paramKeys.add("archivedTs")
@@ -1528,8 +1527,5 @@ class VersatileProjectSyncClient(
const val METHOD_POST = "POST"
const val METHOD_PUT = "PUT"
const val METHOD_DELETE = "DELETE"
const val REMOTE_ARCHIVED_TS_NOW = 0L
const val REMOTE_ARCHIVED_TS_UNSET = -1L
}
}
-11
View File
@@ -10,9 +10,6 @@
<string name="action_edit">Edit</string>
<string name="action_share">Share</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_archive">Archive</string>
<string name="action_unarchive">Unarchive</string>
@@ -142,12 +139,6 @@
<string name="settings_beta_features_summary">Enable experimental features. Use at your own risk.</string>
<string name="settings_fill_new_bill_from_last">Auto-fill from last bill</string>
<string name="settings_fill_new_bill_from_last_summary">Pre-fill payer, category, mode and owers from the last bill created in the project.</string>
<string name="settings_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_colorpicker_title">Choose Color</string>
@@ -169,8 +160,6 @@
<string name="pref_key_offline_mode" translatable="false">offlineMode</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_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_value_night_mode_no" translatable="false">1</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.
# 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
org.gradle.parallel=true
# org.gradle.parallel=true
android.useAndroidX=true
android.nonTransitiveRClass=false
org.gradle.warning.mode=all