16 Commits

Author SHA1 Message Date
soraefir 119ae757b9 Migrate to SecureStorage 2026-06-30 19:24:25 +02:00
soraefir c3d380ddb4 Migrate to encryptedPref 2026-06-30 18:59:50 +02:00
soraefir a8ba00269b Hide inactive members in drawer 2026-06-30 07:41:34 +02:00
soraefir 756a37de23 String cleanup 2026-06-30 07:37:52 +02:00
soraefir 5f7266013d More cleanup 2026-06-30 07:33:49 +02:00
soraefir e98dc4a86b Cleanup imports and co 2026-06-30 07:30:57 +02:00
soraefir afb14ff7b9 Fix Monochrome 2026-06-30 07:26:33 +02:00
soraefir 1bee1dabbe Disable dependency tree, logo to png 2026-06-30 07:17:57 +02:00
soraefir 84432fe68d Better formating and placeholder 2026-06-29 21:38:01 +02:00
soraefir 7e00b14d27 Fix keyboard padding and better currency management 2026-06-29 21:31:59 +02:00
soraefir ec4d60897a Currency parsing and formating
# Conflicts:
#	app/src/main/java/net/helcel/cowspent/android/main/BillsListComponents.kt
2026-06-29 21:30:35 +02:00
soraefir 5c74336c10 Fixes for currency, logo, render and more 2026-06-27 12:18:33 +02:00
soraefir d617c5c7be project creation and currency db 2026-06-14 21:34:58 +02:00
soraefir a9a303478c better currency handling 2026-06-14 21:33:08 +02:00
sora 110ab397b8 Update README.md 2026-06-09 19:19:24 +02:00
sora 3675e87651 Upload files to ".github/images" 2026-06-09 19:18:02 +02:00
44 changed files with 1093 additions and 479 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+2 -1
View File
@@ -1,9 +1,10 @@
<!--suppress ALL --> <!--suppress ALL -->
<div align="center"> <div align="center">
<h1>Cowspent</h1> <h1>Cowspent</h1>
<img width="100px" src="./metadata/en-US/images/icon.webp" alt="Logo"> <img width="100px" src="./metadata/en-US/images/icon.png" alt="Logo">
<p> <p>
Shared budget manager able to sync with [Nextcloud Cospend](https://github.com/julien-nc/cospend-nc). Shared budget manager able to sync with [Nextcloud Cospend](https://github.com/julien-nc/cospend-nc).
This work is based upon [MoneyBuster](https://gitlab.com/eneiluj/moneybuster). This work is based upon [MoneyBuster](https://gitlab.com/eneiluj/moneybuster).
+14 -4
View File
@@ -82,11 +82,18 @@ android {
androidResources { androidResources {
generateLocaleConfig = true generateLocaleConfig = true
} }
dependenciesInfo {
// Disables dependency metadata when building APKs (for IzzyOnDroid/F-Droid)
includeInApk = false
// Disables dependency metadata when building Android App Bundles (for Google Play)
includeInBundle = false
}
} }
dependencies { dependencies {
implementation 'androidx.compose.foundation:foundation:1.11.2' implementation 'androidx.compose.foundation:foundation:1.11.3'
implementation 'androidx.compose.runtime:runtime:1.11.2' implementation 'androidx.compose.runtime:runtime:1.11.3'
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.5' coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.5'
implementation 'androidx.preference:preference-ktx:1.2.1' implementation 'androidx.preference:preference-ktx:1.2.1'
@@ -99,7 +106,7 @@ dependencies {
implementation 'com.github.nextcloud:Android-SingleSignOn:1.1.0' implementation 'com.github.nextcloud:Android-SingleSignOn:1.1.0'
implementation 'com.opencsv:opencsv:5.12.0' implementation 'com.opencsv:opencsv:5.12.0'
implementation platform('androidx.compose:compose-bom:2026.05.01') implementation platform('androidx.compose:compose-bom:2026.06.00')
implementation 'androidx.compose.ui:ui' implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.material:material' implementation 'androidx.compose.material:material'
implementation 'androidx.compose.material:material-icons-extended' implementation 'androidx.compose.material:material-icons-extended'
@@ -107,5 +114,8 @@ dependencies {
debugImplementation 'androidx.compose.ui:ui-tooling' debugImplementation 'androidx.compose.ui:ui-tooling'
implementation 'androidx.activity:activity-compose:1.13.0' implementation 'androidx.activity:activity-compose:1.13.0'
implementation 'androidx.activity:activity-ktx:1.13.0' implementation 'androidx.activity:activity-ktx:1.13.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0' implementation 'androidx.security:security-crypto:1.1.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.11.0'
implementation "androidx.datastore:datastore-preferences:1.2.1"
implementation "com.google.crypto.tink:tink-android:1.22.0"
} }
@@ -13,8 +13,8 @@ import android.webkit.WebView
import android.webkit.WebViewClient import android.webkit.WebViewClient
import android.widget.Toast import android.widget.Toast
import androidx.activity.OnBackPressedCallback import androidx.activity.OnBackPressedCallback
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.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -33,7 +33,6 @@ import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.nextcloud.android.sso.AccountImporter import com.nextcloud.android.sso.AccountImporter
import com.nextcloud.android.sso.helper.SingleAccountHelper import com.nextcloud.android.sso.helper.SingleAccountHelper
import com.nextcloud.android.sso.model.SingleSignOnAccount
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -42,6 +41,7 @@ import net.helcel.cowspent.android.main.MainConstants
import net.helcel.cowspent.theme.ThemeUtils import net.helcel.cowspent.theme.ThemeUtils
import net.helcel.cowspent.util.CospendClientUtil import net.helcel.cowspent.util.CospendClientUtil
import net.helcel.cowspent.util.CospendClientUtil.LoginStatus import net.helcel.cowspent.util.CospendClientUtil.LoginStatus
import net.helcel.cowspent.util.SecureStorage
import java.net.URLDecoder import java.net.URLDecoder
import java.util.Locale import java.util.Locale
@@ -69,7 +69,6 @@ class AccountActivity : AppCompatActivity() {
} }
private lateinit var preferences: SharedPreferences private lateinit var preferences: SharedPreferences
private var oldPassword = ""
private var useWebLogin = true private var useWebLogin = true
private var showLoginDialog by mutableStateOf(false) private var showLoginDialog by mutableStateOf(false)
@@ -129,8 +128,6 @@ class AccountActivity : AppCompatActivity() {
) )
} }
} }
oldPassword = preferences.getString(SETTINGS_PASSWORD, DEFAULT_SETTINGS) ?: ""
viewModel.validateUrl() viewModel.validateUrl()
} }
@@ -224,11 +221,7 @@ class AccountActivity : AppCompatActivity() {
private fun legacyLogin() { private fun legacyLogin() {
val url = CospendClientUtil.formatURL(viewModel.serverUrl.trim()) val url = CospendClientUtil.formatURL(viewModel.serverUrl.trim())
val username = viewModel.username val username = viewModel.username
var password = viewModel.password val password = viewModel.password
if (password.isEmpty()) {
password = oldPassword
}
performLogin(url, username, password) performLogin(url, username, password)
} }
@@ -311,10 +304,10 @@ class AccountActivity : AppCompatActivity() {
} }
if (status == LoginStatus.OK) { if (status == LoginStatus.OK) {
SecureStorage.savePassword(applicationContext, SETTINGS_PASSWORD, password)
preferences.edit { preferences.edit {
putString(SETTINGS_URL, url) putString(SETTINGS_URL, url)
putString(SETTINGS_USERNAME, username) putString(SETTINGS_USERNAME, username)
putString(SETTINGS_PASSWORD, password)
remove(SETTINGS_KEY_ETAG) remove(SETTINGS_KEY_ETAG)
remove(SETTINGS_KEY_LAST_MODIFIED) remove(SETTINGS_KEY_LAST_MODIFIED)
} }
@@ -13,6 +13,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import net.helcel.cowspent.util.CospendClientUtil import net.helcel.cowspent.util.CospendClientUtil
import net.helcel.cowspent.util.SecureStorage
class AccountViewModel(application: Application) : AndroidViewModel(application) { class AccountViewModel(application: Application) : AndroidViewModel(application) {
private val preferences: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(application) private val preferences: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(application)
@@ -60,14 +61,15 @@ class AccountViewModel(application: Application) : AndroidViewModel(application)
} else { } else {
preferences.getString(AccountActivity.SETTINGS_USERNAME, "") preferences.getString(AccountActivity.SETTINGS_USERNAME, "")
} }
val password = if (useSso) {
""
} else {
preferences.getString(AccountActivity.SETTINGS_PASSWORD, "")
}
if (!url.isNullOrEmpty() && !username.isNullOrEmpty()) { if (!url.isNullOrEmpty() && !username.isNullOrEmpty()) {
viewModelScope.launch { viewModelScope.launch {
val password = if (useSso) {
""
} else {
SecureStorage.getPassword(getApplication(), AccountActivity.SETTINGS_PASSWORD)
}
isValidatingLogin = true isValidatingLogin = true
isLoggedIn = withContext(Dispatchers.IO) { isLoggedIn = withContext(Dispatchers.IO) {
if (useSso) { if (useSso) {
@@ -87,6 +89,9 @@ class AccountViewModel(application: Application) : AndroidViewModel(application)
} }
fun logout() { fun logout() {
viewModelScope.launch {
SecureStorage.removePassword(getApplication(), AccountActivity.SETTINGS_PASSWORD)
}
preferences.edit { preferences.edit {
remove(AccountActivity.SETTINGS_USE_SSO) remove(AccountActivity.SETTINGS_USE_SSO)
remove(AccountActivity.SETTINGS_SSO_URL) remove(AccountActivity.SETTINGS_SSO_URL)
@@ -27,6 +27,7 @@ import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.theme.ThemeUtils import net.helcel.cowspent.theme.ThemeUtils
import net.helcel.cowspent.util.BillParser import net.helcel.cowspent.util.BillParser
import net.helcel.cowspent.util.CategoryUtils import net.helcel.cowspent.util.CategoryUtils
import net.helcel.cowspent.util.SupportUtil
import java.text.ParseException import java.text.ParseException
import java.time.ZoneId import java.time.ZoneId
import java.util.Calendar import java.util.Calendar
@@ -107,7 +108,8 @@ class EditBillActivity : AppCompatActivity() {
val createIntent = Intent(this@EditBillActivity, QrCodeScannerActivity::class.java) val createIntent = Intent(this@EditBillActivity, QrCodeScannerActivity::class.java)
scanQRCodeLauncher.launch(createIntent) scanQRCodeLauncher.launch(createIntent)
}, },
onDelete = if (bill.id > 0) { { deleteBillAsked() } } else null, onDuplicate = if (!viewModel.isNewBill) { { duplicateCurrentBill() } } else null,
onDelete = if (!viewModel.isNewBill) { { deleteBillAsked() } } else null,
accessLevel = db.getProject(bill.projectId)?.myAccessLevel ?: DBProject.ACCESS_LEVEL_ADMIN accessLevel = db.getProject(bill.projectId)?.myAccessLevel ?: DBProject.ACCESS_LEVEL_ADMIN
) )
} }
@@ -185,12 +187,18 @@ class EditBillActivity : AppCompatActivity() {
} }
} }
calendar.timeInMillis = bill.timestamp * 1000 calendar.timeInMillis = bill.timestamp * 1000
val members = db.getMembersOfProject(bill.projectId, null) val allMembers = db.getMembersOfProject(bill.projectId, null)
val billOwerIds = bill.billOwersIds
val members = allMembers.filter { m ->
m.isActivated || m.id == bill.payerId || billOwerIds.contains(m.id)
}
val project = db.getProject(bill.projectId) val project = db.getProject(bill.projectId)
val currencies = db.getCurrencies(bill.projectId) val currencies = db.getCurrencies(bill.projectId)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
viewModel.currencies = currencies viewModel.currencies = currencies
viewModel.mainCurrencyName = project?.currencyName ?: "" viewModel.mainCurrencyName = project?.currencyName ?: ""
viewModel.isNewBill = (bill.id == 0L)
viewModel.initFromBill(bill, members, customSplits) viewModel.initFromBill(bill, members, customSplits)
} }
} }
@@ -206,6 +214,7 @@ class EditBillActivity : AppCompatActivity() {
calendar.timeInMillis = austrianBill.date.time calendar.timeInMillis = austrianBill.date.time
viewModel.timestamp = calendar.timeInMillis / 1000 viewModel.timestamp = calendar.timeInMillis / 1000
viewModel.amount = austrianBill.amount.toString() viewModel.amount = austrianBill.amount.toString()
viewModel.updateSplits()
return@registerForActivityResult return@registerForActivityResult
} catch (_: ParseException) { } catch (_: ParseException) {
} }
@@ -217,6 +226,7 @@ class EditBillActivity : AppCompatActivity() {
viewModel.timestamp = calendar.timeInMillis / 1000 viewModel.timestamp = calendar.timeInMillis / 1000
} }
viewModel.amount = croatianBill.amount.toString() viewModel.amount = croatianBill.amount.toString()
viewModel.updateSplits()
return@registerForActivityResult return@registerForActivityResult
} catch (_: ParseException) { } catch (_: ParseException) {
} }
@@ -225,6 +235,20 @@ class EditBillActivity : AppCompatActivity() {
} }
} }
private fun duplicateCurrentBill() {
bill = DBBill(
0, 0, bill.projectId, viewModel.payerId, viewModel.amountAsDouble,
System.currentTimeMillis() / 1000, viewModel.what, DBBill.STATE_ADDED,
viewModel.repeat, bill.paymentMode, viewModel.categoryRemoteId,
viewModel.getFinalComment(), viewModel.paymentModeRemoteId
)
calendar.timeInMillis = System.currentTimeMillis()
viewModel.timestamp = calendar.timeInMillis / 1000
viewModel.isNewBill = true
showToast(this, "Duplicating bill...")
}
private fun onBack() { private fun onBack() {
if (!valuesHaveChanged()) { if (!valuesHaveChanged()) {
finish() finish()
@@ -298,9 +322,9 @@ class EditBillActivity : AppCompatActivity() {
return !(bill.what == viewModel.what && return !(bill.what == viewModel.what &&
bill.timestamp == viewModel.timestamp && bill.timestamp == viewModel.timestamp &&
bill.amount == viewModel.amountAsDouble && bill.amount == viewModel.getFinalAmount() &&
bill.payerId == viewModel.payerId && bill.payerId == viewModel.payerId &&
bill.comment == viewModel.comment && bill.comment == viewModel.getFinalComment() &&
bill.repeat == viewModel.repeat && bill.repeat == viewModel.repeat &&
bill.categoryRemoteId == viewModel.categoryRemoteId && bill.categoryRemoteId == viewModel.categoryRemoteId &&
bill.paymentModeRemoteId == viewModel.paymentModeRemoteId && bill.paymentModeRemoteId == viewModel.paymentModeRemoteId &&
@@ -312,13 +336,17 @@ class EditBillActivity : AppCompatActivity() {
val isCustomSplit = viewModel.isCustomSplit val isCustomSplit = viewModel.isCustomSplit
if (isCustomSplit) { if (isCustomSplit) {
val splits = viewModel.owersCustomSplit.filter { (id, amount) -> val splits: Map<Long, Double> = viewModel.owersCustomSplit.filter { (id, amountStr) ->
viewModel.owersSelection[id] == true && (amount.replace(',', '.').toDoubleOrNull() viewModel.owersSelection[id] == true && (amountStr.replace(',', '.').toDoubleOrNull()
?: 0.0) > 0 ?: 0.0) > 0
}.mapValues { it.value.replace(',', '.').toDoubleOrNull() ?: 0.0 } }.mapValues {
val uiAmount = it.value.replace(',', '.').toDoubleOrNull() ?: 0.0
SupportUtil.round2(uiAmount / viewModel.selectedCurrencyRate)
}
if (splits.isEmpty()) return@withContext 0L if (splits.isEmpty()) return@withContext 0L
val finalComment = viewModel.getFinalComment()
val splitEntries = splits.entries.toList() val splitEntries = splits.entries.toList()
// Pool of existing bills in this group that we can potentially reuse // Pool of existing bills in this group that we can potentially reuse
@@ -360,7 +388,7 @@ class EditBillActivity : AppCompatActivity() {
existingBill.paymentMode, existingBill.paymentMode,
viewModel.paymentModeRemoteId, viewModel.paymentModeRemoteId,
viewModel.categoryRemoteId, viewModel.categoryRemoteId,
viewModel.comment finalComment
) )
if (firstSavedId == 0L) firstSavedId = billToUseId if (firstSavedId == 0L) firstSavedId = billToUseId
} else { } else {
@@ -368,7 +396,7 @@ class EditBillActivity : AppCompatActivity() {
val newBill = DBBill( val newBill = DBBill(
0, 0, bill.projectId, viewModel.payerId, amount, 0, 0, bill.projectId, viewModel.payerId, amount,
viewModel.timestamp, viewModel.what, DBBill.STATE_ADDED, viewModel.repeat, viewModel.timestamp, viewModel.what, DBBill.STATE_ADDED, viewModel.repeat,
bill.paymentMode, viewModel.categoryRemoteId, viewModel.comment, viewModel.paymentModeRemoteId bill.paymentMode, viewModel.categoryRemoteId, finalComment, viewModel.paymentModeRemoteId
) )
newBill.billOwers = listOf(DBBillOwer(0, 0, memberId)) newBill.billOwers = listOf(DBBillOwer(0, 0, memberId))
val newId = db.addBill(newBill) val newId = db.addBill(newBill)
@@ -398,7 +426,8 @@ class EditBillActivity : AppCompatActivity() {
return@withContext firstSavedId return@withContext firstSavedId
} else { } else {
val newAmount = viewModel.amountAsDouble val newAmount = viewModel.getFinalAmount()
val finalComment = viewModel.getFinalComment()
val newOwersIds = viewModel.getOwersIds() val newOwersIds = viewModel.getOwersIds()
if (bill.id != 0L) { if (bill.id != 0L) {
@@ -414,7 +443,7 @@ class EditBillActivity : AppCompatActivity() {
bill.paymentMode, bill.paymentMode,
viewModel.paymentModeRemoteId, viewModel.paymentModeRemoteId,
viewModel.categoryRemoteId, viewModel.categoryRemoteId,
viewModel.comment finalComment
) )
if (groupedBillIds != null) { if (groupedBillIds != null) {
for (id in groupedBillIds) { for (id in groupedBillIds) {
@@ -431,7 +460,7 @@ class EditBillActivity : AppCompatActivity() {
val newBill = DBBill( val newBill = DBBill(
0, 0, bill.projectId, viewModel.payerId, newAmount, 0, 0, bill.projectId, viewModel.payerId, newAmount,
viewModel.timestamp, viewModel.what, DBBill.STATE_ADDED, viewModel.repeat, viewModel.timestamp, viewModel.what, DBBill.STATE_ADDED, viewModel.repeat,
bill.paymentMode, viewModel.categoryRemoteId, viewModel.comment, viewModel.paymentModeRemoteId bill.paymentMode, viewModel.categoryRemoteId, finalComment, viewModel.paymentModeRemoteId
) )
newOwersIds.forEach { newBill.billOwers += DBBillOwer(0, 0, it) } newOwersIds.forEach { newBill.billOwers += DBBillOwer(0, 0, it) }
val newBillId = db.addBill(newBill) val newBillId = db.addBill(newBill)
@@ -45,6 +45,7 @@ fun EditBillScreen(
onDateClick: () -> Unit, onDateClick: () -> Unit,
onTimeClick: () -> Unit, onTimeClick: () -> Unit,
onScan: () -> Unit, onScan: () -> Unit,
onDuplicate: (() -> Unit)? = null,
onDelete: (() -> Unit)? = null, onDelete: (() -> Unit)? = null,
accessLevel: Int = DBProject.ACCESS_LEVEL_ADMIN accessLevel: Int = DBProject.ACCESS_LEVEL_ADMIN
) { ) {
@@ -59,7 +60,7 @@ fun EditBillScreen(
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text(stringResource(if (viewModel.what.isEmpty()) R.string.simple_new_bill else R.string.simple_edit_bill)) }, title = { Text(stringResource(if (viewModel.isNewBill) R.string.simple_new_bill else R.string.simple_edit_bill)) },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
@@ -70,6 +71,11 @@ fun EditBillScreen(
IconButton(onClick = onScan) { IconButton(onClick = onScan) {
Icon(Icons.Default.QrCodeScanner, contentDescription = null) Icon(Icons.Default.QrCodeScanner, contentDescription = null)
} }
if (onDuplicate != null) {
IconButton(onClick = onDuplicate) {
Icon(Icons.Default.ContentCopy, contentDescription = "Duplicate")
}
}
if (onDelete != null) { if (onDelete != null) {
IconButton(onClick = onDelete) { IconButton(onClick = onDelete) {
Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.action_delete)) Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.action_delete))
@@ -111,6 +117,7 @@ fun EditBillScreen(
Column( Column(
modifier = Modifier modifier = Modifier
.padding(padding) .padding(padding)
.imePadding()
.padding(16.dp) .padding(16.dp)
.fillMaxSize() .fillMaxSize()
.verticalScroll(scrollState) .verticalScroll(scrollState)
@@ -160,7 +167,6 @@ fun BillBasicInfoSection(
val context = LocalContext.current val context = LocalContext.current
val currencyDialogTitle = val currencyDialogTitle =
stringResource(R.string.currency_dialog_title, viewModel.mainCurrencyName) stringResource(R.string.currency_dialog_title, viewModel.mainCurrencyName)
val noCurrencyError = stringResource(R.string.no_currency_error)
OutlinedTextField( OutlinedTextField(
value = viewModel.what, value = viewModel.what,
@@ -182,25 +188,37 @@ fun BillBasicInfoSection(
enabled = canEdit, enabled = canEdit,
placeholder = { Text("0") }, placeholder = { Text("0") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.Default.AttachMoney, contentDescription = null) }, leadingIcon = {
val currencyToShow = viewModel.selectedCurrencyName.ifEmpty {
viewModel.mainCurrencyName.ifEmpty { "$" }
}
TextIconDisplay(
textIcon = TextIcon.Symbol(currencyToShow),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
)
},
trailingIcon = { trailingIcon = {
IconButton( IconButton(
enabled = canEdit, enabled = canEdit,
onClick = { onClick = {
if (viewModel.currencies.isNotEmpty()) { val mainLabel = viewModel.mainCurrencyName.ifEmpty { "$" }
viewModel.showDialog( val options = listOf("$mainLabel | Base") + viewModel.currencies.map {
title = currencyDialogTitle, "${it.name} | 1 $mainLabel = ${it.exchangeRate} ${it.name}"
items = viewModel.currencies.map { "${it.name} (${it.exchangeRate})" },
onItemSelected = { index ->
viewModel.convertCurrency(viewModel.currencies[index])
}
)
} else {
showToast(context, noCurrencyError)
} }
viewModel.showDialog(
title = currencyDialogTitle,
items = options,
onItemSelected = { index ->
if (index == 0) {
viewModel.resetCurrency()
} else {
viewModel.convertCurrency(viewModel.currencies[index - 1])
}
}
)
} }
) { ) {
Icon(Icons.Default.CurrencyExchange, contentDescription = null) Icon(Icons.Default.SwapHoriz, contentDescription = "Change Currency")
} }
}, },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
@@ -221,7 +239,7 @@ fun BillBasicInfoSection(
onClick = onDateClick, onClick = onDateClick,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
enabled = canEdit, enabled = canEdit,
leadingIcon = { Icon(Icons.Default.Event, contentDescription = null) } leadingIcon = { Icon(Icons.Default.Event, contentDescription = "Select Date") }
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
ClickableOutlinedTextField( ClickableOutlinedTextField(
@@ -229,7 +247,7 @@ fun BillBasicInfoSection(
onClick = onTimeClick, onClick = onTimeClick,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
enabled = canEdit, enabled = canEdit,
leadingIcon = { Icon(Icons.Default.AccessTime, contentDescription = null) } leadingIcon = { Icon(Icons.Default.AccessTime, contentDescription = "Select Time") }
) )
} }
} }
@@ -252,12 +270,8 @@ fun PayerSection(
leadingIcon = { leadingIcon = {
Box(modifier = Modifier.padding(start = 12.dp)) { Box(modifier = Modifier.padding(start = 12.dp)) {
if (selectedPayer != null) { if (selectedPayer != null) {
UserAvatar( MemberAvatar(
name = selectedPayer.name, member = selectedPayer,
r = selectedPayer.r,
g = selectedPayer.g,
b = selectedPayer.b,
disabled = !selectedPayer.isActivated,
size = 24.dp size = 24.dp
) )
} else { } else {
@@ -271,12 +285,8 @@ fun PayerSection(
viewModel.payerId = member.id viewModel.payerId = member.id
payerExpanded = false payerExpanded = false
}) { }) {
UserAvatar( MemberAvatar(
name = member.name, member = member,
r = member.r,
g = member.g,
b = member.b,
disabled = !member.isActivated,
size = 24.dp size = 24.dp
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
@@ -381,12 +391,8 @@ fun OwerSelectionSection(
enabled = canEdit, enabled = canEdit,
onCheckedChange = { viewModel.toggleMember(member.id, it) } onCheckedChange = { viewModel.toggleMember(member.id, it) }
) )
UserAvatar( MemberAvatar(
name = member.name, member = member,
r = member.r,
g = member.g,
b = member.b,
disabled = !member.isActivated,
size = 32.dp size = 32.dp
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
@@ -587,6 +593,7 @@ fun EditBillScreenPreview() {
viewModel = EditBillViewModel().apply { viewModel = EditBillViewModel().apply {
what = "Pizza" what = "Pizza"
amount = "12.50" amount = "12.50"
mainCurrencyName = "EUR"
members = listOf( members = listOf(
DBMember(1, 0, 0, "Alice", true, 1.0, 0, null, null, null, null, null), DBMember(1, 0, 0, "Alice", true, 1.0, 0, null, null, null, null, null),
DBMember(2, 0, 0, "Bob", true, 1.0, 0, null, null, null, null, null) DBMember(2, 0, 0, "Bob", true, 1.0, 0, null, null, null, null, null)
@@ -1,6 +1,7 @@
package net.helcel.cowspent.android.bill_edit package net.helcel.cowspent.android.bill_edit
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableDoubleStateOf
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateMapOf
@@ -10,6 +11,7 @@ import androidx.lifecycle.ViewModel
import net.helcel.cowspent.model.DBBill import net.helcel.cowspent.model.DBBill
import net.helcel.cowspent.model.DBMember import net.helcel.cowspent.model.DBMember
import net.helcel.cowspent.android.helper.DialogState import net.helcel.cowspent.android.helper.DialogState
import net.helcel.cowspent.android.helper.parseAmount
import net.helcel.cowspent.util.SupportUtil import net.helcel.cowspent.util.SupportUtil
import net.helcel.cowspent.util.evalMath import net.helcel.cowspent.util.evalMath
@@ -25,9 +27,12 @@ class EditBillViewModel : ViewModel() {
var repeat by mutableStateOf(DBBill.NON_REPEATED) var repeat by mutableStateOf(DBBill.NON_REPEATED)
var paymentModeRemoteId by mutableIntStateOf(0) var paymentModeRemoteId by mutableIntStateOf(0)
var categoryRemoteId by mutableIntStateOf(0) var categoryRemoteId by mutableIntStateOf(0)
var isNewBill by mutableStateOf(false)
var currencies by mutableStateOf<List<DBCurrency>>(emptyList()) var currencies by mutableStateOf<List<DBCurrency>>(emptyList())
var mainCurrencyName by mutableStateOf("") var mainCurrencyName by mutableStateOf("")
var selectedCurrencyName by mutableStateOf("")
var selectedCurrencyRate by mutableDoubleStateOf(1.0)
var members by mutableStateOf<List<DBMember>>(emptyList()) var members by mutableStateOf<List<DBMember>>(emptyList())
var owersSelection = mutableStateMapOf<Long, Boolean>() var owersSelection = mutableStateMapOf<Long, Boolean>()
@@ -38,13 +43,8 @@ class EditBillViewModel : ViewModel() {
val amountAsDouble: Double val amountAsDouble: Double
get() { get() {
val amountStr = amount.replace(',', '.') return parseAmount(amount) ?: try {
return try { evalMath(amount.replace(',', '.'))
if (amountStr.matches("[0-9.]+".toRegex())) {
amountStr.toDouble()
} else {
evalMath(amountStr)
}
} catch (_: Exception) { } catch (_: Exception) {
0.0 0.0
} }
@@ -128,46 +128,81 @@ class EditBillViewModel : ViewModel() {
} }
fun convertCurrency(currency: DBCurrency) { fun convertCurrency(currency: DBCurrency) {
val originalAmountStr = amount selectedCurrencyName = currency.name ?: ""
val originalAmount = amountAsDouble selectedCurrencyRate = currency.exchangeRate
if (originalAmount == 0.0) return
// We don't change 'amount' here anymore as per request.
val newAmount = originalAmount / currency.exchangeRate // It stays as the original amount.
amount = SupportUtil.round2(newAmount).toString()
val currencyLabel = currency.name ?: ""
val conversionNote = "($originalAmountStr $currencyLabel)"
if (!comment.contains(conversionNote)) {
if (comment.isNotEmpty() && !comment.endsWith(" ")) {
comment += " "
}
comment += conversionNote
}
if (isCustomSplit) {
owersCustomSplit.keys.toList().forEach { id ->
val value = owersCustomSplit[id] ?: ""
val partAmount = value.replace(',', '.').toDoubleOrNull() ?: 0.0
if (partAmount != 0.0) {
val newPartAmount = partAmount / currency.exchangeRate
owersCustomSplit[id] = SupportUtil.round2(newPartAmount).toString()
}
}
}
updateSplits() updateSplits()
} }
fun resetCurrency() {
selectedCurrencyName = ""
selectedCurrencyRate = 1.0
updateSplits()
}
fun getFinalAmount(): Double {
return SupportUtil.round2(amountAsDouble / selectedCurrencyRate)
}
fun getFinalComment(): String {
var baseComment = comment
val regex = "\\s*#[^:]+:[\\d.,]+@[\\d.,]+".toRegex()
baseComment = baseComment.replace(regex, "").trim()
return if (selectedCurrencyName.isNotEmpty() && selectedCurrencyRate != 1.0) {
val metadata = "#$selectedCurrencyName:$amount@$selectedCurrencyRate"
if (baseComment.isEmpty()) metadata else "$baseComment $metadata"
} else {
baseComment
}
}
fun initFromBill(bill: DBBill, members: List<DBMember>, customSplits: Map<Long, Double>? = null) { fun initFromBill(bill: DBBill, members: List<DBMember>, customSplits: Map<Long, Double>? = null) {
this.members = members this.members = members
what = bill.what what = bill.what
amount = if (bill.amount == 0.0) "" else bill.amount.toString()
comment = bill.comment ?: ""
timestamp = bill.timestamp timestamp = bill.timestamp
payerId = bill.payerId payerId = bill.payerId
repeat = bill.repeat ?: DBBill.NON_REPEATED repeat = bill.repeat ?: DBBill.NON_REPEATED
paymentModeRemoteId = bill.paymentModeRemoteId paymentModeRemoteId = bill.paymentModeRemoteId
categoryRemoteId = bill.categoryRemoteId categoryRemoteId = bill.categoryRemoteId
val rawComment = bill.comment ?: ""
// Try to parse existing conversion metadata #CURR:ORIG@RATE
// Using [^:]+ for currency name to support symbols and names with spaces
val regex = "#([^:]+):([\\d.,]+)@([\\d.,]+)".toRegex()
val match = regex.find(rawComment)
if (match != null) {
val (currName, origAmount, rate) = match.destructured
selectedCurrencyName = currName
selectedCurrencyRate = rate.replace(',', '.').toDoubleOrNull() ?: 1.0
amount = origAmount
comment = rawComment.replace(match.value, "").trim()
// Check if latest rate is different
val latestCurrency = currencies.find { it.name == selectedCurrencyName }
if (latestCurrency != null && latestCurrency.exchangeRate != selectedCurrencyRate) {
showDialog(
title = "Update Exchange Rate?",
message = "The exchange rate for $selectedCurrencyName has changed from $selectedCurrencyRate to ${latestCurrency.exchangeRate}. Do you want to update the conversion for the saved total?",
positiveText = "Update Rate",
negativeText = "Keep Old",
onConfirm = {
selectedCurrencyRate = latestCurrency.exchangeRate
updateSplits()
}
)
}
} else {
selectedCurrencyName = ""
selectedCurrencyRate = 1.0
amount = if (bill.amount == 0.0) "" else bill.amount.toString()
comment = rawComment
}
owersSelection.clear() owersSelection.clear()
owersCustomSplit.clear() owersCustomSplit.clear()
@@ -178,13 +213,23 @@ class EditBillViewModel : ViewModel() {
val selected = customSplits.containsKey(member.id) val selected = customSplits.containsKey(member.id)
owersSelection[member.id] = selected owersSelection[member.id] = selected
if (selected) { if (selected) {
owersCustomSplit[member.id] = SupportUtil.round2(customSplits[member.id]!!).toString() // If we have metadata, the custom splits from DB are also converted.
// We should show them as "Original" if possible?
// Actually, if we use metadata, we should probably store original splits too,
// but for now let's just reverse the rate for display.
val dbPart = customSplits[member.id]!!
val uiPart = if (selectedCurrencyRate != 1.0) dbPart * selectedCurrencyRate else dbPart
owersCustomSplit[member.id] = SupportUtil.round2(uiPart).toString()
} }
} }
} else { } else {
// Even split logic
val billOwerIds = bill.billOwersIds val billOwerIds = bill.billOwersIds
val selectedCount = billOwerIds.size val selectedCount = billOwerIds.size
val evenSplit = if (selectedCount > 0) bill.amount / selectedCount else 0.0
// Use UI amount for even split calculation
val uiAmount = amountAsDouble
val evenSplit = if (selectedCount > 0) uiAmount / selectedCount else 0.0
val evenSplitStr = if (evenSplit == 0.0) "" else SupportUtil.round2(evenSplit).toString() val evenSplitStr = if (evenSplit == 0.0) "" else SupportUtil.round2(evenSplit).toString()
for (member in members) { for (member in members) {
@@ -15,6 +15,7 @@ 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.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.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
@@ -66,8 +67,10 @@ class ManageCurrenciesActivity : AppCompatActivity() {
viewModel = viewModel, viewModel = viewModel,
onBack = { finish() }, onBack = { finish() },
onSaveMain = { saveMainCurrency() }, onSaveMain = { saveMainCurrency() },
onAdd = { addCurrency() }, onAdd = { addOrUpdateCurrency() },
onDelete = { deleteCurrency(it) } onDelete = { deleteCurrency(it) },
onEdit = { startEditing(it) },
onCancelEdit = { cancelEditing() }
) )
} }
} }
@@ -86,11 +89,17 @@ class ManageCurrenciesActivity : AppCompatActivity() {
val project = db!!.getProject(selectedProjectID) val project = db!!.getProject(selectedProjectID)
if (project != null) { if (project != null) {
db!!.syncIfRemote(project) db!!.syncIfRemote(project)
withContext(Dispatchers.Main) { if (project.type == ProjectType.COSPEND) {
if (!db!!.cowspentServerSyncHelper withContext(Dispatchers.Main) {
.editRemoteProject(selectedProjectID, project.name, null, null, newMainCurrencyName, editMainCurrencyCallBack) if (!db!!.cowspentServerSyncHelper
) { .editRemoteProject(selectedProjectID, project.name, null, null, newMainCurrencyName, editMainCurrencyCallBack)
showToast(this@ManageCurrenciesActivity, getString(R.string.remote_project_operation_no_network), Toast.LENGTH_LONG) ) {
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)
} }
} }
} }
@@ -98,22 +107,44 @@ class ManageCurrenciesActivity : AppCompatActivity() {
} }
} }
private fun addCurrency() { private fun addOrUpdateCurrency() {
val exchangeRate = try { viewModel.newCurrencyRate.toDouble() } catch (_: Exception) { 0.0 } val exchangeRate = try { viewModel.newCurrencyRate.toDouble() } catch (_: Exception) { 0.0 }
val newCurrency = DBCurrency( val currencyName = viewModel.newCurrencyName
0, 0, selectedProjectID, val editingId = viewModel.editingCurrencyId
viewModel.newCurrencyName, exchangeRate, DBBill.STATE_ADDED
)
lifecycleScope.launch { lifecycleScope.launch {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
db!!.addCurrencyAndSync(newCurrency) 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)
}
} }
viewModel.newCurrencyName = "" cancelEditing()
viewModel.newCurrencyRate = ""
updateCurrenciesList() updateCurrenciesList()
} }
} }
private fun startEditing(currency: DBCurrency) {
viewModel.editingCurrencyId = currency.id
viewModel.newCurrencyName = currency.name ?: ""
viewModel.newCurrencyRate = currency.exchangeRate.toString()
}
private fun cancelEditing() {
viewModel.editingCurrencyId = null
viewModel.newCurrencyName = ""
viewModel.newCurrencyRate = ""
}
private fun deleteCurrency(currency: DBCurrency) { private fun deleteCurrency(currency: DBCurrency) {
lifecycleScope.launch { lifecycleScope.launch {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
@@ -1,22 +1,57 @@
package net.helcel.cowspent.android.currencies package net.helcel.cowspent.android.currencies
import android.annotation.SuppressLint import android.annotation.SuppressLint
import androidx.compose.foundation.layout.* import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.* import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Card
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Scaffold
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.TextFieldDefaults
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Done
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
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.res.stringResource import androidx.compose.ui.res.stringResource
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 androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
import net.helcel.cowspent.R import net.helcel.cowspent.R
import net.helcel.cowspent.android.helper.AlertDialog import net.helcel.cowspent.android.helper.AlertDialog
import net.helcel.cowspent.android.helper.formatAmount
import net.helcel.cowspent.model.DBCurrency import net.helcel.cowspent.model.DBCurrency
import kotlin.time.Duration.Companion.milliseconds
@Composable @Composable
fun ManageCurrenciesScreen( fun ManageCurrenciesScreen(
@@ -24,7 +59,9 @@ fun ManageCurrenciesScreen(
onBack: () -> Unit, onBack: () -> Unit,
onSaveMain: () -> Unit, onSaveMain: () -> Unit,
onAdd: () -> Unit, onAdd: () -> Unit,
onDelete: (DBCurrency) -> Unit onDelete: (DBCurrency) -> Unit,
onEdit: (DBCurrency) -> Unit,
onCancelEdit: () -> Unit
) { ) {
val dialogState = viewModel.dialogState val dialogState = viewModel.dialogState
if (dialogState != null) { if (dialogState != null) {
@@ -73,55 +110,151 @@ fun ManageCurrenciesScreen(
Column( Column(
modifier = Modifier modifier = Modifier
.padding(padding) .padding(padding)
.padding(16.dp) .imePadding()
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colors.onSurface.copy(alpha = 0.02f))
) { ) {
Text(stringResource(R.string.main_currency), style = MaterialTheme.typography.h6) LaunchedEffect(viewModel.mainCurrencyName) {
Row(verticalAlignment = Alignment.CenterVertically) { if (viewModel.mainCurrencyName.isNotEmpty()) {
OutlinedTextField( delay(500.milliseconds)
value = viewModel.mainCurrencyName, onSaveMain()
onValueChange = { viewModel.mainCurrencyName = it },
label = { Text(stringResource(R.string.currency_edit_name)) },
modifier = Modifier.weight(1f)
)
Spacer(modifier = Modifier.width(8.dp))
Button(onClick = onSaveMain, enabled = viewModel.mainCurrencyName.isNotEmpty()) {
Text(stringResource(R.string.save_or_discard_bill_dialog_save))
} }
} }
Spacer(modifier = Modifier.height(24.dp)) Column(modifier = Modifier.padding(16.dp)) {
// Section 1: Main Currency
Text(stringResource(R.string.add_currency_title), style = MaterialTheme.typography.h6) Text(
Row(verticalAlignment = Alignment.CenterVertically) { text = stringResource(R.string.main_currency).uppercase(),
OutlinedTextField( style = MaterialTheme.typography.overline,
value = viewModel.newCurrencyName, color = MaterialTheme.colors.primary,
onValueChange = { viewModel.newCurrencyName = it }, fontWeight = FontWeight.Bold
label = { Text(stringResource(R.string.currency_edit_name)) },
modifier = Modifier.weight(1f)
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField( Card(
value = viewModel.newCurrencyRate, shape = RoundedCornerShape(12.dp),
onValueChange = { viewModel.newCurrencyRate = it }, elevation = 2.dp,
label = { Text(stringResource(R.string.currency_rate)) }, modifier = Modifier.fillMaxWidth()
modifier = Modifier.weight(1f) ) {
OutlinedTextField(
value = viewModel.mainCurrencyName,
onValueChange = { viewModel.mainCurrencyName = it },
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("e.g. EUR", fontSize = 14.sp) },
colors = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent
)
)
}
Spacer(modifier = Modifier.height(24.dp))
// Section 2: Exchange Rates
val isEditing = viewModel.editingCurrencyId != null
Text(
text = (if (isEditing) "Edit exchange rate" else "Add exchange rate").uppercase(),
style = MaterialTheme.typography.overline,
color = if (isEditing) MaterialTheme.colors.secondary else MaterialTheme.colors.primary,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
// Integrated Add/Edit Ribbon
Surface(
color = MaterialTheme.colors.surface,
shape = RoundedCornerShape(12.dp),
modifier = Modifier.fillMaxWidth(),
elevation = 2.dp
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text("1 ", style = MaterialTheme.typography.body2, color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f))
Text(viewModel.mainCurrencyName.ifEmpty { "$" }, fontWeight = FontWeight.Bold)
Text(
text = " = ",
style = MaterialTheme.typography.h6,
color = if (isEditing) MaterialTheme.colors.secondary else MaterialTheme.colors.primary,
modifier = Modifier.padding(horizontal = 4.dp)
)
OutlinedTextField(
value = viewModel.newCurrencyRate,
onValueChange = { viewModel.newCurrencyRate = it },
modifier = Modifier.weight(1.2f).height(52.dp),
placeholder = { Text("1", fontSize = 12.sp) },
singleLine = true,
textStyle = LocalTextStyle.current.copy(fontWeight = FontWeight.ExtraBold, fontSize = 14.sp),
shape = RoundedCornerShape(8.dp),
colors = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = if (isEditing) MaterialTheme.colors.secondary else MaterialTheme.colors.primary,
unfocusedBorderColor = MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
backgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.02f)
)
)
Spacer(modifier = Modifier.width(8.dp))
OutlinedTextField(
value = viewModel.newCurrencyName,
onValueChange = { viewModel.newCurrencyName = it },
modifier = Modifier.weight(1f).height(52.dp),
placeholder = { Text(viewModel.mainCurrencyName.ifEmpty { "$" }, fontSize = 12.sp) },
singleLine = true,
textStyle = LocalTextStyle.current.copy(fontWeight = FontWeight.Bold, fontSize = 14.sp),
shape = RoundedCornerShape(8.dp),
colors = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = if (isEditing) MaterialTheme.colors.secondary else MaterialTheme.colors.primary,
unfocusedBorderColor = MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
backgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.02f)
)
)
Row(modifier = Modifier.padding(start = 4.dp)) {
IconButton(
onClick = onAdd,
enabled = viewModel.isAddEnabled()
) {
Icon(
imageVector = if (isEditing) Icons.Default.Done else Icons.Default.Add,
contentDescription = null,
tint = if (viewModel.isAddEnabled()) {
if (isEditing) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
} else MaterialTheme.colors.onSurface.copy(alpha = 0.2f)
)
}
if (isEditing) {
IconButton(onClick = onCancelEdit) {
Icon(Icons.Default.Close, contentDescription = null, tint = MaterialTheme.colors.error)
}
}
}
}
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "SAVED RATES",
style = MaterialTheme.typography.overline,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.4f),
fontWeight = FontWeight.Bold
) )
} }
Spacer(modifier = Modifier.height(8.dp))
Button( LazyColumn(
onClick = onAdd, modifier = Modifier.weight(1f).fillMaxWidth(),
modifier = Modifier.fillMaxWidth(), contentPadding = PaddingValues(16.dp, 0.dp, 16.dp, 16.dp),
enabled = viewModel.isAddEnabled() verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
Text(stringResource(R.string.simple_add))
}
Spacer(modifier = Modifier.height(24.dp))
LazyColumn(modifier = Modifier.weight(1f)) {
items(viewModel.currencies) { currency -> items(viewModel.currencies) { currency ->
CurrencyRow(currency, onDelete = { onDelete(currency) }) CurrencyRow(
currency = currency,
mainCurrencyName = viewModel.mainCurrencyName,
isEditing = viewModel.editingCurrencyId == currency.id,
onEdit = { onEdit(currency) },
onDelete = { onDelete(currency) }
)
} }
} }
} }
@@ -129,20 +262,70 @@ fun ManageCurrenciesScreen(
} }
@Composable @Composable
fun CurrencyRow(currency: DBCurrency, onDelete: () -> Unit) { fun CurrencyRow(
Row( currency: DBCurrency,
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), mainCurrencyName: String,
verticalAlignment = Alignment.CenterVertically isEditing: Boolean,
onEdit: () -> Unit,
onDelete: () -> Unit
) {
Card(
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()
) { ) {
Column(modifier = Modifier.weight(1f)) { Row(
Text(currency.name ?: "", style = MaterialTheme.typography.subtitle1) modifier = Modifier
Text("Rate: ${currency.exchangeRate}", style = MaterialTheme.typography.caption) .fillMaxWidth()
} .clickable(onClick = onEdit)
IconButton(onClick = onDelete) { .padding(16.dp, 12.dp),
Icon(Icons.Default.Delete, contentDescription = null, tint = MaterialTheme.colors.error) verticalAlignment = Alignment.CenterVertically
) {
Row(
modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "1 ",
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
)
Text(
text = mainCurrencyName.ifEmpty { "$" },
style = MaterialTheme.typography.body1,
fontWeight = FontWeight.Bold
)
Text(
text = " = ",
style = MaterialTheme.typography.h6,
color = if (isEditing) MaterialTheme.colors.secondary else MaterialTheme.colors.primary,
modifier = Modifier.padding(horizontal = 8.dp)
)
Text(
text = formatAmount(currency.exchangeRate),
style = MaterialTheme.typography.body1,
fontWeight = FontWeight.ExtraBold,
color = if (isEditing) MaterialTheme.colors.secondary else MaterialTheme.colors.onSurface
)
Text(
text = " ${currency.name ?: ""}",
style = MaterialTheme.typography.body1,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
)
}
IconButton(onClick = onDelete) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = null,
tint = MaterialTheme.colors.error.copy(alpha = 0.7f),
modifier = Modifier.size(20.dp)
)
}
} }
} }
Divider()
} }
@Preview(showBackground = true) @Preview(showBackground = true)
@@ -151,6 +334,9 @@ fun CurrencyRowPreview() {
MaterialTheme { MaterialTheme {
CurrencyRow( CurrencyRow(
currency = DBCurrency(1, 0, 0, "USD", 1.0, 0), currency = DBCurrency(1, 0, 0, "USD", 1.0, 0),
mainCurrencyName = "EUR",
isEditing = false,
onEdit = {},
onDelete = {} onDelete = {}
) )
} }
@@ -172,7 +358,9 @@ fun ManageCurrenciesScreenPreview() {
onBack = {}, onBack = {},
onSaveMain = {}, onSaveMain = {},
onAdd = {}, onAdd = {},
onDelete = {} onDelete = {},
onEdit = {},
onCancelEdit = {}
) )
} }
} }
@@ -13,6 +13,8 @@ class ManageCurrenciesViewModel : ViewModel() {
var newCurrencyName by mutableStateOf("") var newCurrencyName by mutableStateOf("")
var newCurrencyRate by mutableStateOf("") var newCurrencyRate by mutableStateOf("")
var editingCurrencyId by mutableStateOf<Long?>(null)
var currencies by mutableStateOf<List<DBCurrency>>(emptyList()) var currencies by mutableStateOf<List<DBCurrency>>(emptyList())
var dialogState by mutableStateOf<DialogState?>(null) var dialogState by mutableStateOf<DialogState?>(null)
@@ -11,6 +11,7 @@ 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.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale import androidx.compose.ui.draw.scale
@@ -23,9 +24,11 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import net.helcel.cowspent.R import net.helcel.cowspent.R
import net.helcel.cowspent.android.helper.UserAvatar import net.helcel.cowspent.android.helper.MemberAvatar
import net.helcel.cowspent.android.helper.formatBalance import net.helcel.cowspent.android.helper.formatBalance
import net.helcel.cowspent.android.helper.lazyVerticalScrollbar import net.helcel.cowspent.android.helper.lazyVerticalScrollbar
import net.helcel.cowspent.android.helper.TextIcon
import net.helcel.cowspent.android.helper.TextIconDisplay
import net.helcel.cowspent.model.DBMember import net.helcel.cowspent.model.DBMember
import net.helcel.cowspent.model.DBProject import net.helcel.cowspent.model.DBProject
import net.helcel.cowspent.model.ProjectType import net.helcel.cowspent.model.ProjectType
@@ -38,6 +41,7 @@ fun Drawer(
selectedProjectId: Long, selectedProjectId: Long,
selectedMemberId: Long?, selectedMemberId: Long?,
lastSyncText: String, lastSyncText: String,
mainCurrency: String? = null,
showArchived: Boolean = false, showArchived: Boolean = false,
onProjectClick: (Long) -> Unit, onProjectClick: (Long) -> Unit,
onProjectOptionsClick: (Long) -> Unit, onProjectOptionsClick: (Long) -> Unit,
@@ -96,22 +100,40 @@ fun Drawer(
// Members Section // Members Section
val membersState = rememberLazyListState() val membersState = rememberLazyListState()
val sortedMembers = remember(members, memberBalances, selectedMemberId) {
members.filter {
val balance = memberBalances[it.id] ?: 0.0
it.isActivated || balance > 0.01 || balance < -0.01 || it.id == selectedMemberId
}.sortedWith(compareBy<DBMember> {
val balance = memberBalances[it.id] ?: 0.0
when {
balance > 0.01 -> 0
balance < -0.01 -> 1
else -> 2
}
}.thenBy {
val balance = memberBalances[it.id] ?: 0.0
if (balance > 0.01) -balance else balance
}.thenBy { it.name })
}
LazyColumn( LazyColumn(
state = membersState, state = membersState,
modifier = Modifier modifier = Modifier
.heightIn(max = maxBottomHeight) .heightIn(max = maxBottomHeight)
.lazyVerticalScrollbar(membersState) .lazyVerticalScrollbar(membersState)
) { ) {
if (members.isNotEmpty()) { if (sortedMembers.isNotEmpty()) {
item { item {
DrawerItem( DrawerItem(
icon = Icons.Default.Receipt, icon = Icons.Default.Receipt,
text = stringResource(R.string.label_all_bills), text = stringResource(R.string.label_all_bills),
secondaryText = mainCurrency,
selected = selectedMemberId == null, selected = selectedMemberId == null,
onClick = { onMemberClick(null) } onClick = { onMemberClick(null) }
) )
} }
items(members) { member -> items(sortedMembers) { member ->
val balance = memberBalances[member.id] ?: 0.0 val balance = memberBalances[member.id] ?: 0.0
MemberDrawerItem( MemberDrawerItem(
member = member, member = member,
@@ -241,6 +263,7 @@ fun DrawerItem(
member: DBMember? = null, member: DBMember? = null,
text: String? = null, text: String? = null,
balanceText: String? = null, balanceText: String? = null,
secondaryText: String? = null,
balanceColor: Color = Color.Unspecified, balanceColor: Color = Color.Unspecified,
selected: Boolean = false, selected: Boolean = false,
alpha: Float = 1f, alpha: Float = 1f,
@@ -259,13 +282,8 @@ fun DrawerItem(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
if (member != null) { if (member != null) {
UserAvatar( MemberAvatar(
name = member.name, member = member,
r = member.r,
g = member.g,
b = member.b,
avatar = member.avatar,
disabled = !member.isActivated,
size = 24.dp, size = 24.dp,
alpha = alpha alpha = alpha
) )
@@ -294,6 +312,17 @@ fun DrawerItem(
modifier = Modifier.padding(end = 8.dp) modifier = Modifier.padding(end = 8.dp)
) )
} }
if (secondaryText != null) {
Box(modifier = Modifier
.padding(end = 8.dp)
) {
TextIconDisplay(
textIcon = TextIcon.Symbol(secondaryText),
tint = contentColor.copy(alpha = 0.6f)
)
}
}
if (onSecondaryClick != null) { if (onSecondaryClick != null) {
IconButton(onClick = onSecondaryClick) { IconButton(onClick = onSecondaryClick) {
@@ -340,6 +369,7 @@ fun DrawerPreview() {
selectedProjectId = 1, selectedProjectId = 1,
selectedMemberId = null, selectedMemberId = null,
lastSyncText = "Last sync: 5 mins ago", lastSyncText = "Last sync: 5 mins ago",
mainCurrency = "EUR",
onProjectClick = {}, onProjectClick = {},
onProjectOptionsClick = {}, onProjectOptionsClick = {},
onMemberClick = {}, onMemberClick = {},
@@ -11,6 +11,7 @@ import androidx.compose.runtime.Composable
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.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
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
@@ -110,28 +111,55 @@ fun AlertDialog(
if (message != null) Spacer(Modifier.height(8.dp)) if (message != null) Spacer(Modifier.height(8.dp))
LazyColumn { LazyColumn {
itemsIndexed(items) { index, item -> itemsIndexed(items) { index, item ->
Row( Column {
modifier = Modifier Row(
.fillMaxWidth() modifier = Modifier
.clickable { .fillMaxWidth()
onItemSelected?.invoke(index) .clickable {
onItemSelected?.invoke(index)
}
.padding(vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (itemIcons != null && index < itemIcons.size) {
Icon(
itemIcons[index],
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colors.primary.copy(alpha = 0.7f)
)
Spacer(Modifier.width(16.dp))
}
val text = item.toString()
if (text.contains("|")) {
val parts = text.split("|")
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = parts[0].trim(),
style = MaterialTheme.typography.body1,
fontWeight = FontWeight.Bold
)
Text(
text = parts[1].trim(),
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
)
}
} else {
Text(
text = text,
style = MaterialTheme.typography.body1
)
} }
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (itemIcons != null && index < itemIcons.size) {
Icon(
itemIcons[index],
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colors.primary.copy(alpha = 0.7f)
)
Spacer(Modifier.width(16.dp))
} }
Text( if (index < items.size - 1) {
text = item.toString(), Divider(color = MaterialTheme.colors.onSurface.copy(alpha = 0.08f))
style = MaterialTheme.typography.body1 }
)
} }
} }
} }
@@ -216,28 +244,55 @@ fun AlertDialogContent(
if (message != null) Spacer(Modifier.height(8.dp)) if (message != null) Spacer(Modifier.height(8.dp))
LazyColumn { LazyColumn {
itemsIndexed(items) { index, item -> itemsIndexed(items) { index, item ->
Row( Column {
modifier = Modifier Row(
.fillMaxWidth() modifier = Modifier
.clickable { .fillMaxWidth()
onItemSelected?.invoke(index) .clickable {
onItemSelected?.invoke(index)
}
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (itemIcons != null && index < itemIcons.size) {
Icon(
itemIcons[index],
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colors.primary.copy(alpha = 0.7f)
)
Spacer(Modifier.width(16.dp))
}
val text = item.toString()
if (text.contains("|")) {
val parts = text.split("|")
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = parts[0].trim(),
style = MaterialTheme.typography.body1,
fontWeight = FontWeight.Bold
)
Text(
text = parts[1].trim(),
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
)
}
} else {
Text(
text = text,
style = MaterialTheme.typography.body1
)
} }
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (itemIcons != null && index < itemIcons.size) {
Icon(
itemIcons[index],
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colors.primary.copy(alpha = 0.7f)
)
Spacer(Modifier.width(16.dp))
} }
Text( if (index < items.size - 1) {
text = item.toString(), Divider(color = MaterialTheme.colors.onSurface.copy(alpha = 0.08f))
style = MaterialTheme.typography.body1 }
)
} }
} }
} }
@@ -1,20 +1,80 @@
package net.helcel.cowspent.android.helper package net.helcel.cowspent.android.helper
import net.helcel.cowspent.util.SupportUtil import java.text.NumberFormat
import java.util.Locale import java.util.Locale
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.round import kotlin.math.round
/**
* Formats amount using k/M notation (e.g., 1.5k for 1500).
*/
fun formatShortValue(value: Double): String { fun formatShortValue(value: Double): String {
val absValue = abs(value)
val sign = if (value < 0) "-" else ""
return when { return when {
value >= 1_000_000 -> String.format(Locale.ROOT, "%.1fM", value / 1_000_000).replace(".0", "") absValue >= 1_000_000 -> sign + formatAmount(absValue / 1_000_000) + "M"
value >= 1_000 -> String.format(Locale.ROOT, "%.1fk", value / 1_000).replace(".0", "") absValue >= 1_000 -> sign + formatAmount(absValue / 1_000) + "k"
else -> String.format(Locale.ROOT, "%.0f", value) else -> sign + formatAmount(absValue)
} }
} }
/**
* Formats balance with sign and locale-aware number formatting.
*/
fun formatBalance(balance: Double): String { fun formatBalance(balance: Double): String {
val rbalance = round(abs(balance) * 100.0) / 100.0 val rbalance = round(abs(balance) * 100.0) / 100.0
val balanceSign = if (balance > 0.01) "+" else if (balance < -0.01) "-" else "" val balanceSign = if (balance > 0.01) "+" else if (balance < -0.01) "-" else ""
return if (rbalance == 0.0) "" else "$balanceSign${SupportUtil.normalNumberFormat.format(rbalance)}" return if (rbalance == 0.0) "" else "$balanceSign${formatAmount(rbalance)}"
}
/**
* Formats amount using system locale or specified locale.
*/
fun formatAmount(value: Double, locale: Locale = Locale.getDefault()): String {
val formatter = NumberFormat.getNumberInstance(locale)
formatter.maximumFractionDigits = 2
formatter.minimumFractionDigits = 0
return formatter.format(value)
}
/**
* Parses amount from string robustly.
* Tries common separators and cleans up non-numeric characters (except separators).
*/
fun parseAmount(input: String?): Double? {
if (input.isNullOrBlank()) return null
// Remove non-numeric characters except for delimiters and minus sign
val cleaned = input.replace(Regex("[^0-9,.-]"), "").trim()
if (cleaned.isEmpty()) return null
// If there's both a comma and a dot, we assume the last one is the decimal separator
val lastDot = cleaned.lastIndexOf('.')
val lastComma = cleaned.lastIndexOf(',')
return if (lastDot != -1 && lastComma != -1) {
if (lastDot > lastComma) {
// Dot is decimal, comma is thousands
cleaned.replace(",", "").toDoubleOrNull()
} else {
// Comma is decimal, dot is thousands
cleaned.replace(".", "").replace(",", ".").toDoubleOrNull()
}
} else if (lastComma != -1) {
// Only comma. Could be decimal or thousands.
if (cleaned.count { it == ',' } == 1) {
cleaned.replace(',', '.').toDoubleOrNull()
} else {
// Multiple commas -> thousands
cleaned.replace(",", "").toDoubleOrNull()
}
} else {
// Only dots or no separators
if (cleaned.count { it == '.' } > 1) {
cleaned.replace(".", "").toDoubleOrNull()
} else {
cleaned.toDoubleOrNull()
}
}
} }
@@ -0,0 +1,33 @@
package net.helcel.cowspent.android.helper
import androidx.compose.material.LocalContentColor
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
sealed class TextIcon {
data class Symbol(val text: String) : TextIcon()
}
/**
* A helper Composable to display a [TextIcon].
*/
@Composable
fun TextIconDisplay(
textIcon: TextIcon,
modifier: Modifier = Modifier,
tint: Color = LocalContentColor.current
) {
when (textIcon) {
is TextIcon.Symbol -> Text(
text = textIcon.text,
modifier = modifier,
color = tint,
style = MaterialTheme.typography.body1,
fontWeight = FontWeight.Bold
)
}
}
@@ -4,37 +4,49 @@ import android.Manifest
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Bundle import android.os.Bundle
import android.os.VibrationEffect
import android.os.Vibrator
import android.util.Log import android.util.Log
import androidx.activity.enableEdgeToEdge
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import net.helcel.cowspent.theme.ThemeUtils import androidx.camera.core.CameraSelector
import androidx.camera.core.* import androidx.camera.core.ExperimentalGetImage
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView import androidx.camera.view.PreviewView
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.* import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.runtime.* import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
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 androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.google.zxing.* import androidx.lifecycle.compose.LocalLifecycleOwner
import com.google.zxing.BarcodeFormat
import com.google.zxing.BinaryBitmap
import com.google.zxing.DecodeHintType
import com.google.zxing.MultiFormatReader
import com.google.zxing.PlanarYUVLuminanceSource
import com.google.zxing.Result
import com.google.zxing.common.HybridBinarizer import com.google.zxing.common.HybridBinarizer
import net.helcel.cowspent.R import net.helcel.cowspent.R
import net.helcel.cowspent.android.main.MainConstants import net.helcel.cowspent.android.main.MainConstants
import net.helcel.cowspent.theme.ThemeUtils
import java.util.concurrent.ExecutorService import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors import java.util.concurrent.Executors
@@ -21,6 +21,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.util.Base64 import android.util.Base64
import net.helcel.cowspent.model.DBMember
@Composable @Composable
fun UserAvatar( fun UserAvatar(
@@ -91,6 +92,26 @@ fun UserAvatar(
} }
} }
@Composable
fun MemberAvatar(
member: DBMember,
modifier: Modifier = Modifier,
size: Dp = 40.dp,
alpha: Float = 1f
) {
UserAvatar(
name = member.name,
modifier = modifier,
size = size,
r = member.r,
g = member.g,
b = member.b,
avatar = member.avatar,
disabled = !member.isActivated,
alpha = alpha
)
}
@Preview @Preview
@Composable @Composable
fun UserAvatarPreview() { fun UserAvatarPreview() {
@@ -2,9 +2,25 @@ package net.helcel.cowspent.android.main
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.* import androidx.compose.material.Button
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Receipt import androidx.compose.material.icons.filled.Receipt
import androidx.compose.material.icons.filled.Repeat import androidx.compose.material.icons.filled.Repeat
@@ -16,7 +32,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import net.helcel.cowspent.R import net.helcel.cowspent.R
import net.helcel.cowspent.android.helper.UserAvatar import net.helcel.cowspent.android.helper.MemberAvatar
import net.helcel.cowspent.model.DBBill import net.helcel.cowspent.model.DBBill
import net.helcel.cowspent.model.DBMember import net.helcel.cowspent.model.DBMember
import net.helcel.cowspent.util.SupportUtil import net.helcel.cowspent.util.SupportUtil
@@ -83,13 +99,8 @@ fun BillItemRow(bill: DBBill, payer: DBMember?, onClick: () -> Unit) {
) { ) {
if (payer != null) { if (payer != null) {
Box { Box {
UserAvatar( MemberAvatar(
name = payer.name, member = payer,
r = payer.r,
g = payer.g,
b = payer.b,
avatar = payer.avatar,
disabled = !payer.isActivated,
size = 40.dp size = 40.dp
) )
if (bill.repeat != null && bill.repeat != DBBill.NON_REPEATED) { if (bill.repeat != null && bill.repeat != DBBill.NON_REPEATED) {
@@ -430,6 +430,7 @@ fun BillsListScreen(
} }
}, },
drawerContent = { drawerContent = {
val selectedProject = viewModel.projects.find { it.id == viewModel.selectedProjectId }
Drawer( Drawer(
projects = viewModel.projects, projects = viewModel.projects,
members = viewModel.members, members = viewModel.members,
@@ -437,6 +438,7 @@ fun BillsListScreen(
selectedProjectId = viewModel.selectedProjectId, selectedProjectId = viewModel.selectedProjectId,
selectedMemberId = viewModel.selectedMemberId, selectedMemberId = viewModel.selectedMemberId,
lastSyncText = viewModel.lastSyncText, lastSyncText = viewModel.lastSyncText,
mainCurrency = selectedProject?.currencyName,
showArchived = showArchived, showArchived = showArchived,
onProjectClick = { onProjectClick = {
viewModel.selectedMemberId = null viewModel.selectedMemberId = null
@@ -1,12 +1,10 @@
package net.helcel.cowspent.android.main package net.helcel.cowspent.android.main
import android.Manifest
import android.app.SearchManager import android.app.SearchManager
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.content.pm.PackageManager
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
@@ -26,10 +24,8 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AddCircleOutline import androidx.compose.material.icons.filled.AddCircleOutline
import androidx.compose.material.icons.filled.Sync import androidx.compose.material.icons.filled.Sync
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.edit import androidx.core.content.edit
import androidx.core.net.toUri
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.nextcloud.android.sso.helper.SingleAccountHelper import com.nextcloud.android.sso.helper.SingleAccountHelper
@@ -49,7 +45,6 @@ import net.helcel.cowspent.model.Category
import net.helcel.cowspent.model.DBBill import net.helcel.cowspent.model.DBBill
import net.helcel.cowspent.model.DBProject import net.helcel.cowspent.model.DBProject
import net.helcel.cowspent.model.GroupedBill import net.helcel.cowspent.model.GroupedBill
import net.helcel.cowspent.model.ProjectType
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.persistence.CowspentServerSyncHelper import net.helcel.cowspent.persistence.CowspentServerSyncHelper
import net.helcel.cowspent.theme.ThemeUtils import net.helcel.cowspent.theme.ThemeUtils
@@ -444,11 +439,11 @@ class BillsListViewActivity :
fun onManageCurrenciesClick(projectId: Long) { fun onManageCurrenciesClick(projectId: Long) {
lifecycleScope.launch { lifecycleScope.launch {
val proj = withContext(Dispatchers.IO) { db.getProject(projectId) } val proj = withContext(Dispatchers.IO) { db.getProject(projectId) }
if (proj != null && proj.type == ProjectType.COSPEND) { if (proj != null) {
startActivity(Intent(applicationContext, ManageCurrenciesActivity::class.java).apply { startActivity(Intent(applicationContext, ManageCurrenciesActivity::class.java).apply {
putExtra(ManageCurrenciesActivity.EXTRA_PROJECT_ID, projectId) putExtra(ManageCurrenciesActivity.EXTRA_PROJECT_ID, projectId)
}) })
} else showToast(this@BillsListViewActivity, getString(R.string.currency_management_unavailable)) }
} }
} }
@@ -21,6 +21,7 @@ import net.helcel.cowspent.model.*
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.* import net.helcel.cowspent.util.*
import java.util.Locale
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.core.content.edit import androidx.core.content.edit
import net.helcel.cowspent.model.ProjectType import net.helcel.cowspent.model.ProjectType
@@ -60,7 +61,8 @@ class NewProjectActivity : AppCompatActivity() {
chooseFromNextcloud() chooseFromNextcloud()
}, },
onOkPressed = { onPressOk() }, onOkPressed = { onPressOk() },
onBack = { finish() } onBack = { finish() },
onFieldsChanged = { updateAuthStatus() }
) )
} }
} }
@@ -85,6 +87,8 @@ class NewProjectActivity : AppCompatActivity() {
viewModel.projectUrl = defaultNcUrl viewModel.projectUrl = defaultNcUrl
} }
} }
updateAuthStatus()
val defaultProjectId = intent.getStringExtra(PARAM_DEFAULT_PROJECT_ID) val defaultProjectId = intent.getStringExtra(PARAM_DEFAULT_PROJECT_ID)
if (defaultProjectId != null) { if (defaultProjectId != null) {
@@ -139,6 +143,17 @@ class NewProjectActivity : AppCompatActivity() {
} }
} }
private fun updateAuthStatus() {
val url = getFormattedUrl()
val fakeProj = DBProject(
0, "", "", "", url,
"", 0L, viewModel.projectType, 0L,
null, false, DBProject.ACCESS_LEVEL_UNKNOWN,
""
)
viewModel.isAuthenticatedAccount = db.cowspentServerSyncHelper.canCreateAuthenticatedProject(fakeProj)
}
private fun onPressOk() { private fun onPressOk() {
val type = viewModel.projectType val type = viewModel.projectType
val todoCreate = viewModel.whatTodoIsCreate val todoCreate = viewModel.whatTodoIsCreate
@@ -186,7 +201,16 @@ class NewProjectActivity : AppCompatActivity() {
} }
private fun createProject() { private fun createProject() {
val isCospendScheme = isCospendSchemeLink(getFormattedUrl()) val url = getFormattedUrl()
val isCospendScheme = isCospendSchemeLink(url)
if (viewModel.whatTodoIsCreate && viewModel.projectId.isEmpty() && viewModel.projectName.isNotEmpty()) {
viewModel.projectId = viewModel.projectName.lowercase(Locale.ROOT)
.replace("[^a-z0-9]".toRegex(), "-")
.replace("-+".toRegex(), "-")
.trim('-')
}
val rid = viewModel.projectId val rid = viewModel.projectId
if (!isCospendScheme && (rid == "" || rid.contains(",") || rid.contains("/"))) { if (!isCospendScheme && (rid == "" || rid.contains(",") || rid.contains("/"))) {
showToast(getString(R.string.error_invalid_project_remote_id), Toast.LENGTH_LONG) showToast(getString(R.string.error_invalid_project_remote_id), Toast.LENGTH_LONG)
@@ -194,7 +218,7 @@ class NewProjectActivity : AppCompatActivity() {
} }
if (viewModel.projectType != ProjectType.LOCAL && !isCospendScheme) { if (viewModel.projectType != ProjectType.LOCAL && !isCospendScheme) {
if (!isValidUrl(getFormattedUrl())) { if (!isValidUrl(url)) {
showToast("Invalid URL", Toast.LENGTH_SHORT) showToast("Invalid URL", Toast.LENGTH_SHORT)
return return
} }
@@ -217,7 +241,16 @@ class NewProjectActivity : AppCompatActivity() {
showToast("Invalid project name", Toast.LENGTH_SHORT) showToast("Invalid project name", Toast.LENGTH_SHORT)
return return
} }
if (!SupportUtil.isValidEmail(viewModel.projectEmail)) {
val fakeProj = DBProject(
0, rid, "", viewModel.projectName, url,
viewModel.projectEmail, 0L, viewModel.projectType, 0L,
null, false, DBProject.ACCESS_LEVEL_UNKNOWN,
""
)
val isAuthenticated = db.cowspentServerSyncHelper.canCreateAuthenticatedProject(fakeProj)
if (!isAuthenticated && !SupportUtil.isValidEmail(viewModel.projectEmail)) {
showToast("Invalid email", Toast.LENGTH_SHORT) showToast("Invalid email", Toast.LENGTH_SHORT)
return return
} }
@@ -226,7 +259,7 @@ class NewProjectActivity : AppCompatActivity() {
if (!db.cowspentServerSyncHelper.createRemoteProject( if (!db.cowspentServerSyncHelper.createRemoteProject(
viewModel.projectId, viewModel.projectName, viewModel.projectId, viewModel.projectName,
viewModel.projectEmail, viewModel.projectPassword, getFormattedUrl(), viewModel.projectType, createRemoteCallBack viewModel.projectEmail, viewModel.projectPassword, url, viewModel.projectType, createRemoteCallBack
) )
) { ) {
viewModel.isCreatingRemoteProject = false viewModel.isCreatingRemoteProject = false
@@ -32,8 +32,13 @@ fun NewProjectScreen(
onImportFile: () -> Unit, onImportFile: () -> Unit,
onChooseFromNextcloud: () -> Unit, onChooseFromNextcloud: () -> Unit,
onOkPressed: () -> Unit, onOkPressed: () -> Unit,
onBack: () -> Unit onBack: () -> Unit,
onFieldsChanged: () -> Unit
) { ) {
LaunchedEffect(viewModel.projectUrl, viewModel.projectType) {
onFieldsChanged()
}
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
@@ -58,6 +63,7 @@ fun NewProjectScreen(
Column( Column(
modifier = Modifier modifier = Modifier
.padding(padding) .padding(padding)
.imePadding()
.padding(16.dp) .padding(16.dp)
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.fillMaxSize() .fillMaxSize()
@@ -115,13 +121,7 @@ fun NewProjectScreen(
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
if (viewModel.whatTodoIsCreate) { if (viewModel.whatTodoIsCreate) {
if (viewModel.projectType == ProjectType.COSPEND) { if (viewModel.projectType == ProjectType.LOCAL) {
Button(
onClick = onChooseFromNextcloud
) {
Text(stringResource(R.string.new_project_from_nextcloud_tooltip))
}
} else if (viewModel.projectType == ProjectType.LOCAL) {
Button( Button(
onClick = onImportFile onClick = onImportFile
) { ) {
@@ -162,16 +162,18 @@ fun NewProjectScreen(
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
} }
OutlinedTextField( if (!viewModel.whatTodoIsCreate || !viewModel.isAuthenticatedAccount || viewModel.projectType != ProjectType.COSPEND) {
value = viewModel.projectId, OutlinedTextField(
onValueChange = { viewModel.projectId = it }, value = viewModel.projectId,
label = { Text(stringResource(R.string.setting_project_id)) }, onValueChange = { viewModel.projectId = it },
modifier = Modifier.fillMaxWidth(), label = { Text(stringResource(R.string.setting_project_id)) },
leadingIcon = { Icon(Icons.AutoMirrored.Filled.LibraryBooks, contentDescription = null) } modifier = Modifier.fillMaxWidth(),
) leadingIcon = { Icon(Icons.AutoMirrored.Filled.LibraryBooks, contentDescription = null) }
)
Spacer(modifier = Modifier.height(8.dp))
}
if (viewModel.projectType != ProjectType.LOCAL && (!viewModel.whatTodoIsCreate || viewModel.projectType == ProjectType.IHATEMONEY)) { if (viewModel.projectType != ProjectType.LOCAL && (!viewModel.whatTodoIsCreate || viewModel.projectType == ProjectType.IHATEMONEY)) {
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField( OutlinedTextField(
value = viewModel.projectPassword, value = viewModel.projectPassword,
onValueChange = { viewModel.projectPassword = it }, onValueChange = { viewModel.projectPassword = it },
@@ -179,10 +181,10 @@ fun NewProjectScreen(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) } leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) }
) )
Spacer(modifier = Modifier.height(8.dp))
} }
if (viewModel.whatTodoIsCreate && viewModel.projectType != ProjectType.LOCAL) { if (viewModel.whatTodoIsCreate && viewModel.projectType != ProjectType.LOCAL) {
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField( OutlinedTextField(
value = viewModel.projectName, value = viewModel.projectName,
onValueChange = { viewModel.projectName = it }, onValueChange = { viewModel.projectName = it },
@@ -190,14 +192,17 @@ fun NewProjectScreen(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.Default.Title, contentDescription = null) } leadingIcon = { Icon(Icons.Default.Title, contentDescription = null) }
) )
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField( if (!viewModel.isAuthenticatedAccount || viewModel.projectType == ProjectType.IHATEMONEY) {
value = viewModel.projectEmail, Spacer(modifier = Modifier.height(8.dp))
onValueChange = { viewModel.projectEmail = it }, OutlinedTextField(
label = { Text(stringResource(R.string.setting_new_project_email)) }, value = viewModel.projectEmail,
modifier = Modifier.fillMaxWidth(), onValueChange = { viewModel.projectEmail = it },
leadingIcon = { Icon(Icons.Default.Email, contentDescription = null) } label = { Text(stringResource(R.string.setting_new_project_email)) },
) modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.Default.Email, contentDescription = null) }
)
}
} }
} }
} }
@@ -334,6 +339,7 @@ fun NewProjectScreenPreview() {
onImportFile = {}, onImportFile = {},
onChooseFromNextcloud = {}, onChooseFromNextcloud = {},
onOkPressed = {}, onOkPressed = {},
onBack = {} onBack = {},
onFieldsChanged = {}
) )
} }
@@ -30,6 +30,8 @@ class NewProjectViewModel : ViewModel() {
var projectName by mutableStateOf("") var projectName by mutableStateOf("")
var projectEmail by mutableStateOf("") var projectEmail by mutableStateOf("")
var isAuthenticatedAccount by mutableStateOf(false)
var showAuthWarningDialog by mutableStateOf(false) var showAuthWarningDialog by mutableStateOf(false)
var showNextcloudProjectDialog by mutableStateOf(false) var showNextcloudProjectDialog by mutableStateOf(false)
var nextcloudProjects by mutableStateOf<List<DBAccountProject>>(emptyList()) var nextcloudProjects by mutableStateOf<List<DBAccountProject>>(emptyList())
@@ -39,12 +41,23 @@ class NewProjectViewModel : ViewModel() {
fun isFormValid(): Boolean { fun isFormValid(): Boolean {
if (whatTodoIsCreate) { if (whatTodoIsCreate) {
if (projectId.isEmpty()) return false if (projectType == ProjectType.LOCAL) {
if (projectType != ProjectType.LOCAL) { return projectId.isNotEmpty()
} else {
if (projectUrl.isEmpty()) return false if (projectUrl.isEmpty()) return false
if (projectName.isEmpty()) return false if (projectName.isEmpty()) return false
if (projectEmail.isEmpty()) return false
if (projectType == ProjectType.IHATEMONEY && projectPassword.isEmpty()) return false if (projectType == ProjectType.COSPEND && isAuthenticatedAccount) {
return true
}
if (projectId.isEmpty()) return false
if (projectType == ProjectType.IHATEMONEY) {
if (projectEmail.isEmpty()) return false
if (projectPassword.isEmpty()) return false
} else {
if (projectEmail.isEmpty()) return false
}
} }
} else { } else {
// Join // Join
@@ -2,6 +2,8 @@ package net.helcel.cowspent.android.project.edit
import android.annotation.SuppressLint import android.annotation.SuppressLint
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
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.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
@@ -80,11 +82,14 @@ fun EditProjectScreen(
} }
} }
) { padding -> ) { padding ->
val scrollState = rememberScrollState()
Column( Column(
modifier = Modifier modifier = Modifier
.padding(padding) .padding(padding)
.imePadding()
.padding(16.dp) .padding(16.dp)
.fillMaxSize() .fillMaxSize()
.verticalScroll(scrollState)
) { ) {
OutlinedTextField( OutlinedTextField(
value = viewModel.name, value = viewModel.name,
@@ -30,7 +30,7 @@ import androidx.compose.ui.res.stringResource
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 net.helcel.cowspent.R import net.helcel.cowspent.R
import net.helcel.cowspent.android.helper.UserAvatar import net.helcel.cowspent.android.helper.MemberAvatar
import net.helcel.cowspent.android.helper.lazyVerticalScrollbar import net.helcel.cowspent.android.helper.lazyVerticalScrollbar
import net.helcel.cowspent.model.DBMember import net.helcel.cowspent.model.DBMember
@@ -84,13 +84,8 @@ fun MemberManagementDialogContent(
.padding(vertical = 12.dp), .padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
UserAvatar( MemberAvatar(
name = member.name, member = member,
r = member.r,
g = member.g,
b = member.b,
avatar = member.avatar,
disabled = !member.isActivated,
size = 40.dp size = 40.dp
) )
Spacer(modifier = Modifier.width(16.dp)) Spacer(modifier = Modifier.width(16.dp))
@@ -66,7 +66,7 @@ import androidx.core.graphics.toColorInt
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import net.helcel.cowspent.R import net.helcel.cowspent.R
import net.helcel.cowspent.android.helper.EditableExposedDropdownMenu import net.helcel.cowspent.android.helper.EditableExposedDropdownMenu
import net.helcel.cowspent.android.helper.UserAvatar import net.helcel.cowspent.android.helper.MemberAvatar
import net.helcel.cowspent.android.helper.formatShortValue import net.helcel.cowspent.android.helper.formatShortValue
import net.helcel.cowspent.model.DBBill import net.helcel.cowspent.model.DBBill
import net.helcel.cowspent.model.DBCategory import net.helcel.cowspent.model.DBCategory
@@ -170,7 +170,7 @@ fun ProjectSankeyDiagram(
leadingIcon = { leadingIcon = {
Box(modifier = Modifier.padding(start = 12.dp)) { Box(modifier = Modifier.padding(start = 12.dp)) {
if (selectedMember != null) { if (selectedMember != null) {
UserAvatar(name = selectedMember.name, r = selectedMember.r, g = selectedMember.g, b = selectedMember.b, disabled = !selectedMember.isActivated, size = 24.dp) MemberAvatar(member = selectedMember, size = 24.dp)
} else Icon(Icons.Default.Group, contentDescription = null) } else Icon(Icons.Default.Group, contentDescription = null)
} }
}, },
@@ -182,7 +182,7 @@ fun ProjectSankeyDiagram(
} }
allMembers.forEach { member -> allMembers.forEach { member ->
DropdownMenuItem(onClick = { selectedMemberId = member.id; expanded = false }) { DropdownMenuItem(onClick = { selectedMemberId = member.id; expanded = false }) {
UserAvatar(name = member.name, r = member.r, g = member.g, b = member.b, disabled = !member.isActivated, size = 24.dp) MemberAvatar(member = member, size = 24.dp)
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(12.dp))
Text(member.name) Text(member.name)
} }
@@ -12,6 +12,6 @@ class DBAccountProject(
) : Serializable { ) : Serializable {
override fun toString(): String { override fun toString(): String {
return "#DBAccountProject$id/$remoteId,$name, $ncUrl, $password, archivedTs=$archivedTs" return "#DBAccountProject$id/$remoteId,$name, $ncUrl, [SECURE], archivedTs=$archivedTs"
} }
} }
@@ -13,6 +13,7 @@ import androidx.preference.PreferenceManager
import net.helcel.cowspent.R import net.helcel.cowspent.R
import net.helcel.cowspent.android.main.BillsListViewActivity import net.helcel.cowspent.android.main.BillsListViewActivity
import net.helcel.cowspent.model.* import net.helcel.cowspent.model.*
import net.helcel.cowspent.util.SecureStorage
import net.helcel.cowspent.util.SupportUtil import net.helcel.cowspent.util.SupportUtil
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
@@ -316,11 +317,12 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
val db = writableDatabase val db = writableDatabase
val values = ContentValues() val values = ContentValues()
values.put(key_remoteId, accountProject.remoteId) values.put(key_remoteId, accountProject.remoteId)
values.put(key_password, accountProject.password)
values.put(key_ncUrl, accountProject.ncUrl) values.put(key_ncUrl, accountProject.ncUrl)
values.put(key_name, accountProject.name) values.put(key_name, accountProject.name)
values.put(key_archived, accountProject.archivedTs ?: 0L) values.put(key_archived, accountProject.archivedTs ?: 0L)
return db.insert(table_account_projects, null, values) val id = db.insert(table_account_projects, null, values)
SecureStorage.savePasswordSync(context, "AccountProjectPassword_$id", accountProject.password)
return id
} }
val accountProjects: List<DBAccountProject> val accountProjects: List<DBAccountProject>
@@ -344,11 +346,13 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
@SuppressLint("Range") @SuppressLint("Range")
private fun getAccountProjectFromCursor(cursor: Cursor): DBAccountProject { private fun getAccountProjectFromCursor(cursor: Cursor): DBAccountProject {
val id = cursor.getLong(cursor.getColumnIndex(key_id))
val archivedTs = cursor.getLong(cursor.getColumnIndex(key_archived)) val archivedTs = cursor.getLong(cursor.getColumnIndex(key_archived))
val password = SecureStorage.getPasswordSync(context, "AccountProjectPassword_$id")
return DBAccountProject( return DBAccountProject(
cursor.getLong(cursor.getColumnIndex(key_id)), id,
cursor.getString(cursor.getColumnIndex(key_remoteId)), cursor.getString(cursor.getColumnIndex(key_remoteId)),
cursor.getString(cursor.getColumnIndex(key_password)), password,
cursor.getString(cursor.getColumnIndex(key_name)), cursor.getString(cursor.getColumnIndex(key_name)),
cursor.getString(cursor.getColumnIndex(key_ncUrl)), cursor.getString(cursor.getColumnIndex(key_ncUrl)),
if (archivedTs > 0) archivedTs else null if (archivedTs > 0) archivedTs else null
@@ -553,7 +557,11 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
} }
fun getCurrencies(projectId: Long): List<DBCurrency> { fun getCurrencies(projectId: Long): List<DBCurrency> {
return getCurrenciesCustom("$key_projectid = ?", arrayOf(projectId.toString()), null) return getCurrenciesCustom(
"$key_projectid = ? AND $key_state != ?",
arrayOf(projectId.toString(), DBBill.STATE_DELETED.toString()),
null
)
} }
@WorkerThread @WorkerThread
@@ -604,14 +612,15 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
val db = writableDatabase val db = writableDatabase
val values = ContentValues() val values = ContentValues()
values.put(key_remoteId, project.remoteId) values.put(key_remoteId, project.remoteId)
values.put(key_password, project.password)
values.put(key_bearer_token, project.bearerToken) values.put(key_bearer_token, project.bearerToken)
values.put(key_email, project.email) values.put(key_email, project.email)
values.put(key_name, project.name) values.put(key_name, project.name)
values.put(key_ihmUrl, project.serverUrl) values.put(key_ihmUrl, project.serverUrl)
values.put(key_type, project.type.id) values.put(key_type, project.type.id)
values.put(key_archived, project.archivedTs ?: 0L) values.put(key_archived, project.archivedTs ?: 0L)
return db.insert(table_projects, null, values) val id = db.insert(table_projects, null, values)
SecureStorage.savePasswordSync(context, "ProjectPassword_$id", project.password)
return id
} }
fun getProject(id: Long): DBProject? { fun getProject(id: Long): DBProject? {
@@ -640,11 +649,13 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
@SuppressLint("Range") @SuppressLint("Range")
private fun getProjectFromCursor(cursor: Cursor): DBProject { private fun getProjectFromCursor(cursor: Cursor): DBProject {
val id = cursor.getLong(cursor.getColumnIndex(key_id))
val archivedTs = cursor.getLong(cursor.getColumnIndex(key_archived)) val archivedTs = cursor.getLong(cursor.getColumnIndex(key_archived))
val password = SecureStorage.getPasswordSync(context, "ProjectPassword_$id") ?: ""
return DBProject( return DBProject(
cursor.getLong(cursor.getColumnIndex(key_id)), id,
cursor.getString(cursor.getColumnIndex(key_remoteId)), cursor.getString(cursor.getColumnIndex(key_remoteId)),
cursor.getString(cursor.getColumnIndex(key_password)), password,
cursor.getString(cursor.getColumnIndex(key_name)), cursor.getString(cursor.getColumnIndex(key_name)),
cursor.getString(cursor.getColumnIndex(key_ihmUrl)), cursor.getString(cursor.getColumnIndex(key_ihmUrl)),
cursor.getString(cursor.getColumnIndex(key_email)), cursor.getString(cursor.getColumnIndex(key_email)),
@@ -667,6 +678,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
} }
db.delete(table_members, "$key_projectid = ?", arrayOf(id.toString())) db.delete(table_members, "$key_projectid = ?", arrayOf(id.toString()))
db.delete(table_projects, "$key_id = ?", arrayOf(id.toString())) db.delete(table_projects, "$key_id = ?", arrayOf(id.toString()))
SecureStorage.removePasswordSync(context, "ProjectPassword_$id")
} }
fun updateProject( fun updateProject(
@@ -681,7 +693,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
val values = ContentValues() val values = ContentValues()
if (newName != null) values.put(key_name, newName) if (newName != null) values.put(key_name, newName)
if (newEmail != null) values.put(key_email, newEmail) if (newEmail != null) values.put(key_email, newEmail)
if (newPassword != null) values.put(key_password, newPassword) if (newPassword != null) SecureStorage.savePasswordSync(context, "ProjectPassword_$projId", newPassword)
if (newBearerToken != null) values.put(key_bearer_token, newBearerToken) if (newBearerToken != null) values.put(key_bearer_token, newBearerToken)
if (newLastPayerId != null) values.put(key_lastPayerId, newLastPayerId) if (newLastPayerId != null) values.put(key_lastPayerId, newLastPayerId)
if (newLastSyncedTimestamp != null) values.put(key_lastSyncTimestamp, newLastSyncedTimestamp) if (newLastSyncedTimestamp != null) values.put(key_lastSyncTimestamp, newLastSyncedTimestamp)
@@ -721,7 +733,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
val values = ContentValues() val values = ContentValues()
if (newName != null) values.put(key_name, newName) if (newName != null) values.put(key_name, newName)
if (newEmail != null) values.put(key_email, newEmail) if (newEmail != null) values.put(key_email, newEmail)
if (newPassword != null) values.put(key_password, newPassword) if (newPassword != null) SecureStorage.savePasswordSync(context, "ProjectPassword_$projId", newPassword)
if (newBearerToken != null) values.put(key_bearer_token, newBearerToken) if (newBearerToken != null) values.put(key_bearer_token, newBearerToken)
if (newLastPayerId != null) values.put(key_lastPayerId, newLastPayerId) if (newLastPayerId != null) values.put(key_lastPayerId, newLastPayerId)
if (newLastSyncedTimestamp != null) values.put(key_lastSyncTimestamp, newLastSyncedTimestamp) if (newLastSyncedTimestamp != null) values.put(key_lastSyncTimestamp, newLastSyncedTimestamp)
@@ -320,48 +320,69 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
} }
} }
val currenciesToDelete = dbHelper.getCurrenciesOfProjectWithState(project.id, DBBill.STATE_DELETED) if (project.type == ProjectType.COSPEND) {
for (cToDel in currenciesToDelete) { val currenciesToDelete = dbHelper.getCurrenciesOfProjectWithState(project.id, DBBill.STATE_DELETED)
try { for (cToDel in currenciesToDelete) {
val deleteRemoteCurrencyResponse = client!!.deleteRemoteCurrency(project, cToDel.remoteId) try {
if (deleteRemoteCurrencyResponse.stringContent == "OK") { val deleteRemoteCurrencyResponse = client!!.deleteRemoteCurrency(project, cToDel.remoteId)
Log.d(TAG, "successfully deleted currency on remote project : delete it locally") if (deleteRemoteCurrencyResponse.stringContent == "OK") {
dbHelper.deleteCurrency(cToDel.id) Log.d(TAG, "successfully deleted currency on remote project : delete it locally")
} dbHelper.deleteCurrency(cToDel.id)
} catch (e: IOException) { }
if (e.message == "\"Not Found\"") { } catch (e: IOException) {
Log.d(TAG, "failed to delete currency on remote project : delete it locally anyway") if (e.message == "\"Not Found\"") {
dbHelper.deleteCurrency(cToDel.id) Log.d(TAG, "failed to delete currency on remote project : delete it locally anyway")
} else { dbHelper.deleteCurrency(cToDel.id)
throw e } else {
throw e
}
} }
} }
} else {
val currenciesToDelete = dbHelper.getCurrenciesOfProjectWithState(project.id, DBBill.STATE_DELETED)
for (cToDel in currenciesToDelete) {
dbHelper.deleteCurrency(cToDel.id)
}
} }
val currenciesToEdit = dbHelper.getCurrenciesOfProjectWithState(project.id, DBBill.STATE_EDITED) if (project.type == ProjectType.COSPEND) {
for (cToEdit in currenciesToEdit) { val currenciesToEdit = dbHelper.getCurrenciesOfProjectWithState(project.id, DBBill.STATE_EDITED)
try { for (cToEdit in currenciesToEdit) {
val editRemoteCurrencyResponse = client!!.editRemoteCurrency(project, cToEdit) try {
if (editRemoteCurrencyResponse.stringContent == cToEdit.remoteId.toString()) { val editRemoteCurrencyResponse = client!!.editRemoteCurrency(project, cToEdit)
dbHelper.setCurrencyState(cToEdit.id, DBBill.STATE_OK) if (editRemoteCurrencyResponse.stringContent == cToEdit.remoteId.toString()) {
Log.d(TAG, "SUCCESSFUL remote currency edition (${editRemoteCurrencyResponse.stringContent})") dbHelper.setCurrencyState(cToEdit.id, DBBill.STATE_OK)
} else { Log.d(TAG, "SUCCESSFUL remote currency edition (${editRemoteCurrencyResponse.stringContent})")
Log.d(TAG, "FAILED to edit remote currency (${editRemoteCurrencyResponse.stringContent})") } else {
} Log.d(TAG, "FAILED to edit remote currency (${editRemoteCurrencyResponse.stringContent})")
} catch (e: IOException) { }
if (e.message == "{\"message\": \"Internal Server Error\"}") { } catch (e: IOException) {
Log.d(TAG, "FAILED to edit remote currency : it does not exist remotely") if (e.message == "{\"message\": \"Internal Server Error\"}") {
} else { Log.d(TAG, "FAILED to edit remote currency : it does not exist remotely")
throw e } else {
throw e
}
} }
} }
} else {
val currenciesToEdit = dbHelper.getCurrenciesOfProjectWithState(project.id, DBBill.STATE_EDITED)
for (cToEdit in currenciesToEdit) {
dbHelper.setCurrencyState(cToEdit.id, DBBill.STATE_OK)
}
} }
val currencyToAdd = dbHelper.getCurrenciesOfProjectWithState(project.id, DBBill.STATE_ADDED) if (project.type == ProjectType.COSPEND) {
for (cToAdd in currencyToAdd) { val currencyToAdd = dbHelper.getCurrenciesOfProjectWithState(project.id, DBBill.STATE_ADDED)
val createRemoteCurrencyResponse = client!!.createRemoteCurrency(project, cToAdd) for (cToAdd in currencyToAdd) {
val newRemoteId = createRemoteCurrencyResponse.stringContent.toLong() val createRemoteCurrencyResponse = client!!.createRemoteCurrency(project, cToAdd)
if (newRemoteId > 0) { val newRemoteId = createRemoteCurrencyResponse.stringContent.toLong()
if (newRemoteId > 0) {
dbHelper.setCurrencyState(cToAdd.id, DBBill.STATE_OK)
}
}
} else {
val currencyToAdd = dbHelper.getCurrenciesOfProjectWithState(project.id, DBBill.STATE_ADDED)
for (cToAdd in currencyToAdd) {
dbHelper.setCurrencyState(cToAdd.id, DBBill.STATE_OK) dbHelper.setCurrencyState(cToAdd.id, DBBill.STATE_OK)
} }
} }
@@ -111,6 +111,7 @@ object ThemeUtils {
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.navigationBarsPadding() .navigationBarsPadding()
.imePadding()
) { ) {
content() content()
} }
@@ -35,13 +35,13 @@ class NextcloudClient(
val method = if (useOcsApi) METHOD_GET else METHOD_POST val method = if (useOcsApi) METHOD_GET else METHOD_POST
return if (nextcloudAPI != null) { return if (nextcloudAPI != null) {
Log.d(javaClass.simpleName, "using SSO to get/sync account projects") Log.d(javaClass.simpleName, "using SSO to get/sync account projects")
Log.d(javaClass.simpleName, "Sync projects target $target") // Log.d(javaClass.simpleName, "Sync projects target $target")
ServerResponse.AccountProjectsResponse( ServerResponse.AccountProjectsResponse(
requestServerWithSSO(nextcloudAPI, target, method, null, useOcsApi), requestServerWithSSO(nextcloudAPI, target, method, null, useOcsApi),
useOcsApi useOcsApi
) )
} else { } else {
Log.d(javaClass.simpleName, "Sync projects target $target") // Log.d(javaClass.simpleName, "Sync projects target $target")
ServerResponse.AccountProjectsResponse( ServerResponse.AccountProjectsResponse(
requestServer(target, method, null, "", true, useOcsApi), requestServer(target, method, null, "", true, useOcsApi),
useOcsApi useOcsApi
@@ -184,7 +184,7 @@ class NextcloudClient(
): VersatileProjectSyncClient.ResponseData { ): VersatileProjectSyncClient.ResponseData {
val result = StringBuilder() val result = StringBuilder()
val targetURL = url + target.replace("^/".toRegex(), "") val targetURL = url + target.replace("^/".toRegex(), "")
Log.d(javaClass.simpleName, "method and target URL: $method $targetURL") // Log.d(javaClass.simpleName, "method and target URL: $method $targetURL")
val httpCon = SupportUtil.getHttpURLConnection(targetURL) val httpCon = SupportUtil.getHttpURLConnection(targetURL)
httpCon.requestMethod = method httpCon.requestMethod = method
if (needLogin) { if (needLogin) {
@@ -206,7 +206,7 @@ class NextcloudClient(
var paramData: ByteArray? = null var paramData: ByteArray? = null
if (params != null) { if (params != null) {
paramData = params.toString().toByteArray() paramData = params.toString().toByteArray()
Log.d(javaClass.simpleName, "Params: $params") // Log.d(javaClass.simpleName, "Params: $params")
httpCon.setFixedLengthStreamingMode(paramData.size) httpCon.setFixedLengthStreamingMode(paramData.size)
httpCon.setRequestProperty("Content-Type", application_json) httpCon.setRequestProperty("Content-Type", application_json)
httpCon.doOutput = true httpCon.doOutput = true
@@ -263,11 +263,11 @@ class NextcloudClient(
httpCon.setRequestProperty("OCS-APIRequest", "true") httpCon.setRequestProperty("OCS-APIRequest", "true")
} }
httpCon.connectTimeout = 10 * 1000 // 10 seconds httpCon.connectTimeout = 10 * 1000 // 10 seconds
Log.d(javaClass.simpleName, "$method $targetURL") // Log.d(javaClass.simpleName, "$method $targetURL")
var paramData: ByteArray? var paramData: ByteArray?
if (params != null) { if (params != null) {
paramData = params.toString().toByteArray() paramData = params.toString().toByteArray()
Log.d(javaClass.simpleName, "Params: $params") // Log.d(javaClass.simpleName, "Params: $params")
httpCon.setFixedLengthStreamingMode(paramData.size) httpCon.setFixedLengthStreamingMode(paramData.size)
httpCon.setRequestProperty("Content-Type", application_json) httpCon.setRequestProperty("Content-Type", application_json)
httpCon.doOutput = true httpCon.doOutput = true
@@ -0,0 +1,84 @@
package net.helcel.cowspent.util
import android.content.Context
import android.util.Base64
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.google.crypto.tink.Aead
import com.google.crypto.tink.KeyTemplates
import com.google.crypto.tink.RegistryConfiguration
import com.google.crypto.tink.aead.AeadConfig
import com.google.crypto.tink.integration.android.AndroidKeysetManager
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "secure_prefs")
object SecureStorage {
private var aead: Aead? = null
@Synchronized
private fun getAead(context: Context): Aead {
aead?.let { return it }
AeadConfig.register()
val masterKeyUri = "android-keystore://secure_storage_master_key"
val keysetManager = AndroidKeysetManager.Builder()
.withSharedPref(context, "tink_keyset", "secure_storage_keyset_prefs")
.withKeyTemplate(KeyTemplates.get("AES256_GCM"))
.withMasterKeyUri(masterKeyUri)
.build()
val newAead = keysetManager.keysetHandle.getPrimitive(RegistryConfiguration.get(), Aead::class.java)
aead = newAead
return newAead
}
private fun encrypt(context: Context, value: String): String {
val encrypted = getAead(context).encrypt(value.toByteArray(), null)
return Base64.encodeToString(encrypted, Base64.NO_WRAP)
}
private fun decrypt(context: Context, encryptedValue: String): String? {
return try {
val decoded = Base64.decode(encryptedValue, Base64.NO_WRAP)
val decrypted = getAead(context).decrypt(decoded, null)
String(decrypted)
} catch (_: Exception) {
null
}
}
suspend fun savePassword(context: Context, key: String, password: String?) {
if (password == null) {
removePassword(context, key)
} else {
val encrypted = encrypt(context, password)
context.dataStore.edit { it[stringPreferencesKey(key)] = encrypted }
}
}
suspend fun getPassword(context: Context, key: String): String? {
val encrypted = context.dataStore.data.map { it[stringPreferencesKey(key)] }.first()
return encrypted?.let { decrypt(context, it) }
}
suspend fun removePassword(context: Context, key: String) {
context.dataStore.edit { it.remove(stringPreferencesKey(key)) }
}
// Synchronous alternatives for legacy code
fun savePasswordSync(context: Context, key: String, password: String?) = runBlocking {
savePassword(context, key, password)
}
fun getPasswordSync(context: Context, key: String): String? = runBlocking {
getPassword(context, key)
}
fun removePasswordSync(context: Context, key: String) = runBlocking {
removePassword(context, key)
}
}
@@ -1,11 +1,12 @@
package net.helcel.cowspent.util package net.helcel.cowspent.util
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.util.Log import android.util.Log
import net.helcel.cowspent.model.* import net.helcel.cowspent.model.CreditDebt
import net.helcel.cowspent.model.DBBill
import net.helcel.cowspent.model.DBMember
import net.helcel.cowspent.model.Transaction
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import org.json.JSONException import org.json.JSONException
import org.json.JSONObject import org.json.JSONObject
@@ -81,13 +81,9 @@ class VersatileProjectSyncClient(
else else
project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId
useOcsApiRequest = cospendVersionGT161 useOcsApiRequest = cospendVersionGT161
if (cospendVersionGT161) {
Log.i(TAG, "using new API (weblogin, $username:$password) for getProjectInfo")
}
} else if (canAccessProjectWithSSO(project)) { } else if (canAccessProjectWithSSO(project)) {
return if (cospendVersionGT161) { return if (cospendVersionGT161) {
target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId
Log.i(TAG, "using new API for getProjectInfo")
ServerResponse.ProjectResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_GET, null, null, true), true) ServerResponse.ProjectResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_GET, null, null, true), true)
} else { } else {
target = "/index.php/apps/cospend/api-priv/projects/" + project.remoteId target = "/index.php/apps/cospend/api-priv/projects/" + project.remoteId
@@ -99,7 +95,6 @@ class VersatileProjectSyncClient(
project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password)
else else
project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password)
Log.i(TAG, "using public API, target is: ${target}for getProjectInfo")
} }
} else { } else {
target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId
@@ -172,13 +167,9 @@ class VersatileProjectSyncClient(
else else
project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId
useOcsApiRequest = cospendVersionGT161 useOcsApiRequest = cospendVersionGT161
if (cospendVersionGT161) {
Log.i(TAG, "using new API (weblogin, $username:$password) for editRemoteProject")
}
} else if (canAccessProjectWithSSO(project)) { } else if (canAccessProjectWithSSO(project)) {
return if (cospendVersionGT161) { return if (cospendVersionGT161) {
target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId
Log.i(TAG, "using new API for editRemoteProject")
ServerResponse.EditRemoteProjectResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_PUT, paramKeys, paramValues, true), true) ServerResponse.EditRemoteProjectResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_PUT, paramKeys, paramValues, true), true)
} else { } else {
target = "/index.php/apps/cospend/api-priv/projects/" + project.remoteId target = "/index.php/apps/cospend/api-priv/projects/" + project.remoteId
@@ -190,7 +181,6 @@ class VersatileProjectSyncClient(
project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password)
else else
project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password)
Log.i(TAG, "using public API, target is: ${target}for editRemoteProject")
} }
} else { } else {
target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId
@@ -239,13 +229,9 @@ class VersatileProjectSyncClient(
else else
project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/members/" + member.remoteId project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/members/" + member.remoteId
useOcsApiRequest = cospendVersionGT161 useOcsApiRequest = cospendVersionGT161
if (cospendVersionGT161) {
Log.i(TAG, "using new API (weblogin, $username:$password) for editRemoteMember")
}
} else if (canAccessProjectWithSSO(project)) { } else if (canAccessProjectWithSSO(project)) {
return if (cospendVersionGT161) { return if (cospendVersionGT161) {
target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/members/" + member.remoteId target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/members/" + member.remoteId
Log.i(TAG, "using new API for editRemoteMember")
ServerResponse.EditRemoteMemberResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_PUT, paramKeys, paramValues, true), true) ServerResponse.EditRemoteMemberResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_PUT, paramKeys, paramValues, true), true)
} else { } else {
target = "/index.php/apps/cospend/api-priv/projects/" + project.remoteId + "/members/" + member.remoteId target = "/index.php/apps/cospend/api-priv/projects/" + project.remoteId + "/members/" + member.remoteId
@@ -257,7 +243,6 @@ class VersatileProjectSyncClient(
project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/members/" + member.remoteId project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/members/" + member.remoteId
else else
project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/members/" + member.remoteId project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/members/" + member.remoteId
Log.i(TAG, "using public API, target is: ${target}for editRemoteMember")
} }
} else { } else {
target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/members/" + member.remoteId target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/members/" + member.remoteId
@@ -333,9 +318,6 @@ class VersatileProjectSyncClient(
else else
project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/bills/" + bill.remoteId project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/bills/" + bill.remoteId
useOcsApiRequest = cospendVersionGT161 useOcsApiRequest = cospendVersionGT161
if (cospendVersionGT161) {
Log.i(TAG, "using new API (weblogin, $username:$password) for editRemoteBill")
}
} else if (canAccessProjectWithSSO(project)) { } else if (canAccessProjectWithSSO(project)) {
return if (cospendVersionGT161) { return if (cospendVersionGT161) {
target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/bills/" + bill.remoteId target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/bills/" + bill.remoteId
@@ -351,7 +333,6 @@ class VersatileProjectSyncClient(
project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/bills/" + bill.remoteId project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/bills/" + bill.remoteId
else else
project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/bills/" + bill.remoteId project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/bills/" + bill.remoteId
Log.i(TAG, "using public API, target is: ${target}for editRemoteBill")
} }
} else { } else {
target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/bills/" + bill.remoteId target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/bills/" + bill.remoteId
@@ -389,9 +370,6 @@ class VersatileProjectSyncClient(
else else
project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId
useOcsApiRequest = cospendVersionGT161 useOcsApiRequest = cospendVersionGT161
if (cospendVersionGT161) {
Log.i(TAG, "using new API (weblogin, $username:$password) for deleteRemoteProject")
}
} else if (canAccessProjectWithSSO(project)) { } else if (canAccessProjectWithSSO(project)) {
return if (cospendVersionGT161) { return if (cospendVersionGT161) {
target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId
@@ -407,7 +385,6 @@ class VersatileProjectSyncClient(
project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password)
else else
project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password)
Log.i(TAG, "using public API, target is: ${target}for deleteRemoteProject")
} }
} else { } else {
target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId
@@ -439,9 +416,6 @@ class VersatileProjectSyncClient(
else else
project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/bills/" + billRemoteId project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/bills/" + billRemoteId
useOcsApiRequest = cospendVersionGT161 useOcsApiRequest = cospendVersionGT161
if (cospendVersionGT161) {
Log.i(TAG, "using new API (weblogin, $username:$password) for deleteRemoteBill")
}
} else if (canAccessProjectWithSSO(project)) { } else if (canAccessProjectWithSSO(project)) {
return if (cospendVersionGT161) { return if (cospendVersionGT161) {
target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/bills/" + billRemoteId target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/bills/" + billRemoteId
@@ -457,7 +431,6 @@ class VersatileProjectSyncClient(
project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/bills/" + billRemoteId project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/bills/" + billRemoteId
else else
project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/bills/" + billRemoteId project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/bills/" + billRemoteId
Log.i(TAG, "using public API, target is: ${target}for deleteRemoteProject")
} }
} else { } else {
target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/bills/" + billRemoteId target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/bills/" + billRemoteId
@@ -528,9 +501,7 @@ class VersatileProjectSyncClient(
else else
project.getRequestBaseUrl(false) + "/api-priv/projects" project.getRequestBaseUrl(false) + "/api-priv/projects"
useOcsApiRequest = cospendVersionGT161 useOcsApiRequest = cospendVersionGT161
if (cospendVersionGT161) {
Log.i(TAG, "using new API (weblogin, $username:$password) for createAuthenticatedRemoteProject")
}
return ServerResponse.CreateRemoteProjectResponse( return ServerResponse.CreateRemoteProjectResponse(
requestServer( requestServer(
target, METHOD_POST, paramKeys, paramValues, target, METHOD_POST, paramKeys, paramValues,
@@ -594,9 +565,6 @@ class VersatileProjectSyncClient(
else else
project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/bills" project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/bills"
useOcsApiRequest = cospendVersionGT161 useOcsApiRequest = cospendVersionGT161
if (cospendVersionGT161) {
Log.i(TAG, "using new API (weblogin, $username:$password) for createRemoteBill")
}
} else if (canAccessProjectWithSSO(project)) { } else if (canAccessProjectWithSSO(project)) {
return if (cospendVersionGT161) { return if (cospendVersionGT161) {
target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/bills" target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/bills"
@@ -612,7 +580,6 @@ class VersatileProjectSyncClient(
project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/bills" project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/bills"
else else
project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/bills" project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/bills"
Log.i(TAG, "using public API, target is: ${target}for createRemoteBill")
} }
} else { } else {
target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/bills" target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/bills"
@@ -664,9 +631,6 @@ class VersatileProjectSyncClient(
else else
project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/members" project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/members"
useOcsApiRequest = cospendVersionGT161 useOcsApiRequest = cospendVersionGT161
if (cospendVersionGT161) {
Log.i(TAG, "using new API (weblogin, $username:$password) for createRemoteMember")
}
} else if (canAccessProjectWithSSO(project)) { } else if (canAccessProjectWithSSO(project)) {
return if (cospendVersionGT161) { return if (cospendVersionGT161) {
target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/members" target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/members"
@@ -682,7 +646,6 @@ class VersatileProjectSyncClient(
project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/members" project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/members"
else else
project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/members" project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/members"
Log.i(TAG, "using public API, target is: ${target}for createRemoteBill")
} }
} else { } else {
target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/members" target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/members"
@@ -717,9 +680,6 @@ class VersatileProjectSyncClient(
else else
project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/bills?lastchanged=" + tsLastSync project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/bills?lastchanged=" + tsLastSync
useOcsApiRequest = cospendVersionGT161 useOcsApiRequest = cospendVersionGT161
if (cospendVersionGT161) {
Log.i(TAG, "using new API (weblogin, $username:$password) for getBills")
}
return ServerResponse.BillsResponse( return ServerResponse.BillsResponse(
requestServer( requestServer(
target, METHOD_GET, null, null, target, METHOD_GET, null, null,
@@ -750,7 +710,6 @@ class VersatileProjectSyncClient(
project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/bills?lastChanged=" + tsLastSync project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/bills?lastChanged=" + tsLastSync
else else
project.getRequestBaseUrl(false) + "/apiv2/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/bills?lastchanged=" + tsLastSync project.getRequestBaseUrl(false) + "/apiv2/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/bills?lastchanged=" + tsLastSync
Log.i(TAG, "using public API, target is: ${target}for getBills")
return ServerResponse.BillsResponse( return ServerResponse.BillsResponse(
requestServer( requestServer(
target, METHOD_GET, null, null, target, METHOD_GET, null, null,
@@ -790,9 +749,6 @@ class VersatileProjectSyncClient(
else else
project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/members" project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/members"
useOcsApiRequest = cospendVersionGT161 useOcsApiRequest = cospendVersionGT161
if (cospendVersionGT161) {
Log.i(TAG, "using new API (weblogin, $username:$password) for getMembers, projectId: " + project.remoteId)
}
} else if (canAccessProjectWithSSO(project)) { } else if (canAccessProjectWithSSO(project)) {
return if (cospendVersionGT161) { return if (cospendVersionGT161) {
target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/members" target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/members"
@@ -808,7 +764,6 @@ class VersatileProjectSyncClient(
project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/members" project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/members"
else else
project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/members" project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/members"
Log.i(TAG, "using public API, target is: ${target}for getMembers")
} }
} else { } else {
target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/members" target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/members"
@@ -847,9 +802,6 @@ class VersatileProjectSyncClient(
else else
project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/currency" project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/currency"
useOcsApiRequest = cospendVersionGT161 useOcsApiRequest = cospendVersionGT161
if (cospendVersionGT161) {
Log.i(TAG, "using new API (weblogin, $username:$password) for createRemoteCurrency")
}
} else if (canAccessProjectWithSSO(project)) { } else if (canAccessProjectWithSSO(project)) {
return if (cospendVersionGT161) { return if (cospendVersionGT161) {
target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/currency" target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/currency"
@@ -865,7 +817,6 @@ class VersatileProjectSyncClient(
project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/currency" project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/currency"
else else
project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/currency" project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/currency"
Log.i(TAG, "using public API, target is: ${target}for createRemoteCurrency")
} }
} else { } else {
target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/currency" target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/currency"
@@ -904,9 +855,6 @@ class VersatileProjectSyncClient(
else else
project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/currency/" + currency.remoteId project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/currency/" + currency.remoteId
useOcsApiRequest = cospendVersionGT161 useOcsApiRequest = cospendVersionGT161
if (cospendVersionGT161) {
Log.i(TAG, "using new API (weblogin, $username:$password) for editRemoteCurrency")
}
} else if (canAccessProjectWithSSO(project)) { } else if (canAccessProjectWithSSO(project)) {
return if (cospendVersionGT161) { return if (cospendVersionGT161) {
target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/currency/" + currency.remoteId target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/currency/" + currency.remoteId
@@ -922,7 +870,6 @@ class VersatileProjectSyncClient(
project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/currency/" + currency.remoteId project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/currency/" + currency.remoteId
else else
project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/currency/" + currency.remoteId project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/currency/" + currency.remoteId
Log.i(TAG, "using public API, target is: ${target}for createRemoteCurrency")
} }
} else { } else {
target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/currency/" + currency.remoteId target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/currency/" + currency.remoteId
@@ -954,9 +901,6 @@ class VersatileProjectSyncClient(
else else
project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/currency/" + currencyRemoteId project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/currency/" + currencyRemoteId
useOcsApiRequest = cospendVersionGT161 useOcsApiRequest = cospendVersionGT161
if (cospendVersionGT161) {
Log.i(TAG, "using new API (weblogin, $username:$password) for deleteRemoteCurrency")
}
} else if (canAccessProjectWithSSO(project)) { } else if (canAccessProjectWithSSO(project)) {
return if (cospendVersionGT161) { return if (cospendVersionGT161) {
target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/currency/" + currencyRemoteId target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/currency/" + currencyRemoteId
@@ -972,7 +916,6 @@ class VersatileProjectSyncClient(
project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/currency/" + currencyRemoteId project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/currency/" + currencyRemoteId
else else
project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/currency/" + currencyRemoteId project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/currency/" + currencyRemoteId
Log.i(TAG, "using public API, target is: ${target}for deleteRemoteCurrency")
} }
} else { } else {
target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/currency/" + currencyRemoteId target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/currency/" + currencyRemoteId
@@ -1071,7 +1014,6 @@ class VersatileProjectSyncClient(
httpCon.setRequestProperty("Accept", "application/json") httpCon.setRequestProperty("Accept", "application/json")
} }
httpCon.connectTimeout = 10 * 1000 // 10 seconds httpCon.connectTimeout = 10 * 1000 // 10 seconds
Log.d(javaClass.simpleName, "$method $target")
if (paramKeys != null && paramValues != null) { if (paramKeys != null && paramValues != null) {
var dataString = "" var dataString = ""
for (i in paramKeys.indices) { for (i in paramKeys.indices) {
@@ -1084,7 +1026,6 @@ class VersatileProjectSyncClient(
dataString += URLEncoder.encode(value, "UTF-8") dataString += URLEncoder.encode(value, "UTF-8")
} }
val data = dataString.toByteArray() val data = dataString.toByteArray()
Log.d(javaClass.simpleName, "Params: $dataString")
httpCon.setFixedLengthStreamingMode(data.size) httpCon.setFixedLengthStreamingMode(data.size)
httpCon.setRequestProperty("Content-Type", "application/x-www-form-urlencoded") httpCon.setRequestProperty("Content-Type", "application/x-www-form-urlencoded")
httpCon.setRequestProperty("Content-Length", data.size.toString()) httpCon.setRequestProperty("Content-Length", data.size.toString())
@@ -29,7 +29,6 @@
<path android:fillColor="#a57939" android:pathData="m65.1818,16.5746l-3.5658.2422c-.1304.0089-.2591.0347-.3828.077l-7.081,2.42c-.043.0147-.0854.0314-.1269.0499l-2.6409,1.1807c-.1171.0524-.2341.0883-.3607.1091-1.3737.2264-10.1731,1.8874-10.0194,7.2658,0,0-.4646,3.6003,1.951,6.1473,1.8803,1.9826,2.9889,4.6478,2.5192,7.3395-.3101,1.7768-1.2515,3.5306-3.4702,4.594h0l2.9104,1.0217c.4295.1508.767.4887.9174.9183l1.4763,4.2192,1.6616-1.7712c1.1136-1.1945,1.9689-5.8742,1.9689-5.8742.4855-3.6065.5032-7.264.0031-10.8685-.0148-.1064-.0287-.2031-.0417-.2889h0s1.2606-.965,1.2606-.965c.1608-.1231.3449-.2123.5411-.2622l5.5633-1.4149c.1716-.0436.3341-.1174.4799-.2177l4.3017-2.9608c.1811-.1247.3326-.2877.4436-.4775l2.6005-4.4465c.1016-.1737.1671-.3661.1927-.5658l.4849-3.7874c.1198-.9359-.6453-1.748-1.5867-1.6841Z"/> <path android:fillColor="#a57939" android:pathData="m65.1818,16.5746l-3.5658.2422c-.1304.0089-.2591.0347-.3828.077l-7.081,2.42c-.043.0147-.0854.0314-.1269.0499l-2.6409,1.1807c-.1171.0524-.2341.0883-.3607.1091-1.3737.2264-10.1731,1.8874-10.0194,7.2658,0,0-.4646,3.6003,1.951,6.1473,1.8803,1.9826,2.9889,4.6478,2.5192,7.3395-.3101,1.7768-1.2515,3.5306-3.4702,4.594h0l2.9104,1.0217c.4295.1508.767.4887.9174.9183l1.4763,4.2192,1.6616-1.7712c1.1136-1.1945,1.9689-5.8742,1.9689-5.8742.4855-3.6065.5032-7.264.0031-10.8685-.0148-.1064-.0287-.2031-.0417-.2889h0s1.2606-.965,1.2606-.965c.1608-.1231.3449-.2123.5411-.2622l5.5633-1.4149c.1716-.0436.3341-.1174.4799-.2177l4.3017-2.9608c.1811-.1247.3326-.2877.4436-.4775l2.6005-4.4465c.1016-.1737.1671-.3661.1927-.5658l.4849-3.7874c.1198-.9359-.6453-1.748-1.5867-1.6841Z"/>
<path android:fillColor="#a57939" android:pathData="m6.867,16.5746l3.5658.2422c.1304.0089.2591.0347.3828.077l7.081,2.42c.043.0147.0854.0314.1269.0499l2.6409,1.1807c.1171.0524.2341.0883.3607.1091,1.3737.2264,10.1731,1.8874,10.0194,7.2658,0,0,.4645,3.6003-1.951,6.1473-1.8803,1.9826-2.9889,4.6478-2.5192,7.3395.3101,1.7768,1.2515,3.5306,3.4702,4.594h0l-2.9104,1.0217c-.4295.1508-.767.4887-.9174.9183l-1.4763,4.2192-1.6616-1.7712c-1.1136-1.1945-2.0412-6.9083-2.0412-6.9083-.3413-3.1673-.1698-6.3689.5077-9.4817h0c.1234-.5672-.092-1.1546-.553-1.5075l-.4665-.3571c-.1858-.1422-.4024-.2389-.6323-.2824l-6.0587-1.1441c-.2049-.0387-.3994-.1197-.5711-.2379l-4.2616-2.9332c-.1811-.1247-.3326-.2877-.4436-.4775l-2.6005-4.4465c-.1016-.1737-.1671-.3661-.1927-.5657l-.485-3.7875c-.1198-.9359.6453-1.7481,1.5867-1.6841Z"/> <path android:fillColor="#a57939" android:pathData="m6.867,16.5746l3.5658.2422c.1304.0089.2591.0347.3828.077l7.081,2.42c.043.0147.0854.0314.1269.0499l2.6409,1.1807c.1171.0524.2341.0883.3607.1091,1.3737.2264,10.1731,1.8874,10.0194,7.2658,0,0,.4645,3.6003-1.951,6.1473-1.8803,1.9826-2.9889,4.6478-2.5192,7.3395.3101,1.7768,1.2515,3.5306,3.4702,4.594h0l-2.9104,1.0217c-.4295.1508-.767.4887-.9174.9183l-1.4763,4.2192-1.6616-1.7712c-1.1136-1.1945-2.0412-6.9083-2.0412-6.9083-.3413-3.1673-.1698-6.3689.5077-9.4817h0c.1234-.5672-.092-1.1546-.553-1.5075l-.4665-.3571c-.1858-.1422-.4024-.2389-.6323-.2824l-6.0587-1.1441c-.2049-.0387-.3994-.1197-.5711-.2379l-4.2616-2.9332c-.1811-.1247-.3326-.2877-.4436-.4775l-2.6005-4.4465c-.1016-.1737-.1671-.3661-.1927-.5657l-.485-3.7875c-.1198-.9359.6453-1.7481,1.5867-1.6841Z"/>
<path android:fillColor="#f4aa41" android:pathData="m27.3197,47.9945l-.6598,1.1318c-.7217,2.1691-.9617,4.4695-.7033,6.7408h0c.1194,1.0495.9592,1.871,2.0111,1.9674l.0811.0074,6.4963-.5146c1.249-.0989,2.5045-.0796,3.7499.0577l5.9262.6536c.7598.0838,1.5171-.1772,2.064-.7112h0c.4669-.4559.7436-1.0718.7746-1.7237l.0484-1.0203c.1133-2.3893-.4008-4.7667-1.4912-6.8958h0c-.4317-.843-1.2874-1.3848-2.234-1.4148l-8.6286-.2728h-3.9632c-1.4296,0-2.7515.7595-3.4715,1.9945Z"/> <path android:fillColor="#f4aa41" android:pathData="m27.3197,47.9945l-.6598,1.1318c-.7217,2.1691-.9617,4.4695-.7033,6.7408h0c.1194,1.0495.9592,1.871,2.0111,1.9674l.0811.0074,6.4963-.5146c1.249-.0989,2.5045-.0796,3.7499.0577l5.9262.6536c.7598.0838,1.5171-.1772,2.064-.7112h0c.4669-.4559.7436-1.0718.7746-1.7237l.0484-1.0203c.1133-2.3893-.4008-4.7667-1.4912-6.8958h0c-.4317-.843-1.2874-1.3848-2.234-1.4148l-8.6286-.2728h-3.9632c-1.4296,0-2.7515.7595-3.4715,1.9945Z"/>
</group> </group>
<group android:name="line"> <group android:name="line">
<path android:fillColor="#000" android:pathData="m26.1616,31.0595s-3.0474-.219-3.8284-1-.781-2.0474,0-2.8284,2.0474-.781,2.8284,0c.7811.781,1,3.8284,1,3.8284Z"/> <path android:fillColor="#000" android:pathData="m26.1616,31.0595s-3.0474-.219-3.8284-1-.781-2.0474,0-2.8284,2.0474-.781,2.8284,0c.7811.781,1,3.8284,1,3.8284Z"/>
@@ -0,0 +1,31 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="72"
android:viewportHeight="72">
<group
android:scaleX="0.63461536"
android:scaleY="0.63461536"
android:translateX="13.153846"
android:translateY="13.153846">
<!-- Cow Outlines -->
<group android:name="color">
<path android:fillColor="#000" android:pathData="m15,12.0211h0c0,.6116.3056,1.1827.8145,1.5219l4.2449,2.83c.5764.3843,1.774.8721,1.688,1.5596l-1.7051,6.7284c-.028.2241-.0143.4514.0405.6705l1.8343,7.3371c.0548.2191.0685.4464.0405.6705l-.9152,7.322c-.028.2241-.0143.4514.0405.6705l1.7456,6.9825c.1104.4414.3813.8259.7599,1.0783l1.7312,1.1541c.433.2887.7225.7481.7961,1.2632l-.2598,3.7979s-.2916,3.1088,2.3012,6.051c1.6332,1.8533,5.7148,3.8846,8.2656,3.8847,3.3409,0,5.9547-1.9378,7.3949-3.6465.8914-1.0576,2.5398-2.4978,3.2821-5.8244.2014-.9023-.0996-3.6967-.0996-3.6967,0-.2477.4009-1.311.4985-1.5386l1.3063-2.5077c.0158-.0369.0264-.0595.0389-.0795.5394-.8642.7885-1.8786.8851-2.8927,0,0,.9043-4.9747,1.1585-6.6406.005-.0325-.4996-5.5158-.4953-5.5485l1.5496-6.8172c.0386-.2315.0323-.4683-.0187-.6975l-1.5364-6.9139c-.2151-.9678.3801-1.9308,1.3419-2.1712l1.6416-.4104c.4075-.1019.7676-.341,1.0196-.677l2.2445-2.9927c.2375-.3166.3658-.7017.3658-1.0974v-.1102c0-1.1701-1.1152-1.1434-2.2575-.8896l-6.191.4868c-.3593.0798-.7343.0494-1.0761-.0873l-4.3602-1.7441c-.0767-.0307-.1555-.0562-.2357-.0762l-7.3628-1.8407c-.3375-.0844-.692-.0708-1.022.0393l-5.3714,1.7905c-.0821.0274-.1622.0605-.2396.0992l-3.4104,1.7052c-.3092.1546-.656.2184-1,.184l-7.4623-.7462c-1.0768-.1077-2.0111.7379-2.0111,1.82Z"/>
<path android:fillColor="#000" android:pathData="m65.1818,16.5746l-3.5658.2422c-.1304.0089-.2591.0347-.3828.077l-7.081,2.42c-.043.0147-.0854.0314-.1269.0499l-2.6409,1.1807c-.1171.0524-.2341.0883-.3607.1091-1.3737.2264-10.1731,1.8874-10.0194,7.2658,0,0-.4646,3.6003,1.951,6.1473,1.8803,1.9826,2.9889,4.6478,2.5192,7.3395-.3101,1.7768-1.2515,3.5306-3.4702,4.594h0l2.9104,1.0217c.4295.1508.767.4887.9174.9183l1.4763,4.2192,1.6616-1.7712c1.1136-1.1945,1.9689-5.8742,1.9689-5.8742.4855-3.6065.5032-7.264.0031-10.8685-.0148-.1064-.0287-.2031-.0417-.2889h0s1.2606-.965,1.2606-.965c.1608-.1231.3449-.2123.5411-.2622l5.5633-1.4149c.1716-.0436.3341-.1174.4799-.2177l4.3017-2.9608c.1811-.1247.3326-.2877.4436-.4775l2.6005-4.4465c.1016-.1737.1671-.3661.1927-.5658l.4849-3.7874c.1198-.9359-.6453-1.748-1.5867-1.6841Z"/>
<path android:fillColor="#000" android:pathData="m6.867,16.5746l3.5658.2422c.1304.0089.2591.0347.3828.077l7.081,2.42c.043.0147.0854.0314.1269.0499l2.6409,1.1807c.1171.0524.2341.0883.3607.1091,1.3737.2264,10.1731,1.8874,10.0194,7.2658,0,0,.4645,3.6003-1.951,6.1473-1.8803,1.9826-2.9889,4.6478-2.5192,7.3395.3101,1.7768,1.2515,3.5306,3.4702,4.594h0l-2.9104,1.0217c-.4295.1508-.767.4887-.9174.9183l-1.4763,4.2192-1.6616-1.7712c-1.1136-1.1945-2.0412-6.9083-2.0412-6.9083-.3413-3.1673-.1698-6.3689.5077-9.4817h0c.1234-.5672-.092-1.1546-.553-1.5075l-.4665-.3571c-.1858-.1422-.4024-.2389-.6323-.2824l-6.0587-1.1441c-.2049-.0387-.3994-.1197-.5711-.2379l-4.2616-2.9332c-.1811-.1247-.3326-.2877-.4436-.4775l-2.6005-4.4465c-.1016-.1737-.1671-.3661-.1927-.5657l-.485-3.7875c-.1198-.9359.6453-1.7481,1.5867-1.6841Z"/>
<path android:fillColor="#000" android:pathData="m27.3197,47.9945l-.6598,1.1318c-.7217,2.1691-.9617,4.4695-.7033,6.7408h0c.1194,1.0495.9592,1.871,2.0111,1.9674l.0811.0074,6.4963-.5146c1.249-.0989,2.5045-.0796,3.7499.0577l5.9262.6536c.7598.0838,1.5171-.1772,2.064-.7112h0c.4669-.4559.7436-1.0718.7746-1.7237l.0484-1.0203c.1133-2.3893-.4008-4.7667-1.4912-6.8958h0c-.4317-.843-1.2874-1.3848-2.234-1.4148l-8.6286-.2728h-3.9632c-1.4296,0-2.7515.7595-3.4715,1.9945Z"/>
</group>
<group android:name="line">
<path android:fillColor="#000" android:pathData="m26.1616,31.0595s-3.0474-.219-3.8284-1-.781-2.0474,0-2.8284,2.0474-.781,2.8284,0c.7811.781,1,3.8284,1,3.8284Z"/>
<path android:strokeColor="#000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2" android:pathData="m19.6191,26.2353l2.0377-9.248s-7.6806-.627-6.505-7.4454c0,0,9.9534,3.2917,12.7748.4702,0,0,6.2375-6.6642,16.1956-.0429,3.3226,2.2093,12.7748-.4702,12.7748-.4702,1.1756,6.8184-6.505,7.4454-6.505,7.4454l2.0377,9.248"/>
<path android:strokeColor="#000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2" android:pathData="m28.4825,46h15.7265c.272,0,.5324.1138.7082.3213.8467.9992,3.1442,4.3471,2.0943,10.2966-.1836,1.0404-1.1647,1.7418-2.2037,1.5503-3.4759-.6406-11.324-1.7893-16.7592-.3263-1.1058.2976-2.2065-.454-2.3432-1.5909-.302-2.5115-.3157-6.6284,2.0103-9.8682.1751-.2438.4667-.3828.7668-.3828Z"/>
<path android:strokeColor="#000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2" android:pathData="m29.8671,50.277s-.6865,2.855,3.1538,2.926"/>
<path android:strokeColor="#000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2" android:pathData="m29.1276,61.4663s6.3482,7.9157,13.7936.3135"/>
<path android:strokeColor="#000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2" android:pathData="m17.5177,20.1544c-3.0642-1.9576-7.3791-4.5873-12.4353-3.3426,0,0-1.4891,14.0288,15.3611,15.5179l1.2133.881s-2.2906,8.9177,1.2618,16.1018"/>
<path android:fillColor="#000" android:pathData="m45.8871,31.0139s3.0474-.219,3.8284-1c.7811-.781.7811-2.0474,0-2.8284-.781-.781-2.0474-.781-2.8284,0-.781.781-1,3.8284-1,3.8284Z"/>
<path android:strokeColor="#000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2" android:pathData="m43.1817,50.2341s.6865,2.855-3.1538,2.926"/>
<path android:strokeColor="#000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2" android:pathData="m54.7743,19.9585c3.0474-1.888,7.2683-4.4017,12.1921-3.1896,0,0,1.4891,14.0288-15.3611,15.5179l-1.2133.881s2.5382,7.9772-1.0141,15.1613"/>
</group>
</group>
</vector>
-52
View File
@@ -1,52 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="128dp"
android:height="128dp"
android:viewportWidth="72"
android:viewportHeight="72">
<path
android:fillColor="#FFD700"
android:pathData="M36,2 C17.22,2 2,17.22 2,36 C2,54.78 17.22,70 36,70 C54.78,70 70,54.78 70,36 C70,17.22 54.78,2 36,2 Z" />
<!-- Inner Face (Expanded path) -->
<path
android:fillColor="#DAA520"
android:pathData="M36,8 C20.54,8 8,20.54 8,36 C8,51.46 20.54,64 36,64 C51.46,64 64,51.46 64,36 C64,20.54 51.46,8 36,8 Z" />
<!-- Center Detail (Pure path replacement for the center circle) -->
<path
android:fillColor="#FFD700"
android:pathData="M36,22 C28.27,22 22,28.27 22,36 C22,43.73 28.27,50 36,50 C43.73,50 50,43.73 50,36 C50,28.27 43.73,22 36,22 Z" />
<group android:name="color">
<path android:fillColor="#fff" android:pathData="m15,12.0211h0c0,.6116.3056,1.1827.8145,1.5219l4.2449,2.83c.5764.3843,1.774.8721,1.688,1.5596l-1.7051,6.7284c-.028.2241-.0143.4514.0405.6705l1.8343,7.3371c.0548.2191.0685.4464.0405.6705l-.9152,7.322c-.028.2241-.0143.4514.0405.6705l1.7456,6.9825c.1104.4414.3813.8259.7599,1.0783l1.7312,1.1541c.433.2887.7225.7481.7961,1.2632l-.2598,3.7979s-.2916,3.1088,2.3012,6.051c1.6332,1.8533,5.7148,3.8846,8.2656,3.8847,3.3409,0,5.9547-1.9378,7.3949-3.6465.8914-1.0576,2.5398-2.4978,3.2821-5.8244.2014-.9023-.0996-3.6967-.0996-3.6967,0-.2477.4009-1.311.4985-1.5386l1.3063-2.5077c.0158-.0369.0264-.0595.0389-.0795.5394-.8642.7885-1.8786.8851-2.8927,0,0,.9043-4.9747,1.1585-6.6406.005-.0325-.4996-5.5158-.4953-5.5485l1.5496-6.8172c.0386-.2315.0323-.4683-.0187-.6975l-1.5364-6.9139c-.2151-.9678.3801-1.9308,1.3419-2.1712l1.6416-.4104c.4075-.1019.7676-.341,1.0196-.677l2.2445-2.9927c.2375-.3166.3658-.7017.3658-1.0974v-.1102c0-1.1701-1.1152-1.1434-2.2575-.8896l-6.191.4868c-.3593.0798-.7343.0494-1.0761-.0873l-4.3602-1.7441c-.0767-.0307-.1555-.0562-.2357-.0762l-7.3628-1.8407c-.3375-.0844-.692-.0708-1.022.0393l-5.3714,1.7905c-.0821.0274-.1622.0605-.2396.0992l-3.4104,1.7052c-.3092.1546-.656.2184-1,.184l-7.4623-.7462c-1.0768-.1077-2.0111.7379-2.0111,1.82Z"/>
<path android:fillColor="#a57939" android:pathData="m65.1818,16.5746l-3.5658.2422c-.1304.0089-.2591.0347-.3828.077l-7.081,2.42c-.043.0147-.0854.0314-.1269.0499l-2.6409,1.1807c-.1171.0524-.2341.0883-.3607.1091-1.3737.2264-10.1731,1.8874-10.0194,7.2658,0,0-.4646,3.6003,1.951,6.1473,1.8803,1.9826,2.9889,4.6478,2.5192,7.3395-.3101,1.7768-1.2515,3.5306-3.4702,4.594h0l2.9104,1.0217c.4295.1508.767.4887.9174.9183l1.4763,4.2192,1.6616-1.7712c1.1136-1.1945,1.9689-5.8742,1.9689-5.8742.4855-3.6065.5032-7.264.0031-10.8685-.0148-.1064-.0287-.2031-.0417-.2889h0s1.2606-.965,1.2606-.965c.1608-.1231.3449-.2123.5411-.2622l5.5633-1.4149c.1716-.0436.3341-.1174.4799-.2177l4.3017-2.9608c.1811-.1247.3326-.2877.4436-.4775l2.6005-4.4465c.1016-.1737.1671-.3661.1927-.5658l.4849-3.7874c.1198-.9359-.6453-1.748-1.5867-1.6841Z"/>
<path android:fillColor="#a57939" android:pathData="m6.867,16.5746l3.5658.2422c.1304.0089.2591.0347.3828.077l7.081,2.42c.043.0147.0854.0314.1269.0499l2.6409,1.1807c.1171.0524.2341.0883.3607.1091,1.3737.2264,10.1731,1.8874,10.0194,7.2658,0,0,.4645,3.6003-1.951,6.1473-1.8803,1.9826-2.9889,4.6478-2.5192,7.3395.3101,1.7768,1.2515,3.5306,3.4702,4.594h0l-2.9104,1.0217c-.4295.1508-.767.4887-.9174.9183l-1.4763,4.2192-1.6616-1.7712c-1.1136-1.1945-2.0412-6.9083-2.0412-6.9083-.3413-3.1673-.1698-6.3689.5077-9.4817h0c.1234-.5672-.092-1.1546-.553-1.5075l-.4665-.3571c-.1858-.1422-.4024-.2389-.6323-.2824l-6.0587-1.1441c-.2049-.0387-.3994-.1197-.5711-.2379l-4.2616-2.9332c-.1811-.1247-.3326-.2877-.4436-.4775l-2.6005-4.4465c-.1016-.1737-.1671-.3661-.1927-.5657l-.485-3.7875c-.1198-.9359.6453-1.7481,1.5867-1.6841Z"/>
<path android:fillColor="#f4aa41" android:pathData="m27.3197,47.9945l-.6598,1.1318c-.7217,2.1691-.9617,4.4695-.7033,6.7408h0c.1194,1.0495.9592,1.871,2.0111,1.9674l.0811.0074,6.4963-.5146c1.249-.0989,2.5045-.0796,3.7499.0577l5.9262.6536c.7598.0838,1.5171-.1772,2.064-.7112h0c.4669-.4559.7436-1.0718.7746-1.7237l.0484-1.0203c.1133-2.3893-.4008-4.7667-1.4912-6.8958h0c-.4317-.843-1.2874-1.3848-2.234-1.4148l-8.6286-.2728h-3.9632c-1.4296,0-2.7515.7595-3.4715,1.9945Z"/>
</group>
<group android:name="line">
<path android:fillColor="#000" android:pathData="m26.1616,31.0595s-3.0474-.219-3.8284-1-.781-2.0474,0-2.8284,2.0474-.781,2.8284,0c.7811.781,1,3.8284,1,3.8284Z"/>
<path android:strokeColor="#000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2" android:pathData="m19.6191,26.2353l2.0377-9.248s-7.6806-.627-6.505-7.4454c0,0,9.9534,3.2917,12.7748.4702,0,0,6.2375-6.6642,16.1956-.0429,3.3226,2.2093,12.7748-.4702,12.7748-.4702,1.1756,6.8184-6.505,7.4454-6.505,7.4454l2.0377,9.248"/>
<path android:strokeColor="#000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2" android:pathData="m28.4825,46h15.7265c.272,0,.5324.1138.7082.3213.8467.9992,3.1442,4.3471,2.0943,10.2966-.1836,1.0404-1.1647,1.7418-2.2037,1.5503-3.4759-.6406-11.324-1.7893-16.7592-.3263-1.1058.2976-2.2065-.454-2.3432-1.5909-.302-2.5115-.3157-6.6284,2.0103-9.8682.1751-.2438.4667-.3828.7668-.3828Z"/>
<path android:strokeColor="#000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2" android:pathData="m29.8671,50.277s-.6865,2.855,3.1538,2.926"/>
<path android:strokeColor="#000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2" android:pathData="m29.1276,61.4663s6.3482,7.9157,13.7936.3135"/>
<path android:strokeColor="#000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2" android:pathData="m17.5177,20.1544c-3.0642-1.9576-7.3791-4.5873-12.4353-3.3426,0,0-1.4891,14.0288,15.3611,15.5179l1.2133.881s-2.2906,8.9177,1.2618,16.1018"/>
<path android:fillColor="#000" android:pathData="m45.8871,31.0139s3.0474-.219,3.8284-1c.7811-.781.7811-2.0474,0-2.8284-.781-.781-2.0474-.781-2.8284,0-.781.781-1,3.8284-1,3.8284Z"/>
<path android:strokeColor="#000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2" android:pathData="m43.1817,50.2341s.6865,2.855-3.1538,2.926"/>
<path android:strokeColor="#000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2" android:pathData="m54.7743,19.9585c3.0474-1.888,7.2683-4.4017,12.1921-3.1896,0,0,1.4891,14.0288-15.3611,15.5179l-1.2133.881s2.5382,7.9772-1.0141,15.1613"/>
</group>
<!-- Massive Background Coin Base -->
<path
android:pathData="M 256,76 A 180,180 0 1,1 255.9,76 Z"
android:fillColor="#FBBF24"/>
<!-- Coin Decorative Dotted Inner Rim -->
<path
android:pathData="M 256,101 A 155,155 0 1,1 255.9,101 Z"
android:strokeColor="#D97706"
android:strokeWidth="4"/>
</vector>
@@ -2,5 +2,5 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/> <background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/> <foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground"/> <monochrome android:drawable="@drawable/ic_launcher_monochrome"/>
</adaptive-icon> </adaptive-icon>
@@ -2,5 +2,5 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/> <background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/> <foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground"/> <monochrome android:drawable="@drawable/ic_launcher_monochrome"/>
</adaptive-icon> </adaptive-icon>
-6
View File
@@ -20,14 +20,9 @@
<string name="save_or_discard_bill_dialog_save">Save</string> <string name="save_or_discard_bill_dialog_save">Save</string>
<string name="save_or_discard_bill_dialog_discard">Discard</string> <string name="save_or_discard_bill_dialog_discard">Discard</string>
<string name="currency_manager">Currency manager</string> <string name="currency_manager">Currency manager</string>
<string name="add_currency_title">Add currency</string>
<string name="currency_management_unavailable">Currency management is only available for Cospend projects</string>
<string name="setting_comment">Comment</string> <string name="setting_comment">Comment</string>
<string name="currency_dialog_title">Convert current amount from another currency into %s</string> <string name="currency_dialog_title">Convert current amount from another currency into %s</string>
<string name="no_currency_error">There is no additional currency</string>
<string name="main_currency">Main currency</string> <string name="main_currency">Main currency</string>
<string name="currency_edit_name">Name</string>
<string name="currency_rate">Exchange rate</string>
<string name="setting_owers">For whom?</string> <string name="setting_owers">For whom?</string>
<string name="setting_project_id">Project ID/name</string> <string name="setting_project_id">Project ID/name</string>
@@ -75,7 +70,6 @@
<string name="simple_ok" tools:ignore="ButtonCase,Typos">Ok</string> <string name="simple_ok" tools:ignore="ButtonCase,Typos">Ok</string>
<string name="simple_yes">Yes</string> <string name="simple_yes">Yes</string>
<string name="simple_no">No</string> <string name="simple_no">No</string>
<string name="simple_add">Add</string>
<string name="simple_edit_bill">Edit bill</string> <string name="simple_edit_bill">Edit bill</string>
<string name="simple_new_bill">New bill</string> <string name="simple_new_bill">New bill</string>
<string name="simple_add_project">Add project in Cowspent</string> <string name="simple_add_project">Add project in Cowspent</string>
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
networkTimeout=10000 networkTimeout=10000
retries=0 retries=0
retryBackOffMs=500 retryBackOffMs=500
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB