2 Commits
Author SHA1 Message Date
sora 2fca158549 Refactor 2026-09-04 02:17:56 +02:00
sora 7b03955595 Sync improvements 2026-09-03 23:48:41 +02:00
15 changed files with 198 additions and 140 deletions
+7
View File
@@ -17,6 +17,8 @@ android {
targetSdk = 37
versionName project.hasProperty('VERSION_NAME') ? project.property('VERSION_NAME') : "1.4"
versionCode project.hasProperty('VERSION_CODE') ? project.property('VERSION_CODE').toInteger() : 1
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
signingConfigs {
@@ -136,6 +138,11 @@ 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'
androidTestImplementation 'androidx.test.ext:junit:1.3.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0'
androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
debugImplementation 'androidx.compose.ui:ui-test-manifest'
}
tasks.register('jacocoTestReport', JacocoReport) {
@@ -48,7 +48,7 @@ import java.util.Locale
class AccountActivity : AppCompatActivity() {
private val viewModel: AccountViewModel by viewModels()
internal val viewModel: AccountViewModel by viewModels()
companion object {
private val TAG = AccountActivity::class.java.simpleName
@@ -34,7 +34,7 @@ import java.util.Calendar
class EditBillActivity : AppCompatActivity() {
private val viewModel: EditBillViewModel by viewModels()
internal val viewModel: EditBillViewModel by viewModels()
private lateinit var db: CowspentSQLiteOpenHelper
private lateinit var bill: DBBill
private var projectType: ProjectType = ProjectType.LOCAL
@@ -17,7 +17,7 @@ import net.helcel.cowspent.model.DBBill
import net.helcel.cowspent.model.ProjectType
class LabelBillsActivity : AppCompatActivity() {
private val viewModel: LabelBillsViewModel by viewModels()
internal val viewModel: LabelBillsViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
@@ -7,14 +7,8 @@ import androidx.activity.enableEdgeToEdge
import androidx.activity.compose.setContent
import androidx.activity.viewModels
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.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.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.theme.ThemeUtils
@@ -23,9 +17,8 @@ import net.helcel.cowspent.util.ICallback
class ManageCurrenciesActivity : AppCompatActivity() {
private val viewModel: ManageCurrenciesViewModel by viewModels()
internal val viewModel: ManageCurrenciesViewModel by viewModels()
private var db: CowspentSQLiteOpenHelper? = null
private var selectedProjectID: Long = -1
private val editMainCurrencyCallBack: ICallback = object : ICallback {
override fun onFinish() {}
@@ -47,131 +40,45 @@ class ManageCurrenciesActivity : AppCompatActivity() {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
intent.extras?.let {
selectedProjectID = it.getLong(EXTRA_PROJECT_ID)
}
val selectedProjectID = intent.getLongExtra(EXTRA_PROJECT_ID, -1L)
if (selectedProjectID == -1L) {
Log.e(TAG, "Missing project id")
finish()
return
}
db = CowspentSQLiteOpenHelper.getInstance(this)
lifecycleScope.launch {
val project = withContext(Dispatchers.IO) { db!!.getProject(selectedProjectID) }
viewModel.mainCurrencyName = project?.currencyName?.let { if (it == "null") "" else it } ?: ""
updateCurrenciesList()
viewModel.projectId = selectedProjectID
viewModel.loadCurrencies()
setContent {
ThemeUtils.CowspentTheme {
ManageCurrenciesScreen(
viewModel = viewModel,
onBack = { finish() },
onSaveMain = { saveMainCurrency() },
onAdd = { addOrUpdateCurrency() },
onDelete = { deleteCurrency(it) },
onEdit = { startEditing(it) },
onCancelEdit = { cancelEditing() }
)
}
db = CowspentSQLiteOpenHelper.getInstance(this)
setContent {
ThemeUtils.CowspentTheme {
ManageCurrenciesScreen(
viewModel = viewModel,
onBack = { finish() },
onSaveMain = { saveMainCurrency() },
onAdd = { viewModel.addCurrency() },
onDelete = { viewModel.deleteCurrency(it.id) },
onEdit = { viewModel.startEditing(it) },
onCancelEdit = { viewModel.cancelEditing() }
)
}
}
}
private fun saveMainCurrency() {
val newMainCurrencyName = viewModel.mainCurrencyName
lifecycleScope.launch {
withContext(Dispatchers.IO) {
db!!.updateProject(
projId = selectedProjectID,
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)
}
}
val project = db?.getProject(viewModel.projectId)
if (project != null) {
if (project.type == ProjectType.COSPEND) {
if (!db!!.cowspentServerSyncHelper.isSyncPossible) {
showToast(this, getString(R.string.remote_project_operation_no_network), Toast.LENGTH_LONG)
}
} else {
showToast(this, getString(R.string.currency_saved_success), Toast.LENGTH_LONG)
}
}
}
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
}
viewModel.saveMainCurrency(editMainCurrencyCallBack)
}
override fun onSupportNavigateUp(): Boolean {
@@ -1,6 +1,7 @@
package net.helcel.cowspent.android.currencies
import android.annotation.SuppressLint
import android.app.Application
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -41,6 +42,7 @@ import androidx.compose.runtime.LaunchedEffect
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.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
@@ -347,9 +349,10 @@ fun CurrencyRowPreview() {
@Preview(showBackground = true)
@Composable
fun ManageCurrenciesScreenPreview() {
val application = LocalContext.current.applicationContext as Application
MaterialTheme {
ManageCurrenciesScreen(
viewModel = ManageCurrenciesViewModel().apply {
viewModel = ManageCurrenciesViewModel(application).apply {
mainCurrencyName = "EUR"
currencies = listOf(
DBCurrency(1, 0, 0, "USD", 1.1, 0),
@@ -1,14 +1,24 @@
package net.helcel.cowspent.android.currencies
import android.app.Application
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
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.model.DBBill
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 newCurrencyName by mutableStateOf("")
var newCurrencyRate by mutableStateOf("")
@@ -19,6 +29,105 @@ class ManageCurrenciesViewModel : ViewModel() {
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(
title: String? = null,
message: String? = null,
@@ -10,7 +10,7 @@ import androidx.appcompat.app.AppCompatActivity
import net.helcel.cowspent.theme.ThemeUtils
class LabelManagementActivity : AppCompatActivity() {
private val viewModel: LabelManagementViewModel by viewModels()
internal val viewModel: LabelManagementViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
@@ -63,7 +63,7 @@ class BillsListViewActivity :
AppCompatActivity(),
IRefreshBillsListCallback {
private val viewModel: BillsListViewModel by viewModels()
internal val viewModel: BillsListViewModel by viewModels()
companion object {
var DEBUG = false
@@ -754,19 +754,34 @@ class BillsListViewActivity :
}
if (db.cowspentServerSyncHelper.isSyncPossible) {
viewModel.isRefreshing = true
val selectedProjectId = PreferenceManager.getDefaultSharedPreferences(applicationContext).getLong("selected_project", 0)
if (selectedProjectId != 0L) {
lifecycleScope.launch {
val proj = withContext(Dispatchers.IO) { db.getProject(selectedProjectId) }
if (proj != null && !proj.isLocal) {
viewModel.isRefreshing = manual
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 viewModel.isRefreshing = false
} else if (manual) {
viewModel.isRefreshing = false
}
// Sync other projects (they will be queued)
remoteProjects.filter { it.id != selectedProjectId }.forEach {
db.cowspentServerSyncHelper.scheduleSync(false, it.id, manual)
}
} else {
viewModel.isRefreshing = false
}
if (CowspentServerSyncHelper.isNextcloudAccountConfigured(applicationContext)) {
db.cowspentServerSyncHelper.runAccountProjectsSync()
}
} else viewModel.isRefreshing = false
if (CowspentServerSyncHelper.isNextcloudAccountConfigured(applicationContext)) {
db.cowspentServerSyncHelper.runAccountProjectsSync()
}
}
}
@@ -31,7 +31,7 @@ import net.helcel.cowspent.android.project.ProjectImportHelper
class NewProjectActivity : AppCompatActivity() {
private val viewModel: NewProjectViewModel by viewModels()
internal val viewModel: NewProjectViewModel by viewModels()
private lateinit var db: CowspentSQLiteOpenHelper
override fun onCreate(savedInstanceState: Bundle?) {
@@ -23,7 +23,7 @@ import net.helcel.cowspent.android.main.MainConstants
class EditProjectActivity : AppCompatActivity() {
private val viewModel: EditProjectViewModel by viewModels()
internal val viewModel: EditProjectViewModel by viewModels()
private lateinit var db: CowspentSQLiteOpenHelper
private lateinit var project: DBProject
@@ -22,7 +22,7 @@ import net.helcel.cowspent.theme.ThemeUtils
class MemberManagementActivity : AppCompatActivity() {
private val viewModel: MemberManagementViewModel by viewModels()
internal val viewModel: MemberManagementViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
@@ -47,7 +47,7 @@ class ProjectStatisticsActivity : AppCompatActivity() {
}
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 {
return Intent(context, ProjectStatisticsActivity::class.java).apply {
@@ -7,6 +7,7 @@ import android.database.Cursor
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
import android.text.TextUtils
import androidx.annotation.VisibleForTesting
import androidx.annotation.WorkerThread
import androidx.preference.PreferenceManager
import net.helcel.cowspent.R
@@ -1243,11 +1244,20 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
@Volatile
private var instance: CowspentSQLiteOpenHelper? = null
@VisibleForTesting
var overrideInstance: CowspentSQLiteOpenHelper? = null
@JvmStatic
fun getInstance(context: Context): CowspentSQLiteOpenHelper {
overrideInstance?.let { return it }
return instance ?: synchronized(this) {
instance ?: CowspentSQLiteOpenHelper(context.applicationContext).also { instance = it }
}
}
@VisibleForTesting
fun resetInstance() {
instance = null
}
}
}
@@ -8,6 +8,7 @@ import android.content.SharedPreferences
import android.net.ConnectivityManager
import android.os.IBinder
import android.util.Log
import androidx.annotation.VisibleForTesting
import androidx.core.content.edit
import androidx.core.graphics.toColorInt
import androidx.preference.PreferenceManager
@@ -1673,6 +1674,12 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
return instance!!
}
@VisibleForTesting
fun resetInstance() {
instance = null
projectIdsToSync.clear()
}
fun isNextcloudAccountConfigured(context: Context): Boolean {
val preferences = PreferenceManager.getDefaultSharedPreferences(context)
return !preferences.getString(AccountActivity.SETTINGS_URL, AccountActivity.DEFAULT_SETTINGS).isNullOrEmpty() ||