Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
119ae757b9
|
|||
|
c3d380ddb4
|
|||
|
a8ba00269b
|
|||
|
756a37de23
|
|||
|
5f7266013d
|
|||
|
e98dc4a86b
|
|||
|
afb14ff7b9
|
|||
|
1bee1dabbe
|
|||
|
84432fe68d
|
|||
|
7e00b14d27
|
|||
|
ec4d60897a
|
|||
|
5c74336c10
|
|||
|
d617c5c7be
|
|||
|
a9a303478c
|
|||
| 110ab397b8 | |||
| 3675e87651 | |||
| 65799496f9 | |||
|
2967dbd001
|
Binary file not shown.
|
After Width: | Height: | Size: 6.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
@@ -1,9 +1,10 @@
|
||||
<!--suppress ALL -->
|
||||
<div align="center">
|
||||
<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>
|
||||
|
||||
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).
|
||||
|
||||
+17
-7
@@ -1,7 +1,7 @@
|
||||
plugins {
|
||||
id 'com.android.application' version '9.2.1'
|
||||
id 'org.jetbrains.kotlin.plugin.serialization' version '2.3.21'
|
||||
id 'org.jetbrains.kotlin.plugin.compose' version '2.3.21'
|
||||
id 'org.jetbrains.kotlin.plugin.serialization' version '2.4.0'
|
||||
id 'org.jetbrains.kotlin.plugin.compose' version '2.4.0'
|
||||
}
|
||||
|
||||
android {
|
||||
@@ -14,7 +14,7 @@ android {
|
||||
applicationId "net.helcel.cowspent"
|
||||
minSdk = 26
|
||||
targetSdk = 37
|
||||
versionName "1.0"
|
||||
versionName "1.0b"
|
||||
versionCode project.hasProperty('VERSION_CODE') ? project.property('VERSION_CODE').toInteger() : 1
|
||||
}
|
||||
|
||||
@@ -82,11 +82,18 @@ android {
|
||||
androidResources {
|
||||
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 {
|
||||
implementation 'androidx.compose.foundation:foundation:1.11.2'
|
||||
implementation 'androidx.compose.runtime:runtime:1.11.2'
|
||||
implementation 'androidx.compose.foundation:foundation:1.11.3'
|
||||
implementation 'androidx.compose.runtime:runtime:1.11.3'
|
||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.5'
|
||||
|
||||
implementation 'androidx.preference:preference-ktx:1.2.1'
|
||||
@@ -99,7 +106,7 @@ dependencies {
|
||||
implementation 'com.github.nextcloud:Android-SingleSignOn:1.1.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.material:material'
|
||||
implementation 'androidx.compose.material:material-icons-extended'
|
||||
@@ -107,5 +114,8 @@ dependencies {
|
||||
debugImplementation 'androidx.compose.ui:ui-tooling'
|
||||
implementation 'androidx.activity:activity-compose: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"
|
||||
}
|
||||
|
||||
@@ -20,11 +20,14 @@
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme">
|
||||
|
||||
<activity android:name="net.helcel.cowspent.android.currencies.ManageCurrenciesActivity" />
|
||||
<activity
|
||||
android:name="net.helcel.cowspent.android.currencies.ManageCurrenciesActivity"
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
|
||||
<activity
|
||||
android:name="net.helcel.cowspent.android.main.BillsListViewActivity"
|
||||
android:theme="@style/AppTheme.NoActionBar"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:exported="true">
|
||||
|
||||
<intent-filter>
|
||||
@@ -45,7 +48,7 @@
|
||||
android:name="net.helcel.cowspent.android.account.AccountActivity"
|
||||
android:label="@string/settings_server_settings"
|
||||
android:parentActivityName="net.helcel.cowspent.android.settings.PreferencesActivity"
|
||||
android:windowSoftInputMode="stateVisible"
|
||||
android:windowSoftInputMode="adjustResize|stateVisible"
|
||||
android:launchMode="standard"
|
||||
/>
|
||||
|
||||
@@ -66,7 +69,7 @@
|
||||
android:name="net.helcel.cowspent.android.project.create.NewProjectActivity"
|
||||
android:label="@string/simple_add_project"
|
||||
android:parentActivityName="net.helcel.cowspent.android.main.BillsListViewActivity"
|
||||
android:windowSoftInputMode="stateHidden"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:launchMode="standard"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
@@ -93,7 +96,7 @@
|
||||
android:name="net.helcel.cowspent.android.project.edit.EditProjectActivity"
|
||||
android:label="@string/simple_edit_project"
|
||||
android:parentActivityName="net.helcel.cowspent.android.main.BillsListViewActivity"
|
||||
android:windowSoftInputMode="stateHidden"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:launchMode="standard">
|
||||
</activity>
|
||||
|
||||
@@ -101,7 +104,7 @@
|
||||
android:name="net.helcel.cowspent.android.bill_edit.EditBillActivity"
|
||||
android:label="@string/simple_edit_bill"
|
||||
android:parentActivityName="net.helcel.cowspent.android.main.BillsListViewActivity"
|
||||
android:windowSoftInputMode="stateHidden"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:launchMode="standard">
|
||||
</activity>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package net.helcel.cowspent.android.about
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import net.helcel.cowspent.theme.ThemeUtils
|
||||
@@ -8,6 +9,7 @@ import net.helcel.cowspent.theme.ThemeUtils
|
||||
class AboutActivity : AppCompatActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setContent {
|
||||
|
||||
@@ -14,6 +14,7 @@ import android.webkit.WebViewClient
|
||||
import android.widget.Toast
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -32,7 +33,6 @@ import androidx.lifecycle.lifecycleScope
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.nextcloud.android.sso.AccountImporter
|
||||
import com.nextcloud.android.sso.helper.SingleAccountHelper
|
||||
import com.nextcloud.android.sso.model.SingleSignOnAccount
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -41,6 +41,7 @@ import net.helcel.cowspent.android.main.MainConstants
|
||||
import net.helcel.cowspent.theme.ThemeUtils
|
||||
import net.helcel.cowspent.util.CospendClientUtil
|
||||
import net.helcel.cowspent.util.CospendClientUtil.LoginStatus
|
||||
import net.helcel.cowspent.util.SecureStorage
|
||||
import java.net.URLDecoder
|
||||
import java.util.Locale
|
||||
|
||||
@@ -68,11 +69,11 @@ class AccountActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
private lateinit var preferences: SharedPreferences
|
||||
private var oldPassword = ""
|
||||
private var useWebLogin = true
|
||||
private var showLoginDialog by mutableStateOf(false)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
preferences = PreferenceManager.getDefaultSharedPreferences(applicationContext)
|
||||
@@ -127,8 +128,6 @@ class AccountActivity : AppCompatActivity() {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
oldPassword = preferences.getString(SETTINGS_PASSWORD, DEFAULT_SETTINGS) ?: ""
|
||||
viewModel.validateUrl()
|
||||
}
|
||||
|
||||
@@ -222,11 +221,7 @@ class AccountActivity : AppCompatActivity() {
|
||||
private fun legacyLogin() {
|
||||
val url = CospendClientUtil.formatURL(viewModel.serverUrl.trim())
|
||||
val username = viewModel.username
|
||||
var password = viewModel.password
|
||||
|
||||
if (password.isEmpty()) {
|
||||
password = oldPassword
|
||||
}
|
||||
val password = viewModel.password
|
||||
|
||||
performLogin(url, username, password)
|
||||
}
|
||||
@@ -309,10 +304,10 @@ class AccountActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
if (status == LoginStatus.OK) {
|
||||
SecureStorage.savePassword(applicationContext, SETTINGS_PASSWORD, password)
|
||||
preferences.edit {
|
||||
putString(SETTINGS_URL, url)
|
||||
putString(SETTINGS_USERNAME, username)
|
||||
putString(SETTINGS_PASSWORD, password)
|
||||
remove(SETTINGS_KEY_ETAG)
|
||||
remove(SETTINGS_KEY_LAST_MODIFIED)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.helcel.cowspent.util.CospendClientUtil
|
||||
import net.helcel.cowspent.util.SecureStorage
|
||||
|
||||
class AccountViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private val preferences: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(application)
|
||||
@@ -60,14 +61,15 @@ class AccountViewModel(application: Application) : AndroidViewModel(application)
|
||||
} else {
|
||||
preferences.getString(AccountActivity.SETTINGS_USERNAME, "")
|
||||
}
|
||||
val password = if (useSso) {
|
||||
""
|
||||
} else {
|
||||
preferences.getString(AccountActivity.SETTINGS_PASSWORD, "")
|
||||
}
|
||||
|
||||
if (!url.isNullOrEmpty() && !username.isNullOrEmpty()) {
|
||||
viewModelScope.launch {
|
||||
val password = if (useSso) {
|
||||
""
|
||||
} else {
|
||||
SecureStorage.getPassword(getApplication(), AccountActivity.SETTINGS_PASSWORD)
|
||||
}
|
||||
|
||||
isValidatingLogin = true
|
||||
isLoggedIn = withContext(Dispatchers.IO) {
|
||||
if (useSso) {
|
||||
@@ -87,6 +89,9 @@ class AccountViewModel(application: Application) : AndroidViewModel(application)
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
viewModelScope.launch {
|
||||
SecureStorage.removePassword(getApplication(), AccountActivity.SETTINGS_PASSWORD)
|
||||
}
|
||||
preferences.edit {
|
||||
remove(AccountActivity.SETTINGS_USE_SSO)
|
||||
remove(AccountActivity.SETTINGS_SSO_URL)
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.app.TimePickerDialog
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
@@ -26,6 +27,7 @@ import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
|
||||
import net.helcel.cowspent.theme.ThemeUtils
|
||||
import net.helcel.cowspent.util.BillParser
|
||||
import net.helcel.cowspent.util.CategoryUtils
|
||||
import net.helcel.cowspent.util.SupportUtil
|
||||
import java.text.ParseException
|
||||
import java.time.ZoneId
|
||||
import java.util.Calendar
|
||||
@@ -39,6 +41,7 @@ class EditBillActivity : AppCompatActivity() {
|
||||
private val calendar = Calendar.getInstance()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
db = CowspentSQLiteOpenHelper.getInstance(this)
|
||||
|
||||
@@ -105,7 +108,8 @@ class EditBillActivity : AppCompatActivity() {
|
||||
val createIntent = Intent(this@EditBillActivity, QrCodeScannerActivity::class.java)
|
||||
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
|
||||
)
|
||||
}
|
||||
@@ -183,12 +187,18 @@ class EditBillActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
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 currencies = db.getCurrencies(bill.projectId)
|
||||
withContext(Dispatchers.Main) {
|
||||
viewModel.currencies = currencies
|
||||
viewModel.mainCurrencyName = project?.currencyName ?: ""
|
||||
viewModel.isNewBill = (bill.id == 0L)
|
||||
viewModel.initFromBill(bill, members, customSplits)
|
||||
}
|
||||
}
|
||||
@@ -204,6 +214,7 @@ class EditBillActivity : AppCompatActivity() {
|
||||
calendar.timeInMillis = austrianBill.date.time
|
||||
viewModel.timestamp = calendar.timeInMillis / 1000
|
||||
viewModel.amount = austrianBill.amount.toString()
|
||||
viewModel.updateSplits()
|
||||
return@registerForActivityResult
|
||||
} catch (_: ParseException) {
|
||||
}
|
||||
@@ -215,6 +226,7 @@ class EditBillActivity : AppCompatActivity() {
|
||||
viewModel.timestamp = calendar.timeInMillis / 1000
|
||||
}
|
||||
viewModel.amount = croatianBill.amount.toString()
|
||||
viewModel.updateSplits()
|
||||
return@registerForActivityResult
|
||||
} catch (_: ParseException) {
|
||||
}
|
||||
@@ -223,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() {
|
||||
if (!valuesHaveChanged()) {
|
||||
finish()
|
||||
@@ -296,9 +322,9 @@ class EditBillActivity : AppCompatActivity() {
|
||||
|
||||
return !(bill.what == viewModel.what &&
|
||||
bill.timestamp == viewModel.timestamp &&
|
||||
bill.amount == viewModel.amountAsDouble &&
|
||||
bill.amount == viewModel.getFinalAmount() &&
|
||||
bill.payerId == viewModel.payerId &&
|
||||
bill.comment == viewModel.comment &&
|
||||
bill.comment == viewModel.getFinalComment() &&
|
||||
bill.repeat == viewModel.repeat &&
|
||||
bill.categoryRemoteId == viewModel.categoryRemoteId &&
|
||||
bill.paymentModeRemoteId == viewModel.paymentModeRemoteId &&
|
||||
@@ -310,13 +336,17 @@ class EditBillActivity : AppCompatActivity() {
|
||||
val isCustomSplit = viewModel.isCustomSplit
|
||||
|
||||
if (isCustomSplit) {
|
||||
val splits = viewModel.owersCustomSplit.filter { (id, amount) ->
|
||||
viewModel.owersSelection[id] == true && (amount.replace(',', '.').toDoubleOrNull()
|
||||
val splits: Map<Long, Double> = viewModel.owersCustomSplit.filter { (id, amountStr) ->
|
||||
viewModel.owersSelection[id] == true && (amountStr.replace(',', '.').toDoubleOrNull()
|
||||
?: 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
|
||||
|
||||
|
||||
val finalComment = viewModel.getFinalComment()
|
||||
val splitEntries = splits.entries.toList()
|
||||
|
||||
// Pool of existing bills in this group that we can potentially reuse
|
||||
@@ -358,7 +388,7 @@ class EditBillActivity : AppCompatActivity() {
|
||||
existingBill.paymentMode,
|
||||
viewModel.paymentModeRemoteId,
|
||||
viewModel.categoryRemoteId,
|
||||
viewModel.comment
|
||||
finalComment
|
||||
)
|
||||
if (firstSavedId == 0L) firstSavedId = billToUseId
|
||||
} else {
|
||||
@@ -366,7 +396,7 @@ class EditBillActivity : AppCompatActivity() {
|
||||
val newBill = DBBill(
|
||||
0, 0, bill.projectId, viewModel.payerId, amount,
|
||||
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))
|
||||
val newId = db.addBill(newBill)
|
||||
@@ -396,7 +426,8 @@ class EditBillActivity : AppCompatActivity() {
|
||||
|
||||
return@withContext firstSavedId
|
||||
} else {
|
||||
val newAmount = viewModel.amountAsDouble
|
||||
val newAmount = viewModel.getFinalAmount()
|
||||
val finalComment = viewModel.getFinalComment()
|
||||
val newOwersIds = viewModel.getOwersIds()
|
||||
|
||||
if (bill.id != 0L) {
|
||||
@@ -412,7 +443,7 @@ class EditBillActivity : AppCompatActivity() {
|
||||
bill.paymentMode,
|
||||
viewModel.paymentModeRemoteId,
|
||||
viewModel.categoryRemoteId,
|
||||
viewModel.comment
|
||||
finalComment
|
||||
)
|
||||
if (groupedBillIds != null) {
|
||||
for (id in groupedBillIds) {
|
||||
@@ -429,7 +460,7 @@ class EditBillActivity : AppCompatActivity() {
|
||||
val newBill = DBBill(
|
||||
0, 0, bill.projectId, viewModel.payerId, newAmount,
|
||||
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) }
|
||||
val newBillId = db.addBill(newBill)
|
||||
|
||||
@@ -45,6 +45,7 @@ fun EditBillScreen(
|
||||
onDateClick: () -> Unit,
|
||||
onTimeClick: () -> Unit,
|
||||
onScan: () -> Unit,
|
||||
onDuplicate: (() -> Unit)? = null,
|
||||
onDelete: (() -> Unit)? = null,
|
||||
accessLevel: Int = DBProject.ACCESS_LEVEL_ADMIN
|
||||
) {
|
||||
@@ -59,7 +60,7 @@ fun EditBillScreen(
|
||||
Scaffold(
|
||||
topBar = {
|
||||
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 = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
|
||||
@@ -70,6 +71,11 @@ fun EditBillScreen(
|
||||
IconButton(onClick = onScan) {
|
||||
Icon(Icons.Default.QrCodeScanner, contentDescription = null)
|
||||
}
|
||||
if (onDuplicate != null) {
|
||||
IconButton(onClick = onDuplicate) {
|
||||
Icon(Icons.Default.ContentCopy, contentDescription = "Duplicate")
|
||||
}
|
||||
}
|
||||
if (onDelete != null) {
|
||||
IconButton(onClick = onDelete) {
|
||||
Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.action_delete))
|
||||
@@ -111,6 +117,7 @@ fun EditBillScreen(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(padding)
|
||||
.imePadding()
|
||||
.padding(16.dp)
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scrollState)
|
||||
@@ -160,7 +167,6 @@ fun BillBasicInfoSection(
|
||||
val context = LocalContext.current
|
||||
val currencyDialogTitle =
|
||||
stringResource(R.string.currency_dialog_title, viewModel.mainCurrencyName)
|
||||
val noCurrencyError = stringResource(R.string.no_currency_error)
|
||||
|
||||
OutlinedTextField(
|
||||
value = viewModel.what,
|
||||
@@ -182,25 +188,37 @@ fun BillBasicInfoSection(
|
||||
enabled = canEdit,
|
||||
placeholder = { Text("0") },
|
||||
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 = {
|
||||
IconButton(
|
||||
enabled = canEdit,
|
||||
onClick = {
|
||||
if (viewModel.currencies.isNotEmpty()) {
|
||||
viewModel.showDialog(
|
||||
title = currencyDialogTitle,
|
||||
items = viewModel.currencies.map { "${it.name} (${it.exchangeRate})" },
|
||||
onItemSelected = { index ->
|
||||
viewModel.convertCurrency(viewModel.currencies[index])
|
||||
}
|
||||
)
|
||||
} else {
|
||||
showToast(context, noCurrencyError)
|
||||
val mainLabel = viewModel.mainCurrencyName.ifEmpty { "$" }
|
||||
val options = listOf("$mainLabel | Base") + viewModel.currencies.map {
|
||||
"${it.name} | 1 $mainLabel = ${it.exchangeRate} ${it.name}"
|
||||
}
|
||||
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)
|
||||
@@ -221,7 +239,7 @@ fun BillBasicInfoSection(
|
||||
onClick = onDateClick,
|
||||
modifier = Modifier.weight(1f),
|
||||
enabled = canEdit,
|
||||
leadingIcon = { Icon(Icons.Default.Event, contentDescription = null) }
|
||||
leadingIcon = { Icon(Icons.Default.Event, contentDescription = "Select Date") }
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
ClickableOutlinedTextField(
|
||||
@@ -229,7 +247,7 @@ fun BillBasicInfoSection(
|
||||
onClick = onTimeClick,
|
||||
modifier = Modifier.weight(1f),
|
||||
enabled = canEdit,
|
||||
leadingIcon = { Icon(Icons.Default.AccessTime, contentDescription = null) }
|
||||
leadingIcon = { Icon(Icons.Default.AccessTime, contentDescription = "Select Time") }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -252,12 +270,8 @@ fun PayerSection(
|
||||
leadingIcon = {
|
||||
Box(modifier = Modifier.padding(start = 12.dp)) {
|
||||
if (selectedPayer != null) {
|
||||
UserAvatar(
|
||||
name = selectedPayer.name,
|
||||
r = selectedPayer.r,
|
||||
g = selectedPayer.g,
|
||||
b = selectedPayer.b,
|
||||
disabled = !selectedPayer.isActivated,
|
||||
MemberAvatar(
|
||||
member = selectedPayer,
|
||||
size = 24.dp
|
||||
)
|
||||
} else {
|
||||
@@ -271,12 +285,8 @@ fun PayerSection(
|
||||
viewModel.payerId = member.id
|
||||
payerExpanded = false
|
||||
}) {
|
||||
UserAvatar(
|
||||
name = member.name,
|
||||
r = member.r,
|
||||
g = member.g,
|
||||
b = member.b,
|
||||
disabled = !member.isActivated,
|
||||
MemberAvatar(
|
||||
member = member,
|
||||
size = 24.dp
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
@@ -381,12 +391,8 @@ fun OwerSelectionSection(
|
||||
enabled = canEdit,
|
||||
onCheckedChange = { viewModel.toggleMember(member.id, it) }
|
||||
)
|
||||
UserAvatar(
|
||||
name = member.name,
|
||||
r = member.r,
|
||||
g = member.g,
|
||||
b = member.b,
|
||||
disabled = !member.isActivated,
|
||||
MemberAvatar(
|
||||
member = member,
|
||||
size = 32.dp
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
@@ -587,6 +593,7 @@ fun EditBillScreenPreview() {
|
||||
viewModel = EditBillViewModel().apply {
|
||||
what = "Pizza"
|
||||
amount = "12.50"
|
||||
mainCurrencyName = "EUR"
|
||||
members = listOf(
|
||||
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)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package net.helcel.cowspent.android.bill_edit
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableDoubleStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
@@ -10,6 +11,7 @@ import androidx.lifecycle.ViewModel
|
||||
import net.helcel.cowspent.model.DBBill
|
||||
import net.helcel.cowspent.model.DBMember
|
||||
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.evalMath
|
||||
|
||||
@@ -25,9 +27,12 @@ class EditBillViewModel : ViewModel() {
|
||||
var repeat by mutableStateOf(DBBill.NON_REPEATED)
|
||||
var paymentModeRemoteId by mutableIntStateOf(0)
|
||||
var categoryRemoteId by mutableIntStateOf(0)
|
||||
var isNewBill by mutableStateOf(false)
|
||||
|
||||
var currencies by mutableStateOf<List<DBCurrency>>(emptyList())
|
||||
var mainCurrencyName by mutableStateOf("")
|
||||
var selectedCurrencyName by mutableStateOf("")
|
||||
var selectedCurrencyRate by mutableDoubleStateOf(1.0)
|
||||
var members by mutableStateOf<List<DBMember>>(emptyList())
|
||||
|
||||
var owersSelection = mutableStateMapOf<Long, Boolean>()
|
||||
@@ -38,13 +43,8 @@ class EditBillViewModel : ViewModel() {
|
||||
|
||||
val amountAsDouble: Double
|
||||
get() {
|
||||
val amountStr = amount.replace(',', '.')
|
||||
return try {
|
||||
if (amountStr.matches("[0-9.]+".toRegex())) {
|
||||
amountStr.toDouble()
|
||||
} else {
|
||||
evalMath(amountStr)
|
||||
}
|
||||
return parseAmount(amount) ?: try {
|
||||
evalMath(amount.replace(',', '.'))
|
||||
} catch (_: Exception) {
|
||||
0.0
|
||||
}
|
||||
@@ -128,46 +128,81 @@ class EditBillViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun convertCurrency(currency: DBCurrency) {
|
||||
val originalAmountStr = amount
|
||||
val originalAmount = amountAsDouble
|
||||
if (originalAmount == 0.0) return
|
||||
|
||||
val newAmount = originalAmount / currency.exchangeRate
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
selectedCurrencyName = currency.name ?: ""
|
||||
selectedCurrencyRate = currency.exchangeRate
|
||||
|
||||
// We don't change 'amount' here anymore as per request.
|
||||
// It stays as the original amount.
|
||||
|
||||
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) {
|
||||
this.members = members
|
||||
what = bill.what
|
||||
amount = if (bill.amount == 0.0) "" else bill.amount.toString()
|
||||
comment = bill.comment ?: ""
|
||||
timestamp = bill.timestamp
|
||||
payerId = bill.payerId
|
||||
repeat = bill.repeat ?: DBBill.NON_REPEATED
|
||||
paymentModeRemoteId = bill.paymentModeRemoteId
|
||||
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()
|
||||
owersCustomSplit.clear()
|
||||
@@ -178,13 +213,23 @@ class EditBillViewModel : ViewModel() {
|
||||
val selected = customSplits.containsKey(member.id)
|
||||
owersSelection[member.id] = 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 {
|
||||
// Even split logic
|
||||
val billOwerIds = bill.billOwersIds
|
||||
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()
|
||||
|
||||
for (member in members) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package net.helcel.cowspent.android.bill_label
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
@@ -20,6 +21,7 @@ class LabelBillsActivity : AppCompatActivity() {
|
||||
private val viewModel: LabelBillsViewModel by viewModels()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val projectId = intent.getLongExtra(EXTRA_PROJECT_ID, -1L)
|
||||
|
||||
+48
-15
@@ -3,6 +3,7 @@ package net.helcel.cowspent.android.currencies
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
@@ -14,6 +15,7 @@ import net.helcel.cowspent.R
|
||||
import net.helcel.cowspent.android.helper.showToast
|
||||
import net.helcel.cowspent.model.DBBill
|
||||
import net.helcel.cowspent.model.DBCurrency
|
||||
import net.helcel.cowspent.model.ProjectType
|
||||
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
|
||||
import net.helcel.cowspent.theme.ThemeUtils
|
||||
import net.helcel.cowspent.util.ICallback
|
||||
@@ -40,6 +42,7 @@ class ManageCurrenciesActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
intent.extras?.let {
|
||||
@@ -64,8 +67,10 @@ class ManageCurrenciesActivity : AppCompatActivity() {
|
||||
viewModel = viewModel,
|
||||
onBack = { finish() },
|
||||
onSaveMain = { saveMainCurrency() },
|
||||
onAdd = { addCurrency() },
|
||||
onDelete = { deleteCurrency(it) }
|
||||
onAdd = { addOrUpdateCurrency() },
|
||||
onDelete = { deleteCurrency(it) },
|
||||
onEdit = { startEditing(it) },
|
||||
onCancelEdit = { cancelEditing() }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -84,11 +89,17 @@ class ManageCurrenciesActivity : AppCompatActivity() {
|
||||
val project = db!!.getProject(selectedProjectID)
|
||||
if (project != null) {
|
||||
db!!.syncIfRemote(project)
|
||||
withContext(Dispatchers.Main) {
|
||||
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)
|
||||
if (project.type == ProjectType.COSPEND) {
|
||||
withContext(Dispatchers.Main) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
withContext(Dispatchers.Main) {
|
||||
showToast(this@ManageCurrenciesActivity, getString(R.string.currency_saved_success), Toast.LENGTH_LONG)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,22 +107,44 @@ class ManageCurrenciesActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun addCurrency() {
|
||||
private fun addOrUpdateCurrency() {
|
||||
val exchangeRate = try { viewModel.newCurrencyRate.toDouble() } catch (_: Exception) { 0.0 }
|
||||
val newCurrency = DBCurrency(
|
||||
0, 0, selectedProjectID,
|
||||
viewModel.newCurrencyName, exchangeRate, DBBill.STATE_ADDED
|
||||
)
|
||||
val currencyName = viewModel.newCurrencyName
|
||||
val editingId = viewModel.editingCurrencyId
|
||||
|
||||
lifecycleScope.launch {
|
||||
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 = ""
|
||||
viewModel.newCurrencyRate = ""
|
||||
cancelEditing()
|
||||
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) {
|
||||
lifecycleScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
|
||||
+242
-54
@@ -1,22 +1,57 @@
|
||||
package net.helcel.cowspent.android.currencies
|
||||
|
||||
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.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.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.Done
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
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.android.helper.AlertDialog
|
||||
import net.helcel.cowspent.android.helper.formatAmount
|
||||
import net.helcel.cowspent.model.DBCurrency
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@Composable
|
||||
fun ManageCurrenciesScreen(
|
||||
@@ -24,7 +59,9 @@ fun ManageCurrenciesScreen(
|
||||
onBack: () -> Unit,
|
||||
onSaveMain: () -> Unit,
|
||||
onAdd: () -> Unit,
|
||||
onDelete: (DBCurrency) -> Unit
|
||||
onDelete: (DBCurrency) -> Unit,
|
||||
onEdit: (DBCurrency) -> Unit,
|
||||
onCancelEdit: () -> Unit
|
||||
) {
|
||||
val dialogState = viewModel.dialogState
|
||||
if (dialogState != null) {
|
||||
@@ -73,55 +110,151 @@ fun ManageCurrenciesScreen(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(padding)
|
||||
.padding(16.dp)
|
||||
.imePadding()
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colors.onSurface.copy(alpha = 0.02f))
|
||||
) {
|
||||
Text(stringResource(R.string.main_currency), style = MaterialTheme.typography.h6)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedTextField(
|
||||
value = viewModel.mainCurrencyName,
|
||||
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))
|
||||
LaunchedEffect(viewModel.mainCurrencyName) {
|
||||
if (viewModel.mainCurrencyName.isNotEmpty()) {
|
||||
delay(500.milliseconds)
|
||||
onSaveMain()
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Text(stringResource(R.string.add_currency_title), style = MaterialTheme.typography.h6)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedTextField(
|
||||
value = viewModel.newCurrencyName,
|
||||
onValueChange = { viewModel.newCurrencyName = it },
|
||||
label = { Text(stringResource(R.string.currency_edit_name)) },
|
||||
modifier = Modifier.weight(1f)
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
// Section 1: Main Currency
|
||||
Text(
|
||||
text = stringResource(R.string.main_currency).uppercase(),
|
||||
style = MaterialTheme.typography.overline,
|
||||
color = MaterialTheme.colors.primary,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
OutlinedTextField(
|
||||
value = viewModel.newCurrencyRate,
|
||||
onValueChange = { viewModel.newCurrencyRate = it },
|
||||
label = { Text(stringResource(R.string.currency_rate)) },
|
||||
modifier = Modifier.weight(1f)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Card(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
elevation = 2.dp,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
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(
|
||||
onClick = onAdd,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = viewModel.isAddEnabled()
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
contentPadding = PaddingValues(16.dp, 0.dp, 16.dp, 16.dp),
|
||||
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 ->
|
||||
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
|
||||
fun CurrencyRow(currency: DBCurrency, onDelete: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
fun CurrencyRow(
|
||||
currency: DBCurrency,
|
||||
mainCurrencyName: String,
|
||||
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)) {
|
||||
Text(currency.name ?: "", style = MaterialTheme.typography.subtitle1)
|
||||
Text("Rate: ${currency.exchangeRate}", style = MaterialTheme.typography.caption)
|
||||
}
|
||||
IconButton(onClick = onDelete) {
|
||||
Icon(Icons.Default.Delete, contentDescription = null, tint = MaterialTheme.colors.error)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onEdit)
|
||||
.padding(16.dp, 12.dp),
|
||||
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)
|
||||
@@ -151,6 +334,9 @@ fun CurrencyRowPreview() {
|
||||
MaterialTheme {
|
||||
CurrencyRow(
|
||||
currency = DBCurrency(1, 0, 0, "USD", 1.0, 0),
|
||||
mainCurrencyName = "EUR",
|
||||
isEditing = false,
|
||||
onEdit = {},
|
||||
onDelete = {}
|
||||
)
|
||||
}
|
||||
@@ -172,7 +358,9 @@ fun ManageCurrenciesScreenPreview() {
|
||||
onBack = {},
|
||||
onSaveMain = {},
|
||||
onAdd = {},
|
||||
onDelete = {}
|
||||
onDelete = {},
|
||||
onEdit = {},
|
||||
onCancelEdit = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ class ManageCurrenciesViewModel : ViewModel() {
|
||||
var newCurrencyName by mutableStateOf("")
|
||||
var newCurrencyRate by mutableStateOf("")
|
||||
|
||||
var editingCurrencyId by mutableStateOf<Long?>(null)
|
||||
|
||||
var currencies by mutableStateOf<List<DBCurrency>>(emptyList())
|
||||
|
||||
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.filled.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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.sp
|
||||
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.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.DBProject
|
||||
import net.helcel.cowspent.model.ProjectType
|
||||
@@ -38,6 +41,7 @@ fun Drawer(
|
||||
selectedProjectId: Long,
|
||||
selectedMemberId: Long?,
|
||||
lastSyncText: String,
|
||||
mainCurrency: String? = null,
|
||||
showArchived: Boolean = false,
|
||||
onProjectClick: (Long) -> Unit,
|
||||
onProjectOptionsClick: (Long) -> Unit,
|
||||
@@ -96,22 +100,40 @@ fun Drawer(
|
||||
|
||||
// Members Section
|
||||
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(
|
||||
state = membersState,
|
||||
modifier = Modifier
|
||||
.heightIn(max = maxBottomHeight)
|
||||
.lazyVerticalScrollbar(membersState)
|
||||
) {
|
||||
if (members.isNotEmpty()) {
|
||||
if (sortedMembers.isNotEmpty()) {
|
||||
item {
|
||||
DrawerItem(
|
||||
icon = Icons.Default.Receipt,
|
||||
text = stringResource(R.string.label_all_bills),
|
||||
secondaryText = mainCurrency,
|
||||
selected = selectedMemberId == null,
|
||||
onClick = { onMemberClick(null) }
|
||||
)
|
||||
}
|
||||
items(members) { member ->
|
||||
items(sortedMembers) { member ->
|
||||
val balance = memberBalances[member.id] ?: 0.0
|
||||
MemberDrawerItem(
|
||||
member = member,
|
||||
@@ -241,6 +263,7 @@ fun DrawerItem(
|
||||
member: DBMember? = null,
|
||||
text: String? = null,
|
||||
balanceText: String? = null,
|
||||
secondaryText: String? = null,
|
||||
balanceColor: Color = Color.Unspecified,
|
||||
selected: Boolean = false,
|
||||
alpha: Float = 1f,
|
||||
@@ -259,13 +282,8 @@ fun DrawerItem(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (member != null) {
|
||||
UserAvatar(
|
||||
name = member.name,
|
||||
r = member.r,
|
||||
g = member.g,
|
||||
b = member.b,
|
||||
avatar = member.avatar,
|
||||
disabled = !member.isActivated,
|
||||
MemberAvatar(
|
||||
member = member,
|
||||
size = 24.dp,
|
||||
alpha = alpha
|
||||
)
|
||||
@@ -294,6 +312,17 @@ fun DrawerItem(
|
||||
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) {
|
||||
IconButton(onClick = onSecondaryClick) {
|
||||
@@ -340,6 +369,7 @@ fun DrawerPreview() {
|
||||
selectedProjectId = 1,
|
||||
selectedMemberId = null,
|
||||
lastSyncText = "Last sync: 5 mins ago",
|
||||
mainCurrency = "EUR",
|
||||
onProjectClick = {},
|
||||
onProjectOptionsClick = {},
|
||||
onMemberClick = {},
|
||||
|
||||
@@ -11,6 +11,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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.unit.dp
|
||||
|
||||
@@ -110,28 +111,55 @@ fun AlertDialog(
|
||||
if (message != null) Spacer(Modifier.height(8.dp))
|
||||
LazyColumn {
|
||||
itemsIndexed(items) { index, item ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
onItemSelected?.invoke(index)
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.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(
|
||||
text = item.toString(),
|
||||
style = MaterialTheme.typography.body1
|
||||
)
|
||||
if (index < items.size - 1) {
|
||||
Divider(color = MaterialTheme.colors.onSurface.copy(alpha = 0.08f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -216,28 +244,55 @@ fun AlertDialogContent(
|
||||
if (message != null) Spacer(Modifier.height(8.dp))
|
||||
LazyColumn {
|
||||
itemsIndexed(items) { index, item ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
onItemSelected?.invoke(index)
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.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(
|
||||
text = item.toString(),
|
||||
style = MaterialTheme.typography.body1
|
||||
)
|
||||
if (index < items.size - 1) {
|
||||
Divider(color = MaterialTheme.colors.onSurface.copy(alpha = 0.08f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,80 @@
|
||||
package net.helcel.cowspent.android.helper
|
||||
|
||||
import net.helcel.cowspent.util.SupportUtil
|
||||
import java.text.NumberFormat
|
||||
import java.util.Locale
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.round
|
||||
|
||||
/**
|
||||
* Formats amount using k/M notation (e.g., 1.5k for 1500).
|
||||
*/
|
||||
fun formatShortValue(value: Double): String {
|
||||
val absValue = abs(value)
|
||||
val sign = if (value < 0) "-" else ""
|
||||
return when {
|
||||
value >= 1_000_000 -> String.format(Locale.ROOT, "%.1fM", value / 1_000_000).replace(".0", "")
|
||||
value >= 1_000 -> String.format(Locale.ROOT, "%.1fk", value / 1_000).replace(".0", "")
|
||||
else -> String.format(Locale.ROOT, "%.0f", value)
|
||||
absValue >= 1_000_000 -> sign + formatAmount(absValue / 1_000_000) + "M"
|
||||
absValue >= 1_000 -> sign + formatAmount(absValue / 1_000) + "k"
|
||||
else -> sign + formatAmount(absValue)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats balance with sign and locale-aware number formatting.
|
||||
*/
|
||||
fun formatBalance(balance: Double): String {
|
||||
val rbalance = round(abs(balance) * 100.0) / 100.0
|
||||
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,36 +4,49 @@ import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import android.os.VibrationEffect
|
||||
import android.os.Vibrator
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import net.helcel.cowspent.theme.ThemeUtils
|
||||
import androidx.camera.core.*
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.ExperimentalGetImage
|
||||
import androidx.camera.core.ImageAnalysis
|
||||
import androidx.camera.core.ImageProxy
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.camera.view.PreviewView
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
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.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.platform.LocalContext
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.app.ActivityCompat
|
||||
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 net.helcel.cowspent.R
|
||||
import net.helcel.cowspent.android.main.MainConstants
|
||||
import net.helcel.cowspent.theme.ThemeUtils
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
@@ -42,6 +55,7 @@ class QrCodeScannerActivity : AppCompatActivity() {
|
||||
private var isScanning = true
|
||||
|
||||
override fun onCreate(state: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(state)
|
||||
|
||||
cameraExecutor = Executors.newSingleThreadExecutor()
|
||||
|
||||
@@ -21,6 +21,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Base64
|
||||
import net.helcel.cowspent.model.DBMember
|
||||
|
||||
@Composable
|
||||
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
|
||||
@Composable
|
||||
fun UserAvatarPreview() {
|
||||
|
||||
@@ -2,9 +2,25 @@ package net.helcel.cowspent.android.main
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
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.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.filled.Receipt
|
||||
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.unit.dp
|
||||
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.DBMember
|
||||
import net.helcel.cowspent.util.SupportUtil
|
||||
@@ -83,13 +99,8 @@ fun BillItemRow(bill: DBBill, payer: DBMember?, onClick: () -> Unit) {
|
||||
) {
|
||||
if (payer != null) {
|
||||
Box {
|
||||
UserAvatar(
|
||||
name = payer.name,
|
||||
r = payer.r,
|
||||
g = payer.g,
|
||||
b = payer.b,
|
||||
avatar = payer.avatar,
|
||||
disabled = !payer.isActivated,
|
||||
MemberAvatar(
|
||||
member = payer,
|
||||
size = 40.dp
|
||||
)
|
||||
if (bill.repeat != null && bill.repeat != DBBill.NON_REPEATED) {
|
||||
|
||||
@@ -430,6 +430,7 @@ fun BillsListScreen(
|
||||
}
|
||||
},
|
||||
drawerContent = {
|
||||
val selectedProject = viewModel.projects.find { it.id == viewModel.selectedProjectId }
|
||||
Drawer(
|
||||
projects = viewModel.projects,
|
||||
members = viewModel.members,
|
||||
@@ -437,6 +438,7 @@ fun BillsListScreen(
|
||||
selectedProjectId = viewModel.selectedProjectId,
|
||||
selectedMemberId = viewModel.selectedMemberId,
|
||||
lastSyncText = viewModel.lastSyncText,
|
||||
mainCurrency = selectedProject?.currencyName,
|
||||
showArchived = showArchived,
|
||||
onProjectClick = {
|
||||
viewModel.selectedMemberId = null
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
package net.helcel.cowspent.android.main
|
||||
|
||||
import android.Manifest
|
||||
import android.app.SearchManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
@@ -17,6 +15,7 @@ import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
@@ -25,10 +24,8 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AddCircleOutline
|
||||
import androidx.compose.material.icons.filled.Sync
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.edit
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.nextcloud.android.sso.helper.SingleAccountHelper
|
||||
@@ -48,7 +45,6 @@ import net.helcel.cowspent.model.Category
|
||||
import net.helcel.cowspent.model.DBBill
|
||||
import net.helcel.cowspent.model.DBProject
|
||||
import net.helcel.cowspent.model.GroupedBill
|
||||
import net.helcel.cowspent.model.ProjectType
|
||||
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
|
||||
import net.helcel.cowspent.persistence.CowspentServerSyncHelper
|
||||
import net.helcel.cowspent.theme.ThemeUtils
|
||||
@@ -193,6 +189,7 @@ class BillsListViewActivity :
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
isActivityVisible = true
|
||||
if (savedInstanceState != null) {
|
||||
@@ -442,11 +439,11 @@ class BillsListViewActivity :
|
||||
fun onManageCurrenciesClick(projectId: Long) {
|
||||
lifecycleScope.launch {
|
||||
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 {
|
||||
putExtra(ManageCurrenciesActivity.EXTRA_PROJECT_ID, projectId)
|
||||
})
|
||||
} else showToast(this@BillsListViewActivity, getString(R.string.currency_management_unavailable))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.util.Patterns
|
||||
import android.widget.Toast
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
@@ -20,6 +21,7 @@ import net.helcel.cowspent.model.*
|
||||
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
|
||||
import net.helcel.cowspent.theme.ThemeUtils
|
||||
import net.helcel.cowspent.util.*
|
||||
import java.util.Locale
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.content.edit
|
||||
import net.helcel.cowspent.model.ProjectType
|
||||
@@ -33,6 +35,7 @@ class NewProjectActivity : AppCompatActivity() {
|
||||
private lateinit var db: CowspentSQLiteOpenHelper
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
db = CowspentSQLiteOpenHelper.getInstance(this)
|
||||
|
||||
@@ -58,7 +61,8 @@ class NewProjectActivity : AppCompatActivity() {
|
||||
chooseFromNextcloud()
|
||||
},
|
||||
onOkPressed = { onPressOk() },
|
||||
onBack = { finish() }
|
||||
onBack = { finish() },
|
||||
onFieldsChanged = { updateAuthStatus() }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -83,6 +87,8 @@ class NewProjectActivity : AppCompatActivity() {
|
||||
viewModel.projectUrl = defaultNcUrl
|
||||
}
|
||||
}
|
||||
|
||||
updateAuthStatus()
|
||||
|
||||
val defaultProjectId = intent.getStringExtra(PARAM_DEFAULT_PROJECT_ID)
|
||||
if (defaultProjectId != null) {
|
||||
@@ -137,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() {
|
||||
val type = viewModel.projectType
|
||||
val todoCreate = viewModel.whatTodoIsCreate
|
||||
@@ -184,7 +201,16 @@ class NewProjectActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
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
|
||||
if (!isCospendScheme && (rid == "" || rid.contains(",") || rid.contains("/"))) {
|
||||
showToast(getString(R.string.error_invalid_project_remote_id), Toast.LENGTH_LONG)
|
||||
@@ -192,7 +218,7 @@ class NewProjectActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
if (viewModel.projectType != ProjectType.LOCAL && !isCospendScheme) {
|
||||
if (!isValidUrl(getFormattedUrl())) {
|
||||
if (!isValidUrl(url)) {
|
||||
showToast("Invalid URL", Toast.LENGTH_SHORT)
|
||||
return
|
||||
}
|
||||
@@ -215,7 +241,16 @@ class NewProjectActivity : AppCompatActivity() {
|
||||
showToast("Invalid project name", Toast.LENGTH_SHORT)
|
||||
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)
|
||||
return
|
||||
}
|
||||
@@ -224,7 +259,7 @@ class NewProjectActivity : AppCompatActivity() {
|
||||
|
||||
if (!db.cowspentServerSyncHelper.createRemoteProject(
|
||||
viewModel.projectId, viewModel.projectName,
|
||||
viewModel.projectEmail, viewModel.projectPassword, getFormattedUrl(), viewModel.projectType, createRemoteCallBack
|
||||
viewModel.projectEmail, viewModel.projectPassword, url, viewModel.projectType, createRemoteCallBack
|
||||
)
|
||||
) {
|
||||
viewModel.isCreatingRemoteProject = false
|
||||
|
||||
@@ -32,8 +32,13 @@ fun NewProjectScreen(
|
||||
onImportFile: () -> Unit,
|
||||
onChooseFromNextcloud: () -> Unit,
|
||||
onOkPressed: () -> Unit,
|
||||
onBack: () -> Unit
|
||||
onBack: () -> Unit,
|
||||
onFieldsChanged: () -> Unit
|
||||
) {
|
||||
LaunchedEffect(viewModel.projectUrl, viewModel.projectType) {
|
||||
onFieldsChanged()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
@@ -58,6 +63,7 @@ fun NewProjectScreen(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(padding)
|
||||
.imePadding()
|
||||
.padding(16.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.fillMaxSize()
|
||||
@@ -115,13 +121,7 @@ fun NewProjectScreen(
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
if (viewModel.whatTodoIsCreate) {
|
||||
if (viewModel.projectType == ProjectType.COSPEND) {
|
||||
Button(
|
||||
onClick = onChooseFromNextcloud
|
||||
) {
|
||||
Text(stringResource(R.string.new_project_from_nextcloud_tooltip))
|
||||
}
|
||||
} else if (viewModel.projectType == ProjectType.LOCAL) {
|
||||
if (viewModel.projectType == ProjectType.LOCAL) {
|
||||
Button(
|
||||
onClick = onImportFile
|
||||
) {
|
||||
@@ -162,16 +162,18 @@ fun NewProjectScreen(
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = viewModel.projectId,
|
||||
onValueChange = { viewModel.projectId = it },
|
||||
label = { Text(stringResource(R.string.setting_project_id)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
leadingIcon = { Icon(Icons.AutoMirrored.Filled.LibraryBooks, contentDescription = null) }
|
||||
)
|
||||
if (!viewModel.whatTodoIsCreate || !viewModel.isAuthenticatedAccount || viewModel.projectType != ProjectType.COSPEND) {
|
||||
OutlinedTextField(
|
||||
value = viewModel.projectId,
|
||||
onValueChange = { viewModel.projectId = it },
|
||||
label = { Text(stringResource(R.string.setting_project_id)) },
|
||||
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)) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = viewModel.projectPassword,
|
||||
onValueChange = { viewModel.projectPassword = it },
|
||||
@@ -179,10 +181,10 @@ fun NewProjectScreen(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) }
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
if (viewModel.whatTodoIsCreate && viewModel.projectType != ProjectType.LOCAL) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = viewModel.projectName,
|
||||
onValueChange = { viewModel.projectName = it },
|
||||
@@ -190,14 +192,17 @@ fun NewProjectScreen(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
leadingIcon = { Icon(Icons.Default.Title, contentDescription = null) }
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = viewModel.projectEmail,
|
||||
onValueChange = { viewModel.projectEmail = it },
|
||||
label = { Text(stringResource(R.string.setting_new_project_email)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
leadingIcon = { Icon(Icons.Default.Email, contentDescription = null) }
|
||||
)
|
||||
|
||||
if (!viewModel.isAuthenticatedAccount || viewModel.projectType == ProjectType.IHATEMONEY) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = viewModel.projectEmail,
|
||||
onValueChange = { viewModel.projectEmail = it },
|
||||
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 = {},
|
||||
onChooseFromNextcloud = {},
|
||||
onOkPressed = {},
|
||||
onBack = {}
|
||||
onBack = {},
|
||||
onFieldsChanged = {}
|
||||
)
|
||||
}
|
||||
|
||||
+17
-4
@@ -30,6 +30,8 @@ class NewProjectViewModel : ViewModel() {
|
||||
var projectName by mutableStateOf("")
|
||||
var projectEmail by mutableStateOf("")
|
||||
|
||||
var isAuthenticatedAccount by mutableStateOf(false)
|
||||
|
||||
var showAuthWarningDialog by mutableStateOf(false)
|
||||
var showNextcloudProjectDialog by mutableStateOf(false)
|
||||
var nextcloudProjects by mutableStateOf<List<DBAccountProject>>(emptyList())
|
||||
@@ -39,12 +41,23 @@ class NewProjectViewModel : ViewModel() {
|
||||
|
||||
fun isFormValid(): Boolean {
|
||||
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 (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 {
|
||||
// Join
|
||||
|
||||
@@ -3,6 +3,7 @@ package net.helcel.cowspent.android.project.edit
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
@@ -27,6 +28,7 @@ class EditProjectActivity : AppCompatActivity() {
|
||||
private lateinit var project: DBProject
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
db = CowspentSQLiteOpenHelper.getInstance(this)
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ package net.helcel.cowspent.android.project.edit
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
@@ -80,11 +82,14 @@ fun EditProjectScreen(
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
val scrollState = rememberScrollState()
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(padding)
|
||||
.imePadding()
|
||||
.padding(16.dp)
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scrollState)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = viewModel.name,
|
||||
|
||||
+3
-8
@@ -30,7 +30,7 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.model.DBMember
|
||||
|
||||
@@ -84,13 +84,8 @@ fun MemberManagementDialogContent(
|
||||
.padding(vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
UserAvatar(
|
||||
name = member.name,
|
||||
r = member.r,
|
||||
g = member.g,
|
||||
b = member.b,
|
||||
avatar = member.avatar,
|
||||
disabled = !member.isActivated,
|
||||
MemberAvatar(
|
||||
member = member,
|
||||
size = 40.dp
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
@@ -3,6 +3,7 @@ package net.helcel.cowspent.android.settings
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -25,6 +26,7 @@ import net.helcel.cowspent.util.ColorUtils
|
||||
class PreferencesActivity : AppCompatActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
|
||||
|
||||
+299
-366
@@ -1,34 +1,86 @@
|
||||
package net.helcel.cowspent.android.statistics
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.draggable
|
||||
import androidx.compose.foundation.gestures.rememberDraggableState
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
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.width
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.DropdownMenuItem
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.IconButton
|
||||
import androidx.compose.material.LocalContentColor
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Group
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.material.icons.filled.ViewColumn
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.layout.LayoutCoordinates
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.core.graphics.toColorInt
|
||||
import kotlinx.coroutines.launch
|
||||
import net.helcel.cowspent.R
|
||||
import net.helcel.cowspent.android.helper.*
|
||||
import net.helcel.cowspent.android.helper.EditableExposedDropdownMenu
|
||||
import net.helcel.cowspent.android.helper.MemberAvatar
|
||||
import net.helcel.cowspent.android.helper.formatShortValue
|
||||
import net.helcel.cowspent.model.DBBill
|
||||
import net.helcel.cowspent.model.DBCategory
|
||||
import net.helcel.cowspent.model.DBMember
|
||||
import androidx.core.graphics.toColorInt
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private object SankeyDimens {
|
||||
val NodeHeight = 76.dp
|
||||
val TotalNodeHeight = 42.dp
|
||||
val NormalGap = 8.dp
|
||||
val FocusGap = 16.dp
|
||||
val ActiveMinWidth = 160.dp
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@SuppressLint("UseKtx")
|
||||
@@ -41,15 +93,11 @@ fun ProjectSankeyDiagram(
|
||||
onShareReady: (String) -> Unit
|
||||
) {
|
||||
val shareStatsIntro = stringResource(R.string.share_stats_intro, projectName)
|
||||
|
||||
var selectedMemberId by remember { mutableLongStateOf(-1L) }
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
val activeBills = remember(allBills) {
|
||||
allBills.filter {
|
||||
it.state != DBBill.STATE_DELETED &&
|
||||
it.categoryRemoteId != DBBill.CATEGORY_REIMBURSEMENT
|
||||
}
|
||||
allBills.filter { it.state != DBBill.STATE_DELETED && it.categoryRemoteId != DBBill.CATEGORY_REIMBURSEMENT }
|
||||
}
|
||||
|
||||
val membersMap = remember(allMembers) { allMembers.associateBy { it.id } }
|
||||
@@ -62,408 +110,293 @@ fun ProjectSankeyDiagram(
|
||||
return
|
||||
}
|
||||
|
||||
// Consolidated spending calculation
|
||||
val spendings = remember(activeBills, selectedMemberId, membersMap) {
|
||||
val spentMap = mutableMapOf<Long, Double>()
|
||||
val spentMap = mutableMapOf<Long, Double>().apply { membersMap.keys.forEach { put(it, 0.0) } }
|
||||
val catMap = mutableMapOf<Int, Double>()
|
||||
membersMap.keys.forEach { spentMap[it] = 0.0 }
|
||||
|
||||
activeBills.forEach { bill ->
|
||||
val totalWeight = bill.billOwers.sumOf { bo ->
|
||||
membersMap[bo.memberId]?.weight ?: 1.0
|
||||
}
|
||||
val totalWeight = bill.billOwers.sumOf { membersMap[it.memberId]?.weight ?: 1.0 }
|
||||
if (totalWeight > 0) {
|
||||
if (selectedMemberId == -1L) {
|
||||
catMap[bill.categoryRemoteId] = (catMap[bill.categoryRemoteId] ?: 0.0) + bill.amount
|
||||
bill.billOwers.forEach { bo ->
|
||||
val weight = membersMap[bo.memberId]?.weight ?: 1.0
|
||||
val share = (bill.amount / totalWeight) * weight
|
||||
spentMap[bo.memberId] = (spentMap[bo.memberId] ?: 0.0) + share
|
||||
spentMap[bo.memberId] = (spentMap[bo.memberId] ?: 0.0) + (bill.amount / totalWeight) * weight
|
||||
}
|
||||
} else {
|
||||
bill.billOwers.find { it.memberId == selectedMemberId }?.let { bo ->
|
||||
val weight = membersMap[bo.memberId]?.weight ?: 1.0
|
||||
val share = (bill.amount / totalWeight) * weight
|
||||
catMap[bill.categoryRemoteId] = (catMap[bill.categoryRemoteId] ?: 0.0) + share
|
||||
spentMap[selectedMemberId] = (spentMap[selectedMemberId] ?: 0.0) + share
|
||||
catMap[bill.categoryRemoteId] = (catMap[bill.categoryRemoteId] ?: 0.0) + (bill.amount / totalWeight) * weight
|
||||
spentMap[selectedMemberId] = (spentMap[selectedMemberId] ?: 0.0) + (bill.amount / totalWeight) * weight
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val memberList = spentMap.toList().filter { it.second > 0 }.sortedByDescending { it.second }
|
||||
val categoryList = catMap.toList().sortedByDescending { it.second }
|
||||
memberList to categoryList
|
||||
spentMap.toList().filter { it.second > 0 }.sortedByDescending { it.second } to catMap.toList().sortedByDescending { it.second }
|
||||
}
|
||||
|
||||
val displayMemberSpendings = spendings.first
|
||||
val displayCategorySpendings = spendings.second
|
||||
val totalAmount = remember(displayMemberSpendings) { displayMemberSpendings.sumOf { it.second } }
|
||||
var isSpecialMode by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(displayMemberSpendings, displayCategorySpendings, totalAmount, projectName) {
|
||||
val statsText = StringBuilder()
|
||||
statsText.append("// ").append(shareStatsIntro.replace("\n", "\n// ")).append("\n\n")
|
||||
|
||||
val middleNode = if (selectedMemberId == -1L) "Total" else "Spent"
|
||||
|
||||
displayMemberSpendings.forEach { (memberId, amount) ->
|
||||
val name = membersMap[memberId]?.name ?: "???"
|
||||
statsText.append("$name [${formatShortValue(amount)}] $middleNode\n")
|
||||
}
|
||||
|
||||
statsText.append("\n")
|
||||
|
||||
displayCategorySpendings.forEach { (catRemoteId, amount) ->
|
||||
val name = categoriesMap[catRemoteId]?.name ?: "Other"
|
||||
statsText.append("$middleNode [${formatShortValue(amount)}] $name\n")
|
||||
}
|
||||
|
||||
statsText.append("\n")
|
||||
|
||||
displayMemberSpendings.forEach { (memberId, _) ->
|
||||
val member = membersMap[memberId]
|
||||
if (member != null) {
|
||||
val hexColor = String.format("#%02x%02x%02x", member.r ?: 128, member.g ?: 128, member.b ?: 128)
|
||||
statsText.append(":${member.name} $hexColor\n")
|
||||
val statsText = StringBuilder().apply {
|
||||
append("// ").append(shareStatsIntro.replace("\n", "\n// ")).append("\n\n")
|
||||
val middleNode = if (selectedMemberId == -1L) "Total" else "Spent"
|
||||
displayMemberSpendings.forEach { (id, amount) -> append("${membersMap[id]?.name ?: "???"} [${formatShortValue(amount)}] $middleNode\n") }
|
||||
append("\n")
|
||||
displayCategorySpendings.forEach { (id, amount) -> append("$middleNode [${formatShortValue(amount)}] ${categoriesMap[id]?.name ?: "Other"}\n") }
|
||||
append("\n")
|
||||
displayMemberSpendings.forEach { (id, _) ->
|
||||
membersMap[id]?.let { append(":${it.name} ${String.format("#%02x%02x%02x", it.r ?: 128, it.g ?: 128, it.b ?: 128)}\n") }
|
||||
}
|
||||
displayCategorySpendings.forEach { (id, _) -> categoriesMap[id]?.let { append(":${it.name ?: "Other"} ${it.color}\n") } }
|
||||
}
|
||||
|
||||
displayCategorySpendings.forEach { (catRemoteId, _) ->
|
||||
val category = categoriesMap[catRemoteId]
|
||||
if (category != null) {
|
||||
statsText.append(":${category.name ?: "Other"} ${category.color}\n")
|
||||
}
|
||||
}
|
||||
|
||||
onShareReady(statsText.toString())
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||
val selectedMember = membersMap[selectedMemberId]
|
||||
EditableExposedDropdownMenu(
|
||||
value = selectedMember?.name ?: "All Members",
|
||||
placeholder = "Filter by member",
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = it },
|
||||
onDismissRequest = { expanded = false },
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
||||
leadingIcon = {
|
||||
Box(modifier = Modifier.padding(start = 12.dp)) {
|
||||
if (selectedMember != null) {
|
||||
UserAvatar(
|
||||
name = selectedMember.name,
|
||||
r = selectedMember.r,
|
||||
g = selectedMember.g,
|
||||
b = selectedMember.b,
|
||||
disabled = !selectedMember.isActivated,
|
||||
size = 24.dp
|
||||
)
|
||||
} else {
|
||||
Icon(Icons.Default.Group, contentDescription = null)
|
||||
}
|
||||
}
|
||||
},
|
||||
content = {
|
||||
DropdownMenuItem(onClick = {
|
||||
selectedMemberId = -1L
|
||||
expanded = false
|
||||
}) {
|
||||
Icon(Icons.Default.Group, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text("All Members")
|
||||
}
|
||||
allMembers.forEach { member ->
|
||||
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
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(member.name)
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Box(modifier = Modifier.padding(16.dp)) {
|
||||
val selectedMember = membersMap[selectedMemberId]
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
EditableExposedDropdownMenu(
|
||||
value = selectedMember?.name ?: "All Members",
|
||||
placeholder = "Filter by member",
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = it },
|
||||
onDismissRequest = { expanded = false },
|
||||
modifier = Modifier.weight(1f).padding(bottom = 16.dp),
|
||||
leadingIcon = {
|
||||
Box(modifier = Modifier.padding(start = 12.dp)) {
|
||||
if (selectedMember != null) {
|
||||
MemberAvatar(member = selectedMember, size = 24.dp)
|
||||
} else Icon(Icons.Default.Group, contentDescription = null)
|
||||
}
|
||||
},
|
||||
content = {
|
||||
DropdownMenuItem(onClick = { selectedMemberId = -1L; expanded = false }) {
|
||||
Icon(Icons.Default.Group, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text("All Members")
|
||||
}
|
||||
allMembers.forEach { member ->
|
||||
DropdownMenuItem(onClick = { selectedMemberId = member.id; expanded = false }) {
|
||||
MemberAvatar(member = member, size = 24.dp)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(member.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
IconButton(onClick = { isSpecialMode = !isSpecialMode }, modifier = Modifier.padding(bottom = 16.dp)) {
|
||||
Icon(Icons.Default.ViewColumn, contentDescription = "Focus Mode", tint = if (isSpecialMode) MaterialTheme.colors.primary else LocalContentColor.current)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (totalAmount <= 0) {
|
||||
Box(modifier = Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
Text("No spending data for selected filter", style = MaterialTheme.typography.body1)
|
||||
}
|
||||
} else {
|
||||
BoxWithConstraints(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||
val nodeHeightMember = 76.dp
|
||||
val nodeHeightCategory = 76.dp
|
||||
val nodeHeightTotal = 42.dp
|
||||
val horizontalGap = 8.dp
|
||||
|
||||
val memberCount = displayMemberSpendings.size
|
||||
val categoryCount = displayCategorySpendings.size
|
||||
|
||||
val maxGaps = maxOf(memberCount - 1, categoryCount - 1, 0)
|
||||
val usableWidth = (maxWidth.value - horizontalGap.value * maxGaps).coerceAtLeast(0f)
|
||||
val moneyScale = if (totalAmount > 0) usableWidth / totalAmount.toFloat() else 0f
|
||||
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
val nodeHeightMemberPx = nodeHeightMember.toPx()
|
||||
val nodeHeightCategoryPx = nodeHeightCategory.toPx()
|
||||
val nodeHeightTotalPx = nodeHeightTotal.toPx()
|
||||
val gapPx = horizontalGap.toPx()
|
||||
|
||||
val totalWidthPx = size.width
|
||||
val usableWidthPx = totalWidthPx - gapPx * maxGaps
|
||||
val moneyScalePx = if (totalAmount > 0) usableWidthPx / totalAmount else 0.0
|
||||
|
||||
val totalNodeWidthPx = (totalAmount * moneyScalePx).toFloat()
|
||||
val totalNodeXPx = (totalWidthPx - totalNodeWidthPx) / 2
|
||||
|
||||
val topY = 0f
|
||||
val middleY = (size.height - nodeHeightTotalPx) / 2
|
||||
val bottomY = size.height - nodeHeightCategoryPx
|
||||
|
||||
val totalNodeColor = Color(0xFF333333)
|
||||
|
||||
// Flows
|
||||
var currentXTop = (totalWidthPx - (totalAmount * moneyScalePx + (memberCount - 1).coerceAtLeast(0) * gapPx).toFloat()) / 2
|
||||
var currentXInTotalTop = totalNodeXPx
|
||||
displayMemberSpendings.forEach { (memberId, amount) ->
|
||||
val member = membersMap[memberId]
|
||||
val boxWidth = (amount * moneyScalePx).toFloat()
|
||||
val color = member?.let {
|
||||
Color(it.r ?: 128, it.g ?: 128, it.b ?: 128)
|
||||
} ?: Color.Gray
|
||||
|
||||
if (boxWidth > 0.5f) {
|
||||
drawSankeyFlow(
|
||||
startX = currentXTop,
|
||||
startY = topY + nodeHeightMemberPx * 0.5f,
|
||||
startWidth = boxWidth,
|
||||
endX = currentXInTotalTop,
|
||||
endY = middleY + nodeHeightTotalPx * 0.5f,
|
||||
endWidth = boxWidth,
|
||||
startColor = color.copy(alpha = 0.5f),
|
||||
endColor = totalNodeColor.copy(alpha = 0.35f)
|
||||
)
|
||||
}
|
||||
currentXTop += boxWidth + gapPx
|
||||
currentXInTotalTop += boxWidth
|
||||
}
|
||||
|
||||
var currentXBottom = (totalWidthPx - (totalAmount * moneyScalePx + (categoryCount - 1).coerceAtLeast(0) * gapPx).toFloat()) / 2
|
||||
var currentXInTotalBottom = totalNodeXPx
|
||||
displayCategorySpendings.forEach { (catRemoteId, amount) ->
|
||||
val category = categoriesMap[catRemoteId]
|
||||
val boxWidth = (amount * moneyScalePx).toFloat()
|
||||
val color = category?.color?.let {
|
||||
try { Color(it.toColorInt()) } catch (_: Exception) { Color(0xFF999999) }
|
||||
} ?: Color(0xFF999999)
|
||||
|
||||
if (boxWidth > 0.5f) {
|
||||
drawSankeyFlow(
|
||||
startX = currentXInTotalBottom,
|
||||
startY = middleY + nodeHeightTotalPx * 0.5f,
|
||||
startWidth = boxWidth,
|
||||
endX = currentXBottom,
|
||||
endY = bottomY + nodeHeightCategoryPx * 0.5f,
|
||||
endWidth = boxWidth,
|
||||
startColor = totalNodeColor.copy(alpha = 0.35f),
|
||||
endColor = color.copy(alpha = 0.5f)
|
||||
)
|
||||
}
|
||||
currentXInTotalBottom += boxWidth
|
||||
currentXBottom += boxWidth + gapPx
|
||||
}
|
||||
|
||||
// Nodes
|
||||
currentXTop = (totalWidthPx - (totalAmount * moneyScalePx + (memberCount - 1).coerceAtLeast(0) * gapPx).toFloat()) / 2
|
||||
displayMemberSpendings.forEach { (memberId, amount) ->
|
||||
val member = membersMap[memberId]
|
||||
val width = (amount * moneyScalePx).toFloat()
|
||||
val color = member?.let {
|
||||
Color(it.r ?: 128, it.g ?: 128, it.b ?: 128)
|
||||
} ?: Color.Gray
|
||||
|
||||
if (width > 0.5f) {
|
||||
drawRoundRect(
|
||||
color = color,
|
||||
topLeft = Offset(currentXTop, topY),
|
||||
size = Size(width, nodeHeightMemberPx),
|
||||
cornerRadius = CornerRadius(10.dp.toPx())
|
||||
)
|
||||
}
|
||||
currentXTop += width + gapPx
|
||||
}
|
||||
|
||||
drawRect(
|
||||
color = totalNodeColor,
|
||||
topLeft = Offset(totalNodeXPx, middleY),
|
||||
size = Size(totalNodeWidthPx, nodeHeightTotalPx)
|
||||
)
|
||||
|
||||
currentXBottom = (totalWidthPx - (totalAmount * moneyScalePx + (categoryCount - 1).coerceAtLeast(0) * gapPx).toFloat()) / 2
|
||||
displayCategorySpendings.forEach { (catRemoteId, amount) ->
|
||||
val category = categoriesMap[catRemoteId]
|
||||
val width = (amount * moneyScalePx).toFloat()
|
||||
val color = category?.color?.let {
|
||||
try { Color(it.toColorInt()) } catch (_: Exception) { Color(0xFF999999) }
|
||||
} ?: Color(0xFF999999)
|
||||
|
||||
if (width > 0.5f) {
|
||||
drawRoundRect(
|
||||
color = color,
|
||||
topLeft = Offset(currentXBottom, bottomY),
|
||||
size = Size(width, nodeHeightCategoryPx),
|
||||
cornerRadius = CornerRadius(10.dp.toPx())
|
||||
)
|
||||
}
|
||||
currentXBottom += width + gapPx
|
||||
}
|
||||
}
|
||||
|
||||
// Member labels
|
||||
var currentXTopLabel = (maxWidth.value - (totalAmount.toFloat() * moneyScale + (memberCount - 1).coerceAtLeast(0) * horizontalGap.value)) / 2
|
||||
displayMemberSpendings.forEach { (memberId, amount) ->
|
||||
val member = membersMap[memberId]
|
||||
val widthValue = amount.toFloat() * moneyScale
|
||||
val width = widthValue.dp
|
||||
if (widthValue > 12f) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.offset(x = currentXTopLabel.dp, y = 0.dp)
|
||||
.size(width, nodeHeightMember),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (width >= 36.dp) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = member?.name ?: "???",
|
||||
fontSize = 16.sp,
|
||||
color = Color.White,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(
|
||||
text = formatShortValue(amount),
|
||||
fontSize = 14.sp,
|
||||
color = Color.White.copy(alpha = 0.9f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
currentXTopLabel += widthValue + horizontalGap.value
|
||||
}
|
||||
|
||||
// Total label
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.fillMaxWidth()
|
||||
.height(nodeHeightTotal),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = if (selectedMemberId == -1L) formatShortValue(totalAmount) else "SPENT: ${formatShortValue(totalAmount)}",
|
||||
fontSize = 16.sp,
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center,
|
||||
fontWeight = FontWeight.ExtraBold
|
||||
)
|
||||
}
|
||||
|
||||
// Category labels
|
||||
var currentXBottomLabel = (maxWidth.value - (totalAmount.toFloat() * moneyScale + (categoryCount - 1).coerceAtLeast(0) * horizontalGap.value)) / 2
|
||||
displayCategorySpendings.forEach { (catRemoteId, amount) ->
|
||||
val category = categoriesMap[catRemoteId]
|
||||
val widthValue = amount.toFloat() * moneyScale
|
||||
val width = widthValue.dp
|
||||
if (widthValue > 12f) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.offset(x = currentXBottomLabel.dp)
|
||||
.size(width, nodeHeightCategory),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = category?.icon ?: "❔",
|
||||
fontSize = 20.sp
|
||||
)
|
||||
if (width >= 32.dp) {
|
||||
Text(
|
||||
text = formatShortValue(amount),
|
||||
fontSize = 14.sp,
|
||||
color = Color.White.copy(alpha = 0.9f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
currentXBottomLabel += widthValue + horizontalGap.value
|
||||
}
|
||||
}
|
||||
SankeyContent(isSpecialMode, selectedMemberId, totalAmount, displayMemberSpendings, displayCategorySpendings, membersMap, categoriesMap)
|
||||
}
|
||||
Spacer(Modifier.fillMaxWidth().height(32.dp))
|
||||
}
|
||||
}
|
||||
|
||||
private fun DrawScope.drawSankeyFlow(
|
||||
startX: Float,
|
||||
startY: Float,
|
||||
startWidth: Float,
|
||||
endX: Float,
|
||||
endY: Float,
|
||||
endWidth: Float,
|
||||
startColor: Color,
|
||||
endColor: Color
|
||||
private data class NodeLayout(val centerX: Float, val visualWidth: Float)
|
||||
|
||||
@SuppressLint("FrequentlyChangingValue")
|
||||
@Composable
|
||||
private fun SankeyContent(
|
||||
isSpecialMode: Boolean,
|
||||
selectedMemberId: Long,
|
||||
totalAmount: Double,
|
||||
displayMemberSpendings: List<Pair<Long, Double>>,
|
||||
displayCategorySpendings: List<Pair<Int, Double>>,
|
||||
membersMap: Map<Long, DBMember>,
|
||||
categoriesMap: Map<Int, DBCategory>
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val topFocalIndex = remember { Animatable(0f) }
|
||||
val bottomFocalIndex = remember { Animatable(0f) }
|
||||
|
||||
var diagramAreaCoordinates by remember { mutableStateOf<LayoutCoordinates?>(null) }
|
||||
var totalNodeCoordinates by remember { mutableStateOf<LayoutCoordinates?>(null) }
|
||||
|
||||
LaunchedEffect(isSpecialMode) {
|
||||
if (!isSpecialMode) {
|
||||
topFocalIndex.snapTo(0f)
|
||||
bottomFocalIndex.snapTo(0f)
|
||||
}
|
||||
}
|
||||
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize().onGloballyPositioned { diagramAreaCoordinates = it }) {
|
||||
val totalMaxWidthPx = with(density) { maxWidth.toPx() }
|
||||
val normalGapPx = with(density) { SankeyDimens.NormalGap.toPx() }
|
||||
val focusGapPx = with(density) { SankeyDimens.FocusGap.toPx() }
|
||||
val activeMinPx = with(density) { SankeyDimens.ActiveMinWidth.toPx() }
|
||||
|
||||
val moneyScalePx = if (totalAmount > 0) (totalMaxWidthPx - (maxOf(displayMemberSpendings.size, displayCategorySpendings.size) - 1) * normalGapPx).coerceAtLeast(0f) / totalAmount else 0.0
|
||||
|
||||
fun calculateRowLayout(spendings: List<Pair<*, Double>>, focalIndex: Float): List<NodeLayout> {
|
||||
val visualWidths = spendings.mapIndexed { i, (_, amount) ->
|
||||
val propWidthPx = (amount * moneyScalePx).toFloat()
|
||||
if (!isSpecialMode) propWidthPx
|
||||
else {
|
||||
val focusAmount = (1f - abs(i - focalIndex)).coerceIn(0f, 1f)
|
||||
maxOf(propWidthPx, activeMinPx * focusAmount)
|
||||
}
|
||||
}
|
||||
|
||||
return if (!isSpecialMode) {
|
||||
val totalWidthPx = visualWidths.sum() + (spendings.size - 1).coerceAtLeast(0) * normalGapPx
|
||||
var currentX = (totalMaxWidthPx - totalWidthPx) / 2
|
||||
visualWidths.map { w ->
|
||||
NodeLayout(currentX + w / 2, w).also { currentX += w + normalGapPx }
|
||||
}
|
||||
} else {
|
||||
val centers = mutableListOf<Float>()
|
||||
var currentX = 0f
|
||||
visualWidths.forEachIndexed { i, w ->
|
||||
if (i > 0) currentX += visualWidths[i - 1] / 2 + focusGapPx + w / 2
|
||||
else currentX = w / 2
|
||||
centers.add(currentX)
|
||||
}
|
||||
|
||||
val focalPointX = if (spendings.isEmpty()) 0f else {
|
||||
val idx = focalIndex.coerceIn(0f, (spendings.size - 1).toFloat())
|
||||
val low = idx.toInt()
|
||||
val high = (low + 1).coerceAtMost(spendings.size - 1)
|
||||
val fract = idx - low
|
||||
centers[low] * (1 - fract) + centers[high] * fract
|
||||
}
|
||||
|
||||
centers.mapIndexed { i, c ->
|
||||
NodeLayout(totalMaxWidthPx / 2 + (c - focalPointX), visualWidths[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val topLayout = calculateRowLayout(displayMemberSpendings, topFocalIndex.value)
|
||||
val bottomLayout = calculateRowLayout(displayCategorySpendings, bottomFocalIndex.value)
|
||||
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
val root = diagramAreaCoordinates ?: return@Canvas
|
||||
val totalRect = totalNodeCoordinates?.let {
|
||||
val pos = it.positionInRoot() - root.positionInRoot()
|
||||
Rect(pos.x, pos.y, pos.x + it.size.width, pos.y + it.size.height)
|
||||
} ?: return@Canvas
|
||||
val flowMoneyScalePx = totalRect.width / totalAmount
|
||||
val totalNodeColor = Color(0xFF333333)
|
||||
|
||||
displayMemberSpendings.forEachIndexed { index, (_, amount) ->
|
||||
val flowWidth = (amount * flowMoneyScalePx).toFloat()
|
||||
val layout = topLayout[index]
|
||||
if (flowWidth > 0.5f) {
|
||||
val color = membersMap[displayMemberSpendings[index].first]?.let { Color(it.r ?: 128, it.g ?: 128, it.b ?: 128) } ?: Color.Gray
|
||||
drawSankeyFlow(layout.centerX - layout.visualWidth / 2, SankeyDimens.NodeHeight.toPx() / 2, layout.visualWidth, totalRect.left + displayMemberSpendings.take(index).sumOf { it.second * flowMoneyScalePx }.toFloat(), totalRect.top + totalRect.height / 2, flowWidth, color.copy(alpha = 0.5f), totalNodeColor.copy(alpha = 0.35f))
|
||||
}
|
||||
}
|
||||
displayCategorySpendings.forEachIndexed { index, (_, amount) ->
|
||||
val flowWidth = (amount * flowMoneyScalePx).toFloat()
|
||||
val layout = bottomLayout[index]
|
||||
if (flowWidth > 0.5f) {
|
||||
val color = categoriesMap[displayCategorySpendings[index].first]?.color?.let { try { Color(it.toColorInt()) } catch (_: Exception) { Color(0xFF999999) } } ?: Color(0xFF999999)
|
||||
drawSankeyFlow(totalRect.left + displayCategorySpendings.take(index).sumOf { it.second * flowMoneyScalePx }.toFloat(), totalRect.top + totalRect.height / 2, flowWidth, layout.centerX - layout.visualWidth / 2, size.height - SankeyDimens.NodeHeight.toPx() / 2, layout.visualWidth, totalNodeColor.copy(alpha = 0.35f), color.copy(alpha = 0.5f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// TOP Row
|
||||
Box(modifier = Modifier.fillMaxWidth().height(SankeyDimens.NodeHeight).then(if (isSpecialMode) Modifier.draggable(
|
||||
orientation = Orientation.Horizontal,
|
||||
state = rememberDraggableState { delta ->
|
||||
scope.launch {
|
||||
val currentIdx = topFocalIndex.value.roundToInt().coerceIn(displayMemberSpendings.indices)
|
||||
val step = topLayout[currentIdx].visualWidth + focusGapPx
|
||||
topFocalIndex.snapTo((topFocalIndex.value - delta / step).coerceIn(0f, (displayMemberSpendings.size - 1).toFloat()))
|
||||
}
|
||||
},
|
||||
onDragStopped = {
|
||||
topFocalIndex.animateTo(topFocalIndex.value.roundToInt().toFloat(), spring(Spring.DampingRatioLowBouncy, Spring.StiffnessLow))
|
||||
}
|
||||
) else Modifier)) {
|
||||
displayMemberSpendings.forEachIndexed { index, (id, amount) ->
|
||||
val member = membersMap[id]
|
||||
val layout = topLayout[index]
|
||||
val wDp = with(density) { layout.visualWidth.toDp() }
|
||||
val xDp = with(density) { (layout.centerX - layout.visualWidth / 2).toDp() }
|
||||
|
||||
Box(modifier = Modifier.offset(x = xDp).width(wDp).fillMaxHeight()
|
||||
.then(if (isSpecialMode) Modifier.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {
|
||||
scope.launch { topFocalIndex.animateTo(index.toFloat(), spring(Spring.DampingRatioLowBouncy, Spring.StiffnessLow)) }
|
||||
} else Modifier), contentAlignment = Alignment.Center) {
|
||||
Column(modifier = Modifier.fillMaxSize().background(color = member?.let { Color(it.r ?: 128, it.g ?: 128, it.b ?: 128) } ?: Color.Gray, shape = RoundedCornerShape(10.dp)), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
|
||||
Text(text = member?.name ?: "???", color = Color.White, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center, modifier = Modifier.wrapContentWidth(unbounded = true))
|
||||
if (wDp >= 40.dp) Text(text = formatShortValue(amount), fontSize = 12.sp, color = Color.White.copy(alpha = 0.9f), textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
Box(modifier = Modifier.width(with(density) { (totalAmount * moneyScalePx).toFloat().toDp() }).height(SankeyDimens.TotalNodeHeight).background(Color(0xFF333333)).onGloballyPositioned { totalNodeCoordinates = it }, contentAlignment = Alignment.Center) {
|
||||
Text(text = if (selectedMemberId == -1L) formatShortValue(totalAmount) else "SPENT: ${formatShortValue(totalAmount)}", color = Color.White, fontWeight = FontWeight.ExtraBold, fontSize = 16.sp, modifier = Modifier.padding(horizontal = 8.dp), maxLines = 1)
|
||||
}
|
||||
}
|
||||
|
||||
// BOTTOM Row
|
||||
Box(modifier = Modifier.fillMaxWidth().height(SankeyDimens.NodeHeight).then(if (isSpecialMode) Modifier.draggable(
|
||||
orientation = Orientation.Horizontal,
|
||||
state = rememberDraggableState { delta ->
|
||||
scope.launch {
|
||||
val currentIdx = bottomFocalIndex.value.roundToInt().coerceIn(displayCategorySpendings.indices)
|
||||
val step = bottomLayout[currentIdx].visualWidth + focusGapPx
|
||||
bottomFocalIndex.snapTo((bottomFocalIndex.value - delta / step).coerceIn(0f, (displayCategorySpendings.size - 1).toFloat()))
|
||||
}
|
||||
},
|
||||
onDragStopped = {
|
||||
bottomFocalIndex.animateTo(bottomFocalIndex.value.roundToInt().toFloat(), spring(Spring.DampingRatioLowBouncy, Spring.StiffnessLow))
|
||||
}
|
||||
) else Modifier)) {
|
||||
displayCategorySpendings.forEachIndexed { index, (id, amount) ->
|
||||
val category = categoriesMap[id]
|
||||
val layout = bottomLayout[index]
|
||||
val wDp = with(density) { layout.visualWidth.toDp() }
|
||||
val xDp = with(density) { (layout.centerX - layout.visualWidth / 2).toDp() }
|
||||
|
||||
Box(modifier = Modifier.offset(x = xDp).width(wDp).fillMaxHeight()
|
||||
.then(if (isSpecialMode) Modifier.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {
|
||||
scope.launch { bottomFocalIndex.animateTo(index.toFloat(), spring(Spring.DampingRatioLowBouncy, Spring.StiffnessLow)) }
|
||||
} else Modifier), contentAlignment = Alignment.Center) {
|
||||
Column(modifier = Modifier.fillMaxSize().background(color = category?.color?.let { try { Color(it.toColorInt()) } catch (_: Exception) { Color(0xFF999999) } } ?: Color(0xFF999999), shape = RoundedCornerShape(10.dp)), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
|
||||
Text(text = category?.icon ?: "❔", fontSize = 20.sp, textAlign = TextAlign.Center, modifier = Modifier.wrapContentWidth(unbounded = true))
|
||||
if (wDp >= 40.dp) Text(text = formatShortValue(amount), fontSize = 12.sp, color = Color.White.copy(alpha = 0.9f), textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun DrawScope.drawSankeyFlow(startX: Float, startY: Float, startWidth: Float, endX: Float, endY: Float, endWidth: Float, startColor: Color, endColor: Color) {
|
||||
val path = Path().apply {
|
||||
moveTo(startX, startY)
|
||||
cubicTo(
|
||||
startX, startY + (endY - startY) * 0.5f,
|
||||
endX, endY - (endY - startY) * 0.5f,
|
||||
endX, endY
|
||||
)
|
||||
cubicTo(startX, startY + (endY - startY) * 0.5f, endX, endY - (endY - startY) * 0.5f, endX, endY)
|
||||
lineTo(endX + endWidth, endY)
|
||||
cubicTo(
|
||||
endX + endWidth, endY - (endY - startY) * 0.5f,
|
||||
startX + startWidth, startY + (endY - startY) * 0.5f,
|
||||
startX + startWidth, startY
|
||||
)
|
||||
cubicTo(endX + endWidth, endY - (endY - startY) * 0.5f, startX + startWidth, startY + (endY - startY) * 0.5f, startX + startWidth, startY)
|
||||
close()
|
||||
}
|
||||
drawPath(
|
||||
path = path,
|
||||
brush = Brush.verticalGradient(
|
||||
colors = listOf(startColor, endColor),
|
||||
startY = startY,
|
||||
endY = endY
|
||||
)
|
||||
)
|
||||
drawPath(path = path, brush = Brush.verticalGradient(colors = listOf(startColor, endColor), startY = startY, endY = endY))
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, widthDp = 360, heightDp = 640)
|
||||
@Composable
|
||||
fun ProjectSankeyDiagramPreview() {
|
||||
MaterialTheme {
|
||||
ProjectSankeyDiagram(
|
||||
projectName = "Test Project",
|
||||
allMembers = StatisticsMockData.members,
|
||||
allBills = StatisticsMockData.bills,
|
||||
customCategories = emptyList(),
|
||||
onShareReady = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
fun ProjectSankeyDiagramPreview() = MaterialTheme { ProjectSankeyDiagram("Test Project", StatisticsMockData.members, StatisticsMockData.bills, emptyList()) {} }
|
||||
|
||||
@@ -3,6 +3,7 @@ package net.helcel.cowspent.android.statistics
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
@@ -15,6 +16,7 @@ import net.helcel.cowspent.theme.ThemeUtils
|
||||
class ProjectStatisticsActivity : AppCompatActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val projectId = intent.getLongExtra(EXTRA_PROJECT_ID, -1L)
|
||||
|
||||
@@ -12,6 +12,6 @@ class DBAccountProject(
|
||||
) : Serializable {
|
||||
|
||||
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.android.main.BillsListViewActivity
|
||||
import net.helcel.cowspent.model.*
|
||||
import net.helcel.cowspent.util.SecureStorage
|
||||
import net.helcel.cowspent.util.SupportUtil
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
@@ -316,11 +317,12 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
|
||||
val db = writableDatabase
|
||||
val values = ContentValues()
|
||||
values.put(key_remoteId, accountProject.remoteId)
|
||||
values.put(key_password, accountProject.password)
|
||||
values.put(key_ncUrl, accountProject.ncUrl)
|
||||
values.put(key_name, accountProject.name)
|
||||
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>
|
||||
@@ -344,11 +346,13 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
|
||||
|
||||
@SuppressLint("Range")
|
||||
private fun getAccountProjectFromCursor(cursor: Cursor): DBAccountProject {
|
||||
val id = cursor.getLong(cursor.getColumnIndex(key_id))
|
||||
val archivedTs = cursor.getLong(cursor.getColumnIndex(key_archived))
|
||||
val password = SecureStorage.getPasswordSync(context, "AccountProjectPassword_$id")
|
||||
return DBAccountProject(
|
||||
cursor.getLong(cursor.getColumnIndex(key_id)),
|
||||
id,
|
||||
cursor.getString(cursor.getColumnIndex(key_remoteId)),
|
||||
cursor.getString(cursor.getColumnIndex(key_password)),
|
||||
password,
|
||||
cursor.getString(cursor.getColumnIndex(key_name)),
|
||||
cursor.getString(cursor.getColumnIndex(key_ncUrl)),
|
||||
if (archivedTs > 0) archivedTs else null
|
||||
@@ -553,7 +557,11 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
|
||||
}
|
||||
|
||||
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
|
||||
@@ -604,14 +612,15 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
|
||||
val db = writableDatabase
|
||||
val values = ContentValues()
|
||||
values.put(key_remoteId, project.remoteId)
|
||||
values.put(key_password, project.password)
|
||||
values.put(key_bearer_token, project.bearerToken)
|
||||
values.put(key_email, project.email)
|
||||
values.put(key_name, project.name)
|
||||
values.put(key_ihmUrl, project.serverUrl)
|
||||
values.put(key_type, project.type.id)
|
||||
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? {
|
||||
@@ -640,11 +649,13 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
|
||||
|
||||
@SuppressLint("Range")
|
||||
private fun getProjectFromCursor(cursor: Cursor): DBProject {
|
||||
val id = cursor.getLong(cursor.getColumnIndex(key_id))
|
||||
val archivedTs = cursor.getLong(cursor.getColumnIndex(key_archived))
|
||||
val password = SecureStorage.getPasswordSync(context, "ProjectPassword_$id") ?: ""
|
||||
return DBProject(
|
||||
cursor.getLong(cursor.getColumnIndex(key_id)),
|
||||
id,
|
||||
cursor.getString(cursor.getColumnIndex(key_remoteId)),
|
||||
cursor.getString(cursor.getColumnIndex(key_password)),
|
||||
password,
|
||||
cursor.getString(cursor.getColumnIndex(key_name)),
|
||||
cursor.getString(cursor.getColumnIndex(key_ihmUrl)),
|
||||
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_projects, "$key_id = ?", arrayOf(id.toString()))
|
||||
SecureStorage.removePasswordSync(context, "ProjectPassword_$id")
|
||||
}
|
||||
|
||||
fun updateProject(
|
||||
@@ -681,7 +693,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
|
||||
val values = ContentValues()
|
||||
if (newName != null) values.put(key_name, newName)
|
||||
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 (newLastPayerId != null) values.put(key_lastPayerId, newLastPayerId)
|
||||
if (newLastSyncedTimestamp != null) values.put(key_lastSyncTimestamp, newLastSyncedTimestamp)
|
||||
@@ -721,7 +733,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
|
||||
val values = ContentValues()
|
||||
if (newName != null) values.put(key_name, newName)
|
||||
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 (newLastPayerId != null) values.put(key_lastPayerId, newLastPayerId)
|
||||
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)
|
||||
for (cToDel in currenciesToDelete) {
|
||||
try {
|
||||
val deleteRemoteCurrencyResponse = client!!.deleteRemoteCurrency(project, cToDel.remoteId)
|
||||
if (deleteRemoteCurrencyResponse.stringContent == "OK") {
|
||||
Log.d(TAG, "successfully deleted currency on remote project : delete it locally")
|
||||
dbHelper.deleteCurrency(cToDel.id)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
if (e.message == "\"Not Found\"") {
|
||||
Log.d(TAG, "failed to delete currency on remote project : delete it locally anyway")
|
||||
dbHelper.deleteCurrency(cToDel.id)
|
||||
} else {
|
||||
throw e
|
||||
if (project.type == ProjectType.COSPEND) {
|
||||
val currenciesToDelete = dbHelper.getCurrenciesOfProjectWithState(project.id, DBBill.STATE_DELETED)
|
||||
for (cToDel in currenciesToDelete) {
|
||||
try {
|
||||
val deleteRemoteCurrencyResponse = client!!.deleteRemoteCurrency(project, cToDel.remoteId)
|
||||
if (deleteRemoteCurrencyResponse.stringContent == "OK") {
|
||||
Log.d(TAG, "successfully deleted currency on remote project : delete it locally")
|
||||
dbHelper.deleteCurrency(cToDel.id)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
if (e.message == "\"Not Found\"") {
|
||||
Log.d(TAG, "failed to delete currency on remote project : delete it locally anyway")
|
||||
dbHelper.deleteCurrency(cToDel.id)
|
||||
} 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)
|
||||
for (cToEdit in currenciesToEdit) {
|
||||
try {
|
||||
val editRemoteCurrencyResponse = client!!.editRemoteCurrency(project, cToEdit)
|
||||
if (editRemoteCurrencyResponse.stringContent == cToEdit.remoteId.toString()) {
|
||||
dbHelper.setCurrencyState(cToEdit.id, DBBill.STATE_OK)
|
||||
Log.d(TAG, "SUCCESSFUL remote currency edition (${editRemoteCurrencyResponse.stringContent})")
|
||||
} else {
|
||||
Log.d(TAG, "FAILED to edit remote currency (${editRemoteCurrencyResponse.stringContent})")
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
if (e.message == "{\"message\": \"Internal Server Error\"}") {
|
||||
Log.d(TAG, "FAILED to edit remote currency : it does not exist remotely")
|
||||
} else {
|
||||
throw e
|
||||
if (project.type == ProjectType.COSPEND) {
|
||||
val currenciesToEdit = dbHelper.getCurrenciesOfProjectWithState(project.id, DBBill.STATE_EDITED)
|
||||
for (cToEdit in currenciesToEdit) {
|
||||
try {
|
||||
val editRemoteCurrencyResponse = client!!.editRemoteCurrency(project, cToEdit)
|
||||
if (editRemoteCurrencyResponse.stringContent == cToEdit.remoteId.toString()) {
|
||||
dbHelper.setCurrencyState(cToEdit.id, DBBill.STATE_OK)
|
||||
Log.d(TAG, "SUCCESSFUL remote currency edition (${editRemoteCurrencyResponse.stringContent})")
|
||||
} else {
|
||||
Log.d(TAG, "FAILED to edit remote currency (${editRemoteCurrencyResponse.stringContent})")
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
if (e.message == "{\"message\": \"Internal Server Error\"}") {
|
||||
Log.d(TAG, "FAILED to edit remote currency : it does not exist remotely")
|
||||
} 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)
|
||||
for (cToAdd in currencyToAdd) {
|
||||
val createRemoteCurrencyResponse = client!!.createRemoteCurrency(project, cToAdd)
|
||||
val newRemoteId = createRemoteCurrencyResponse.stringContent.toLong()
|
||||
if (newRemoteId > 0) {
|
||||
if (project.type == ProjectType.COSPEND) {
|
||||
val currencyToAdd = dbHelper.getCurrenciesOfProjectWithState(project.id, DBBill.STATE_ADDED)
|
||||
for (cToAdd in currencyToAdd) {
|
||||
val createRemoteCurrencyResponse = client!!.createRemoteCurrency(project, cToAdd)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,14 +100,19 @@ object ThemeUtils {
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colors.background
|
||||
) {
|
||||
Column {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colors.primary)
|
||||
.statusBarsPadding()
|
||||
)
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.navigationBarsPadding()
|
||||
.imePadding()
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,13 +35,13 @@ class NextcloudClient(
|
||||
val method = if (useOcsApi) METHOD_GET else METHOD_POST
|
||||
return if (nextcloudAPI != null) {
|
||||
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(
|
||||
requestServerWithSSO(nextcloudAPI, target, method, null, useOcsApi),
|
||||
useOcsApi
|
||||
)
|
||||
} else {
|
||||
Log.d(javaClass.simpleName, "Sync projects target $target")
|
||||
// Log.d(javaClass.simpleName, "Sync projects target $target")
|
||||
ServerResponse.AccountProjectsResponse(
|
||||
requestServer(target, method, null, "", true, useOcsApi),
|
||||
useOcsApi
|
||||
@@ -184,7 +184,7 @@ class NextcloudClient(
|
||||
): VersatileProjectSyncClient.ResponseData {
|
||||
val result = StringBuilder()
|
||||
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)
|
||||
httpCon.requestMethod = method
|
||||
if (needLogin) {
|
||||
@@ -206,7 +206,7 @@ class NextcloudClient(
|
||||
var paramData: ByteArray? = null
|
||||
if (params != null) {
|
||||
paramData = params.toString().toByteArray()
|
||||
Log.d(javaClass.simpleName, "Params: $params")
|
||||
// Log.d(javaClass.simpleName, "Params: $params")
|
||||
httpCon.setFixedLengthStreamingMode(paramData.size)
|
||||
httpCon.setRequestProperty("Content-Type", application_json)
|
||||
httpCon.doOutput = true
|
||||
@@ -263,11 +263,11 @@ class NextcloudClient(
|
||||
httpCon.setRequestProperty("OCS-APIRequest", "true")
|
||||
}
|
||||
httpCon.connectTimeout = 10 * 1000 // 10 seconds
|
||||
Log.d(javaClass.simpleName, "$method $targetURL")
|
||||
// Log.d(javaClass.simpleName, "$method $targetURL")
|
||||
var paramData: ByteArray?
|
||||
if (params != null) {
|
||||
paramData = params.toString().toByteArray()
|
||||
Log.d(javaClass.simpleName, "Params: $params")
|
||||
// Log.d(javaClass.simpleName, "Params: $params")
|
||||
httpCon.setFixedLengthStreamingMode(paramData.size)
|
||||
httpCon.setRequestProperty("Content-Type", application_json)
|
||||
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
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
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 org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
|
||||
@@ -81,13 +81,9 @@ class VersatileProjectSyncClient(
|
||||
else
|
||||
project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId
|
||||
useOcsApiRequest = cospendVersionGT161
|
||||
if (cospendVersionGT161) {
|
||||
Log.i(TAG, "using new API (weblogin, $username:$password) for getProjectInfo")
|
||||
}
|
||||
} else if (canAccessProjectWithSSO(project)) {
|
||||
return if (cospendVersionGT161) {
|
||||
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)
|
||||
} else {
|
||||
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)
|
||||
else
|
||||
project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password)
|
||||
Log.i(TAG, "using public API, target is: ${target}for getProjectInfo")
|
||||
}
|
||||
} else {
|
||||
target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId
|
||||
@@ -172,13 +167,9 @@ class VersatileProjectSyncClient(
|
||||
else
|
||||
project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId
|
||||
useOcsApiRequest = cospendVersionGT161
|
||||
if (cospendVersionGT161) {
|
||||
Log.i(TAG, "using new API (weblogin, $username:$password) for editRemoteProject")
|
||||
}
|
||||
} else if (canAccessProjectWithSSO(project)) {
|
||||
return if (cospendVersionGT161) {
|
||||
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)
|
||||
} else {
|
||||
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)
|
||||
else
|
||||
project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password)
|
||||
Log.i(TAG, "using public API, target is: ${target}for editRemoteProject")
|
||||
}
|
||||
} else {
|
||||
target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId
|
||||
@@ -239,13 +229,9 @@ class VersatileProjectSyncClient(
|
||||
else
|
||||
project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/members/" + member.remoteId
|
||||
useOcsApiRequest = cospendVersionGT161
|
||||
if (cospendVersionGT161) {
|
||||
Log.i(TAG, "using new API (weblogin, $username:$password) for editRemoteMember")
|
||||
}
|
||||
} else if (canAccessProjectWithSSO(project)) {
|
||||
return if (cospendVersionGT161) {
|
||||
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)
|
||||
} else {
|
||||
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
|
||||
else
|
||||
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 {
|
||||
target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/members/" + member.remoteId
|
||||
@@ -333,9 +318,6 @@ class VersatileProjectSyncClient(
|
||||
else
|
||||
project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/bills/" + bill.remoteId
|
||||
useOcsApiRequest = cospendVersionGT161
|
||||
if (cospendVersionGT161) {
|
||||
Log.i(TAG, "using new API (weblogin, $username:$password) for editRemoteBill")
|
||||
}
|
||||
} else if (canAccessProjectWithSSO(project)) {
|
||||
return if (cospendVersionGT161) {
|
||||
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
|
||||
else
|
||||
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 {
|
||||
target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/bills/" + bill.remoteId
|
||||
@@ -389,9 +370,6 @@ class VersatileProjectSyncClient(
|
||||
else
|
||||
project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId
|
||||
useOcsApiRequest = cospendVersionGT161
|
||||
if (cospendVersionGT161) {
|
||||
Log.i(TAG, "using new API (weblogin, $username:$password) for deleteRemoteProject")
|
||||
}
|
||||
} else if (canAccessProjectWithSSO(project)) {
|
||||
return if (cospendVersionGT161) {
|
||||
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)
|
||||
else
|
||||
project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password)
|
||||
Log.i(TAG, "using public API, target is: ${target}for deleteRemoteProject")
|
||||
}
|
||||
} else {
|
||||
target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId
|
||||
@@ -439,9 +416,6 @@ class VersatileProjectSyncClient(
|
||||
else
|
||||
project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/bills/" + billRemoteId
|
||||
useOcsApiRequest = cospendVersionGT161
|
||||
if (cospendVersionGT161) {
|
||||
Log.i(TAG, "using new API (weblogin, $username:$password) for deleteRemoteBill")
|
||||
}
|
||||
} else if (canAccessProjectWithSSO(project)) {
|
||||
return if (cospendVersionGT161) {
|
||||
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
|
||||
else
|
||||
project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/bills/" + billRemoteId
|
||||
Log.i(TAG, "using public API, target is: ${target}for deleteRemoteProject")
|
||||
}
|
||||
} else {
|
||||
target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/bills/" + billRemoteId
|
||||
@@ -528,9 +501,7 @@ class VersatileProjectSyncClient(
|
||||
else
|
||||
project.getRequestBaseUrl(false) + "/api-priv/projects"
|
||||
useOcsApiRequest = cospendVersionGT161
|
||||
if (cospendVersionGT161) {
|
||||
Log.i(TAG, "using new API (weblogin, $username:$password) for createAuthenticatedRemoteProject")
|
||||
}
|
||||
|
||||
return ServerResponse.CreateRemoteProjectResponse(
|
||||
requestServer(
|
||||
target, METHOD_POST, paramKeys, paramValues,
|
||||
@@ -594,9 +565,6 @@ class VersatileProjectSyncClient(
|
||||
else
|
||||
project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/bills"
|
||||
useOcsApiRequest = cospendVersionGT161
|
||||
if (cospendVersionGT161) {
|
||||
Log.i(TAG, "using new API (weblogin, $username:$password) for createRemoteBill")
|
||||
}
|
||||
} else if (canAccessProjectWithSSO(project)) {
|
||||
return if (cospendVersionGT161) {
|
||||
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"
|
||||
else
|
||||
project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/bills"
|
||||
Log.i(TAG, "using public API, target is: ${target}for createRemoteBill")
|
||||
}
|
||||
} else {
|
||||
target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/bills"
|
||||
@@ -664,9 +631,6 @@ class VersatileProjectSyncClient(
|
||||
else
|
||||
project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/members"
|
||||
useOcsApiRequest = cospendVersionGT161
|
||||
if (cospendVersionGT161) {
|
||||
Log.i(TAG, "using new API (weblogin, $username:$password) for createRemoteMember")
|
||||
}
|
||||
} else if (canAccessProjectWithSSO(project)) {
|
||||
return if (cospendVersionGT161) {
|
||||
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"
|
||||
else
|
||||
project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/members"
|
||||
Log.i(TAG, "using public API, target is: ${target}for createRemoteBill")
|
||||
}
|
||||
} else {
|
||||
target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/members"
|
||||
@@ -717,9 +680,6 @@ class VersatileProjectSyncClient(
|
||||
else
|
||||
project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/bills?lastchanged=" + tsLastSync
|
||||
useOcsApiRequest = cospendVersionGT161
|
||||
if (cospendVersionGT161) {
|
||||
Log.i(TAG, "using new API (weblogin, $username:$password) for getBills")
|
||||
}
|
||||
return ServerResponse.BillsResponse(
|
||||
requestServer(
|
||||
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
|
||||
else
|
||||
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(
|
||||
requestServer(
|
||||
target, METHOD_GET, null, null,
|
||||
@@ -790,9 +749,6 @@ class VersatileProjectSyncClient(
|
||||
else
|
||||
project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/members"
|
||||
useOcsApiRequest = cospendVersionGT161
|
||||
if (cospendVersionGT161) {
|
||||
Log.i(TAG, "using new API (weblogin, $username:$password) for getMembers, projectId: " + project.remoteId)
|
||||
}
|
||||
} else if (canAccessProjectWithSSO(project)) {
|
||||
return if (cospendVersionGT161) {
|
||||
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"
|
||||
else
|
||||
project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/members"
|
||||
Log.i(TAG, "using public API, target is: ${target}for getMembers")
|
||||
}
|
||||
} else {
|
||||
target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/members"
|
||||
@@ -847,9 +802,6 @@ class VersatileProjectSyncClient(
|
||||
else
|
||||
project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/currency"
|
||||
useOcsApiRequest = cospendVersionGT161
|
||||
if (cospendVersionGT161) {
|
||||
Log.i(TAG, "using new API (weblogin, $username:$password) for createRemoteCurrency")
|
||||
}
|
||||
} else if (canAccessProjectWithSSO(project)) {
|
||||
return if (cospendVersionGT161) {
|
||||
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"
|
||||
else
|
||||
project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/currency"
|
||||
Log.i(TAG, "using public API, target is: ${target}for createRemoteCurrency")
|
||||
}
|
||||
} else {
|
||||
target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/currency"
|
||||
@@ -904,9 +855,6 @@ class VersatileProjectSyncClient(
|
||||
else
|
||||
project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/currency/" + currency.remoteId
|
||||
useOcsApiRequest = cospendVersionGT161
|
||||
if (cospendVersionGT161) {
|
||||
Log.i(TAG, "using new API (weblogin, $username:$password) for editRemoteCurrency")
|
||||
}
|
||||
} else if (canAccessProjectWithSSO(project)) {
|
||||
return if (cospendVersionGT161) {
|
||||
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
|
||||
else
|
||||
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 {
|
||||
target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/currency/" + currency.remoteId
|
||||
@@ -954,9 +901,6 @@ class VersatileProjectSyncClient(
|
||||
else
|
||||
project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/currency/" + currencyRemoteId
|
||||
useOcsApiRequest = cospendVersionGT161
|
||||
if (cospendVersionGT161) {
|
||||
Log.i(TAG, "using new API (weblogin, $username:$password) for deleteRemoteCurrency")
|
||||
}
|
||||
} else if (canAccessProjectWithSSO(project)) {
|
||||
return if (cospendVersionGT161) {
|
||||
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
|
||||
else
|
||||
project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/currency/" + currencyRemoteId
|
||||
Log.i(TAG, "using public API, target is: ${target}for deleteRemoteCurrency")
|
||||
}
|
||||
} else {
|
||||
target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/currency/" + currencyRemoteId
|
||||
@@ -1071,7 +1014,6 @@ class VersatileProjectSyncClient(
|
||||
httpCon.setRequestProperty("Accept", "application/json")
|
||||
}
|
||||
httpCon.connectTimeout = 10 * 1000 // 10 seconds
|
||||
Log.d(javaClass.simpleName, "$method $target")
|
||||
if (paramKeys != null && paramValues != null) {
|
||||
var dataString = ""
|
||||
for (i in paramKeys.indices) {
|
||||
@@ -1084,7 +1026,6 @@ class VersatileProjectSyncClient(
|
||||
dataString += URLEncoder.encode(value, "UTF-8")
|
||||
}
|
||||
val data = dataString.toByteArray()
|
||||
Log.d(javaClass.simpleName, "Params: $dataString")
|
||||
httpCon.setFixedLengthStreamingMode(data.size)
|
||||
httpCon.setRequestProperty("Content-Type", "application/x-www-form-urlencoded")
|
||||
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="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"/>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
+1
-1
@@ -2,5 +2,5 @@
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@drawable/ic_launcher_monochrome"/>
|
||||
</adaptive-icon>
|
||||
+1
-1
@@ -2,5 +2,5 @@
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@drawable/ic_launcher_monochrome"/>
|
||||
</adaptive-icon>
|
||||
@@ -20,14 +20,9 @@
|
||||
<string name="save_or_discard_bill_dialog_save">Save</string>
|
||||
<string name="save_or_discard_bill_dialog_discard">Discard</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="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="currency_edit_name">Name</string>
|
||||
<string name="currency_rate">Exchange rate</string>
|
||||
<string name="setting_owers">For whom?</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_yes">Yes</string>
|
||||
<string name="simple_no">No</string>
|
||||
<string name="simple_add">Add</string>
|
||||
<string name="simple_edit_bill">Edit bill</string>
|
||||
<string name="simple_new_bill">New bill</string>
|
||||
<string name="simple_add_project">Add project in Cowspent</string>
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
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
|
||||
retries=0
|
||||
retryBackOffMs=500
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
Reference in New Issue
Block a user