Compare commits
4
Commits
a4ec8b914e
...
691bc82d52
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
691bc82d52
|
||
|
|
8564f64576
|
||
|
|
c6be8eda6b
|
||
|
|
7904aabbc3
|
@@ -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 {
|
||||
@@ -74,6 +76,18 @@ 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.*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +150,14 @@ 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'
|
||||
}
|
||||
|
||||
tasks.register('jacocoTestReport', JacocoReport) {
|
||||
|
||||
@@ -48,7 +48,7 @@ fun AboutScreen(
|
||||
title = { Text(stringResource(R.string.title_about)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
backgroundColor = MaterialTheme.colors.primary,
|
||||
|
||||
@@ -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
|
||||
@@ -331,7 +331,7 @@ class EditBillActivity : AppCompatActivity() {
|
||||
bill.amount == viewModel.getFinalAmount() &&
|
||||
bill.payerId == viewModel.payerId &&
|
||||
bill.comment == viewModel.getFinalComment() &&
|
||||
bill.repeat == viewModel.repeat &&
|
||||
(bill.repeat ?: DBBill.NON_REPEATED) == viewModel.repeat &&
|
||||
bill.categoryId == viewModel.categoryId &&
|
||||
bill.paymentModeId == viewModel.paymentModeId &&
|
||||
!owersChanged)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -15,6 +15,7 @@ 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
|
||||
@@ -223,7 +224,10 @@ 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),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(60.dp)
|
||||
.testTag("CategoryButton_$name"),
|
||||
contentPadding = PaddingValues(2.dp)
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
|
||||
|
||||
+24
-117
@@ -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
|
||||
}
|
||||
|
||||
viewModel.projectId = selectedProjectID
|
||||
viewModel.loadCurrencies()
|
||||
|
||||
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()
|
||||
|
||||
setContent {
|
||||
ThemeUtils.CowspentTheme {
|
||||
ManageCurrenciesScreen(
|
||||
viewModel = viewModel,
|
||||
onBack = { finish() },
|
||||
onSaveMain = { saveMainCurrency() },
|
||||
onAdd = { addOrUpdateCurrency() },
|
||||
onDelete = { deleteCurrency(it) },
|
||||
onEdit = { startEditing(it) },
|
||||
onCancelEdit = { cancelEditing() }
|
||||
)
|
||||
}
|
||||
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,8 @@ 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.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
@@ -216,7 +219,7 @@ fun ManageCurrenciesScreen(
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isEditing) Icons.Default.Done else Icons.Default.Add,
|
||||
contentDescription = null,
|
||||
contentDescription = if (isEditing) "Done" else "Add",
|
||||
tint = if (viewModel.isAddEnabled()) {
|
||||
if (isEditing) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
|
||||
} else MaterialTheme.colors.onSurface.copy(alpha = 0.2f)
|
||||
@@ -274,7 +277,9 @@ 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()
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag("CurrencyRow_${currency.name}")
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -320,7 +325,7 @@ fun CurrencyRow(
|
||||
IconButton(onClick = onDelete) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = null,
|
||||
contentDescription = "Delete",
|
||||
tint = MaterialTheme.colors.error.copy(alpha = 0.7f),
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
@@ -347,9 +352,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),
|
||||
|
||||
+111
-2
@@ -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()
|
||||
|
||||
@@ -107,7 +107,7 @@ fun LabelManagementScreenContent(
|
||||
showEditDialog = true
|
||||
}
|
||||
}) {
|
||||
Icon(Icons.Default.Add, contentDescription = null)
|
||||
Icon(Icons.Default.Add, contentDescription = "Add Label")
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
|
||||
@@ -293,7 +293,7 @@ fun BillsListScreen(
|
||||
isSearchExpanded = false
|
||||
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
|
||||
viewModel.searchQuery = ""
|
||||
}) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_close_search))
|
||||
}
|
||||
} else {
|
||||
IconButton(onClick = {
|
||||
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 }) {
|
||||
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.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
|
||||
@@ -53,7 +54,9 @@ 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
|
||||
@@ -63,13 +66,16 @@ class BillsListViewActivity :
|
||||
AppCompatActivity(),
|
||||
IRefreshBillsListCallback {
|
||||
|
||||
private val viewModel: BillsListViewModel by viewModels()
|
||||
internal val viewModel: BillsListViewModel by viewModels()
|
||||
|
||||
companion object {
|
||||
var DEBUG = false
|
||||
|
||||
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"
|
||||
|
||||
@@ -276,7 +282,7 @@ class BillsListViewActivity :
|
||||
labelBillsLauncher.launch(LabelBillsActivity.createIntent(this, selectedProjectId))
|
||||
}
|
||||
},
|
||||
onRefresh = { synchronize(true) }
|
||||
onRefresh = { synchronize(SyncTrigger.MANUAL) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -330,10 +336,7 @@ class BillsListViewActivity :
|
||||
}
|
||||
viewModel.isRefreshing = false
|
||||
|
||||
if (db.cowspentServerSyncHelper.isSyncPossible) {
|
||||
db.cowspentServerSyncHelper.addCallbackPull(syncCallBack)
|
||||
synchronize()
|
||||
}
|
||||
synchronize(SyncTrigger.APP_OPEN)
|
||||
|
||||
registerBroadcastReceiver()
|
||||
updateAvatarInDrawer(CowspentServerSyncHelper.isNextcloudAccountConfigured(this))
|
||||
@@ -347,6 +350,9 @@ 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
|
||||
}
|
||||
|
||||
@@ -373,7 +379,7 @@ class BillsListViewActivity :
|
||||
navigationSelection = Category(null, null)
|
||||
refreshLists(true)
|
||||
|
||||
synchronize()
|
||||
synchronize(SyncTrigger.PROJECT_OPEN)
|
||||
}
|
||||
|
||||
fun onManageProjectClick(projectId: Long) {
|
||||
@@ -406,7 +412,7 @@ class BillsListViewActivity :
|
||||
}
|
||||
setupDrawerProjects()
|
||||
refreshLists()
|
||||
synchronize()
|
||||
synchronize(SyncTrigger.PROJECT_OPEN)
|
||||
val projectNameString = proj.name.ifEmpty { proj.remoteId }
|
||||
showToast(this@BillsListViewActivity, getString(R.string.remove_project_confirmation, projectNameString))
|
||||
}
|
||||
@@ -422,19 +428,24 @@ class BillsListViewActivity :
|
||||
val proj = withContext(Dispatchers.IO) { db.getProject(projectId) } ?: return@launch
|
||||
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) {
|
||||
db.updateProject(
|
||||
projId = projectId,
|
||||
newArchivedTs = newArchivedTs
|
||||
newArchivedTs = localArchivedTs
|
||||
)
|
||||
}
|
||||
|
||||
if (!proj.isLocal) {
|
||||
db.cowspentServerSyncHelper.editRemoteProject(
|
||||
projId = projectId,
|
||||
newArchivedTs = newArchivedTs,
|
||||
newArchivedTs = remoteArchivedTs,
|
||||
callback = object : ICallback {
|
||||
override fun onFinish() {}
|
||||
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 offlineMode = preferences.getBoolean(getString(R.string.pref_key_offline_mode), false)
|
||||
if (offlineMode && !manual) {
|
||||
return
|
||||
}
|
||||
// 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 }
|
||||
|
||||
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) {
|
||||
db.cowspentServerSyncHelper.addCallbackPull(syncCallBack)
|
||||
db.cowspentServerSyncHelper.scheduleSync(false, selectedProjectId, manual)
|
||||
} else viewModel.isRefreshing = false
|
||||
db.cowspentServerSyncHelper.addCallbackPull(syncCallBack)
|
||||
|
||||
val started = if (accountSyncDue) {
|
||||
if (CowspentServerSyncHelper.isNextcloudAccountConfigured(applicationContext)) {
|
||||
db.cowspentServerSyncHelper.runAccountProjectsSync()
|
||||
}
|
||||
} else viewModel.isRefreshing = false
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -810,7 +853,7 @@ class BillsListViewActivity :
|
||||
refreshLists()
|
||||
}
|
||||
MainConstants.BROADCAST_SYNC_PROJECT -> {
|
||||
synchronize()
|
||||
synchronize(SyncTrigger.PROJECT_OPEN)
|
||||
}
|
||||
MainConstants.BROADCAST_NETWORK_AVAILABLE -> {
|
||||
}
|
||||
@@ -838,7 +881,7 @@ class BillsListViewActivity :
|
||||
refreshLists()
|
||||
if (db.cowspentServerSyncHelper.isSyncPossible) {
|
||||
db.cowspentServerSyncHelper.addCallbackPull(syncCallBack)
|
||||
synchronize()
|
||||
synchronize(SyncTrigger.PROJECT_OPEN)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?) {
|
||||
|
||||
@@ -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.enableEdgeToEdge
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
@@ -13,17 +13,16 @@ 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() {
|
||||
|
||||
private val viewModel: EditProjectViewModel by viewModels()
|
||||
internal val viewModel: EditProjectViewModel by viewModels()
|
||||
private lateinit var db: CowspentSQLiteOpenHelper
|
||||
private lateinit var project: DBProject
|
||||
|
||||
@@ -56,29 +55,33 @@ 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
|
||||
|
||||
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
|
||||
}
|
||||
val nameChanged = changes.name
|
||||
val emailChanged = changes.email
|
||||
val pwdChanged = changes.newPassword
|
||||
val currentPwdChanged = changes.currentPassword
|
||||
|
||||
if (project.isLocal) {
|
||||
val targetPwd = if (pwdChanged) newPwd else currentPwd
|
||||
|
||||
@@ -7,6 +7,7 @@ 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("")
|
||||
@@ -17,6 +18,31 @@ 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,6 +12,7 @@ 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
|
||||
@@ -100,6 +101,7 @@ fun MemberEditDialogContent(
|
||||
.fillMaxWidth()
|
||||
.clickable { isActivated = !isActivated }
|
||||
.padding(vertical = 8.dp)
|
||||
.testTag("member_activated_row")
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Block,
|
||||
|
||||
+1
-1
@@ -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()
|
||||
|
||||
@@ -20,6 +20,7 @@ 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
|
||||
@@ -49,6 +50,7 @@ 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
|
||||
@@ -57,6 +59,7 @@ 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(
|
||||
@@ -81,7 +84,9 @@ 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)
|
||||
|
||||
@@ -128,13 +133,25 @@ 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 = null)
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
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(
|
||||
@@ -401,13 +436,7 @@ fun SettingsListPreference(
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
RadioButton(
|
||||
selected = (key == value),
|
||||
onClick = {
|
||||
onValueChange(key)
|
||||
showDialog = false
|
||||
}
|
||||
)
|
||||
RadioButton(selected = (key == value), onClick = null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
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
|
||||
fun SettingsIcon(icon: Any?) {
|
||||
Box(modifier = Modifier.size(24.dp), contentAlignment = Alignment.Center) {
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
@@ -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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
@@ -285,9 +285,18 @@ fun ProjectStatisticsTable(
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
LazyColumn(modifier = Modifier.weight(1f)) {
|
||||
items(stats.memberStats) { m ->
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp, horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
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
|
||||
) {
|
||||
Text(m.name, modifier = Modifier.weight(2f), color = MaterialTheme.colors.onSurface, fontWeight = FontWeight.Medium)
|
||||
|
||||
Text(
|
||||
|
||||
@@ -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
|
||||
@@ -390,8 +391,8 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
|
||||
|
||||
fun getActivatedMembersOfProject(projId: Long): List<DBMember> {
|
||||
return getMembersCustom(
|
||||
"$key_projectid = ? AND $key_activated = 1",
|
||||
arrayOf(projId.toString()),
|
||||
"$key_projectid = ? AND $key_activated = 1 AND $key_state != ?",
|
||||
arrayOf(projId.toString(), DBBill.STATE_DELETED.toString()),
|
||||
"$key_name ASC"
|
||||
)
|
||||
}
|
||||
@@ -570,70 +571,54 @@ 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 = $projectId)")
|
||||
andWhere.add("($key_projectid = ?)")
|
||||
args.add(projectId.toString())
|
||||
andWhere.add("($key_state != ${DBBill.STATE_DELETED})")
|
||||
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())
|
||||
|
||||
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))
|
||||
}
|
||||
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(), order)
|
||||
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)))"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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
|
||||
@@ -1240,6 +1225,9 @@ 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
|
||||
|
||||
@@ -1249,5 +1237,15 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
|
||||
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.os.IBinder
|
||||
import android.util.Log
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.core.content.edit
|
||||
import androidx.core.graphics.toColorInt
|
||||
import androidx.preference.PreferenceManager
|
||||
@@ -90,14 +91,38 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
|
||||
}
|
||||
|
||||
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) ...")
|
||||
updateNetworkStatus()
|
||||
if (isSyncPossible && (!syncActive || onlyLocalChanges)) {
|
||||
val project = dbHelper.getProject(projId)
|
||||
val project = resolveProject()
|
||||
if (project != null) {
|
||||
Log.d(TAG, "... starting now")
|
||||
val syncTask = SyncTask(onlyLocalChanges, project, forceFullSync)
|
||||
@@ -1027,8 +1052,21 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
|
||||
}
|
||||
var status = LoginStatus.OK
|
||||
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(
|
||||
project!!, newName, newEmail, newPassword, newMainCurrencyName, newArchivedTs
|
||||
currentProj,
|
||||
finalName,
|
||||
newEmail ?: currentProj.email,
|
||||
newPassword,
|
||||
finalCurrency,
|
||||
newArchivedTs
|
||||
)
|
||||
if (BillsListViewActivity.DEBUG) {
|
||||
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) {
|
||||
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) {
|
||||
@@ -1673,6 +1716,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() ||
|
||||
|
||||
@@ -86,7 +86,7 @@ class NextcloudClient(
|
||||
}
|
||||
|
||||
@Throws(TokenMismatchException::class, NextcloudHttpRequestFailedException::class)
|
||||
private fun requestServerWithSSO(
|
||||
internal fun requestServerWithSSO(
|
||||
nextcloudAPI: NextcloudAPI,
|
||||
target: String,
|
||||
method: String,
|
||||
@@ -141,7 +141,7 @@ class NextcloudClient(
|
||||
}
|
||||
|
||||
@Throws(TokenMismatchException::class, NextcloudHttpRequestFailedException::class)
|
||||
private fun imageRequestServerWithSSO(
|
||||
internal fun imageRequestServerWithSSO(
|
||||
nextcloudAPI: NextcloudAPI,
|
||||
target: String,
|
||||
method: String,
|
||||
@@ -182,7 +182,7 @@ class NextcloudClient(
|
||||
}
|
||||
|
||||
@Throws(IOException::class, NextcloudHttpRequestFailedException::class)
|
||||
private fun requestServer(
|
||||
internal fun requestServer(
|
||||
target: String,
|
||||
method: String, params: JSONObject?, lastETag: String?, needLogin: Boolean, isOCSRequest: Boolean
|
||||
): VersatileProjectSyncClient.ResponseData {
|
||||
@@ -248,7 +248,7 @@ class NextcloudClient(
|
||||
}
|
||||
|
||||
@Throws(IOException::class, NextcloudHttpRequestFailedException::class)
|
||||
private fun imageRequestServer(
|
||||
internal fun imageRequestServer(
|
||||
target: String,
|
||||
method: String, params: JSONObject?, lastETag: String?, needLogin: Boolean, isOCSRequest: Boolean
|
||||
): VersatileProjectSyncClient.ResponseData {
|
||||
|
||||
@@ -158,10 +158,18 @@ class VersatileProjectSyncClient(
|
||||
paramKeys.add("password")
|
||||
paramValues.add(newPassword)
|
||||
}
|
||||
if (newEmail != null) {
|
||||
paramKeys.add("contact_email")
|
||||
paramValues.add(newEmail)
|
||||
}
|
||||
if (newMainCurrencyName != null) {
|
||||
paramKeys.add("currencyName")
|
||||
paramValues.add(newMainCurrencyName)
|
||||
}
|
||||
if (newArchivedTs != null) {
|
||||
paramKeys.add("archivedTs")
|
||||
paramValues.add(newArchivedTs.toString())
|
||||
}
|
||||
}
|
||||
if (canAccessProjectWithNCLogin(project)) {
|
||||
username = this.username
|
||||
@@ -1520,5 +1528,8 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
<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>
|
||||
@@ -139,6 +142,12 @@
|
||||
<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>
|
||||
|
||||
@@ -160,6 +169,8 @@
|
||||
<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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user