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
27 changed files with 551 additions and 278 deletions
+22
View File
@@ -17,6 +17,8 @@ android {
targetSdk = 37 targetSdk = 37
versionName project.hasProperty('VERSION_NAME') ? project.property('VERSION_NAME') : "1.4" versionName project.hasProperty('VERSION_NAME') ? project.property('VERSION_NAME') : "1.4"
versionCode project.hasProperty('VERSION_CODE') ? project.property('VERSION_CODE').toInteger() : 1 versionCode project.hasProperty('VERSION_CODE') ? project.property('VERSION_CODE').toInteger() : 1
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
} }
signingConfigs { signingConfigs {
@@ -74,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.*"]
}
}
} }
} }
@@ -136,6 +150,14 @@ 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.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'
} }
tasks.register('jacocoTestReport', JacocoReport) { tasks.register('jacocoTestReport', JacocoReport) {
@@ -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,
@@ -48,7 +48,7 @@ import java.util.Locale
class AccountActivity : AppCompatActivity() { class AccountActivity : AppCompatActivity() {
private val viewModel: AccountViewModel by viewModels() internal val viewModel: AccountViewModel by viewModels()
companion object { companion object {
private val TAG = AccountActivity::class.java.simpleName private val TAG = AccountActivity::class.java.simpleName
@@ -34,7 +34,7 @@ import java.util.Calendar
class EditBillActivity : AppCompatActivity() { class EditBillActivity : AppCompatActivity() {
private val viewModel: EditBillViewModel by viewModels() internal val viewModel: EditBillViewModel by viewModels()
private lateinit var db: CowspentSQLiteOpenHelper private lateinit var db: CowspentSQLiteOpenHelper
private lateinit var bill: DBBill private lateinit var bill: DBBill
private var projectType: ProjectType = ProjectType.LOCAL private var projectType: ProjectType = ProjectType.LOCAL
@@ -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)
@@ -17,7 +17,7 @@ import net.helcel.cowspent.model.DBBill
import net.helcel.cowspent.model.ProjectType import net.helcel.cowspent.model.ProjectType
class LabelBillsActivity : AppCompatActivity() { class LabelBillsActivity : AppCompatActivity() {
private val viewModel: LabelBillsViewModel by viewModels() internal val viewModel: LabelBillsViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge() enableEdgeToEdge()
@@ -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) {
@@ -7,14 +7,8 @@ import androidx.activity.enableEdgeToEdge
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
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.model.DBBill
import net.helcel.cowspent.model.DBCurrency
import net.helcel.cowspent.model.ProjectType import net.helcel.cowspent.model.ProjectType
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.theme.ThemeUtils import net.helcel.cowspent.theme.ThemeUtils
@@ -23,9 +17,8 @@ import net.helcel.cowspent.util.ICallback
class ManageCurrenciesActivity : AppCompatActivity() { class ManageCurrenciesActivity : AppCompatActivity() {
private val viewModel: ManageCurrenciesViewModel by viewModels() internal val viewModel: ManageCurrenciesViewModel by viewModels()
private var db: CowspentSQLiteOpenHelper? = null private var db: CowspentSQLiteOpenHelper? = null
private var selectedProjectID: Long = -1
private val editMainCurrencyCallBack: ICallback = object : ICallback { private val editMainCurrencyCallBack: ICallback = object : ICallback {
override fun onFinish() {} override fun onFinish() {}
@@ -47,131 +40,45 @@ class ManageCurrenciesActivity : AppCompatActivity() {
enableEdgeToEdge() enableEdgeToEdge()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
intent.extras?.let { val selectedProjectID = intent.getLongExtra(EXTRA_PROJECT_ID, -1L)
selectedProjectID = it.getLong(EXTRA_PROJECT_ID)
}
if (selectedProjectID == -1L) { if (selectedProjectID == -1L) {
Log.e(TAG, "Missing project id") Log.e(TAG, "Missing project id")
finish() finish()
return return
} }
db = CowspentSQLiteOpenHelper.getInstance(this) viewModel.projectId = selectedProjectID
viewModel.loadCurrencies()
lifecycleScope.launch {
val project = withContext(Dispatchers.IO) { db!!.getProject(selectedProjectID) }
viewModel.mainCurrencyName = project?.currencyName?.let { if (it == "null") "" else it } ?: ""
updateCurrenciesList()
setContent { db = CowspentSQLiteOpenHelper.getInstance(this)
ThemeUtils.CowspentTheme {
ManageCurrenciesScreen( setContent {
viewModel = viewModel, ThemeUtils.CowspentTheme {
onBack = { finish() }, ManageCurrenciesScreen(
onSaveMain = { saveMainCurrency() }, viewModel = viewModel,
onAdd = { addOrUpdateCurrency() }, onBack = { finish() },
onDelete = { deleteCurrency(it) }, onSaveMain = { saveMainCurrency() },
onEdit = { startEditing(it) }, onAdd = { viewModel.addCurrency() },
onCancelEdit = { cancelEditing() } onDelete = { viewModel.deleteCurrency(it.id) },
) onEdit = { viewModel.startEditing(it) },
} onCancelEdit = { viewModel.cancelEditing() }
)
} }
} }
} }
private fun saveMainCurrency() { private fun saveMainCurrency() {
val newMainCurrencyName = viewModel.mainCurrencyName val project = db?.getProject(viewModel.projectId)
lifecycleScope.launch { if (project != null) {
withContext(Dispatchers.IO) { if (project.type == ProjectType.COSPEND) {
db!!.updateProject( if (!db!!.cowspentServerSyncHelper.isSyncPossible) {
projId = selectedProjectID, showToast(this, getString(R.string.remote_project_operation_no_network), Toast.LENGTH_LONG)
newCurrencyName = newMainCurrencyName
)
val project = db!!.getProject(selectedProjectID)
if (project != null) {
db!!.syncIfRemote(project)
if (project.type == ProjectType.COSPEND) {
withContext(Dispatchers.Main) {
if (!db!!.cowspentServerSyncHelper
.editRemoteProject(
projId = selectedProjectID,
newName = project.name,
newMainCurrencyName = newMainCurrencyName,
callback = editMainCurrencyCallBack
)
) {
showToast(this@ManageCurrenciesActivity, getString(R.string.remote_project_operation_no_network), Toast.LENGTH_LONG)
}
}
} else {
withContext(Dispatchers.Main) {
showToast(this@ManageCurrenciesActivity, getString(R.string.currency_saved_success), Toast.LENGTH_LONG)
}
}
} }
} else {
showToast(this, getString(R.string.currency_saved_success), Toast.LENGTH_LONG)
} }
} }
} viewModel.saveMainCurrency(editMainCurrencyCallBack)
private fun addOrUpdateCurrency() {
val uiRate = try { viewModel.newCurrencyRate.toDouble() } catch (_: Exception) { 0.0 }
val exchangeRate = if (uiRate != 0.0) 1.0 / uiRate else 0.0
val currencyName = viewModel.newCurrencyName
val editingId = viewModel.editingCurrencyId
lifecycleScope.launch {
withContext(Dispatchers.IO) {
if (editingId != null) {
db!!.updateCurrency(editingId, currencyName, exchangeRate)
val currency = db!!.getCurrency(editingId)
if (currency != null) {
db!!.setCurrencyStateSync(editingId, DBBill.STATE_EDITED)
}
} else {
val newCurrency = DBCurrency(
0, 0, selectedProjectID,
currencyName, exchangeRate, DBBill.STATE_ADDED
)
db!!.addCurrencyAndSync(newCurrency)
}
}
cancelEditing()
updateCurrenciesList()
}
}
private fun startEditing(currency: DBCurrency) {
viewModel.editingCurrencyId = currency.id
viewModel.newCurrencyName = currency.name ?: ""
val uiRate = if (currency.exchangeRate != 0.0) 1.0 / currency.exchangeRate else 0.0
viewModel.newCurrencyRate = uiRate.toString()
}
private fun cancelEditing() {
viewModel.editingCurrencyId = null
viewModel.newCurrencyName = ""
viewModel.newCurrencyRate = ""
}
private fun deleteCurrency(currency: DBCurrency) {
lifecycleScope.launch {
withContext(Dispatchers.IO) {
db!!.setCurrencyStateSync(currency.id, DBBill.STATE_DELETED)
}
updateCurrenciesList()
}
}
private suspend fun updateCurrenciesList() {
val currenciesDB = withContext(Dispatchers.IO) {
val list = db!!.getCurrenciesOfProjectWithState(selectedProjectID, DBBill.STATE_ADDED).toMutableList()
list.addAll(db!!.getCurrenciesOfProjectWithState(selectedProjectID, DBBill.STATE_EDITED))
list.addAll(db!!.getCurrenciesOfProjectWithState(selectedProjectID, DBBill.STATE_OK))
list
}
withContext(Dispatchers.Main) {
viewModel.currencies = currenciesDB
}
} }
override fun onSupportNavigateUp(): Boolean { override fun onSupportNavigateUp(): Boolean {
@@ -1,6 +1,7 @@
package net.helcel.cowspent.android.currencies package net.helcel.cowspent.android.currencies
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Application
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -41,6 +42,8 @@ import androidx.compose.runtime.LaunchedEffect
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.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
@@ -216,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)
@@ -274,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
@@ -320,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)
) )
@@ -347,9 +352,10 @@ fun CurrencyRowPreview() {
@Preview(showBackground = true) @Preview(showBackground = true)
@Composable @Composable
fun ManageCurrenciesScreenPreview() { fun ManageCurrenciesScreenPreview() {
val application = LocalContext.current.applicationContext as Application
MaterialTheme { MaterialTheme {
ManageCurrenciesScreen( ManageCurrenciesScreen(
viewModel = ManageCurrenciesViewModel().apply { viewModel = ManageCurrenciesViewModel(application).apply {
mainCurrencyName = "EUR" mainCurrencyName = "EUR"
currencies = listOf( currencies = listOf(
DBCurrency(1, 0, 0, "USD", 1.1, 0), DBCurrency(1, 0, 0, "USD", 1.1, 0),
@@ -1,14 +1,24 @@
package net.helcel.cowspent.android.currencies package net.helcel.cowspent.android.currencies
import android.app.Application
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.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.lifecycle.ViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.helcel.cowspent.android.helper.DialogState import net.helcel.cowspent.android.helper.DialogState
import net.helcel.cowspent.model.DBBill
import net.helcel.cowspent.model.DBCurrency import net.helcel.cowspent.model.DBCurrency
import net.helcel.cowspent.model.ProjectType
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.util.ICallback
class ManageCurrenciesViewModel : ViewModel() { class ManageCurrenciesViewModel(application: Application) : AndroidViewModel(application) {
var projectId: Long = -1
var mainCurrencyName by mutableStateOf("") var mainCurrencyName by mutableStateOf("")
var newCurrencyName by mutableStateOf("") var newCurrencyName by mutableStateOf("")
var newCurrencyRate by mutableStateOf("") var newCurrencyRate by mutableStateOf("")
@@ -19,6 +29,105 @@ class ManageCurrenciesViewModel : ViewModel() {
var dialogState by mutableStateOf<DialogState?>(null) var dialogState by mutableStateOf<DialogState?>(null)
private val db = CowspentSQLiteOpenHelper.getInstance(application)
fun loadCurrencies() {
if (projectId == -1L) return
viewModelScope.launch {
val project = withContext(Dispatchers.IO) { db.getProject(projectId) }
mainCurrencyName = project?.currencyName?.let { if (it == "null") "" else it } ?: ""
updateCurrenciesList()
}
}
suspend fun updateCurrenciesList() {
val currenciesDB = withContext(Dispatchers.IO) {
val list = db.getCurrenciesOfProjectWithState(projectId, DBBill.STATE_ADDED).toMutableList()
list.addAll(db.getCurrenciesOfProjectWithState(projectId, DBBill.STATE_EDITED))
list.addAll(db.getCurrenciesOfProjectWithState(projectId, DBBill.STATE_OK))
list
}
withContext(Dispatchers.Main) {
currencies = currenciesDB
}
}
fun saveMainCurrency(callback: ICallback) {
val newMainCurrencyName = mainCurrencyName
viewModelScope.launch {
withContext(Dispatchers.IO) {
db.updateProject(
projId = projectId,
newCurrencyName = newMainCurrencyName
)
val project = db.getProject(projectId)
if (project != null) {
db.syncIfRemote(project)
if (project.type == ProjectType.COSPEND) {
withContext(Dispatchers.Main) {
if (!db.cowspentServerSyncHelper
.editRemoteProject(
projId = projectId,
newName = project.name,
newMainCurrencyName = newMainCurrencyName,
callback = callback
)
) {
// Handled by activity showing toast
}
}
}
}
}
}
}
fun addCurrency() {
val uiRate = try { newCurrencyRate.toDouble() } catch (_: Exception) { 0.0 }
val exchangeRate = if (uiRate != 0.0) 1.0 / uiRate else 0.0
val currencyName = newCurrencyName
val editingId = editingCurrencyId
viewModelScope.launch {
withContext(Dispatchers.IO) {
if (editingId != null) {
db.updateCurrency(editingId, currencyName, exchangeRate)
db.setCurrencyStateSync(editingId, DBBill.STATE_EDITED)
} else {
val newCurrency = DBCurrency(
0, 0, projectId,
currencyName, exchangeRate, DBBill.STATE_ADDED
)
db.addCurrencyAndSync(newCurrency)
}
}
cancelEditing()
updateCurrenciesList()
}
}
fun deleteCurrency(currencyId: Long) {
viewModelScope.launch {
withContext(Dispatchers.IO) {
db.setCurrencyStateSync(currencyId, DBBill.STATE_DELETED)
}
updateCurrenciesList()
}
}
fun startEditing(currency: DBCurrency) {
editingCurrencyId = currency.id
newCurrencyName = currency.name ?: ""
val uiRate = if (currency.exchangeRate != 0.0) 1.0 / currency.exchangeRate else 0.0
newCurrencyRate = uiRate.toString()
}
fun cancelEditing() {
editingCurrencyId = null
newCurrencyName = ""
newCurrencyRate = ""
}
fun showDialog( fun showDialog(
title: String? = null, title: String? = null,
message: String? = null, message: String? = null,
@@ -10,7 +10,7 @@ import androidx.appcompat.app.AppCompatActivity
import net.helcel.cowspent.theme.ThemeUtils import net.helcel.cowspent.theme.ThemeUtils
class LabelManagementActivity : AppCompatActivity() { class LabelManagementActivity : AppCompatActivity() {
private val viewModel: LabelManagementViewModel by viewModels() internal val viewModel: LabelManagementViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge() enableEdgeToEdge()
@@ -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
@@ -63,13 +66,16 @@ class BillsListViewActivity :
AppCompatActivity(), AppCompatActivity(),
IRefreshBillsListCallback { IRefreshBillsListCallback {
private val viewModel: BillsListViewModel by viewModels() internal val viewModel: BillsListViewModel by viewModels()
companion object { companion object {
var DEBUG = false var DEBUG = false
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,28 +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 = PreferenceManager.getDefaultSharedPreferences(applicationContext).getLong("selected_project", 0) db.cowspentServerSyncHelper.addCallbackPull(syncCallBack)
if (selectedProjectId != 0L) {
lifecycleScope.launch { val started = if (accountSyncDue) {
val proj = withContext(Dispatchers.IO) { db.getProject(selectedProjectId) } if (CowspentServerSyncHelper.isNextcloudAccountConfigured(applicationContext)) {
if (proj != null && !proj.isLocal) { db.cowspentServerSyncHelper.runAccountProjectsSync()
db.cowspentServerSyncHelper.addCallbackPull(syncCallBack)
db.cowspentServerSyncHelper.scheduleSync(false, selectedProjectId, manual)
} else viewModel.isRefreshing = false
} }
} else viewModel.isRefreshing = false remoteProjects.count {
if (CowspentServerSyncHelper.isNextcloudAccountConfigured(applicationContext)) { db.cowspentServerSyncHelper.scheduleSync(false, it, false) != null
db.cowspentServerSyncHelper.runAccountProjectsSync() }
} 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
} }
} }
@@ -810,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 -> {
} }
@@ -838,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)
} }
} }
} }
@@ -31,7 +31,7 @@ import net.helcel.cowspent.android.project.ProjectImportHelper
class NewProjectActivity : AppCompatActivity() { class NewProjectActivity : AppCompatActivity() {
private val viewModel: NewProjectViewModel by viewModels() internal val viewModel: NewProjectViewModel by viewModels()
private lateinit var db: CowspentSQLiteOpenHelper private lateinit var db: CowspentSQLiteOpenHelper
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@@ -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,17 +13,16 @@ 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() {
private val viewModel: EditProjectViewModel by viewModels() internal val viewModel: EditProjectViewModel by viewModels()
private lateinit var db: CowspentSQLiteOpenHelper private lateinit var db: CowspentSQLiteOpenHelper
private lateinit var project: DBProject private lateinit var project: DBProject
@@ -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,
@@ -22,7 +22,7 @@ import net.helcel.cowspent.theme.ThemeUtils
class MemberManagementActivity : AppCompatActivity() { class MemberManagementActivity : AppCompatActivity() {
private val viewModel: MemberManagementViewModel by viewModels() internal val viewModel: MemberManagementViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge() enableEdgeToEdge()
@@ -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) {
@@ -47,7 +47,7 @@ class ProjectStatisticsActivity : AppCompatActivity() {
} }
companion object { companion object {
private const val EXTRA_PROJECT_ID = "extra_project_id" internal const val EXTRA_PROJECT_ID = "extra_project_id"
fun createIntent(context: Context, projectId: Long): Intent { fun createIntent(context: Context, projectId: Long): Intent {
return Intent(context, ProjectStatisticsActivity::class.java).apply { return Intent(context, ProjectStatisticsActivity::class.java).apply {
@@ -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(
@@ -7,6 +7,7 @@ import android.database.Cursor
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
import androidx.annotation.VisibleForTesting
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import net.helcel.cowspent.R import net.helcel.cowspent.R
@@ -390,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"
) )
} }
@@ -570,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
@@ -1240,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
@@ -1249,5 +1237,15 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
instance ?: CowspentSQLiteOpenHelper(context.applicationContext).also { instance = it } instance ?: CowspentSQLiteOpenHelper(context.applicationContext).also { instance = it }
} }
} }
@VisibleForTesting
fun setInstance(helper: CowspentSQLiteOpenHelper?) {
instance = helper
}
@VisibleForTesting
fun resetInstance() {
instance = null
}
} }
} }
@@ -8,6 +8,7 @@ import android.content.SharedPreferences
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.os.IBinder import android.os.IBinder
import android.util.Log import android.util.Log
import androidx.annotation.VisibleForTesting
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
@@ -90,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)
@@ -1027,8 +1052,21 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
} }
var status = LoginStatus.OK var status = LoginStatus.OK
try { try {
// 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( val response = client!!.editRemoteProject(
project!!, newName, newEmail, newPassword, newMainCurrencyName, newArchivedTs currentProj,
finalName,
newEmail ?: currentProj.email,
newPassword,
finalCurrency,
newArchivedTs
) )
if (BillsListViewActivity.DEBUG) { if (BillsListViewActivity.DEBUG) {
Log.i(TAG, "RESPONSE edit remote project : ${response.stringContent}") Log.i(TAG, "RESPONSE edit remote project : ${response.stringContent}")
@@ -1418,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) {
@@ -1673,6 +1716,12 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
return instance!! return instance!!
} }
@VisibleForTesting
fun resetInstance() {
instance = null
projectIdsToSync.clear()
}
fun isNextcloudAccountConfigured(context: Context): Boolean { fun isNextcloudAccountConfigured(context: Context): Boolean {
val preferences = PreferenceManager.getDefaultSharedPreferences(context) val preferences = PreferenceManager.getDefaultSharedPreferences(context)
return !preferences.getString(AccountActivity.SETTINGS_URL, AccountActivity.DEFAULT_SETTINGS).isNullOrEmpty() || return !preferences.getString(AccountActivity.SETTINGS_URL, AccountActivity.DEFAULT_SETTINGS).isNullOrEmpty() ||
@@ -86,7 +86,7 @@ class NextcloudClient(
} }
@Throws(TokenMismatchException::class, NextcloudHttpRequestFailedException::class) @Throws(TokenMismatchException::class, NextcloudHttpRequestFailedException::class)
private fun requestServerWithSSO( internal fun requestServerWithSSO(
nextcloudAPI: NextcloudAPI, nextcloudAPI: NextcloudAPI,
target: String, target: String,
method: String, method: String,
@@ -141,7 +141,7 @@ class NextcloudClient(
} }
@Throws(TokenMismatchException::class, NextcloudHttpRequestFailedException::class) @Throws(TokenMismatchException::class, NextcloudHttpRequestFailedException::class)
private fun imageRequestServerWithSSO( internal fun imageRequestServerWithSSO(
nextcloudAPI: NextcloudAPI, nextcloudAPI: NextcloudAPI,
target: String, target: String,
method: String, method: String,
@@ -182,7 +182,7 @@ class NextcloudClient(
} }
@Throws(IOException::class, NextcloudHttpRequestFailedException::class) @Throws(IOException::class, NextcloudHttpRequestFailedException::class)
private fun requestServer( internal fun requestServer(
target: String, target: String,
method: String, params: JSONObject?, lastETag: String?, needLogin: Boolean, isOCSRequest: Boolean method: String, params: JSONObject?, lastETag: String?, needLogin: Boolean, isOCSRequest: Boolean
): VersatileProjectSyncClient.ResponseData { ): VersatileProjectSyncClient.ResponseData {
@@ -248,7 +248,7 @@ class NextcloudClient(
} }
@Throws(IOException::class, NextcloudHttpRequestFailedException::class) @Throws(IOException::class, NextcloudHttpRequestFailedException::class)
private fun imageRequestServer( internal fun imageRequestServer(
target: String, target: String,
method: String, params: JSONObject?, lastETag: String?, needLogin: Boolean, isOCSRequest: Boolean method: String, params: JSONObject?, lastETag: String?, needLogin: Boolean, isOCSRequest: Boolean
): VersatileProjectSyncClient.ResponseData { ): VersatileProjectSyncClient.ResponseData {
@@ -158,10 +158,18 @@ class VersatileProjectSyncClient(
paramKeys.add("password") paramKeys.add("password")
paramValues.add(newPassword) paramValues.add(newPassword)
} }
if (newEmail != null) {
paramKeys.add("contact_email")
paramValues.add(newEmail)
}
if (newMainCurrencyName != null) { if (newMainCurrencyName != null) {
paramKeys.add("currencyName") paramKeys.add("currencyName")
paramValues.add(newMainCurrencyName) paramValues.add(newMainCurrencyName)
} }
if (newArchivedTs != null) {
paramKeys.add("archivedTs")
paramValues.add(newArchivedTs.toString())
}
} }
if (canAccessProjectWithNCLogin(project)) { if (canAccessProjectWithNCLogin(project)) {
username = this.username username = this.username
@@ -1520,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