32 Commits

Author SHA1 Message Date
sora eec95cdcb0 Merge branch 'main' into renovate/configure 2026-07-12 14:47:47 +02:00
soraefir 106c4e27fc Fix sync and add beta features 2026-07-09 19:22:04 +02:00
soraefir 47d0471b48 Label sync and workflows 2026-07-05 15:02:36 +02:00
sora 56968dbe3b Update metadata/en-US/full_description.txt 2026-07-02 03:05:16 +02:00
soraefir 56036d5840 Fix toast & minor category/paymentmode fix 2026-07-02 02:43:33 +02:00
soraefir 4fa29da79d Better category/paymentmode storage and handling 2026-07-02 02:37:40 +02:00
sora c2c1017633 Update app/src/main/java/net/helcel/cowspent/model/DBPaymentMode.kt 2026-07-02 02:06:55 +02:00
sora 3aeabf4dd3 Update app/src/main/java/net/helcel/cowspent/model/DBCategory.kt 2026-07-02 02:06:41 +02:00
sora 937c3db1f5 Update app/src/main/java/net/helcel/cowspent/persistence/CowspentSQLiteOpenHelper.kt 2026-07-02 02:06:08 +02:00
sora 5b3c47045c Disable custom Label until fully implemented 2026-07-02 02:01:36 +02:00
soraefir 7581985abd Stripping fix 2026-07-01 22:12:42 +02:00
soraefir 810ca20201 Custom Labels, User archive, Versioning, String and UI cleanup 2026-07-01 22:10:08 +02:00
soraefir 678a90ead9 Archiving and Label options 2026-06-30 20:40:32 +02:00
soraefir 88469fa96c Fix monochrome logo 2026-06-30 19:31:39 +02:00
soraefir 119ae757b9 Migrate to SecureStorage 2026-06-30 19:24:25 +02:00
soraefir c3d380ddb4 Migrate to encryptedPref 2026-06-30 18:59:50 +02:00
soraefir a8ba00269b Hide inactive members in drawer 2026-06-30 07:41:34 +02:00
soraefir 756a37de23 String cleanup 2026-06-30 07:37:52 +02:00
soraefir 5f7266013d More cleanup 2026-06-30 07:33:49 +02:00
soraefir e98dc4a86b Cleanup imports and co 2026-06-30 07:30:57 +02:00
soraefir afb14ff7b9 Fix Monochrome 2026-06-30 07:26:33 +02:00
soraefir 1bee1dabbe Disable dependency tree, logo to png 2026-06-30 07:17:57 +02:00
soraefir 84432fe68d Better formating and placeholder 2026-06-29 21:38:01 +02:00
soraefir 7e00b14d27 Fix keyboard padding and better currency management 2026-06-29 21:31:59 +02:00
soraefir ec4d60897a Currency parsing and formating
# Conflicts:
#	app/src/main/java/net/helcel/cowspent/android/main/BillsListComponents.kt
2026-06-29 21:30:35 +02:00
soraefir 5c74336c10 Fixes for currency, logo, render and more 2026-06-27 12:18:33 +02:00
soraefir d617c5c7be project creation and currency db 2026-06-14 21:34:58 +02:00
soraefir a9a303478c better currency handling 2026-06-14 21:33:08 +02:00
sora 110ab397b8 Update README.md 2026-06-09 19:19:24 +02:00
sora 3675e87651 Upload files to ".github/images" 2026-06-09 19:18:02 +02:00
sora 65799496f9 Update app/build.gradle 2026-06-09 08:51:43 +02:00
soraefir 2967dbd001 fix ui 2026-06-09 01:51:42 +02:00
86 changed files with 4789 additions and 2443 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+2 -1
View File
@@ -52,7 +52,8 @@ jobs:
- name: Build APK
run: |
VERSION_CODE=$(git rev-list --count HEAD)
./gradlew assembleSignedRelease -PVERSION_CODE=$VERSION_CODE
VERSION_NAME=$(git describe --tags --always)
./gradlew assembleSignedRelease -PVERSION_CODE=$VERSION_CODE -PVERSION_NAME=$VERSION_NAME
- name: Release
uses: softprops/action-gh-release@v3
+2 -1
View File
@@ -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).
+21 -7
View File
@@ -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 project.hasProperty('VERSION_NAME') ? project.property('VERSION_NAME') : "1.4"
versionCode project.hasProperty('VERSION_CODE') ? project.property('VERSION_CODE').toInteger() : 1
}
@@ -82,11 +82,22 @@ android {
androidResources {
generateLocaleConfig = true
}
packaging {
jniLibs.keepDebugSymbols.add("**/*.so")
}
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.4'
implementation 'androidx.compose.runtime:runtime:1.11.4'
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.5'
implementation 'androidx.preference:preference-ktx:1.2.1'
@@ -99,7 +110,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.01')
implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.material:material'
implementation 'androidx.compose.material:material-icons-extended'
@@ -107,5 +118,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"
}
+27 -12
View File
@@ -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>
@@ -43,9 +46,9 @@
<activity
android:name="net.helcel.cowspent.android.account.AccountActivity"
android:label="@string/settings_server_settings"
android:label="@string/title_account"
android:parentActivityName="net.helcel.cowspent.android.settings.PreferencesActivity"
android:windowSoftInputMode="stateVisible"
android:windowSoftInputMode="adjustResize|stateVisible"
android:launchMode="standard"
/>
@@ -64,9 +67,9 @@
<activity
android:name="net.helcel.cowspent.android.project.create.NewProjectActivity"
android:label="@string/simple_add_project"
android:label="@string/title_add_project"
android:parentActivityName="net.helcel.cowspent.android.main.BillsListViewActivity"
android:windowSoftInputMode="stateHidden"
android:windowSoftInputMode="adjustResize"
android:launchMode="standard"
android:exported="true">
<intent-filter>
@@ -91,36 +94,48 @@
<activity
android:name="net.helcel.cowspent.android.project.edit.EditProjectActivity"
android:label="@string/simple_edit_project"
android:label="@string/title_edit_project"
android:parentActivityName="net.helcel.cowspent.android.main.BillsListViewActivity"
android:windowSoftInputMode="stateHidden"
android:windowSoftInputMode="adjustResize"
android:launchMode="standard">
</activity>
<activity
android:name="net.helcel.cowspent.android.bill_edit.EditBillActivity"
android:label="@string/simple_edit_bill"
android:label="@string/action_edit"
android:parentActivityName="net.helcel.cowspent.android.main.BillsListViewActivity"
android:windowSoftInputMode="stateHidden"
android:windowSoftInputMode="adjustResize"
android:launchMode="standard">
</activity>
<activity
android:name="net.helcel.cowspent.android.about.AboutActivity"
android:label="@string/simple_about"
android:label="@string/title_about"
android:parentActivityName="net.helcel.cowspent.android.main.BillsListViewActivity" />
<activity
android:name="net.helcel.cowspent.android.statistics.ProjectStatisticsActivity"
android:label="@string/statistic_title"
android:label="@string/title_stats"
android:parentActivityName="net.helcel.cowspent.android.main.BillsListViewActivity"
android:theme="@style/AppTheme.NoActionBar" />
<activity
android:name="net.helcel.cowspent.android.bill_label.LabelBillsActivity"
android:label="@string/label_bills_title"
android:label="@string/title_label_bills"
android:parentActivityName="net.helcel.cowspent.android.main.BillsListViewActivity"
android:theme="@style/AppTheme.NoActionBar" />
<activity
android:name="net.helcel.cowspent.android.label.LabelManagementActivity"
android:label="@string/title_labels"
android:parentActivityName="net.helcel.cowspent.android.main.BillsListViewActivity"
android:windowSoftInputMode="adjustResize" />
<activity
android:name="net.helcel.cowspent.android.project.member.MemberManagementActivity"
android:label="@string/action_members"
android:parentActivityName="net.helcel.cowspent.android.main.BillsListViewActivity"
android:windowSoftInputMode="adjustResize" />
</application>
</manifest>
@@ -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 {
@@ -45,7 +45,7 @@ fun AboutScreen(
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.simple_about)) },
title = { Text(stringResource(R.string.title_about)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
@@ -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)
}
@@ -4,39 +4,18 @@
package net.helcel.cowspent.android.account
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Button
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Scaffold
import androidx.compose.material.Switch
import androidx.compose.material.SwitchDefaults
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.*
import androidx.compose.runtime.Composable
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.text.input.PasswordVisualTransformation
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
@@ -99,7 +78,7 @@ fun AccountScreenContent(
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.settings_server_settings)) },
title = { Text(stringResource(R.string.title_account)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
@@ -123,30 +102,70 @@ fun AccountScreenContent(
}
} else if (isLoggedIn) {
Text(
text = stringResource(R.string.account_logged_in_as, username),
style = MaterialTheme.typography.h6
text = "CURRENT ACCOUNT",
style = MaterialTheme.typography.subtitle1,
color = MaterialTheme.colors.onSurface,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 8.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Person,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
)
Spacer(modifier = Modifier.width(32.dp))
Column {
Text(
text = stringResource(R.string.msg_logged_in_as, username),
style = MaterialTheme.typography.subtitle1
)
Text(
text = serverUrl,
style = MaterialTheme.typography.body2,
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
)
}
}
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = onLogout,
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.account_logout))
Text(stringResource(R.string.action_logout))
}
Spacer(modifier = Modifier.height(24.dp))
}
if (!isValidatingLogin) {
Text(
text = "CONNECTION SETTINGS",
style = MaterialTheme.typography.subtitle1,
color = MaterialTheme.colors.onSurface,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 8.dp, top = if (isLoggedIn) 16.dp else 0.dp)
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 8.dp)
) {
Text(stringResource(R.string.use_sso_toggle), modifier = Modifier.weight(1f))
Icon(
Icons.Default.Sync,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
)
Spacer(modifier = Modifier.width(32.dp))
Text(stringResource(R.string.label_use_sso), modifier = Modifier.weight(1f), style = MaterialTheme.typography.subtitle1)
Switch(
checked = useSso,
onCheckedChange = { onSsoClick(it) },
@@ -163,7 +182,7 @@ fun AccountScreenContent(
OutlinedTextField(
value = serverUrl,
onValueChange = onServerUrlChange,
label = { Text(stringResource(R.string.settings_url)) },
placeholder = { Text(stringResource(R.string.label_url)) },
modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.Default.Link, contentDescription = null) },
trailingIcon = {
@@ -192,7 +211,7 @@ fun AccountScreenContent(
OutlinedTextField(
value = username,
onValueChange = onUsernameChange,
label = { Text(stringResource(R.string.settings_username)) },
placeholder = { Text(stringResource(R.string.label_username)) },
modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.Default.Person, contentDescription = null) },
singleLine = true
@@ -204,7 +223,7 @@ fun AccountScreenContent(
OutlinedTextField(
value = password,
onValueChange = onPasswordChange,
label = { Text(stringResource(R.string.settings_password)) },
placeholder = { Text(stringResource(R.string.label_password)) },
modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) },
visualTransformation = PasswordVisualTransformation(),
@@ -221,7 +240,7 @@ fun AccountScreenContent(
if (isSubmitting) {
CustomCircularProgressIndicator(size = 24.dp, color = Color.White)
} else {
Text(stringResource(R.string.settings_submit))
Text(stringResource(R.string.action_connect))
}
}
}
@@ -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)
@@ -15,6 +15,7 @@ import androidx.compose.material.TextButton
import androidx.compose.material.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -65,8 +66,10 @@ fun LoginDialogContent(
) {
Column(modifier = Modifier.padding(24.dp)) {
Text(
text = "Login",
style = MaterialTheme.typography.h6,
text = "LOGIN",
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.onSurface,
modifier = Modifier.padding(bottom = 16.dp)
)
@@ -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
@@ -25,7 +26,7 @@ import net.helcel.cowspent.model.ProjectType
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 +40,7 @@ class EditBillActivity : AppCompatActivity() {
private val calendar = Calendar.getInstance()
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
db = CowspentSQLiteOpenHelper.getInstance(this)
@@ -49,23 +51,10 @@ class EditBillActivity : AppCompatActivity() {
ThemeUtils.CowspentTheme {
val categories = remember {
val syncedCategories = db.getCategories(bill.projectId)
val defaultCategories = CategoryUtils.getDefaultCategories(this@EditBillActivity, bill.projectId)
val hardcoded = if (projectType == ProjectType.LOCAL) {
defaultCategories
} else {
listOfNotNull(defaultCategories.find { it.remoteId.toInt() == DBBill.CATEGORY_REIMBURSEMENT })
}
syncedCategories + hardcoded
db.getCategories(bill.projectId)
}
val paymentModes = remember {
val syncedPaymentModes = db.getPaymentModes(bill.projectId)
val defaultPaymentModes = CategoryUtils.getDefaultPaymentModes(this@EditBillActivity, bill.projectId)
if (projectType == ProjectType.LOCAL) {
syncedPaymentModes + defaultPaymentModes
} else {
syncedPaymentModes.ifEmpty { defaultPaymentModes }
}
db.getPaymentModes(bill.projectId)
}
EditBillScreen(
@@ -105,7 +94,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
)
}
@@ -140,7 +130,7 @@ class EditBillActivity : AppCompatActivity() {
bill = DBBill(
first.id, 0, first.projectId, first.payerId, totalAmount,
first.timestamp, first.what, first.state, first.repeat,
first.paymentMode, first.categoryRemoteId, first.comment, first.paymentModeRemoteId
first.paymentMode, first.categoryId, first.comment, first.paymentModeId
)
val splits = mutableMapOf<Long, Double>()
@@ -171,8 +161,8 @@ class EditBillActivity : AppCompatActivity() {
bill = DBBill(
0, 0, projectId, btd.payerId, btd.amount,
timeNowSeconds, btd.what, DBBill.STATE_ADDED,
btd.repeat, btd.paymentMode, btd.categoryRemoteId,
btd.comment, btd.paymentModeRemoteId
btd.repeat, btd.paymentMode, btd.categoryId,
btd.comment, btd.paymentModeId
)
val btdOwers = btd.billOwers
val newBillOwers = btdOwers.filter {
@@ -183,12 +173,23 @@ 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)
if (project != null) {
db.ensureDefaultLabels(bill.projectId, project.type)
}
withContext(Dispatchers.Main) {
viewModel.currencies = currencies
viewModel.mainCurrencyName = project?.currencyName ?: ""
viewModel.isNewBill = (bill.id == 0L)
viewModel.initFromBill(bill, members, customSplits)
}
}
@@ -204,6 +205,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 +217,7 @@ class EditBillActivity : AppCompatActivity() {
viewModel.timestamp = calendar.timeInMillis / 1000
}
viewModel.amount = croatianBill.amount.toString()
viewModel.updateSplits()
return@registerForActivityResult
} catch (_: ParseException) {
}
@@ -223,28 +226,42 @@ 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.categoryId,
viewModel.getFinalComment(), viewModel.paymentModeId
)
calendar.timeInMillis = System.currentTimeMillis()
viewModel.timestamp = calendar.timeInMillis / 1000
viewModel.isNewBill = true
showToast(this, "Duplicating bill...")
}
private fun onBack() {
if (!valuesHaveChanged()) {
finish()
return
}
viewModel.showDialog(
title = getString(R.string.save_or_discard_bill_dialog_title),
message = getString(R.string.save_or_discard_bill_dialog_message),
positiveText = getString(R.string.save_or_discard_bill_dialog_save),
title = getString(R.string.dialog_unsaved_changes_title),
message = getString(R.string.dialog_unsaved_changes_msg),
positiveText = getString(R.string.action_save),
onConfirm = { saveBillAsked() },
negativeText = getString(R.string.save_or_discard_bill_dialog_discard),
negativeText = getString(R.string.action_discard),
onCancel = { finish() }
)
}
private fun saveBillAsked() {
val validationError = viewModel.getValidationError(
getString(R.string.error_invalid_bill_what),
getString(R.string.error_invalid_bill_name),
getString(R.string.error_invalid_bill_date),
getString(R.string.error_invalid_bill_payerid),
getString(R.string.error_invalid_bill_payer),
getString(R.string.error_invalid_bill_owers),
getString(R.string.simple_error)
getString(R.string.error_generic)
)
if (validationError != null) {
@@ -262,7 +279,7 @@ class EditBillActivity : AppCompatActivity() {
private fun deleteBillAsked() {
viewModel.showDialog(
title = getString(R.string.confirm_remove_project_dialog_title),
title = getString(R.string.title_confirm),
message = bill.what,
positiveText = getString(R.string.action_delete),
onConfirm = {
@@ -296,12 +313,12 @@ 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 &&
bill.categoryId == viewModel.categoryId &&
bill.paymentModeId == viewModel.paymentModeId &&
!owersChanged)
}
@@ -310,13 +327,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
@@ -356,9 +377,9 @@ class EditBillActivity : AppCompatActivity() {
listOf(memberId),
viewModel.repeat,
existingBill.paymentMode,
viewModel.paymentModeRemoteId,
viewModel.categoryRemoteId,
viewModel.comment
viewModel.paymentModeId,
viewModel.categoryId,
finalComment
)
if (firstSavedId == 0L) firstSavedId = billToUseId
} else {
@@ -366,9 +387,11 @@ 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.categoryId, finalComment, viewModel.paymentModeId
)
newBill.billOwers = listOf(DBBillOwer(0, 0, memberId))
newBill.categoryId = viewModel.categoryId
newBill.paymentModeId = viewModel.paymentModeId
val newId = db.addBill(newBill)
if (firstSavedId == 0L) firstSavedId = newId
}
@@ -396,7 +419,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) {
@@ -410,9 +434,9 @@ class EditBillActivity : AppCompatActivity() {
newOwersIds,
viewModel.repeat,
bill.paymentMode,
viewModel.paymentModeRemoteId,
viewModel.categoryRemoteId,
viewModel.comment
viewModel.paymentModeId,
viewModel.categoryId,
finalComment
)
if (groupedBillIds != null) {
for (id in groupedBillIds) {
@@ -429,7 +453,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.categoryId, finalComment, viewModel.paymentModeId
)
newOwersIds.forEach { newBill.billOwers += DBBillOwer(0, 0, it) }
val newBillId = db.addBill(newBill)
@@ -21,6 +21,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
@@ -30,6 +31,7 @@ import androidx.compose.ui.unit.sp
import net.helcel.cowspent.R
import net.helcel.cowspent.android.helper.*
import net.helcel.cowspent.model.*
import net.helcel.cowspent.util.CategoryUtils
import net.helcel.cowspent.util.SupportUtil
import java.util.Date
import kotlin.math.abs
@@ -45,6 +47,7 @@ fun EditBillScreen(
onDateClick: () -> Unit,
onTimeClick: () -> Unit,
onScan: () -> Unit,
onDuplicate: (() -> Unit)? = null,
onDelete: (() -> Unit)? = null,
accessLevel: Int = DBProject.ACCESS_LEVEL_ADMIN
) {
@@ -59,7 +62,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.action_new_bill else R.string.action_edit)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
@@ -70,6 +73,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))
@@ -83,11 +91,11 @@ fun EditBillScreen(
},
floatingActionButton = {
if (canEdit) {
val errorWhat = stringResource(R.string.error_invalid_bill_what)
val errorWhat = stringResource(R.string.error_invalid_bill_name)
val errorDate = stringResource(R.string.error_invalid_bill_date)
val errorPayer = stringResource(R.string.error_invalid_bill_payerid)
val errorPayer = stringResource(R.string.error_invalid_bill_payer)
val errorOwers = stringResource(R.string.error_invalid_bill_owers)
val errorInvalidForm = stringResource(R.string.simple_error)
val errorInvalidForm = stringResource(R.string.error_generic)
FloatingActionButton(onClick = {
val validationError = viewModel.getValidationError(
@@ -101,7 +109,7 @@ fun EditBillScreen(
}) {
Icon(
Icons.Default.Done,
contentDescription = stringResource(R.string.action_save_bill)
contentDescription = stringResource(R.string.action_save)
)
}
}
@@ -111,6 +119,7 @@ fun EditBillScreen(
Column(
modifier = Modifier
.padding(padding)
.imePadding()
.padding(16.dp)
.fillMaxSize()
.verticalScroll(scrollState)
@@ -157,16 +166,23 @@ fun BillBasicInfoSection(
onDateClick: () -> Unit,
onTimeClick: () -> Unit
) {
Text(
text = "GENERAL",
style = MaterialTheme.typography.subtitle1,
color = MaterialTheme.colors.onSurface,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 8.dp)
)
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,
onValueChange = { viewModel.what = it },
enabled = canEdit,
placeholder = { Text(stringResource(R.string.setting_what)) },
placeholder = { Text(stringResource(R.string.label_what)) },
modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.Default.Title, contentDescription = null) }
)
@@ -182,25 +198,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()) {
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 = viewModel.currencies.map { "${it.name} (${it.exchangeRate})" },
items = options,
onItemSelected = { index ->
viewModel.convertCurrency(viewModel.currencies[index])
if (index == 0) {
viewModel.resetCurrency()
} else {
viewModel.convertCurrency(viewModel.currencies[index - 1])
}
}
)
} else {
showToast(context, noCurrencyError)
}
}
) {
Icon(Icons.Default.CurrencyExchange, contentDescription = null)
Icon(Icons.Default.SwapHoriz, contentDescription = "Change Currency")
}
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
@@ -221,7 +249,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 +257,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") }
)
}
}
@@ -244,20 +272,16 @@ fun PayerSection(
EditableExposedDropdownMenu(
value = selectedPayer?.name ?: "",
placeholder = stringResource(R.string.setting_payer),
placeholder = stringResource(R.string.label_payer),
expanded = payerExpanded,
onExpandedChange = { payerExpanded = it },
onDismissRequest = { payerExpanded = false },
enabled = canEdit,
leadingIcon = {
Box(modifier = Modifier.padding(start = 12.dp)) {
Box(modifier = Modifier) {
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 +295,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))
@@ -330,8 +350,10 @@ fun OwerSelectionSection(
}
Spacer(modifier = Modifier.width(8.dp))
Text(
stringResource(R.string.setting_owers), fontSize = 12.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
text = stringResource(R.string.label_owers).uppercase(),
style = MaterialTheme.typography.subtitle1,
color = MaterialTheme.colors.onSurface,
fontWeight = FontWeight.Bold
)
Spacer(Modifier.weight(1f))
@@ -381,12 +403,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))
@@ -441,19 +459,28 @@ fun BillAdditionalDetailsSection(
paymentModes: List<DBPaymentMode>,
canEdit: Boolean
) {
Text(
text = "DETAILS",
style = MaterialTheme.typography.subtitle1,
color = MaterialTheme.colors.onSurface,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 8.dp, top = 16.dp)
)
val context = LocalContext.current
var categoryExpanded by remember { mutableStateOf(false) }
val selectedCategory =
categories.find { it.remoteId.toInt() == viewModel.categoryRemoteId }
categories.find { it.id == viewModel.categoryId } ?: CategoryUtils.getCategoryById(context, viewModel.categoryId)
EditableExposedDropdownMenu(
value = selectedCategory?.name ?: "",
placeholder = stringResource(R.string.setting_category),
placeholder = stringResource(R.string.label_category),
expanded = categoryExpanded,
onExpandedChange = { categoryExpanded = it },
onDismissRequest = { categoryExpanded = false },
enabled = canEdit,
leadingIcon = {
Box(modifier = Modifier.padding(start = 12.dp)) {
Box(modifier = Modifier) {
if (selectedCategory != null) {
Text(text = selectedCategory.icon, fontSize = 20.sp)
} else {
@@ -463,16 +490,26 @@ fun BillAdditionalDetailsSection(
},
content = {
DropdownMenuItem(onClick = {
viewModel.categoryRemoteId = 0
viewModel.categoryId = 0
categoryExpanded = false
}) {
Icon(Icons.Default.Close, tint = Color.Red, contentDescription = null)
Spacer(modifier = Modifier.width(12.dp))
Text(stringResource(R.string.category_none))
}
categories.forEach { category ->
DropdownMenuItem(onClick = {
viewModel.categoryRemoteId = category.remoteId.toInt()
viewModel.categoryId = DBBill.CATEGORY_REIMBURSEMENT
categoryExpanded = false
}) {
Text(text = "\uD83D\uDCB0", fontSize = 20.sp)
Spacer(modifier = Modifier.width(12.dp))
Text(stringResource(R.string.category_reimbursement))
}
categories.filter { it.remoteId != DBBill.CATEGORY_REIMBURSEMENT }.forEach { category ->
DropdownMenuItem(onClick = {
viewModel.categoryId = category.id
categoryExpanded = false
}) {
Text(text = category.icon, fontSize = 20.sp)
@@ -487,17 +524,17 @@ fun BillAdditionalDetailsSection(
var pmExpanded by remember { mutableStateOf(false) }
val selectedPm =
paymentModes.find { it.remoteId.toInt() == viewModel.paymentModeRemoteId }
paymentModes.find { it.id == viewModel.paymentModeId } ?: CategoryUtils.getPaymentModeById(context, viewModel.paymentModeId)
EditableExposedDropdownMenu(
value = selectedPm?.name ?: "",
placeholder = stringResource(R.string.setting_payment_mode),
placeholder = stringResource(R.string.label_mode),
expanded = pmExpanded,
onExpandedChange = { pmExpanded = it },
onDismissRequest = { pmExpanded = false },
enabled = canEdit,
leadingIcon = {
Box(modifier = Modifier.padding(start = 12.dp)) {
Box(modifier = Modifier) {
if (selectedPm != null) {
Text(text = selectedPm.icon, fontSize = 20.sp)
} else {
@@ -507,7 +544,7 @@ fun BillAdditionalDetailsSection(
},
content = {
DropdownMenuItem(onClick = {
viewModel.paymentModeRemoteId = 0
viewModel.paymentModeId = 0
pmExpanded = false
}) {
Icon(Icons.Default.Close, tint = Color.Red, contentDescription = null)
@@ -516,7 +553,7 @@ fun BillAdditionalDetailsSection(
}
paymentModes.forEach { pm ->
DropdownMenuItem(onClick = {
viewModel.paymentModeRemoteId = pm.remoteId.toInt()
viewModel.paymentModeId = pm.id
pmExpanded = false
}) {
Text(text = pm.icon, fontSize = 20.sp)
@@ -542,7 +579,7 @@ fun BillAdditionalDetailsSection(
EditableExposedDropdownMenu(
value = selectedRepeat?.second ?: "",
placeholder = stringResource(R.string.setting_project_repetition),
placeholder = stringResource(R.string.label_repeat),
expanded = repeatExpanded,
onExpandedChange = { repeatExpanded = it },
onDismissRequest = { repeatExpanded = false },
@@ -566,7 +603,7 @@ fun BillAdditionalDetailsSection(
value = viewModel.comment,
onValueChange = { viewModel.comment = it },
enabled = canEdit,
placeholder = { Text(stringResource(R.string.setting_comment)) },
placeholder = { Text(stringResource(R.string.label_comment)) },
modifier = Modifier.fillMaxWidth(),
singleLine = false,
leadingIcon = {
@@ -587,6 +624,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,21 +1,21 @@
package net.helcel.cowspent.android.bill_edit
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableDoubleStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.vector.ImageVector
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.model.DBBill
import net.helcel.cowspent.model.DBCurrency
import net.helcel.cowspent.model.DBMember
import net.helcel.cowspent.util.SupportUtil
import net.helcel.cowspent.util.evalMath
import net.helcel.cowspent.model.DBCurrency
import androidx.compose.ui.graphics.vector.ImageVector
class EditBillViewModel : ViewModel() {
var what by mutableStateOf("")
var amount by mutableStateOf("")
@@ -23,11 +23,14 @@ class EditBillViewModel : ViewModel() {
var timestamp by mutableLongStateOf(0L)
var payerId by mutableLongStateOf(0L)
var repeat by mutableStateOf(DBBill.NON_REPEATED)
var paymentModeRemoteId by mutableIntStateOf(0)
var categoryRemoteId by mutableIntStateOf(0)
var paymentModeId by mutableLongStateOf(0L)
var categoryId by mutableLongStateOf(0L)
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 +41,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 +126,81 @@ class EditBillViewModel : ViewModel() {
}
fun convertCurrency(currency: DBCurrency) {
val originalAmountStr = amount
val originalAmount = amountAsDouble
if (originalAmount == 0.0) return
selectedCurrencyName = currency.name ?: ""
selectedCurrencyRate = currency.exchangeRate
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()
}
}
}
// 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
paymentModeId = bill.paymentModeId
categoryId = bill.categoryId
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 +211,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
@@ -12,7 +13,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.theme.ThemeUtils
import net.helcel.cowspent.util.CategoryUtils
import net.helcel.cowspent.model.DBBill
import net.helcel.cowspent.model.ProjectType
@@ -20,6 +20,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)
@@ -37,24 +38,20 @@ class LabelBillsActivity : AppCompatActivity() {
val members = db.getMembersOfProject(projectId, null)
val allBills = db.getBillsOfProject(projectId)
val billsToLabel = allBills.filter { it.categoryRemoteId == 0 && it.state != DBBill.STATE_DELETED }
val allCategorized = allBills.filter { it.categoryRemoteId != 0 && it.state != DBBill.STATE_DELETED }
val billsToLabel = allBills.filter { it.categoryId == 0L && it.state != DBBill.STATE_DELETED }
val allCategorized = allBills.filter { it.categoryId != 0L && it.state != DBBill.STATE_DELETED }
val syncedCategories = db.getCategories(projectId)
val defaultCategories = CategoryUtils.getDefaultCategories(this@LabelBillsActivity, projectId)
val hardcoded = if (projectType == ProjectType.LOCAL) {
defaultCategories
} else {
listOfNotNull(defaultCategories.find { it.remoteId.toInt() == DBBill.CATEGORY_REIMBURSEMENT })
}
val categories = syncedCategories + hardcoded
db.ensureDefaultLabels(projectId, projectType)
// Reload from DB to get real IDs
val categories = db.getCategories(projectId)
Quadruple(members, billsToLabel, categories, allCategorized)
}
viewModel.billsToLabel = billsToLabel
viewModel.categories = categories
viewModel.categoriesMap = categories.associateBy { it.remoteId }
viewModel.categoriesMap = categories.associateBy { it.id }
viewModel.allCategorizedBills = allCategorized
viewModel.updateSuggestions()
@@ -42,7 +42,7 @@ fun LabelBillsScreen(
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.label_bills_title)) },
title = { Text(stringResource(R.string.title_label_bills)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
@@ -64,10 +64,11 @@ fun LabelBillsScreen(
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.label_bills_suggested),
text = stringResource(R.string.label_bills_suggested).uppercase(),
style = MaterialTheme.typography.subtitle1,
color = MaterialTheme.colors.onSurface,
fontWeight = FontWeight.Bold,
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth().padding(top = 8.dp)
)
Spacer(modifier = Modifier.height(8.dp))
@@ -83,7 +84,7 @@ fun LabelBillsScreen(
CategoryButton(
icon = category.icon,
name = category.name ?: "",
onClick = { viewModel.labelCurrentBill(db, category.remoteId.toInt()) }
onClick = { viewModel.labelCurrentBill(db, category.id) }
)
}
}
@@ -93,7 +94,7 @@ fun LabelBillsScreen(
}
} else {
Text(
text = stringResource(R.string.label_bills_no_suggestions),
text = stringResource(R.string.msg_no_suggestions),
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.4f),
modifier = Modifier.fillMaxWidth(),
@@ -105,10 +106,11 @@ fun LabelBillsScreen(
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.setting_category),
text = stringResource(R.string.label_category).uppercase(),
style = MaterialTheme.typography.subtitle1,
color = MaterialTheme.colors.onSurface,
fontWeight = FontWeight.Bold,
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth().padding(top = 8.dp)
)
Spacer(modifier = Modifier.height(8.dp))
@@ -124,7 +126,7 @@ fun LabelBillsScreen(
CategoryButton(
icon = category.icon,
name = category.name ?: "",
onClick = { viewModel.labelCurrentBill(db, category.remoteId.toInt()) }
onClick = { viewModel.labelCurrentBill(db, category.id) }
)
}
}
@@ -141,7 +143,7 @@ fun LabelBillsScreen(
Spacer(modifier = Modifier.height(8.dp))
} else {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(stringResource(R.string.label_bills_no_more))
Text(stringResource(R.string.msg_bill_labeled_done))
}
}
}
@@ -243,16 +245,16 @@ fun CategoryButton(icon: String, name: String, onClick: () -> Unit) {
fun LabelBillsScreenPreview() {
val viewModel = LabelBillsViewModel().apply {
billsToLabel = listOf(
DBBill(1L, 0, 1L, 1L, 120.5, System.currentTimeMillis() / 1000, "Groceries at Aldi", 0, null, null, 0, null, -1)
DBBill(1L, 0, 1L, 1L, 120.5, System.currentTimeMillis() / 1000, "Groceries at Aldi", 0, null, null, 0L, null, -1L)
)
val cats = listOf(
DBCategory(1, 1, 1, "Groceries", "🛒", ""),
DBCategory(2, 2, 1, "Leisure", "🥳", ""),
DBCategory(3, 3, 1, "Rent", "🏠", ""),
DBCategory(4, 4, 1, "Bills", "💸", "")
DBCategory(1L, 1L, 1L, "Groceries", "🛒", ""),
DBCategory(2L, 2L, 1L, "Leisure", "🥳", ""),
DBCategory(3L, 3L, 1L, "Rent", "🏠", ""),
DBCategory(4L, 4L, 1L, "Bills", "💸", "")
)
categories = cats
categoriesMap = cats.associateBy { it.remoteId }
categoriesMap = cats.associateBy { it.id }
}
val members = listOf(
DBMember(1L, 0, 1L, "Alice", true, 1.0, 0, 255, 100, 100, null, null),
@@ -43,18 +43,18 @@ class LabelBillsViewModel : ViewModel() {
otherName == name || (name.length > 3 && otherName.contains(name)) || (otherName.length > 3 && name.contains(otherName))
}
val counts = matches.groupBy { it.categoryRemoteId }
val counts = matches.groupBy { it.categoryId }
.mapValues { it.value.size }
.toList()
.sortedByDescending { it.second }
.take(2)
suggestedCategories = counts.mapNotNull { (catId, _) ->
categoriesMap[catId.toLong()]
categoriesMap[catId]
}
}
fun labelCurrentBill(db: CowspentSQLiteOpenHelper, categoryId: Int) {
fun labelCurrentBill(db: CowspentSQLiteOpenHelper, categoryId: Long) {
currentBill?.let { bill ->
db.updateBillAndSync(
bill = bill,
@@ -65,11 +65,11 @@ class LabelBillsViewModel : ViewModel() {
newOwersIds = bill.billOwersIds,
newRepeat = bill.repeat,
newPaymentMode = bill.paymentMode,
newPaymentModeRemoteId = bill.paymentModeRemoteId,
newPaymentModeId = bill.paymentModeId,
newCategoryId = categoryId,
newComment = bill.comment
)
bill.categoryRemoteId = categoryId
bill.categoryId = categoryId
onBillProcessed?.invoke(bill.id)
moveToNext()
}
@@ -87,11 +87,11 @@ class LabelBillsViewModel : ViewModel() {
val start = currentBillIndex
var next = (start + 1) % billsToLabel.size
while (next != start && billsToLabel[next].categoryRemoteId != 0) {
while (next != start && billsToLabel[next].categoryId != 0L) {
next = (next + 1) % billsToLabel.size
}
currentBillIndex = if (billsToLabel[next].categoryRemoteId == 0) {
currentBillIndex = if (billsToLabel[next].categoryId == 0L) {
next
} else {
billsToLabel.size
@@ -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
@@ -31,15 +33,18 @@ class ManageCurrenciesActivity : AppCompatActivity() {
if (message.isEmpty()) {
showToast(this@ManageCurrenciesActivity,getString(R.string.currency_saved_success), Toast.LENGTH_LONG)
} else {
viewModel.showDialog(title=getString(R.string.error_edit_remote_project_helper, message),
message=getString(R.string.currency_manager),
positiveText = getString(android.R.string.ok))
viewModel.showDialog(
title = getString(R.string.dialog_sync_error_title),
message = getString(R.string.error_edit_remote_project_helper, message),
positiveText = getString(android.R.string.ok)
)
}
}
override fun onScheduled() {}
}
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
intent.extras?.let {
@@ -64,8 +69,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,6 +91,7 @@ class ManageCurrenciesActivity : AppCompatActivity() {
val project = db!!.getProject(selectedProjectID)
if (project != null) {
db!!.syncIfRemote(project)
if (project.type == ProjectType.COSPEND) {
withContext(Dispatchers.Main) {
if (!db!!.cowspentServerSyncHelper
.editRemoteProject(selectedProjectID, project.name, null, null, newMainCurrencyName, editMainCurrencyCallBack)
@@ -91,27 +99,54 @@ class ManageCurrenciesActivity : AppCompatActivity() {
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)
}
}
}
}
}
}
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) {
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) {
@@ -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) {
@@ -59,7 +96,7 @@ fun ManageCurrenciesScreen(
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.currency_manager)) },
title = { Text(stringResource(R.string.action_currencies)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
@@ -73,55 +110,152 @@ fun ManageCurrenciesScreen(
Column(
modifier = Modifier
.padding(padding)
.padding(16.dp)
.imePadding()
.fillMaxSize()
.background(MaterialTheme.colors.onSurface.copy(alpha = 0.02f))
) {
LaunchedEffect(viewModel.mainCurrencyName) {
if (viewModel.mainCurrencyName.isNotEmpty()) {
delay(500.milliseconds)
onSaveMain()
}
}
Column(modifier = Modifier.padding(16.dp)) {
// Section 1: Main Currency
Text(
text = stringResource(R.string.main_currency).uppercase(),
style = MaterialTheme.typography.subtitle1,
color = MaterialTheme.colors.onSurface,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 8.dp)
)
Card(
shape = RoundedCornerShape(12.dp),
elevation = 2.dp,
modifier = Modifier.fillMaxWidth()
) {
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)
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("e.g. EUR", fontSize = 14.sp) },
colors = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent
)
)
Spacer(modifier = Modifier.width(8.dp))
Button(onClick = onSaveMain, enabled = viewModel.mainCurrencyName.isNotEmpty()) {
Text(stringResource(R.string.save_or_discard_bill_dialog_save))
}
}
Spacer(modifier = Modifier.height(24.dp))
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)
// Section 2: Exchange Rates
val isEditing = viewModel.editingCurrencyId != null
Text(
text = (if (isEditing) "Edit exchange rate" else "Add exchange rate").uppercase(),
style = MaterialTheme.typography.subtitle1,
color = MaterialTheme.colors.onSurface,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 8.dp)
)
Spacer(modifier = Modifier.width(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 },
label = { Text(stringResource(R.string.currency_rate)) },
modifier = Modifier.weight(1f)
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.height(8.dp))
Button(
)
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,
modifier = Modifier.fillMaxWidth(),
enabled = viewModel.isAddEnabled()
) {
Text(stringResource(R.string.simple_add))
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)
)
}
Spacer(modifier = Modifier.height(24.dp))
if (isEditing) {
IconButton(onClick = onCancelEdit) {
Icon(Icons.Default.Close, contentDescription = null, tint = MaterialTheme.colors.error)
}
}
}
}
}
LazyColumn(modifier = Modifier.weight(1f)) {
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "SAVED RATES",
style = MaterialTheme.typography.subtitle1,
color = MaterialTheme.colors.onSurface,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 8.dp)
)
}
LazyColumn(
modifier = Modifier.weight(1f).fillMaxWidth(),
contentPadding = PaddingValues(16.dp, 0.dp, 16.dp, 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
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 +263,70 @@ fun ManageCurrenciesScreen(
}
@Composable
fun CurrencyRow(currency: DBCurrency, onDelete: () -> Unit) {
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()
) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onEdit)
.padding(16.dp, 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(currency.name ?: "", style = MaterialTheme.typography.subtitle1)
Text("Rate: ${currency.exchangeRate}", style = MaterialTheme.typography.caption)
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(Icons.Default.Delete, contentDescription = null, tint = MaterialTheme.colors.error)
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 +335,9 @@ fun CurrencyRowPreview() {
MaterialTheme {
CurrencyRow(
currency = DBCurrency(1, 0, 0, "USD", 1.0, 0),
mainCurrencyName = "EUR",
isEditing = false,
onEdit = {},
onDelete = {}
)
}
@@ -172,7 +359,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,
@@ -138,20 +160,27 @@ private fun DrawerHeader(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colors.primary)
.padding(16.dp, 4.dp)
.padding(4.dp, 4.dp)
) {
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(modifier = Modifier.size(48.dp)) {
Image(
painter = painterResource(id = R.drawable.ic_launcher_background),
contentDescription = null,
modifier = Modifier.fillMaxSize()
)
Image(
painter = painterResource(id = R.drawable.ic_launcher_foreground),
contentDescription = null,
modifier = Modifier.size(48.dp)
modifier = Modifier.fillMaxSize()
)
}
Spacer(modifier = Modifier.width(8.dp))
Text(
stringResource(id = R.string.app_name),
text = stringResource(id = R.string.app_name),
color = MaterialTheme.colors.onPrimary,
fontSize = 18.sp,
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.weight(1f))
@@ -178,6 +207,7 @@ private fun DrawerHeader(
}
if (lastSyncText.isNotEmpty()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(Modifier.width(16.dp))
Icon(
Icons.Default.Sync,
contentDescription = null,
@@ -241,6 +271,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 +290,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
)
@@ -273,7 +299,7 @@ fun DrawerItem(
Icon(icon, contentDescription = null, tint = contentColor.copy(alpha = 0.6f * alpha))
}
Spacer(modifier = Modifier.width(8.dp))
Spacer(modifier = Modifier.width(32.dp))
val itemText = text ?: member?.name ?: ""
Text(
@@ -295,6 +321,17 @@ fun DrawerItem(
)
}
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) {
Icon(
@@ -340,6 +377,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,13 +111,14 @@ fun AlertDialog(
if (message != null) Spacer(Modifier.height(8.dp))
LazyColumn {
itemsIndexed(items) { index, item ->
Column {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
onItemSelected?.invoke(index)
}
.padding(vertical = 12.dp),
.padding(vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (itemIcons != null && index < itemIcons.size) {
@@ -128,12 +130,38 @@ fun AlertDialog(
)
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 = item.toString(),
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
)
}
}
if (index < items.size - 1) {
Divider(color = MaterialTheme.colors.onSurface.copy(alpha = 0.08f))
}
}
}
}
}
}
@@ -202,7 +230,12 @@ fun AlertDialogContent(
Spacer(Modifier.width(8.dp))
}
if (title != null) {
Text(text = title, style = MaterialTheme.typography.h6)
Text(
text = title.uppercase(),
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.onSurface
)
}
}
}
@@ -216,13 +249,14 @@ fun AlertDialogContent(
if (message != null) Spacer(Modifier.height(8.dp))
LazyColumn {
itemsIndexed(items) { index, item ->
Column {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
onItemSelected?.invoke(index)
}
.padding(vertical = 8.dp),
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (itemIcons != null && index < itemIcons.size) {
@@ -234,12 +268,38 @@ fun AlertDialogContent(
)
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 = item.toString(),
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
)
}
}
if (index < items.size - 1) {
Divider(color = MaterialTheme.colors.onSurface.copy(alpha = 0.08f))
}
}
}
}
}
}
@@ -188,12 +188,13 @@ fun ColorPicker(
Spacer(modifier = Modifier.height(16.dp))
val hueBrush = remember {
Brush.horizontalGradient(
listOf(
Color.Red, Color.Yellow, Color.Green, Color.Cyan, Color.Blue, Color.Magenta, Color.Red
)
)
val hueBrush = remember(lightness, chroma) {
val hueSteps = 36 // More steps for smoother gradient
val colors = (0..hueSteps).map { step ->
val h = (step.toFloat() / hueSteps) * 360f
Color(mLCHtoRBG(lightness, chroma, h))
}
Brush.horizontalGradient(colors)
}
LchSlider(
label = "H",
@@ -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()
@@ -120,7 +134,7 @@ fun QrCodeScannerScreen(
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.scan_qrcode)) },
title = { Text(stringResource(R.string.action_scan_qrcode)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
@@ -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() {
@@ -0,0 +1,46 @@
package net.helcel.cowspent.android.label
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import net.helcel.cowspent.theme.ThemeUtils
class LabelManagementActivity : AppCompatActivity() {
private val viewModel: LabelManagementViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
val projectId = intent.getLongExtra(EXTRA_PROJECT_ID, 0L)
if (projectId == 0L) {
finish()
return
}
viewModel.loadLabels(projectId)
setContent {
ThemeUtils.CowspentTheme {
LabelManagementScreen(
viewModel = viewModel,
onBack = { finish() }
)
}
}
}
companion object {
const val EXTRA_PROJECT_ID = "EXTRA_PROJECT_ID"
fun createIntent(context: Context, projectId: Long): Intent {
return Intent(context, LabelManagementActivity::class.java).apply {
putExtra(EXTRA_PROJECT_ID, projectId)
}
}
}
}
@@ -0,0 +1,399 @@
package net.helcel.cowspent.android.label
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
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.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.graphics.toColorInt
import net.helcel.cowspent.R
import net.helcel.cowspent.android.helper.ColorPicker
import net.helcel.cowspent.android.helper.StatefulAlertDialog
import net.helcel.cowspent.model.DBBill
import net.helcel.cowspent.model.DBCategory
import net.helcel.cowspent.model.DBPaymentMode
@Composable
fun LabelManagementScreen(
viewModel: LabelManagementViewModel,
onBack: () -> Unit
) {
LabelManagementScreenContent(
categories = viewModel.categories,
paymentModes = viewModel.paymentModes,
dialogState = viewModel.dialogState,
onBack = onBack,
onAddCategory = viewModel::addCategory,
onUpdateCategory = viewModel::updateCategory,
onDeleteCategory = viewModel::deleteCategory,
onAddPaymentMode = viewModel::addPaymentMode,
onUpdatePaymentMode = viewModel::updatePaymentMode,
onDeletePaymentMode = viewModel::deletePaymentMode,
onDismissDialog = { viewModel.dialogState = null },
onShowDialog = { viewModel.dialogState = it }
)
}
@Composable
fun LabelManagementScreenContent(
categories: List<DBCategory>,
paymentModes: List<DBPaymentMode>,
dialogState: net.helcel.cowspent.android.helper.DialogState?,
onBack: () -> Unit,
onAddCategory: (String, String, String) -> Unit,
onUpdateCategory: (DBCategory, String, String, String) -> Unit,
onDeleteCategory: (Long) -> Unit,
onAddPaymentMode: (String, String, String) -> Unit,
onUpdatePaymentMode: (DBPaymentMode, String, String, String) -> Unit,
onDeletePaymentMode: (Long) -> Unit,
onDismissDialog: () -> Unit,
onShowDialog: (net.helcel.cowspent.android.helper.DialogState) -> Unit,
initialTab: Int = 0
) {
var selectedTab by remember { mutableIntStateOf(initialTab) }
val tabs = listOf(
stringResource(R.string.label_categories),
stringResource(R.string.label_payment_modes)
)
val deleteLabelTitle = stringResource(R.string.delete_label_confirmation_title)
val deleteLabelMessage = stringResource(R.string.delete_label_confirmation_message)
val yesText = stringResource(R.string.simple_yes)
val noText = stringResource(R.string.simple_no)
var showEditDialog by remember { mutableStateOf(false) }
var editingCategory by remember { mutableStateOf<DBCategory?>(null) }
var editingPaymentMode by remember { mutableStateOf<DBPaymentMode?>(null) }
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.title_labels)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
},
backgroundColor = MaterialTheme.colors.primary,
elevation = 0.dp
)
},
floatingActionButton = {
FloatingActionButton(onClick = {
if (selectedTab == 0) {
editingCategory = null
showEditDialog = true
} else {
editingPaymentMode = null
showEditDialog = true
}
}) {
Icon(Icons.Default.Add, contentDescription = null)
}
}
) { padding ->
Column(modifier = Modifier.padding(padding)) {
TabRow(selectedTabIndex = selectedTab) {
tabs.forEachIndexed { index, title ->
Tab(
selected = selectedTab == index,
onClick = { selectedTab = index },
text = { Text(title) }
)
}
}
if (selectedTab == 0) {
CategoryList(
categories = categories.filter { it.remoteId != DBBill.CATEGORY_REIMBURSEMENT },
onEdit = {
editingCategory = it
showEditDialog = true
},
onDelete = { category ->
onShowDialog(net.helcel.cowspent.android.helper.DialogState(
title = deleteLabelTitle,
message = deleteLabelMessage,
positiveText = yesText,
negativeText = noText,
onConfirm = { onDeleteCategory(category.id) }
))
}
)
} else {
PaymentModeList(
paymentModes = paymentModes,
onEdit = {
editingPaymentMode = it
showEditDialog = true
},
onDelete = { pm ->
onShowDialog(net.helcel.cowspent.android.helper.DialogState(
title = deleteLabelTitle,
message = deleteLabelMessage,
positiveText = yesText,
negativeText = noText,
onConfirm = { onDeletePaymentMode(pm.id) }
))
}
)
}
}
}
if (showEditDialog) {
if (selectedTab == 0) {
EditLabelDialog(
title = if (editingCategory == null) stringResource(R.string.title_add_category) else stringResource(R.string.action_edit),
initialName = editingCategory?.name ?: "",
initialIcon = editingCategory?.icon ?: "",
initialColor = editingCategory?.color ?: "#FF0000",
onDismiss = { showEditDialog = false },
onSave = { name, icon, color ->
if (editingCategory == null) {
onAddCategory(name, icon, color)
} else {
onUpdateCategory(editingCategory!!, name, icon, color)
}
showEditDialog = false
}
)
} else {
EditLabelDialog(
title = if (editingPaymentMode == null) stringResource(R.string.title_add_payment_mode) else stringResource(R.string.action_edit),
initialName = editingPaymentMode?.name ?: "",
initialIcon = editingPaymentMode?.icon ?: "",
initialColor = editingPaymentMode?.color ?: "#00FF00",
onDismiss = { showEditDialog = false },
onSave = { name, icon, color ->
if (editingPaymentMode == null) {
onAddPaymentMode(name, icon, color)
} else {
onUpdatePaymentMode(editingPaymentMode!!, name, icon, color)
}
showEditDialog = false
}
)
}
}
StatefulAlertDialog(
state = dialogState,
onDismissRequest = onDismissDialog
)
}
@Composable
fun CategoryList(
categories: List<DBCategory>,
onEdit: (DBCategory) -> Unit,
onDelete: (DBCategory) -> Unit
) {
LazyColumn {
items(categories) { category ->
LabelItem(
name = category.name ?: "",
icon = category.icon,
color = category.color,
onEdit = { onEdit(category) },
onDelete = { onDelete(category) }
)
}
}
}
@Composable
fun PaymentModeList(
paymentModes: List<DBPaymentMode>,
onEdit: (DBPaymentMode) -> Unit,
onDelete: (DBPaymentMode) -> Unit
) {
LazyColumn {
items(paymentModes) { pm ->
LabelItem(
name = pm.name ?: "",
icon = pm.icon,
color = pm.color,
onEdit = { onEdit(pm) },
onDelete = { onDelete(pm) }
)
}
}
}
@Composable
fun LabelItem(
name: String,
icon: String,
color: String,
onEdit: () -> Unit,
onDelete: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onEdit() }
.padding(16.dp, 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(24.dp)
.clip(CircleShape)
.background(parseColor(color)),
contentAlignment = Alignment.Center
) {
Text(text = icon, fontSize = 12.sp)
}
Spacer(modifier = Modifier.width(32.dp))
Text(text = name, modifier = Modifier.weight(1f), style = MaterialTheme.typography.subtitle1)
IconButton(onClick = onEdit) {
Icon(Icons.Default.Edit, contentDescription = null, tint = MaterialTheme.colors.onSurface.copy(alpha = 0.6f))
}
IconButton(onClick = onDelete) {
Icon(Icons.Default.Delete, contentDescription = null, tint = MaterialTheme.colors.error.copy(alpha = 0.6f))
}
}
}
@Composable
fun EditLabelDialog(
title: String,
initialName: String,
initialIcon: String,
initialColor: String,
onDismiss: () -> Unit,
onSave: (String, String, String) -> Unit
) {
var name by remember { mutableStateOf(initialName) }
var icon by remember { mutableStateOf(initialIcon) }
var color by remember { mutableStateOf(initialColor) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
OutlinedTextField(
value = name,
onValueChange = { name = it },
placeholder = { Text(stringResource(R.string.label_name)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = icon,
onValueChange = { if (it.length <= 2) icon = it },
placeholder = { Text(stringResource(R.string.label_icon)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Spacer(modifier = Modifier.height(16.dp))
Text(text = stringResource(R.string.label_color), style = MaterialTheme.typography.caption)
ColorPicker(
initialColor = parseColorInt(color),
onColorChanged = { color = String.format("#%06X", 0xFFFFFF and it) }
)
}
},
confirmButton = {
Button(
onClick = { onSave(name, icon, color) },
enabled = name.isNotBlank()
) {
Text(stringResource(R.string.action_save))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.simple_cancel))
}
}
)
}
fun parseColor(colorString: String): Color {
return try {
Color(colorString.toColorInt())
} catch (_: Exception) {
Color.Gray
}
}
fun parseColorInt(colorString: String): Int {
return try {
colorString.toColorInt()
} catch (_: Exception) {
Color.Gray.toArgb()
}
}
@Preview(showBackground = true)
@Composable
fun LabelManagementCategoriesPreview() {
MaterialTheme {
LabelManagementScreenContent(
categories = listOf(
DBCategory(1L, 1L, 1L, "Groceries", "🛒", "#FF0000"),
DBCategory(2L, 2L, 1L, "Rent", "🏠", "#00FF00")
),
paymentModes = emptyList(),
dialogState = null,
onBack = {},
onAddCategory = { _, _, _ -> },
onUpdateCategory = { _, _, _, _ -> },
onDeleteCategory = { _ -> },
onAddPaymentMode = { _, _, _ -> },
onUpdatePaymentMode = { _, _, _, _ -> },
onDeletePaymentMode = { _ -> },
onDismissDialog = {},
onShowDialog = {},
initialTab = 0
)
}
}
@Preview(showBackground = true)
@Composable
fun LabelManagementPaymentModesPreview() {
MaterialTheme {
LabelManagementScreenContent(
categories = emptyList(),
paymentModes = listOf(
DBPaymentMode(1L, 1L, 1L, "Cash", "💵", "#0000FF"),
DBPaymentMode(2L, 2L, 1L, "Credit Card", "💳", "#FFFF00")
),
dialogState = null,
onBack = {},
onAddCategory = { _, _, _ -> },
onUpdateCategory = { _, _, _, _ -> },
onDeleteCategory = { _ -> },
onAddPaymentMode = { _, _, _ -> },
onUpdatePaymentMode = { _, _, _, _ -> },
onDeletePaymentMode = { _ -> },
onDismissDialog = {},
onShowDialog = {},
initialTab = 1
)
}
}
@@ -0,0 +1,95 @@
package net.helcel.cowspent.android.label
import android.app.Application
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.helcel.cowspent.android.helper.DialogState
import net.helcel.cowspent.model.DBCategory
import net.helcel.cowspent.model.DBPaymentMode
import net.helcel.cowspent.model.DBBill
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
class LabelManagementViewModel(application: Application) : AndroidViewModel(application) {
private val db = CowspentSQLiteOpenHelper.getInstance(application)
var projectId by mutableLongStateOf(0L)
var categories by mutableStateOf<List<DBCategory>>(emptyList())
var paymentModes by mutableStateOf<List<DBPaymentMode>>(emptyList())
var dialogState by mutableStateOf<DialogState?>(null)
fun loadLabels(projId: Long) {
projectId = projId
viewModelScope.launch {
val project = withContext(Dispatchers.IO) { db.getProject(projId) }
if (project != null) {
withContext(Dispatchers.IO) {
db.ensureDefaultLabels(projId, project.type)
}
}
categories = withContext(Dispatchers.IO) { db.getCategories(projId) }
paymentModes = withContext(Dispatchers.IO) { db.getPaymentModes(projId) }
}
}
fun addCategory(name: String, icon: String, color: String) {
viewModelScope.launch {
withContext(Dispatchers.IO) {
db.addCategoryAndSync(DBCategory(0, 0, projectId, name, icon, color, DBBill.STATE_ADDED))
}
loadLabels(projectId)
}
}
fun updateCategory(cat: DBCategory, name: String, icon: String, color: String) {
viewModelScope.launch {
withContext(Dispatchers.IO) {
db.updateCategoryAndSync(cat, name, icon, color)
}
loadLabels(projectId)
}
}
fun deleteCategory(id: Long) {
viewModelScope.launch {
withContext(Dispatchers.IO) {
db.deleteCategoryAndSync(id)
}
loadLabels(projectId)
}
}
fun addPaymentMode(name: String, icon: String, color: String) {
viewModelScope.launch {
withContext(Dispatchers.IO) {
db.addPaymentModeAndSync(DBPaymentMode(0, 0, projectId, name, icon, color, DBBill.STATE_ADDED))
}
loadLabels(projectId)
}
}
fun updatePaymentMode(pm: DBPaymentMode, name: String, icon: String, color: String) {
viewModelScope.launch {
withContext(Dispatchers.IO) {
db.updatePaymentModeAndSync(pm, name, icon, color)
}
loadLabels(projectId)
}
}
fun deletePaymentMode(id: Long) {
viewModelScope.launch {
withContext(Dispatchers.IO) {
db.deletePaymentModeAndSync(id)
}
loadLabels(projectId)
}
}
}
@@ -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
@@ -13,10 +29,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.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
@@ -28,15 +45,25 @@ fun EmptyProjectsState(onConfigureNextcloud: () -> Unit, onAddManually: () -> Un
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(stringResource(R.string.no_projects_title), style = MaterialTheme.typography.h6)
Text(
text = stringResource(R.string.error_no_projects).uppercase(),
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.onSurface
)
Spacer(modifier = Modifier.height(8.dp))
Text(stringResource(R.string.no_projects_text))
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = onConfigureNextcloud) {
Text(
text = stringResource(R.string.no_projects_text),
style = MaterialTheme.typography.body2,
textAlign = TextAlign.Center,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
)
Spacer(modifier = Modifier.height(24.dp))
Button(onClick = onConfigureNextcloud, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(R.string.configure_account_choice))
}
Spacer(modifier = Modifier.height(8.dp))
Button(onClick = onAddManually) {
Button(onClick = onAddManually, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(R.string.add_project_choice))
}
}
@@ -49,9 +76,19 @@ fun EmptyMembersState() {
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(stringResource(R.string.no_members_title), style = MaterialTheme.typography.h6)
Text(
text = stringResource(R.string.error_no_members).uppercase(),
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.onSurface
)
Spacer(modifier = Modifier.height(8.dp))
Text(stringResource(R.string.no_members_text))
Text(
text = stringResource(R.string.no_members_text),
style = MaterialTheme.typography.body2,
textAlign = TextAlign.Center,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
)
}
}
@@ -62,9 +99,19 @@ fun EmptyBillsState() {
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(stringResource(R.string.no_bills_title), style = MaterialTheme.typography.h6)
Text(
text = stringResource(R.string.error_no_bills).uppercase(),
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.onSurface
)
Spacer(modifier = Modifier.height(8.dp))
Text(stringResource(R.string.no_bills_text))
Text(
text = stringResource(R.string.no_bills_text),
style = MaterialTheme.typography.body2,
textAlign = TextAlign.Center,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
)
}
}
@@ -83,13 +130,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) {
@@ -111,15 +153,21 @@ fun BillItemRow(bill: DBBill, payer: DBMember?, onClick: () -> Unit) {
Icons.Default.Receipt,
contentDescription = null,
modifier = Modifier.size(40.dp),
tint = MaterialTheme.colors.primary
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
)
}
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(bill.formattedWhat.ifEmpty { bill.what }, fontWeight = FontWeight.Bold)
Text(
text = bill.formattedWhat.ifEmpty { bill.what },
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.onSurface
)
Text(
text = bill.formattedSubtitle.ifEmpty { bill.comment ?: "" },
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
@@ -140,15 +188,18 @@ fun SectionHeader(title: String) {
color = MaterialTheme.colors.background,
modifier = Modifier.fillMaxWidth()
) {
Divider(thickness = 2.dp)
Column {
Divider(thickness = 1.dp, color = MaterialTheme.colors.onSurface.copy(alpha = 0.08f))
Text(
text = title,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
style = MaterialTheme.typography.caption.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colors.primary
text = title.uppercase(),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.onSurface
)
}
}
}
@Composable
fun EmptyState() {
@@ -157,7 +208,18 @@ fun EmptyState() {
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(stringResource(R.string.no_bills_title), style = MaterialTheme.typography.h6)
Text(stringResource(R.string.no_bills_text), modifier = Modifier.padding(16.dp))
Text(
text = stringResource(R.string.error_no_bills).uppercase(),
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.onSurface
)
Text(
text = stringResource(R.string.no_bills_text),
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.body2,
textAlign = TextAlign.Center,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
)
}
}
@@ -2,7 +2,6 @@ package net.helcel.cowspent.android.main
import android.annotation.SuppressLint
import android.content.Intent
import android.widget.Toast
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
@@ -46,6 +45,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
@@ -57,14 +57,12 @@ import net.helcel.cowspent.android.drawer.Drawer
import net.helcel.cowspent.android.helper.StatefulAlertDialog
import net.helcel.cowspent.android.project.ProjectOptionsDialogContent
import net.helcel.cowspent.android.project.ProjectShareDialogContent
import net.helcel.cowspent.android.project.member.MemberAddDialogContent
import net.helcel.cowspent.android.project.member.MemberEditDialogContent
import net.helcel.cowspent.android.project.member.MemberManagementDialogContent
import net.helcel.cowspent.android.project.settle.ProjectSettlementDialogContent
import net.helcel.cowspent.android.statistics.ProjectStatisticsActivity
import net.helcel.cowspent.model.DBBill
import net.helcel.cowspent.model.DBMember
import net.helcel.cowspent.model.DBProject
import net.helcel.cowspent.model.ProjectType
import net.helcel.cowspent.model.SectionItem
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.util.IRefreshBillsListCallback
@@ -103,9 +101,12 @@ fun BillsListScreen(
val pullRefreshState = rememberPullRefreshState(viewModel.isRefreshing, onRefresh)
val context = LocalContext.current
val memberAlreadyExistsError = stringResource(R.string.member_already_exists)
val sharedPreferences = remember { PreferenceManager.getDefaultSharedPreferences(context) }
val showArchived = sharedPreferences.getBoolean(stringResource(R.string.pref_key_show_archived), false)
val keyBetaFeatures = stringResource(R.string.pref_key_beta_features)
var showBetaFeatures by remember(keyBetaFeatures) {
mutableStateOf(sharedPreferences.getBoolean(keyBetaFeatures, false))
}
StatefulAlertDialog(
state = viewModel.dialogState,
@@ -137,6 +138,10 @@ fun BillsListScreen(
onProjectAction(projectOptionsProjectId, 3)
viewModel.showProjectOptionsDialogByProjectId = null
},
onManageLabels = {
onProjectAction(projectOptionsProjectId, 8)
viewModel.showProjectOptionsDialogByProjectId = null
},
onStatistics = {
onProjectAction(projectOptionsProjectId, 4)
viewModel.showProjectOptionsDialogByProjectId = null
@@ -155,8 +160,10 @@ fun BillsListScreen(
},
onDismiss = { viewModel.showProjectOptionsDialogByProjectId = null },
isArchived = proj?.isArchived == true,
projectType = proj?.type ?: ProjectType.LOCAL,
accessLevel = proj?.myAccessLevel ?: DBProject.ACCESS_LEVEL_ADMIN,
isShareable = proj?.isShareable() ?: true
isShareable = proj?.isShareable() ?: true,
showBetaFeatures = showBetaFeatures
)
}
}
@@ -208,114 +215,17 @@ fun BillsListScreen(
val manageMembersProjectId = viewModel.showMemberManagementDialogByProjectId
if (manageMembersProjectId != null) {
val members = viewModel.members // Use members from ViewModel as they are already loaded
Dialog(
onDismissRequest = { viewModel.showMemberManagementDialogByProjectId = null },
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
MemberManagementDialogContent(
members = members,
onAddMember = {
viewModel.showAddMemberDialogByProjectId = manageMembersProjectId
viewModel.showMemberManagementDialogByProjectId = null
},
onEditMember = { member ->
viewModel.showEditMemberDialogByProjectId = member.id
viewModel.showMemberManagementDialogByProjectId = null
},
onDismiss = { viewModel.showMemberManagementDialogByProjectId = null }
)
}
}
val addMemberProjectId = viewModel.showAddMemberDialogByProjectId
if (addMemberProjectId != null) {
Dialog(
onDismissRequest = { viewModel.showAddMemberDialogByProjectId = null },
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
MemberAddDialogContent(
onAdd = { memberName ->
val memberNames =
db.getMembersOfProject(addMemberProjectId, null).map { it.name }
if (memberNames.contains(memberName)) {
Toast.makeText(
context,
memberAlreadyExistsError,
Toast.LENGTH_SHORT
).show()
} else {
val color = net.helcel.cowspent.android.helper.TextDrawable.getColorFromName(memberName)
db.addMemberAndSync(
DBMember(
0,
0,
addMemberProjectId,
memberName,
true,
1.0,
DBBill.STATE_ADDED,
android.graphics.Color.red(color),
android.graphics.Color.green(color),
android.graphics.Color.blue(color),
null,
null
)
)
refreshCallback.refreshLists(false)
viewModel.showAddMemberDialogByProjectId = null
viewModel.showMemberManagementDialogByProjectId = addMemberProjectId
}
},
onDismiss = {
viewModel.showAddMemberDialogByProjectId = null
viewModel.showMemberManagementDialogByProjectId = addMemberProjectId
}
)
}
}
val editMemberId = viewModel.showEditMemberDialogByProjectId
if (editMemberId != null) {
val memberToEdit = remember(editMemberId, viewModel.members) {
viewModel.members.find { it.id == editMemberId }
}
if (memberToEdit != null) {
Dialog(
onDismissRequest = { viewModel.showEditMemberDialogByProjectId = null },
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
MemberEditDialogContent(
member = memberToEdit,
onSave = { name, weight, isActivated, r, g, b ->
db.updateMemberAndSync(
memberToEdit,
name,
weight,
isActivated,
r,
g,
b,
"",
""
)
refreshCallback.refreshLists(false)
viewModel.showEditMemberDialogByProjectId = null
viewModel.showMemberManagementDialogByProjectId = memberToEdit.projectId
},
onDelete = {
db.deleteMember(editMemberId)
refreshCallback.refreshLists(false)
viewModel.showEditMemberDialogByProjectId = null
viewModel.showMemberManagementDialogByProjectId = memberToEdit.projectId
},
onDismiss = {
viewModel.showEditMemberDialogByProjectId = null
viewModel.showMemberManagementDialogByProjectId = memberToEdit.projectId
}
)
}
}
}
val shareProjectId = viewModel.showShareDialogByProjectId
@@ -324,8 +234,8 @@ fun BillsListScreen(
viewModel.projects.find { it.id == shareProjectId }
}
if (proj != null) {
val shareIntentTitle = stringResource(R.string.share_share_intent_title, proj.name)
val shareChooserTitle = stringResource(R.string.share_share_chooser_title, proj.name)
val shareIntentTitle = stringResource(R.string.share_intent_title, proj.name)
val shareChooserTitle = stringResource(R.string.share_chooser_title, proj.name)
Dialog(
onDismissRequest = { viewModel.showShareDialogByProjectId = null },
properties = DialogProperties(usePlatformDefaultWidth = false)
@@ -358,7 +268,13 @@ fun BillsListScreen(
TextField(
value = viewModel.searchQuery,
onValueChange = { viewModel.searchQuery = it },
placeholder = { Text(stringResource(R.string.action_search), color = MaterialTheme.colors.onPrimary.copy(alpha = 0.7f)) },
placeholder = {
Text(
text = stringResource(R.string.action_search),
color = MaterialTheme.colors.onPrimary.copy(alpha = 0.7f),
style = MaterialTheme.typography.subtitle1
)
},
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
@@ -381,8 +297,20 @@ fun BillsListScreen(
)
} else {
Column {
if (viewModel.title.isNotEmpty()) Text(viewModel.title)
else Text(stringResource(R.string.app_name))
if (viewModel.title.isNotEmpty()) {
Text(
text = viewModel.title,
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold
)
}
else {
Text(
text = stringResource(R.string.app_name),
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold
)
}
}
}
},
@@ -404,7 +332,7 @@ fun BillsListScreen(
},
actions = {
if (!isSearchExpanded) {
if (viewModel.hasUnlabeledBills) {
if (showBetaFeatures && viewModel.hasUnlabeledBills) {
IconButton(onClick = onLabelBillsClick) {
Icon(
Icons.Default.Category,
@@ -425,11 +353,12 @@ fun BillsListScreen(
val selectedProject = viewModel.projects.find { it.id == viewModel.selectedProjectId }
if (selectedProject != null && !selectedProject.isArchived) {
FloatingActionButton(onClick = onAddBillClick) {
Icon(Icons.Default.Add, contentDescription = stringResource(R.string.action_create_bill))
Icon(Icons.Default.Add, contentDescription = stringResource(R.string.action_new_bill))
}
}
},
drawerContent = {
val selectedProject = viewModel.projects.find { it.id == viewModel.selectedProjectId }
Drawer(
projects = viewModel.projects,
members = viewModel.members,
@@ -437,6 +366,7 @@ fun BillsListScreen(
selectedProjectId = viewModel.selectedProjectId,
selectedMemberId = viewModel.selectedMemberId,
lastSyncText = viewModel.lastSyncText,
mainCurrency = selectedProject?.currencyName,
showArchived = showArchived,
onProjectClick = {
viewModel.selectedMemberId = null
@@ -5,6 +5,7 @@ import android.content.Intent
import net.helcel.cowspent.R
import net.helcel.cowspent.model.*
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.util.CategoryUtils
import net.helcel.cowspent.util.IRefreshBillsListCallback
import java.text.SimpleDateFormat
import java.util.*
@@ -65,11 +66,11 @@ object BillsListUtils {
memberIdToName: Map<Long, String>
) {
val projectName = proj.name.ifEmpty { proj.remoteId }
var text = context.getString(R.string.share_settle_intro, projectName) + "\n"
var text = context.getString(R.string.msg_settle_intro, projectName) + "\n"
for (t in transactions) {
val amount = round(t.amount * 100.0) / 100.0
text += "\n" + context.getString(
R.string.share_settle_sentence,
R.string.msg_settle_sentence,
memberIdToName[t.owerMemberId],
memberIdToName[t.receiverMemberId],
amount
@@ -80,12 +81,12 @@ object BillsListUtils {
shareIntent.type = "text/plain"
shareIntent.putExtra(
Intent.EXTRA_SUBJECT,
context.getString(R.string.share_settle_title, projectName)
context.getString(R.string.title_settle)
)
shareIntent.putExtra(Intent.EXTRA_TEXT, text)
val chooserIntent = Intent.createChooser(
shareIntent,
context.getString(R.string.share_settle_title, projectName)
context.getString(R.string.title_settle)
)
context.startActivity(chooserIntent)
}
@@ -98,6 +99,10 @@ object BillsListUtils {
context: Context
) {
val timestamp = System.currentTimeMillis() / 1000
val proj = db.getProject(projectId)
val categories = db.getCategories(projectId)
val reimbursementCategoryId = CategoryUtils.getReimbursementCategoryId(categories, projectId, proj?.remoteId)
for (t in transactions) {
val owerId = t.owerMemberId
val receiverId = t.receiverMemberId
@@ -106,7 +111,7 @@ object BillsListUtils {
0, 0, projectId, owerId, amount,
timestamp, context.getString(R.string.settle_bill_what),
DBBill.STATE_ADDED, DBBill.NON_REPEATED,
DBBill.PAYMODE_NONE, DBBill.CATEGORY_NONE,
DBBill.PAYMODE_NONE, reimbursementCategoryId,
"", DBBill.PAYMODE_ID_NONE
)
bill.billOwers += DBBillOwer(0, 0, receiverId)
@@ -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,18 +15,15 @@ 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
import androidx.appcompat.view.ActionMode
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
@@ -53,6 +48,7 @@ import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.persistence.CowspentServerSyncHelper
import net.helcel.cowspent.theme.ThemeUtils
import net.helcel.cowspent.util.BillFormatter
import net.helcel.cowspent.util.CategoryUtils
import net.helcel.cowspent.util.CospendClientUtil
import net.helcel.cowspent.util.ExportUtil
import net.helcel.cowspent.util.ICallback
@@ -118,18 +114,8 @@ class BillsListViewActivity :
Log.d(TAG, "CREATED project id: $pid")
lifecycleScope.launch {
val addedProj = withContext(Dispatchers.IO) { db.getProject(pid) }
val message: String
val title: String
if (created) {
Log.e(TAG, "CREATED !!!")
title = getString(R.string.project_create_success_title)
message = getString(R.string.project_create_success_message, addedProj?.remoteId)
} else {
Log.e(TAG, "ADDED !!!")
title = getString(R.string.project_add_success_title)
message = getString(R.string.project_add_success_message, addedProj?.remoteId)
}
showDialog(message, title, Icons.Default.AddCircleOutline)
val message = getString(R.string.msg_project_added, addedProj?.name?.ifEmpty { addedProj.remoteId } ?: pid.toString())
showToast(this@BillsListViewActivity, message)
}
}
}
@@ -145,7 +131,7 @@ class BillsListViewActivity :
}
if (!db.cowspentServerSyncHelper.isSyncPossible) {
if (CowspentServerSyncHelper.isNextcloudAccountConfigured(applicationContext)) {
Toast.makeText(applicationContext, getString(R.string.error_sync, getString(CospendClientUtil.LoginStatus.NO_NETWORK.str)), Toast.LENGTH_LONG).show()
showToast(this@BillsListViewActivity, getString(R.string.error_sync, getString(CospendClientUtil.LoginStatus.NO_NETWORK.str)), Toast.LENGTH_LONG)
}
}
}
@@ -193,6 +179,7 @@ class BillsListViewActivity :
}
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
isActivityVisible = true
if (savedInstanceState != null) {
@@ -223,7 +210,7 @@ class BillsListViewActivity :
lifecycleScope.launch {
val members = withContext(Dispatchers.IO) { db.getActivatedMembersOfProject(selectedProjectId) }
if (members.isEmpty()) {
showToast(this@BillsListViewActivity, getString(R.string.add_bill_impossible_no_member))
showToast(this@BillsListViewActivity, getString(R.string.error_no_members))
} else {
val proj = withContext(Dispatchers.IO) { db.getProject(selectedProjectId) }
val createIntent = Intent(applicationContext, EditBillActivity::class.java).apply {
@@ -257,13 +244,23 @@ class BillsListViewActivity :
onProjectAction = { pid, actionIndex ->
when (actionIndex) {
0 -> onEditProjectClick(pid)
1 -> onRemoveProjectClick(pid)
1 -> {
val proj = db.getProject(pid)
if (proj?.type == ProjectType.COSPEND) {
onArchiveProjectClick(pid)
} else {
onRemoveProjectClick(pid)
}
}
2 -> onManageMembersClick(pid)
3 -> onManageCurrenciesClick(pid)
4 -> onProjectStatisticsClick(pid)
5 -> onSettleProjectClick(pid)
6 -> onShareProjectClick(pid)
7 -> onExportProjectClick(pid)
8 -> {
startActivity(net.helcel.cowspent.android.label.LabelManagementActivity.createIntent(this@BillsListViewActivity, pid))
}
}
},
onAccountSwitcherClick = {
@@ -385,17 +382,10 @@ class BillsListViewActivity :
private fun onEditProjectClick(projectId: Long) {
if (projectId == 0L) return
lifecycleScope.launch {
val proj = withContext(Dispatchers.IO) { db.getProject(projectId) }
if (proj?.isLocal == false) {
val intent = Intent(applicationContext, EditProjectActivity::class.java).apply {
putExtra(EditProjectActivity.PARAM_PROJECT_ID, projectId)
}
editProjectLauncher.launch(intent)
} else {
showToast(this@BillsListViewActivity, getString(R.string.edit_project_local_impossible))
}
}
}
private fun onRemoveProjectClick(projectId: Long) {
@@ -404,8 +394,8 @@ class BillsListViewActivity :
val proj = withContext(Dispatchers.IO) { db.getProject(projectId) } ?: return@launch
viewModel.showDialog(
title = getString(R.string.confirm_remove_project_dialog_title),
message = if (!proj.isLocal) getString(R.string.confirm_remove_project_dialog_message) else null,
title = getString(R.string.title_confirm),
message = if (!proj.isLocal) getString(R.string.dialog_confirm_remove_project_msg) else null,
positiveText = getString(R.string.simple_yes),
onConfirm = {
lifecycleScope.launch {
@@ -426,6 +416,38 @@ class BillsListViewActivity :
}
}
private fun onArchiveProjectClick(projectId: Long) {
if (projectId == 0L) return
lifecycleScope.launch {
val proj = withContext(Dispatchers.IO) { db.getProject(projectId) } ?: return@launch
val isArchiving = !proj.isArchived
val newArchivedTs = if (isArchiving) System.currentTimeMillis() / 1000 else 0L
withContext(Dispatchers.IO) {
db.updateProject(
projId = projectId,
newName = null,
newEmail = null,
newPassword = null,
newLastPayerId = null,
newLastSyncedTimestamp = null,
newCurrencyName = null,
newDeletionDisabled = null,
newMyAccessLevel = null,
newBearerToken = null,
newArchivedTs = newArchivedTs
)
}
setupDrawerProjects()
refreshLists()
val msgRes = if (isArchiving) R.string.action_archive else R.string.action_unarchive
showToast(this@BillsListViewActivity, getString(msgRes))
}
}
fun onManageMembersClick(projectId: Long) {
if (projectId == 0L) return
lifecycleScope.launch {
@@ -435,18 +457,18 @@ class BillsListViewActivity :
return@launch
}
viewModel.showMemberManagementDialogByProjectId = projectId
startActivity(net.helcel.cowspent.android.project.member.MemberManagementActivity.createIntent(this@BillsListViewActivity, projectId))
}
}
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))
}
}
}
@@ -462,7 +484,7 @@ class BillsListViewActivity :
lifecycleScope.launch {
val proj = withContext(Dispatchers.IO) { db.getProject(projectId) }
if (projectId != 0L && proj?.isShareable() == true) viewModel.showShareDialogByProjectId = projectId
else showToast(this@BillsListViewActivity, getString(R.string.share_impossible), Toast.LENGTH_LONG)
else showToast(this@BillsListViewActivity, getString(R.string.error_share_impossible), Toast.LENGTH_LONG)
}
}
@@ -532,11 +554,13 @@ class BillsListViewActivity :
val members = db.getMembersOfProject(proj.id, null)
val bills = db.getBillsOfProject(proj.id)
val categories = db.getCategories(proj.id)
val reimbursementCategoryId = CategoryUtils.getReimbursementCategoryId(categories, proj.id, proj.remoteId)
val balances = HashMap<Long, Double>()
SupportUtil.getStats(
members, bills,
mutableMapOf(), balances, mutableMapOf(), mutableMapOf(),
-1000, -1000, null, null
-1000L, -1000L, reimbursementCategoryId, null, null
)
Triple(proj, members, balances)
}
@@ -563,7 +587,7 @@ class BillsListViewActivity :
} else {
val lastSyncTimestamp = proj.lastSyncedTimestamp ?: 0
val cal = Calendar.getInstance().apply { timeInMillis = lastSyncTimestamp * 1000 }
val text = getString(R.string.drawer_last_sync_text, cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE))
val text = getString(R.string.drawer_last_sync, cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE))
viewModel.lastSyncText = text
}
}
@@ -603,13 +627,13 @@ class BillsListViewActivity :
mid == null || mid == it.payerId || it.billOwersIds.contains(mid)
}
viewModel.hasUnlabeledBills = bills.any { it.categoryRemoteId == 0 && it.state != DBBill.STATE_DELETED }
viewModel.hasUnlabeledBills = bills.any { it.categoryId == 0L && it.state != DBBill.STATE_DELETED }
val projectMembers = db.getMembersOfProject(projId, null)
val memberMap = projectMembers.associateBy { it.id }
val projectPaymentModes = db.getPaymentModes(projId).associateBy { it.remoteId }
val projectCategories = db.getCategories(projId).associateBy { it.remoteId }
val projectPaymentModes = db.getPaymentModes(projId).associateBy { it.id }
val projectCategories = db.getCategories(projId).associateBy { it.id }
BillFormatter.formatBills(
bills,
@@ -661,7 +685,7 @@ class BillsListViewActivity :
lifecycleScope.launch {
val members = withContext(Dispatchers.IO) { db.getActivatedMembersOfProject(selectedProjectId) }
if (members.isEmpty()) {
showToast(this@BillsListViewActivity, getString(R.string.add_bill_impossible_no_member))
showToast(this@BillsListViewActivity, getString(R.string.error_no_member))
} else {
val projType = withContext(Dispatchers.IO) { db.getProject(selectedProjectId)?.type?.id }
val intent = Intent(applicationContext, EditBillActivity::class.java).apply {
@@ -675,15 +699,6 @@ class BillsListViewActivity :
}
}
private fun showDialog(msg: String, title: String, icon: ImageVector) {
viewModel.showDialog(
title = title,
message = msg,
positiveText = getString(android.R.string.ok),
icon = icon
)
}
private fun updateUsernameInDrawer() {
if (!CowspentServerSyncHelper.isNextcloudAccountConfigured(this)) {
val text = getString(R.string.drawer_no_account)
@@ -775,8 +790,8 @@ class BillsListViewActivity :
lifecycleScope.launch {
val project = withContext(Dispatchers.IO) { db.getProject(projectId) } ?: return@launch
viewModel.showDialog(
title = getString(R.string.sync_error_dialog_title),
message = getString(R.string.sync_error_dialog_full_content, project.name, errorMessage),
title = getString(R.string.dialog_sync_error_title),
message = getString(R.string.dialog_sync_error_msg, project.name, errorMessage),
positiveText = getString(R.string.simple_close),
icon = Icons.Default.Sync
)
@@ -796,7 +811,7 @@ class BillsListViewActivity :
}
MainConstants.BROADCAST_SSO_TOKEN_MISMATCH -> {
viewModel.showDialog(
title = getString(R.string.sync_error_dialog_title),
title = getString(R.string.dialog_sync_error_title),
message = getString(R.string.error_token_mismatch),
positiveText = getString(R.string.simple_close),
icon = Icons.Default.Sync
@@ -118,8 +118,8 @@ object ProjectImportHelper {
val payerWeight = if (columns.containsKey("payer_weight")) line[columns["payer_weight"]!!].toDouble() else 1.0
val owersStr = if (columns.containsKey("owers")) line[columns["owers"]!!] else ""
val payerActive = columns.containsKey("payer_active") && line[columns["payer_active"]!!] == "1"
val catId = if (columns.containsKey("categoryid") && line[columns["categoryid"]!!].isNotEmpty()) line[columns["categoryid"]!!].toInt() else 0
val pmId = if (columns.containsKey("paymentmodeid") && line[columns["paymentmodeid"]!!].isNotEmpty()) line[columns["paymentmodeid"]!!].toInt() else 0
val catId = if (columns.containsKey("categoryid") && line[columns["categoryid"]!!].isNotEmpty()) line[columns["categoryid"]!!].toLong() else 0L
val pmId = if (columns.containsKey("paymentmodeid") && line[columns["paymentmodeid"]!!].isNotEmpty()) line[columns["paymentmodeid"]!!].toLong() else 0L
val pm = if (columns.containsKey("paymentmode")) line[columns["paymentmode"]!!] else null
membersActive[payerName] = payerActive
@@ -150,8 +150,16 @@ object ProjectImportHelper {
val memberNameToId = mutableMapOf<String, Long>()
val pid = db.addProject(DBProject(0, projectRemoteId, "", projectRemoteId, null, null, null, ProjectType.LOCAL, 0L, mainCurrencyName, false, DBProject.ACCESS_LEVEL_UNKNOWN, null))
paymentModes.forEach { db.addPaymentMode(DBPaymentMode(0, it.remoteId, pid, it.name, it.icon, it.color)) }
categories.forEach { db.addCategory(DBCategory(0, it.remoteId, pid, it.name, it.icon, it.color)) }
val pmRemoteToLocal = mutableMapOf<Long, Long>()
paymentModes.forEach {
val newId = db.addPaymentMode(DBPaymentMode(0, it.remoteId, pid, it.name, it.icon, it.color))
pmRemoteToLocal[it.remoteId] = newId
}
val catRemoteToLocal = mutableMapOf<Long, Long>()
categories.forEach {
val newId = db.addCategory(DBCategory(0, it.remoteId, pid, it.name, it.icon, it.color))
catRemoteToLocal[it.remoteId] = newId
}
currencies.forEach { db.addCurrency(DBCurrency(0, 0, pid, it.name, it.exchangeRate, DBBill.STATE_OK)) }
membersWeight.keys.forEach { mName ->
@@ -160,7 +168,9 @@ object ProjectImportHelper {
bills.forEach { b ->
val payerId = memberNameToId[billRemoteIdToPayerName[b.remoteId]] ?: 0L
val billId = db.addBill(DBBill(0, 0, pid, payerId, b.amount, b.timestamp, b.what, DBBill.STATE_OK, b.repeat, b.paymentMode, b.categoryRemoteId, b.comment, b.paymentModeRemoteId))
val localCatId = catRemoteToLocal[b.categoryId] ?: 0L
val localPmId = pmRemoteToLocal[b.paymentModeId] ?: 0L
val billId = db.addBill(DBBill(0, 0, pid, payerId, b.amount, b.timestamp, b.what, DBBill.STATE_OK, b.repeat, b.paymentMode, localCatId, b.comment, localPmId))
billRemoteIdToOwerStr[b.remoteId]?.split(", ")?.filter { it.isNotEmpty() }?.forEach { ower ->
memberNameToId[ower.trim()]?.let { owerId -> db.addBillower(billId, owerId) }
}
@@ -4,19 +4,21 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Label
import androidx.compose.material.icons.filled.*
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.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
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.model.DBProject
import net.helcel.cowspent.model.ProjectType
@Composable
fun ProjectOptionsDialogContent(
@@ -24,14 +26,17 @@ fun ProjectOptionsDialogContent(
onRemoveProject: () -> Unit,
onManageMembers: () -> Unit,
onManageCurrencies: () -> Unit,
onManageLabels: () -> Unit,
onStatistics: () -> Unit,
onSettle: () -> Unit,
onShareProject: () -> Unit,
onExportProject: () -> Unit,
onDismiss: () -> Unit,
isArchived: Boolean = false,
projectType: ProjectType = ProjectType.LOCAL,
accessLevel: Int = DBProject.ACCESS_LEVEL_ADMIN,
isShareable: Boolean = true
isShareable: Boolean = true,
showBetaFeatures: Boolean = false
) {
Surface(
shape = MaterialTheme.shapes.large,
@@ -44,46 +49,71 @@ fun ProjectOptionsDialogContent(
.fillMaxWidth()
) {
Text(
text = stringResource(R.string.choose_project_management_action),
style = MaterialTheme.typography.h6,
text = stringResource(R.string.choose_project_management_action).uppercase(),
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.onSurface,
modifier = Modifier.padding(bottom = 8.dp)
)
val options = mutableListOf<ProjectOption>()
val isMaintainer = accessLevel >= DBProject.ACCESS_LEVEL_MAINTAINER || accessLevel == DBProject.ACCESS_LEVEL_UNKNOWN
val isParticipant = accessLevel >= DBProject.ACCESS_LEVEL_PARTICIPANT || accessLevel == DBProject.ACCESS_LEVEL_UNKNOWN
val row1 = mutableListOf<ProjectOption>()
val row2 = mutableListOf<ProjectOption>()
val row3 = mutableListOf<ProjectOption>()
if (!isArchived && isMaintainer) {
options.add(ProjectOption(stringResource(R.string.action_edit_project), Icons.Default.Edit, onEditProject))
}
options.add(ProjectOption(stringResource(R.string.fab_rm_project), Icons.Default.Delete, onRemoveProject))
if (!isArchived && isMaintainer) {
options.add(ProjectOption(stringResource(R.string.fab_manage_members), Icons.Default.Group, onManageMembers))
options.add(ProjectOption(stringResource(R.string.fab_manage_currencies), Icons.Default.MonetizationOn, onManageCurrencies))
}
options.add(ProjectOption(stringResource(R.string.fab_statistics), Icons.Default.BarChart, onStatistics))
if (!isArchived && isParticipant) {
options.add(ProjectOption(stringResource(R.string.fab_settle), Icons.Default.Handshake, onSettle))
row1.add(ProjectOption(stringResource(R.string.action_edit), Icons.Default.Edit, onEditProject))
}
if (isShareable && isParticipant) {
options.add(ProjectOption(stringResource(R.string.action_share_project), Icons.Default.Share, onShareProject))
row1.add(ProjectOption(stringResource(R.string.action_share), Icons.Default.Share, onShareProject))
}
options.add(ProjectOption(stringResource(R.string.fab_export_project), Icons.Default.Download, onExportProject))
// Simple 2-column grid using Rows
for (i in options.indices step 2) {
Row(modifier = Modifier.fillMaxWidth()) {
ProjectOptionItem(
option = options[i],
modifier = Modifier.weight(1f)
)
if (i + 1 < options.size) {
ProjectOptionItem(
option = options[i + 1],
modifier = Modifier.weight(1f)
)
if (projectType == ProjectType.COSPEND) {
val archiveLabel = if (isArchived) stringResource(R.string.action_unarchive) else stringResource(R.string.action_archive)
val archiveIcon = if (isArchived) Icons.Default.Unarchive else Icons.Default.Archive
row1.add(ProjectOption(archiveLabel, archiveIcon, onRemoveProject))
} else {
Spacer(modifier = Modifier.weight(1f))
row1.add(ProjectOption(stringResource(R.string.action_delete), Icons.Default.Delete, onRemoveProject))
}
// Row 2: Manage Member, Manage Labels, Manage Currencies
if (!isArchived && isMaintainer) {
row2.add(ProjectOption(stringResource(R.string.action_members), Icons.Default.Group, onManageMembers))
if (showBetaFeatures && (projectType == ProjectType.LOCAL || projectType == ProjectType.COSPEND)) {
row2.add(ProjectOption(stringResource(R.string.action_labels), Icons.AutoMirrored.Filled.Label, onManageLabels))
}
row2.add(ProjectOption(stringResource(R.string.action_currencies), Icons.Default.MonetizationOn, onManageCurrencies))
}
// Row 3: Statistics, Settle, Export
row3.add(ProjectOption(stringResource(R.string.action_stats), Icons.Default.BarChart, onStatistics))
if (!isArchived && isParticipant) {
row3.add(ProjectOption(stringResource(R.string.action_settle), Icons.Default.Handshake, onSettle))
}
row3.add(ProjectOption(stringResource(R.string.action_export), Icons.Default.Download, onExportProject))
val allRows = listOf(row1, row2, row3).filter { it.isNotEmpty() }
allRows.forEach { rowOptions ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
val emptySpace = 3 - rowOptions.size
if (emptySpace > 0) {
Spacer(modifier = Modifier.weight(emptySpace / 2f))
}
rowOptions.forEach { option ->
ProjectOptionItem(
option = option,
modifier = Modifier.weight(1f)
)
}
if (emptySpace > 0) {
Spacer(modifier = Modifier.weight(emptySpace / 2f))
}
}
}
@@ -145,12 +175,64 @@ fun ProjectOptionsDialogPreview() {
onManageCurrencies = {},
onStatistics = {},
onSettle = {},
onManageLabels = {},
onShareProject = {},
onExportProject = {},
onDismiss = {},
isArchived = false,
projectType = ProjectType.COSPEND,
accessLevel = DBProject.ACCESS_LEVEL_ADMIN,
isShareable = true
isShareable = true,
showBetaFeatures = true
)
}
}
@Preview(showBackground = true)
@Composable
fun ProjectOptionsDialogPreview2() {
MaterialTheme {
ProjectOptionsDialogContent(
onEditProject = {},
onRemoveProject = {},
onManageMembers = {},
onManageCurrencies = {},
onStatistics = {},
onSettle = {},
onManageLabels = {},
onShareProject = {},
onExportProject = {},
onDismiss = {},
isArchived = true,
projectType = ProjectType.COSPEND,
accessLevel = DBProject.ACCESS_LEVEL_ADMIN,
isShareable = true,
showBetaFeatures = true
)
}
}
@Preview(showBackground = true)
@Composable
fun ProjectOptionsDialogPreview3() {
MaterialTheme {
ProjectOptionsDialogContent(
onEditProject = {},
onRemoveProject = {},
onManageMembers = {},
onManageCurrencies = {},
onStatistics = {},
onSettle = {},
onManageLabels = {},
onShareProject = {},
onExportProject = {},
onDismiss = {},
isArchived = false,
projectType = ProjectType.LOCAL,
accessLevel = DBProject.ACCESS_LEVEL_ADMIN,
isShareable = true,
showBetaFeatures = true
)
}
}
@@ -11,6 +11,7 @@ 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.heightIn
@@ -31,8 +32,6 @@ import androidx.compose.material.Text
import androidx.compose.material.contentColorFor
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.QrCode
import androidx.compose.material.icons.filled.Share
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
@@ -42,10 +41,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
@@ -69,7 +66,7 @@ fun ProjectShareDialogContent(
) {
val context = LocalContext.current
val clipboardManager = LocalClipboardManager.current
val qrCodeLinkWarn = stringResource(R.string.qrcode_link_open_attempt_warning)
val qrCodeLinkWarn = stringResource(R.string.msg_share_qr_warn)
val shareUrl = remember { proj.getShareUrl() }
val publicWebUrl = remember { proj.getPublicWebUrl() }
@@ -93,35 +90,24 @@ fun ProjectShareDialogContent(
.heightIn(max = 650.dp)
.padding(16.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(bottom = 8.dp)
) {
Icon(
Icons.Default.Share,
contentDescription = null,
tint = MaterialTheme.colors.primary,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = stringResource(R.string.share_dialog_title),
style = MaterialTheme.typography.h6,
fontWeight = FontWeight.Bold
text = stringResource(R.string.title_share).uppercase(),
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.onSurface,
modifier = Modifier.padding(bottom = 16.dp)
)
}
Column(
modifier = Modifier
.weight(1f, fill = false)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
ShareCard(
title = stringResource(R.string.share_project_public_url_title),
title = stringResource(R.string.title_share_web),
url = publicWebUrl,
description = stringResource(R.string.share_project_public_url_dialog_message),
icon = Icons.Default.Link,
description = stringResource(R.string.msg_share_web),
onUrlClick = {
val i = Intent(Intent.ACTION_VIEW).apply {
data = publicWebUrl.toUri()
@@ -130,22 +116,21 @@ fun ProjectShareDialogContent(
},
onCopyClick = {
clipboardManager.setText(AnnotatedString(publicWebUrl))
Toast.makeText(context, "Link copied to clipboard", Toast.LENGTH_SHORT).show()
Toast.makeText(context, R.string.msg_link_copied, Toast.LENGTH_SHORT).show()
}
)
ShareCard(
title = stringResource(R.string.share_project_public_qrcode_title),
title = stringResource(R.string.title_share_qr),
url = shareUrl,
description = stringResource(R.string.share_project_dialog_message),
icon = Icons.Default.QrCode,
description = stringResource(R.string.msg_share_qr),
qrBitmap = qrBitmap,
onUrlClick = {
Toast.makeText(context, qrCodeLinkWarn, Toast.LENGTH_SHORT).show()
},
onCopyClick = {
clipboardManager.setText(AnnotatedString(shareUrl))
Toast.makeText(context, "Link copied to clipboard", Toast.LENGTH_SHORT).show()
Toast.makeText(context, R.string.msg_link_copied, Toast.LENGTH_SHORT).show()
}
)
}
@@ -165,43 +150,31 @@ private fun ShareCard(
title: String,
url: String,
description: String,
icon: ImageVector,
onUrlClick: () -> Unit,
onCopyClick: () -> Unit,
qrBitmap: Bitmap? = null
) {
Surface(
shape = MaterialTheme.shapes.medium,
color = colorResource(R.color.fg_default_low).copy(alpha = 0.04f),
Column(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colors.primary.copy(alpha = 0.7f)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = title,
style = MaterialTheme.typography.subtitle2,
text = title.uppercase(),
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.8f)
color = MaterialTheme.colors.onSurface,
modifier = Modifier.padding(bottom = 8.dp)
)
}
if (qrBitmap != null) {
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier = Modifier.size(128.dp)
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 12.dp),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.size(128.dp)
.clip(MaterialTheme.shapes.small)
.background(Color.White),
contentAlignment = Alignment.Center
@@ -209,16 +182,15 @@ private fun ShareCard(
Image(
bitmap = qrBitmap.asImageBitmap(),
contentDescription = null,
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxSize()
)
}
}
Spacer(modifier = Modifier.height(8.dp))
}
Surface(
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colors.surface,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.05f),
modifier = Modifier.fillMaxWidth()
) {
Row(
@@ -241,26 +213,25 @@ private fun ShareCard(
Icons.Default.ContentCopy,
contentDescription = "Copy",
modifier = Modifier.size(18.dp),
tint = colorResource(R.color.fg_default_low)
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.4f)
)
}
}
}
Spacer(modifier = Modifier.height(12.dp))
Spacer(modifier = Modifier.height(4.dp))
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
Text(
text = description,
style = MaterialTheme.typography.caption,
textAlign = TextAlign.Center,
textAlign = TextAlign.Start,
lineHeight = 16.sp,
modifier = Modifier.padding(horizontal = 8.dp)
modifier = Modifier.padding(horizontal = 4.dp)
)
}
}
}
}
@Composable
private fun DialogActions(
@@ -291,7 +262,7 @@ private fun DialogActions(
Icon(Icons.Default.Share, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.simple_share_share).uppercase(),
text = stringResource(R.string.action_share).uppercase(),
style = MaterialTheme.typography.button,
fontWeight = FontWeight.Bold
)
@@ -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() }
)
}
}
@@ -84,6 +88,8 @@ class NewProjectActivity : AppCompatActivity() {
}
}
updateAuthStatus()
val defaultProjectId = intent.getStringExtra(PARAM_DEFAULT_PROJECT_ID)
if (defaultProjectId != null) {
viewModel.projectId = defaultProjectId
@@ -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,15 +201,24 @@ 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)
showToast(getString(R.string.error_invalid_project_id), Toast.LENGTH_LONG)
return
}
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
@@ -285,7 +320,6 @@ class NewProjectActivity : AppCompatActivity() {
pid
)
}
showToast(getString(R.string.project_added_success), Toast.LENGTH_LONG)
return pid
}
@@ -19,6 +19,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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
@@ -32,12 +33,17 @@ fun NewProjectScreen(
onImportFile: () -> Unit,
onChooseFromNextcloud: () -> Unit,
onOkPressed: () -> Unit,
onBack: () -> Unit
onBack: () -> Unit,
onFieldsChanged: () -> Unit
) {
LaunchedEffect(viewModel.projectUrl, viewModel.projectType) {
onFieldsChanged()
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.action_add_project)) },
title = { Text(stringResource(R.string.title_add_project)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
@@ -50,7 +56,7 @@ fun NewProjectScreen(
floatingActionButton = {
if (viewModel.isFormValid()) {
FloatingActionButton(onClick = onOkPressed) {
Icon(Icons.Default.Done, contentDescription = stringResource(R.string.action_save_bill))
Icon(Icons.Default.Done, contentDescription = stringResource(R.string.action_save))
}
}
}
@@ -58,17 +64,18 @@ fun NewProjectScreen(
Column(
modifier = Modifier
.padding(padding)
.imePadding()
.padding(16.dp)
.verticalScroll(rememberScrollState())
.fillMaxSize()
) {
// What to do
SectionRow(
label = stringResource(R.string.new_project_what_todo)
label = stringResource(R.string.new_project_action)
) {
Row {
ToggleButton(
text = stringResource(R.string.todo_join_label),
text = stringResource(R.string.todo_join),
selected = !viewModel.whatTodoIsCreate,
onClick = {
viewModel.whatTodoIsCreate = false
@@ -78,7 +85,7 @@ fun NewProjectScreen(
)
Spacer(modifier = Modifier.width(8.dp))
ToggleButton(
text = stringResource(R.string.todo_create_label),
text = stringResource(R.string.todo_create),
selected = viewModel.whatTodoIsCreate,
onClick = { viewModel.whatTodoIsCreate = true }
)
@@ -93,20 +100,20 @@ fun NewProjectScreen(
Row {
if (viewModel.whatTodoIsCreate) {
ToggleButton(
text = stringResource(R.string.where_local_short),
text = stringResource(R.string.where_local),
selected = viewModel.projectType == ProjectType.LOCAL,
onClick = { viewModel.projectType = ProjectType.LOCAL }
)
Spacer(modifier = Modifier.width(8.dp))
}
ToggleButton(
text = stringResource(R.string.where_cospend_short),
text = stringResource(R.string.where_cospend),
selected = viewModel.projectType == ProjectType.COSPEND,
onClick = { viewModel.projectType = ProjectType.COSPEND }
)
Spacer(modifier = Modifier.width(8.dp))
ToggleButton(
text = stringResource(R.string.where_ihatemoney_short),
text = stringResource(R.string.where_ihatemoney),
selected = viewModel.projectType == ProjectType.IHATEMONEY,
onClick = { viewModel.projectType = ProjectType.IHATEMONEY }
)
@@ -115,13 +122,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
) {
@@ -131,7 +132,7 @@ fun NewProjectScreen(
} else {
Spacer(modifier = Modifier.width(16.dp))
Button(onClick = onScanQrCode) {
Text(text = stringResource(R.string.scan_qrcode))
Text(text = stringResource(R.string.action_scan_qrcode))
Spacer(modifier = Modifier.width(4.dp))
Icon(
Icons.Default.QrCode2,
@@ -148,13 +149,8 @@ fun NewProjectScreen(
OutlinedTextField(
value = viewModel.projectUrl,
onValueChange = { viewModel.projectUrl = it },
label = {
Text(
stringResource(
if (viewModel.projectType == ProjectType.COSPEND) R.string.setting_cospend_project_url
else R.string.setting_ihatemoney_project_url
)
)
placeholder = {
Text(stringResource(R.string.label_url))
},
modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.Default.Link, contentDescription = null) }
@@ -162,51 +158,56 @@ fun NewProjectScreen(
Spacer(modifier = Modifier.height(8.dp))
}
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)) },
placeholder = { Text(stringResource(R.string.label_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 },
label = { Text(stringResource(R.string.setting_new_project_password)) },
placeholder = { Text(stringResource(R.string.label_password)) },
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 },
label = { Text(stringResource(R.string.setting_new_project_name)) },
placeholder = { Text(stringResource(R.string.label_project_title)) },
modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.Default.Title, 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)) },
placeholder = { Text(stringResource(R.string.label_email)) },
modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.Default.Email, contentDescription = null) }
)
}
}
}
}
if (viewModel.showAuthWarningDialog) {
AlertDialog(
onDismissRequest = { viewModel.showAuthWarningDialog = false },
title = { Text(stringResource(R.string.auth_project_creation_title)) },
text = { Text(stringResource(R.string.warning_auth_project_creation)) },
title = { Text(stringResource(R.string.app_name)) },
text = { Text(stringResource(R.string.msg_auth_warning)) },
confirmButton = {
TextButton(onClick = {
viewModel.showAuthWarningDialog = false
@@ -260,12 +261,12 @@ fun NewProjectScreen(
if (viewModel.isCreatingRemoteProject) {
AlertDialog(
onDismissRequest = { },
title = { Text(stringResource(R.string.simple_loading)) },
title = { Text(stringResource(R.string.error_loading)) },
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator()
Spacer(modifier = Modifier.width(16.dp))
Text(stringResource(R.string.creating_remote_project))
Text(stringResource(R.string.action_add_project))
}
},
confirmButton = {}
@@ -275,7 +276,7 @@ fun NewProjectScreen(
if (viewModel.errorDialogMessage != null) {
AlertDialog(
onDismissRequest = { viewModel.errorDialogMessage = null },
title = { Text(stringResource(R.string.simple_error)) },
title = { Text(stringResource(R.string.error_generic)) },
text = { Text(viewModel.errorDialogMessage!!) },
confirmButton = {
TextButton(onClick = { viewModel.errorDialogMessage = null }) {
@@ -291,13 +292,17 @@ fun SectionRow(
label: String,
content: @Composable () -> Unit
) {
Row(verticalAlignment = Alignment.Top) {
Column {
Text(label, fontSize = 12.sp, color = MaterialTheme.colors.onSurface)
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) {
Text(
text = label.uppercase(),
style = MaterialTheme.typography.subtitle1,
color = MaterialTheme.colors.onSurface,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 8.dp)
)
content()
}
}
}
@Composable
fun ToggleButton(
@@ -334,6 +339,7 @@ fun NewProjectScreenPreview() {
onImportFile = {},
onChooseFromNextcloud = {},
onOkPressed = {},
onBack = {}
onBack = {},
onFieldsChanged = {}
)
}
@@ -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 (projectType == ProjectType.COSPEND && isAuthenticatedAccount) {
return true
}
if (projectId.isEmpty()) return false
if (projectType == ProjectType.IHATEMONEY) {
if (projectEmail.isEmpty()) return false
if (projectType == ProjectType.IHATEMONEY && projectPassword.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)
@@ -135,7 +137,7 @@ class EditProjectActivity : AppCompatActivity() {
private fun onDeleteRemote() {
viewModel.showDialog(
message = getString(R.string.confirm_delete_project_dialog_title),
message = getString(R.string.title_confirm),
positiveText = getString(R.string.simple_yes),
onConfirm = {
if (!db.cowspentServerSyncHelper.deleteRemoteProject(project.id, deleteCallBack)) {
@@ -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
@@ -14,6 +16,7 @@ import androidx.compose.material.icons.filled.Title
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
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 net.helcel.cowspent.R
@@ -59,47 +62,77 @@ fun EditProjectScreen(
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.simple_edit_project)) },
title = { Text(stringResource(R.string.title_edit_project)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
},
actions = {
if (!viewModel.isLocal) {
IconButton(onClick = onDeleteRemote) {
Icon(Icons.Default.DeleteForever, contentDescription = stringResource(R.string.menu_delete_project_remote))
Icon(Icons.Default.DeleteForever, contentDescription = stringResource(R.string.action_delete))
}
}
},
backgroundColor = MaterialTheme.colors.surface,
backgroundColor = MaterialTheme.colors.primary,
elevation = 0.dp
)
},
floatingActionButton = {
FloatingActionButton(onClick = onSave) {
Icon(Icons.Default.Done, contentDescription = stringResource(R.string.menu_save_project))
Icon(Icons.Default.Done, contentDescription = stringResource(R.string.action_save))
}
}
) { padding ->
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.padding(padding)
.imePadding()
.padding(16.dp)
.fillMaxSize()
.verticalScroll(scrollState)
) {
Text(
text = "GENERAL",
style = MaterialTheme.typography.subtitle1,
color = MaterialTheme.colors.onSurface,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 8.dp)
)
OutlinedTextField(
value = viewModel.name,
onValueChange = { viewModel.name = it },
label = { Text(stringResource(R.string.setting_new_project_name)) },
placeholder = { Text(stringResource(R.string.label_project_title)) },
modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.Default.Title, contentDescription = null) }
)
if (!viewModel.isLocal) {
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = viewModel.email,
onValueChange = { viewModel.email = it },
placeholder = { Text(stringResource(R.string.label_email)) },
modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.Default.Email, contentDescription = null) }
)
Text(
text = "SECURITY",
style = MaterialTheme.typography.subtitle1,
color = MaterialTheme.colors.onSurface,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 8.dp, top = 24.dp)
)
OutlinedTextField(
value = viewModel.password,
onValueChange = { viewModel.password = it },
label = { Text(stringResource(R.string.setting_password)) },
placeholder = { Text("Old " + stringResource(R.string.label_password)) },
modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) }
)
@@ -109,20 +142,11 @@ fun EditProjectScreen(
OutlinedTextField(
value = viewModel.newPassword,
onValueChange = { viewModel.newPassword = it },
label = { Text(stringResource(R.string.setting_new_project_password)) },
placeholder = { Text("New " + stringResource(R.string.label_password)) },
modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.Default.LockOpen, contentDescription = null) }
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = viewModel.email,
onValueChange = { viewModel.email = it },
label = { Text(stringResource(R.string.setting_new_project_email)) },
modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.Default.Email, contentDescription = null) }
)
}
}
}
}
@@ -13,6 +13,7 @@ class EditProjectViewModel : ViewModel() {
var password by mutableStateOf("")
var newPassword by mutableStateOf("")
var email by mutableStateOf("")
var isLocal by mutableStateOf(false)
var dialogState by mutableStateOf<DialogState?>(null)
@@ -53,5 +54,6 @@ class EditProjectViewModel : ViewModel() {
password = project.password
newPassword = project.password
email = project.email?.let { if (it == "null") "" else it } ?: ""
isLocal = project.isLocal
}
}
@@ -6,6 +6,7 @@ import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import androidx.compose.ui.tooling.preview.Preview
@@ -29,15 +30,17 @@ fun MemberAddDialogContent(
.padding(16.dp)
) {
Text(
text = stringResource(R.string.add_member_dialog_title),
style = MaterialTheme.typography.h6,
text = stringResource(R.string.add_member_dialog_title).uppercase(),
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.onSurface,
modifier = Modifier.padding(bottom = 16.dp)
)
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text(stringResource(R.string.member_edit_name)) },
placeholder = { Text(stringResource(R.string.label_name)) },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences),
singleLine = true
@@ -13,6 +13,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
@@ -57,8 +58,10 @@ fun MemberEditDialogContent(
.verticalScroll(rememberScrollState())
) {
Text(
text = stringResource(R.string.edit_member_dialog_title),
style = MaterialTheme.typography.h6,
text = stringResource(R.string.edit_member_dialog_title).uppercase(),
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.onSurface,
modifier = Modifier.padding(bottom = 16.dp)
)
@@ -66,7 +69,7 @@ fun MemberEditDialogContent(
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text(stringResource(R.string.member_edit_name)) },
placeholder = { Text(stringResource(R.string.label_name)) },
modifier = Modifier.fillMaxWidth(),
leadingIcon = {
Icon(Icons.Default.Person, contentDescription = null)
@@ -80,7 +83,7 @@ fun MemberEditDialogContent(
OutlinedTextField(
value = weight,
onValueChange = { weight = it },
label = { Text(stringResource(R.string.member_edit_weight)) },
placeholder = { Text(stringResource(R.string.label_weight)) },
modifier = Modifier.fillMaxWidth(),
leadingIcon = {
Icon(Icons.Default.LineWeight, contentDescription = null)
@@ -98,9 +101,17 @@ fun MemberEditDialogContent(
.clickable { isActivated = !isActivated }
.padding(vertical = 8.dp)
) {
Icon(Icons.Default.Block, contentDescription = null)
Spacer(modifier = Modifier.width(16.dp))
Text(stringResource(R.string.member_edit_toggle), modifier = Modifier.weight(1f))
Icon(
imageVector = Icons.Default.Block,
contentDescription = null,
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
)
Spacer(modifier = Modifier.width(32.dp))
Text(
text = stringResource(R.string.label_activated),
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.subtitle1
)
Checkbox(checked = isActivated, onCheckedChange = { isActivated = it })
}
@@ -113,9 +124,17 @@ fun MemberEditDialogContent(
.fillMaxWidth()
.padding(vertical = 8.dp)
) {
Icon(Icons.Default.Palette, contentDescription = null)
Spacer(modifier = Modifier.width(16.dp))
Text(stringResource(R.string.member_edit_color), modifier = Modifier.weight(1f))
Icon(
imageVector = Icons.Default.Palette,
contentDescription = null,
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
)
Spacer(modifier = Modifier.width(32.dp))
Text(
text = stringResource(R.string.label_color),
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.subtitle1
)
Box(
modifier = Modifier
.size(40.dp)
@@ -0,0 +1,122 @@
package net.helcel.cowspent.android.project.member
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import net.helcel.cowspent.R
import net.helcel.cowspent.model.DBBill
import net.helcel.cowspent.model.DBMember
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.theme.ThemeUtils
class MemberManagementActivity : AppCompatActivity() {
private val viewModel: MemberManagementViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
val projectId = intent.getLongExtra(EXTRA_PROJECT_ID, 0)
if (projectId == 0L) {
finish()
return
}
val db = CowspentSQLiteOpenHelper.getInstance(this)
viewModel.loadMembers(projectId)
setContent {
ThemeUtils.CowspentTheme {
var showAddMemberDialog by remember { mutableStateOf(false) }
var editingMember by remember { mutableStateOf<DBMember?>(null) }
MemberManagementScreen(
members = viewModel.members,
onAddMember = { showAddMemberDialog = true },
onEditMember = { editingMember = it },
onToggleMember = { member, isActivated ->
db.updateMemberAndSync(member, member.name, member.weight, isActivated, member.r, member.g, member.b, "", "")
viewModel.loadMembers(projectId)
},
onBack = { finish() }
)
if (showAddMemberDialog) {
Dialog(
onDismissRequest = { showAddMemberDialog = false },
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
MemberAddDialogContent(
onAdd = { memberName ->
val memberNames = db.getMembersOfProject(projectId, null).map { it.name }
if (memberNames.contains(memberName)) {
Toast.makeText(this, R.string.member_already_exists, Toast.LENGTH_SHORT).show()
} else {
val color = net.helcel.cowspent.android.helper.TextDrawable.getColorFromName(memberName)
db.addMemberAndSync(
DBMember(
0, 0, projectId, memberName, true, 1.0,
DBBill.STATE_ADDED,
android.graphics.Color.red(color),
android.graphics.Color.green(color),
android.graphics.Color.blue(color),
null, null
)
)
viewModel.loadMembers(projectId)
showAddMemberDialog = false
}
},
onDismiss = { showAddMemberDialog = false }
)
}
}
if (editingMember != null) {
val member = editingMember!!
Dialog(
onDismissRequest = { editingMember = null },
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
MemberEditDialogContent(
member = member,
onSave = { name, weight, isActivated, r, g, b ->
db.updateMemberAndSync(member, name, weight, isActivated, r, g, b, "", "")
viewModel.loadMembers(projectId)
editingMember = null
},
onDelete = {
db.deleteMember(member.id)
viewModel.loadMembers(projectId)
editingMember = null
},
onDismiss = { editingMember = null }
)
}
}
}
}
}
companion object {
const val EXTRA_PROJECT_ID = "project_id"
fun createIntent(context: Context, projectId: Long): Intent {
return Intent(context, MemberManagementActivity::class.java).apply {
putExtra(EXTRA_PROJECT_ID, projectId)
}
}
}
}
@@ -1,132 +0,0 @@
package net.helcel.cowspent.android.project.member
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
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.foundation.lazy.rememberLazyListState
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.contentColorFor
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.lazyVerticalScrollbar
import net.helcel.cowspent.model.DBMember
@Composable
fun MemberManagementDialogContent(
members: List<DBMember>,
onAddMember: () -> Unit,
onEditMember: (DBMember) -> Unit,
onDismiss: () -> Unit
) {
val listState = rememberLazyListState()
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colors.surface,
contentColor = contentColorFor(MaterialTheme.colors.surface)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 500.dp)
.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(R.string.fab_manage_members),
style = MaterialTheme.typography.h6
)
IconButton(onClick = onAddMember) {
Icon(Icons.Default.Add,modifier=Modifier.size(32.dp),
contentDescription = stringResource(R.string.fab_add_member))
}
}
Spacer(modifier = Modifier.height(8.dp))
LazyColumn(
state = listState,
modifier = Modifier
.weight(1f, fill = false)
.lazyVerticalScrollbar(listState)
) {
items(members) { member ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onEditMember(member) }
.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,
size = 40.dp
)
Spacer(modifier = Modifier.width(16.dp))
Text(
text = member.name,
style = MaterialTheme.typography.body1
)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.simple_close).uppercase())
}
}
}
}
}
@Preview(showBackground = true)
@Composable
fun MemberManagementDialogContentPreview() {
MaterialTheme {
MemberManagementDialogContent(
members = listOf(
DBMember(1, 0, 1, "Alice", true, 1.0, 0, 255, 100, 100, null, null),
DBMember(2, 0, 1, "Bob", true, 1.0, 0, 100, 255, 100, null, null),
DBMember(3, 0, 1, "Charlie", false, 1.0, 0, 100, 100, 255, null, null)
),
onAddMember = {},
onEditMember = {},
onDismiss = {}
)
}
}
@@ -0,0 +1,162 @@
package net.helcel.cowspent.android.project.member
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.*
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.Delete
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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 net.helcel.cowspent.R
import net.helcel.cowspent.android.helper.MemberAvatar
import net.helcel.cowspent.android.helper.lazyVerticalScrollbar
import net.helcel.cowspent.model.DBMember
@Composable
fun MemberManagementScreen(
members: List<DBMember>,
onAddMember: () -> Unit,
onEditMember: (DBMember) -> Unit,
onToggleMember: (DBMember, Boolean) -> Unit,
onBack: () -> Unit
) {
val listState = rememberLazyListState()
val activatedMembers = members.filter { it.isActivated }
val deactivatedMembers = members.filter { !it.isActivated }
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.action_members)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
},
backgroundColor = MaterialTheme.colors.primary,
elevation = 0.dp
)
},
floatingActionButton = {
FloatingActionButton(onClick = onAddMember) {
Icon(Icons.Default.Add, contentDescription = stringResource(R.string.action_save))
}
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.lazyVerticalScrollbar(listState)
) {
item {
Text(
text = "ACTIVATED MEMBERS",
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.onSurface,
modifier = Modifier.padding(16.dp)
)
}
items(activatedMembers) { member ->
MemberRow(
member = member,
onEditMember = onEditMember,
onToggleMember = { onToggleMember(member, it) }
)
}
if (deactivatedMembers.isNotEmpty()) {
item {
Text(
text = "DEACTIVATED MEMBERS",
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
modifier = Modifier.padding(start = 16.dp, top = 24.dp, end = 16.dp, bottom = 16.dp)
)
}
items(deactivatedMembers) { member ->
MemberRow(
member = member,
onEditMember = onEditMember,
onToggleMember = { onToggleMember(member, it) },
alpha = 0.6f
)
}
}
}
}
}
}
@Composable
fun MemberRow(
member: DBMember,
onEditMember: (DBMember) -> Unit,
onToggleMember: (Boolean) -> Unit,
alpha: Float = 1f
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onEditMember(member) }
.padding(horizontal = 16.dp, vertical = 0.dp),
verticalAlignment = Alignment.CenterVertically
) {
MemberAvatar(
member = member,
size = 24.dp,
alpha = alpha
)
Spacer(modifier = Modifier.width(32.dp))
Text(
text = member.name,
style = MaterialTheme.typography.subtitle1,
modifier = Modifier.weight(1f),
color = MaterialTheme.colors.onSurface.copy(alpha = alpha)
)
IconButton(onClick = { onToggleMember(!member.isActivated) }) {
Icon(
imageVector = if (member.isActivated) Icons.Default.Delete else Icons.Default.Add,
contentDescription = if (member.isActivated) "Deactivate" else "Activate",
tint = if (member.isActivated) MaterialTheme.colors.error.copy(alpha = 0.6f) else MaterialTheme.colors.primary.copy(alpha = 0.6f)
)
}
}
}
@Preview(showBackground = true)
@Composable
fun MemberManagementScreenPreview() {
MaterialTheme {
MemberManagementScreen(
members = listOf(
DBMember(1, 0, 1, "Alice", true, 1.0, 0, 255, 100, 100, null, null),
DBMember(2, 0, 1, "Bob", true, 1.0, 0, 100, 255, 100, null, null),
DBMember(3, 0, 1, "Charlie", false, 1.0, 0, 100, 100, 255, null, null)
),
onAddMember = {},
onEditMember = {},
onToggleMember = { _, _ -> },
onBack = {}
)
}
}
@@ -0,0 +1,40 @@
package net.helcel.cowspent.android.project.member
import android.app.Application
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.helcel.cowspent.android.helper.DialogState
import net.helcel.cowspent.model.DBMember
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
class MemberManagementViewModel(application: Application) : AndroidViewModel(application) {
private val db = CowspentSQLiteOpenHelper.getInstance(application)
var projectId by mutableLongStateOf(0L)
var members by mutableStateOf<List<DBMember>>(emptyList())
var dialogState by mutableStateOf<DialogState?>(null)
fun loadMembers(projId: Long) {
projectId = projId
viewModelScope.launch {
members = withContext(Dispatchers.IO) { db.getMembersOfProject(projId, null) }
}
}
fun deleteMember(id: Long) {
viewModelScope.launch {
withContext(Dispatchers.IO) {
db.deleteMember(id)
}
loadMembers(projectId)
}
}
}
@@ -129,8 +129,10 @@ fun ProjectSettlementUI(
.padding(16.dp)
) {
Text(
text = stringResource(R.string.settle_dialog_title),
style = MaterialTheme.typography.h6,
text = stringResource(R.string.title_settle).uppercase(),
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.onSurface,
modifier = Modifier.padding(bottom = 16.dp)
)
@@ -174,7 +176,7 @@ private fun BalancedStateMessage() {
contentAlignment = Alignment.Center
) {
Text(
text = stringResource(R.string.settle_dialog_balanced),
text = stringResource(R.string.dialog_balanced_msg),
style = MaterialTheme.typography.body1
)
}
@@ -279,9 +281,9 @@ private fun TableHeaderText(
Text(
text = text.uppercase(),
modifier = modifier,
fontWeight = FontWeight.SemiBold,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.overline,
color = colorResource(R.color.fg_default_low),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
textAlign = textAlign,
letterSpacing = 0.8.sp
)
@@ -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) {
@@ -79,6 +79,7 @@ fun SettingsScreen(
val keyColor = stringResource(R.string.pref_key_color)
val keyOfflineMode = stringResource(R.string.pref_key_offline_mode)
val keyShowArchived = stringResource(R.string.pref_key_show_archived)
val keyBetaFeatures = stringResource(R.string.pref_key_beta_features)
// States for preferences
var nightMode by remember(keyNightMode) {
@@ -113,6 +114,9 @@ fun SettingsScreen(
var showArchived by remember(keyShowArchived) {
mutableStateOf(sharedPreferences.getBoolean(keyShowArchived, false))
}
var betaFeatures by remember(keyBetaFeatures) {
mutableStateOf(sharedPreferences.getBoolean(keyBetaFeatures, false))
}
Scaffold(
topBar = {
@@ -135,10 +139,10 @@ fun SettingsScreen(
.verticalScroll(rememberScrollState())
) {
// Appearance
SettingsCategory(stringResource(R.string.settings_appearance_category))
SettingsCategory(stringResource(R.string.settings_appearance))
SettingsSwitchPreference(
title = stringResource(R.string.setting_show_archived),
title = stringResource(R.string.settings_show_archived),
icon = Icons.Default.Archive,
checked = showArchived,
onCheckedChange = {
@@ -160,7 +164,7 @@ fun SettingsScreen(
)
SettingsListPreference(
title = stringResource(R.string.settings_night_mode_title),
title = stringResource(R.string.settings_night_mode),
icon = Icons.Default.Brightness2,
value = nightMode,
entries = mapOf(
@@ -178,7 +182,7 @@ fun SettingsScreen(
)
SettingsListPreference(
title = stringResource(R.string.settings_color_mode_title),
title = stringResource(R.string.settings_color_mode),
icon = Icons.Default.Palette,
value = colorMode,
entries = mapOf(
@@ -203,8 +207,8 @@ fun SettingsScreen(
if (colorMode == "manual") {
SettingsColorPreference(
title = stringResource(R.string.settings_color_title),
summary = stringResource(R.string.settings_color_summary),
title = stringResource(R.string.settings_color_custom),
summary = stringResource(R.string.settings_color_custom),
icon = Icons.Default.Palette,
initialColor = appColor,
onColorSelected = {
@@ -218,10 +222,10 @@ fun SettingsScreen(
}
// Network
SettingsCategory(stringResource(R.string.settings_network_category))
SettingsCategory(stringResource(R.string.settings_network))
SettingsSwitchPreference(
title = stringResource(R.string.settings_offline_mode_title),
title = stringResource(R.string.settings_offline_mode),
summary = stringResource(R.string.settings_offline_mode_summary),
icon = Icons.Default.Sync,
checked = offlineMode,
@@ -234,16 +238,29 @@ fun SettingsScreen(
)
SettingsPreference(
title = stringResource(R.string.settings_server_settings),
title = stringResource(R.string.title_account),
icon = Icons.Default.AccountCircle,
onClick = onAccountSettingsClick
)
// Other
SettingsCategory(stringResource(R.string.settings_other_category))
SettingsCategory(stringResource(R.string.settings_other))
SettingsSwitchPreference(
title = stringResource(R.string.settings_beta_features),
summary = stringResource(R.string.settings_beta_features_summary),
icon = Icons.Default.Info,
checked = betaFeatures,
onCheckedChange = {
betaFeatures = it
sharedPreferences.edit {
putBoolean(keyBetaFeatures, it)
}
}
)
SettingsPreference(
title = stringResource(R.string.settings_about),
title = stringResource(R.string.title_about),
icon = Icons.Default.Info,
onClick = onAboutClick
)
@@ -255,9 +272,9 @@ fun SettingsScreen(
fun SettingsCategory(title: String) {
Text(
text = title.uppercase(),
modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 8.dp),
color = MaterialTheme.colors.primary,
style = MaterialTheme.typography.overline,
modifier = Modifier.padding(start = 16.dp, top = 24.dp, bottom = 8.dp),
color = MaterialTheme.colors.onSurface,
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold
)
}
@@ -1,469 +1,408 @@
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 net.helcel.cowspent.util.CategoryUtils
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")
@Composable
fun ProjectSankeyDiagram(
projectName: String,
projectId: Long,
projectRemoteId: String?,
allMembers: List<DBMember>,
allBills: List<DBBill>,
customCategories: List<DBCategory>,
onShareReady: (String) -> Unit
) {
val shareStatsIntro = stringResource(R.string.share_stats_intro, projectName)
val shareStatsIntro = stringResource(R.string.msg_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
val reimbursementCategoryId = remember(customCategories, projectId, projectRemoteId) {
CategoryUtils.getReimbursementCategoryId(customCategories, projectId, projectRemoteId)
}
val activeBills = remember(allBills, reimbursementCategoryId) {
allBills.filter { it.state != DBBill.STATE_DELETED && it.categoryId != reimbursementCategoryId }
}
val membersMap = remember(allMembers) { allMembers.associateBy { it.id } }
val categoriesMap = remember(customCategories) { customCategories.associateBy { it.remoteId.toInt() } }
val categoriesMap = remember(customCategories) { customCategories.associateBy { it.id } }
if (activeBills.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("No data to display", style = MaterialTheme.typography.h6)
Text("No data to display", style = MaterialTheme.typography.subtitle1, fontWeight = FontWeight.Bold, color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f))
}
return
}
// Consolidated spending calculation
val spendings = remember(activeBills, selectedMemberId, membersMap) {
val spentMap = mutableMapOf<Long, Double>()
val catMap = mutableMapOf<Int, Double>()
membersMap.keys.forEach { spentMap[it] = 0.0 }
val spentMap = mutableMapOf<Long, Double>().apply { membersMap.keys.forEach { put(it, 0.0) } }
val catMap = mutableMapOf<Long, Double>()
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
catMap[bill.categoryId] = (catMap[bill.categoryId] ?: 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.categoryId] = (catMap[bill.categoryId] ?: 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 statsText = StringBuilder().apply {
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")
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") }
}
statsText.append("\n")
displayCategorySpendings.forEach { (catRemoteId, amount) ->
val name = categoriesMap[catRemoteId]?.name ?: "Other"
statsText.append("$middleNode [${formatShortValue(amount)}] $name\n")
displayCategorySpendings.forEach { (id, _) -> categoriesMap[id]?.let { append(":${it.name ?: "Other"} ${it.color}\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")
}
}
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)) {
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.fillMaxWidth().padding(bottom = 16.dp),
modifier = Modifier.weight(1f).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)
}
MemberAvatar(member = selectedMember, size = 24.dp)
} else Icon(Icons.Default.Group, contentDescription = null)
}
},
content = {
DropdownMenuItem(onClick = {
selectedMemberId = -1L
expanded = false
}) {
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
)
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<Long, Double>>,
membersMap: Map<Long, DBMember>,
categoriesMap: Map<Long, 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", 1L, "1", StatisticsMockData.members, StatisticsMockData.bills, emptyList()) {} }
@@ -43,10 +43,10 @@ fun ProjectSpendingGraph(
allBills: List<DBBill>,
onShareReady: (String) -> Unit
) {
val shareStatsIntro = stringResource(R.string.share_stats_intro, projectName)
val shareStatsIntro = stringResource(R.string.msg_stats_intro, projectName)
if (allBills.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("No data to display", style = MaterialTheme.typography.h6)
Text("No data to display", style = MaterialTheme.typography.subtitle1, fontWeight = FontWeight.Bold, color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f))
}
return
}
@@ -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)
@@ -35,7 +35,7 @@ fun ProjectStatisticsScreen(
}
var currentShareText by remember { mutableStateOf("") }
val tabs = listOf(
stringResource(R.string.statistic_title),
stringResource(R.string.title_stats),
"Trend",
"Sankey"
)
@@ -43,7 +43,7 @@ fun ProjectStatisticsScreen(
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.statistic_title)) },
title = { Text(stringResource(R.string.title_stats)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
@@ -84,6 +84,21 @@ fun ProjectStatisticsScreen(
val statsData by produceState<StatisticsData?>(null, proj.id) {
value = withContext(Dispatchers.IO) {
val syncedCategories = db.getCategories(proj.id)
val defaultCategories = CategoryUtils.getDefaultCategories(context, proj.id)
val toEnsure = if (proj.type == ProjectType.LOCAL) {
defaultCategories
} else {
listOfNotNull(defaultCategories.find { it.remoteId == DBBill.CATEGORY_REIMBURSEMENT })
}
toEnsure.forEach { def ->
if (syncedCategories.none { it.remoteId == def.remoteId }) {
db.addCategory(def)
}
}
val members = db.getMembersOfProject(proj.id, null)
val bills = db.getBillsOfProject(proj.id)
val categories = db.getCategories(proj.id)
@@ -94,19 +109,12 @@ fun ProjectStatisticsScreen(
if (statsData != null) {
val data = statsData!!
val defaultCategories = remember(proj.id) { CategoryUtils.getDefaultCategories(context, proj.id) }
val categories = remember(proj.type, data.categories, defaultCategories) {
val hardcoded = if (proj.type == ProjectType.LOCAL) {
defaultCategories
} else {
listOfNotNull(defaultCategories.find { it.remoteId.toInt() == DBBill.CATEGORY_REIMBURSEMENT })
}
(data.categories + hardcoded).distinctBy { it.remoteId }
}
val categories = data.categories
val categoryNoneLabel = stringResource(R.string.category_none)
val sankeyCategories = remember(proj.id, data.categories, defaultCategories, categoryNoneLabel) {
val sankeyCategories = remember(proj.id, data.categories, categoryNoneLabel) {
val noneCategory = DBCategory(0, 0, proj.id, categoryNoneLabel, "", "#9E9E9E")
(data.categories + defaultCategories + noneCategory).distinctBy { it.remoteId }
(data.categories + noneCategory).distinctBy { if (it.id == 0L) "none" else it.id.toString() }
}
when (selectedTab) {
@@ -131,6 +139,8 @@ fun ProjectStatisticsScreen(
2 -> {
ProjectSankeyDiagram(
projectName = proj.name.ifEmpty { proj.remoteId },
projectId = proj.id,
projectRemoteId = proj.remoteId,
allMembers = data.members,
allBills = data.bills,
customCategories = sankeyCategories,
@@ -44,8 +44,8 @@ fun ProjectStatisticsTable(
val sdf = remember { SimpleDateFormat("yyyy-MM-dd", Locale.ROOT) }
val dateFormat = remember { android.text.format.DateFormat.getDateFormat(context) }
var categoryId by remember { mutableIntStateOf(-1000) }
var paymentModeId by remember { mutableIntStateOf(-1000) }
var categoryId by remember { mutableLongStateOf(-1000L) }
var paymentModeId by remember { mutableLongStateOf(-1000L) }
var dateMin by remember { mutableStateOf<String?>(null) }
var dateMax by remember { mutableStateOf<String?>(null) }
@@ -57,14 +57,14 @@ fun ProjectStatisticsTable(
val paymentModeAll = stringResource(R.string.payment_mode_all)
val paymentModeNone = stringResource(R.string.payment_mode_none)
val shareStatsHeader = stringResource(R.string.share_stats_header)
val shareStatsIntro = stringResource(R.string.share_stats_intro, proj.name.ifEmpty { proj.remoteId })
val shareStatsHeader = stringResource(R.string.msg_stats_header)
val shareStatsIntro = stringResource(R.string.msg_stats_intro, proj.name.ifEmpty { proj.remoteId })
val categories = remember(proj.id, customCategories, categoryAll, categoryNone, categoryReimbursement, categoryAllExceptReimbursement) {
val list = mutableListOf<Triple<Int, String, String>>()
list.add(Triple(-1000, "📋", categoryAll))
list.add(Triple(-100, "🧾", categoryAllExceptReimbursement))
list.add(Triple(0, "", categoryNone))
val list = mutableListOf<Triple<Long, String, String>>()
list.add(Triple(-1000L, "📋", categoryAll))
list.add(Triple(-100L, "🧾", categoryAllExceptReimbursement))
list.add(Triple(0L, "", categoryNone))
val catsToUse = if (proj.type == ProjectType.LOCAL) {
CategoryUtils.getDefaultCategories(context, proj.id)
@@ -75,15 +75,15 @@ fun ProjectStatisticsTable(
}
catsToUse.forEach {
list.add(Triple(it.remoteId.toInt(), it.icon, it.name ?: ""))
list.add(Triple(it.id, it.icon, it.name ?: ""))
}
list.distinctBy { it.first }
}
val paymentModes = remember(proj.id, customPaymentModes, paymentModeAll, paymentModeNone) {
val list = mutableListOf<Triple<Int, String, String>>()
list.add(Triple(-1000, "💳", paymentModeAll))
list.add(Triple(0, "", paymentModeNone))
val list = mutableListOf<Triple<Long, String, String>>()
list.add(Triple(-1000L, "💳", paymentModeAll))
list.add(Triple(0L, "", paymentModeNone))
val pmsToUse = if (proj.type == ProjectType.LOCAL) {
CategoryUtils.getDefaultPaymentModes(context, proj.id)
@@ -94,7 +94,7 @@ fun ProjectStatisticsTable(
}
pmsToUse.forEach {
list.add(Triple(it.remoteId.toInt(), it.icon, it.name ?: ""))
list.add(Triple(it.id, it.icon, it.name ?: ""))
}
list.distinctBy { it.first }
}
@@ -105,10 +105,12 @@ fun ProjectStatisticsTable(
val membersPaid = HashMap<Long, Double>()
val membersSpent = HashMap<Long, Double>()
val reimbursementCategoryId = CategoryUtils.getReimbursementCategoryId(customCategories, proj.id, proj.remoteId)
SupportUtil.getStats(
allMembers, allBills,
membersNbBills, membersBalance, membersPaid, membersSpent,
categoryId, paymentModeId, dateMin, dateMax
categoryId, paymentModeId, reimbursementCategoryId, dateMin, dateMax
)
var statsText = shareStatsIntro + "\n\n"
@@ -164,12 +166,12 @@ fun ProjectStatisticsTable(
EditableExposedDropdownMenu(
value = selectedCategory?.third ?: "",
placeholder = stringResource(R.string.setting_category),
placeholder = stringResource(R.string.label_category),
expanded = categoryExpanded,
onExpandedChange = { categoryExpanded = it },
onDismissRequest = { categoryExpanded = false },
leadingIcon = {
Box(modifier = Modifier.padding(start = 12.dp)) {
Box(modifier = Modifier) {
if (selectedCategory != null) {
Text(text = selectedCategory.second, fontSize = 20.sp)
} else {
@@ -198,12 +200,12 @@ fun ProjectStatisticsTable(
EditableExposedDropdownMenu(
value = selectedPm?.third ?: "",
placeholder = stringResource(R.string.setting_payment_mode),
placeholder = stringResource(R.string.label_mode),
expanded = pmExpanded,
onExpandedChange = { pmExpanded = it },
onDismissRequest = { pmExpanded = false },
leadingIcon = {
Box(modifier = Modifier.padding(start = 12.dp)) {
Box(modifier = Modifier) {
if (selectedPm != null) {
Text(text = selectedPm.second, fontSize = 20.sp)
} else {
@@ -269,17 +271,15 @@ fun ProjectStatisticsTable(
Spacer(modifier = Modifier.height(24.dp))
// Table Header
Surface(
color = MaterialTheme.colors.onSurface.copy(alpha = 0.05f),
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colors.background,
modifier = Modifier.fillMaxWidth()
) {
Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp)) {
Text(stringResource(R.string.stats_who), modifier = Modifier.weight(2f), fontWeight = FontWeight.Bold, color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f), fontSize = 12.sp)
Text(stringResource(R.string.stats_paid), modifier = Modifier.weight(1.5f), fontWeight = FontWeight.Bold, textAlign = TextAlign.End, color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f), fontSize = 12.sp)
Text(stringResource(R.string.stats_spent), modifier = Modifier.weight(1.5f), fontWeight = FontWeight.Bold, textAlign = TextAlign.End, color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f), fontSize = 12.sp)
Text(stringResource(R.string.stats_balance), modifier = Modifier.weight(1.5f), fontWeight = FontWeight.Bold, textAlign = TextAlign.End, color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f), fontSize = 12.sp)
Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 12.dp)) {
Text(stringResource(R.string.stats_who).uppercase(), modifier = Modifier.weight(2f), fontWeight = FontWeight.Bold, color = MaterialTheme.colors.onSurface, style = MaterialTheme.typography.overline)
Text(stringResource(R.string.stats_paid).uppercase(), modifier = Modifier.weight(1.5f), fontWeight = FontWeight.Bold, textAlign = TextAlign.End, color = MaterialTheme.colors.onSurface, style = MaterialTheme.typography.overline)
Text(stringResource(R.string.stats_spent).uppercase(), modifier = Modifier.weight(1.5f), fontWeight = FontWeight.Bold, textAlign = TextAlign.End, color = MaterialTheme.colors.onSurface, style = MaterialTheme.typography.overline)
Text(stringResource(R.string.stats_balance).uppercase(), modifier = Modifier.weight(1.5f), fontWeight = FontWeight.Bold, textAlign = TextAlign.End, color = MaterialTheme.colors.onSurface, style = MaterialTheme.typography.overline)
}
}
@@ -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"
}
}
@@ -1,6 +1,5 @@
package net.helcel.cowspent.model
import android.util.Log
import java.io.Serializable
import java.util.Calendar
import java.util.Locale
@@ -16,9 +15,9 @@ open class DBBill(
var state: Int,
var repeat: String?,
var paymentMode: String?,
var categoryRemoteId: Int,
var categoryId: Long,
var comment: String?,
var paymentModeRemoteId: Int
var paymentModeId: Long
) : Item, Serializable {
var formattedWhat: String = ""
@@ -39,7 +38,6 @@ open class DBBill(
get() {
val cal = Calendar.getInstance()
cal.timeInMillis = timestamp * 1000
Log.v("ll", "[$what] get date ts $timestamp year ${cal[Calendar.YEAR]}")
val month = cal[Calendar.MONTH] + 1
val day = cal[Calendar.DAY_OF_MONTH]
return "${cal[Calendar.YEAR]}-${String.format(Locale.ROOT, "%02d", month)}-${
@@ -58,7 +56,7 @@ open class DBBill(
override fun toString(): String {
return "#DBBill$id/$remoteId,$projectId, $payerId, $amount, $timestamp, $what, $state, $repeat, $paymentMode, $categoryRemoteId"
return "#DBBill$id/$remoteId,$projectId, $payerId, $amount, $timestamp, $what, $state, $repeat, $paymentMode, $categoryId"
}
override fun isSection(): Boolean {
@@ -73,15 +71,15 @@ open class DBBill(
const val PAYMODE_TRANSFER = "t"
const val PAYMODE_ONLINE_SERVICE = "o"
const val PAYMODE_ID_NONE = 0
const val PAYMODE_ID_CARD = -1
const val PAYMODE_ID_CASH = -2
const val PAYMODE_ID_CHECK = -3
const val PAYMODE_ID_TRANSFER = -4
const val PAYMODE_ID_ONLINE_SERVICE = -5
const val PAYMODE_ID_NONE = 0L
const val PAYMODE_ID_CARD = -1L
const val PAYMODE_ID_CASH = -2L
const val PAYMODE_ID_CHECK = -3L
const val PAYMODE_ID_TRANSFER = -4L
const val PAYMODE_ID_ONLINE_SERVICE = -5L
@JvmField
val oldPmIdToNew: Map<String, Int> = object : HashMap<String, Int>() {
val oldPmIdToNew: Map<String, Long> = object : HashMap<String, Long>() {
init {
put(PAYMODE_NONE, PAYMODE_ID_NONE)
put(PAYMODE_CARD, PAYMODE_ID_CARD)
@@ -92,19 +90,19 @@ open class DBBill(
}
}
const val CATEGORY_NONE = 0
const val CATEGORY_GROCERIES = -1
const val CATEGORY_LEISURE = -2
const val CATEGORY_RENT = -3
const val CATEGORY_BILLS = -4
const val CATEGORY_CULTURE = -5
const val CATEGORY_HEALTH = -6
const val CATEGORY_SHOPPING = -10
const val CATEGORY_REIMBURSEMENT = -11
const val CATEGORY_RESTAURANT = -12
const val CATEGORY_ACCOMMODATION = -13
const val CATEGORY_TRANSPORT = -14
const val CATEGORY_SPORT = -15
const val CATEGORY_NONE = 0L
const val CATEGORY_GROCERIES = -1L
const val CATEGORY_LEISURE = -2L
const val CATEGORY_RENT = -3L
const val CATEGORY_BILLS = -4L
const val CATEGORY_CULTURE = -5L
const val CATEGORY_HEALTH = -6L
const val CATEGORY_SHOPPING = -10L
const val CATEGORY_REIMBURSEMENT = -11L
const val CATEGORY_RESTAURANT = -12L
const val CATEGORY_ACCOMMODATION = -13L
const val CATEGORY_TRANSPORT = -14L
const val CATEGORY_SPORT = -15L
const val STATE_OK = 0
const val STATE_ADDED = 1
@@ -8,7 +8,8 @@ class DBCategory(
var projectId: Long,
var name: String?,
var icon: String,
var color: String
var color: String,
var state: Int = DBBill.STATE_OK
) : Serializable {
override fun toString(): String {
@@ -8,7 +8,8 @@ class DBPaymentMode(
var projectId: Long,
var name: String?,
var icon: String,
var color: String
var color: String,
var state: Int = DBBill.STATE_OK
) : Serializable {
override fun toString(): String {
@@ -15,9 +15,9 @@ class GroupedBill(
sourceBills.first().state,
sourceBills.first().repeat,
sourceBills.first().paymentMode,
sourceBills.first().categoryRemoteId,
sourceBills.first().categoryId,
sourceBills.first().comment,
sourceBills.first().paymentModeRemoteId
sourceBills.first().paymentModeId
), Serializable {
init {
this.formattedWhat = sourceBills.first().formattedWhat
File diff suppressed because it is too large Load Diff
@@ -265,6 +265,121 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
val members = dbHelper.getMembersOfProject(project.id, null)
val memberIdToRemoteId = members.associate { it.id to it.remoteId }
val categoriesToAdd = dbHelper.getCategoriesOfProjectWithState(project.id, DBBill.STATE_ADDED)
for (catToAdd in categoriesToAdd) {
try {
val categoriesResponse = client!!.getCategories(project)
val remoteCategories = categoriesResponse.getCategories(project.id)
val matchingRemote = remoteCategories.find { it.name == catToAdd.name }
if (matchingRemote != null) {
dbHelper.updateCategory(catToAdd.id, null, null, null, DBBill.STATE_OK, matchingRemote.remoteId)
catToAdd.remoteId = matchingRemote.remoteId
} else {
val createResponse = client!!.createRemoteCategory(project, catToAdd)
val newRemoteId = createResponse.remoteCategoryId
if (newRemoteId > 0) {
dbHelper.updateCategory(catToAdd.id, null, null, null, DBBill.STATE_OK, newRemoteId)
catToAdd.remoteId = newRemoteId
}
}
} catch (e: Exception) {
Log.e(TAG, "CATEGORY SYNC FAILED for ${catToAdd.name}", e)
}
}
val categoriesToEdit = dbHelper.getCategoriesOfProjectWithState(project.id, DBBill.STATE_EDITED)
for (catToEdit in categoriesToEdit) {
try {
client!!.editRemoteCategory(project, catToEdit)
dbHelper.updateCategory(catToEdit.id, null, null, null, DBBill.STATE_OK)
} catch (e: Exception) {
Log.e(TAG, "EDIT CATEGORY FAILED for ${catToEdit.name}, might not exist remotely", e)
// If it fails, we keep the state as EDITED so it tries again next time,
// or we could set it to OK if we think it's a permanent mismatch.
// For now just log it.
}
}
val categoriesToDelete = dbHelper.getCategoriesOfProjectWithState(project.id, DBBill.STATE_DELETED)
for (catToDel in categoriesToDelete) {
try {
client!!.deleteRemoteCategory(project, catToDel.remoteId)
dbHelper.deleteCategory(catToDel.id)
} catch (e: NextcloudHttpRequestFailedException) {
if (e.statusCode == 404 || e.statusCode == 400) {
Log.d(TAG, "failed to delete category on remote project (code ${e.statusCode}) : delete it locally anyway")
dbHelper.deleteCategory(catToDel.id)
} else {
throw e
}
} catch (e: Exception) {
Log.e(TAG, "DELETE CATEGORY FAILED for ${catToDel.name}", e)
}
}
val paymentModesToAdd = dbHelper.getPaymentModesOfProjectWithState(project.id, DBBill.STATE_ADDED)
if (paymentModesToAdd.isNotEmpty()) {
try {
val pmsResponse = client!!.getPaymentModes(project)
val remotePms = pmsResponse.getPaymentModes(project.id)
val remotePmsNames = remotePms.map { it.name }
for (pmToAdd in paymentModesToAdd) {
val searchIndex = remotePmsNames.indexOf(pmToAdd.name)
if (searchIndex != -1) {
val remotePm = remotePms[searchIndex]
dbHelper.updatePaymentMode(pmToAdd.id, null, null, null, DBBill.STATE_OK, remotePm.remoteId)
pmToAdd.remoteId = remotePm.remoteId
} else {
val createRemotePaymentModeResponse = client!!.createRemotePaymentMode(project, pmToAdd)
val newRemoteId = createRemotePaymentModeResponse.remotePaymentModeId
if (newRemoteId > 0) {
dbHelper.updatePaymentMode(pmToAdd.id, null, null, null, DBBill.STATE_OK, newRemoteId)
pmToAdd.remoteId = newRemoteId
}
}
}
} catch (e: NextcloudHttpRequestFailedException) {
Log.e(TAG, "GET PAYMENT MODES FAILED : " + e.message)
}
}
val paymentModesToEdit = dbHelper.getPaymentModesOfProjectWithState(project.id, DBBill.STATE_EDITED)
for (pmToEdit in paymentModesToEdit) {
try {
client!!.editRemotePaymentMode(project, pmToEdit)
dbHelper.updatePaymentMode(pmToEdit.id, null, null, null, DBBill.STATE_OK)
} catch (e: Exception) {
Log.e(TAG, "EDIT PAYMENT MODE FAILED for ${pmToEdit.name}, might not exist remotely", e)
}
}
val paymentModesToDelete = dbHelper.getPaymentModesOfProjectWithState(project.id, DBBill.STATE_DELETED)
for (pmToDel in paymentModesToDelete) {
try {
client!!.deleteRemotePaymentMode(project, pmToDel.remoteId)
dbHelper.deletePaymentMode(pmToDel.id)
} catch (e: NextcloudHttpRequestFailedException) {
if (e.statusCode == 404 || e.statusCode == 400) {
Log.d(TAG, "failed to delete payment mode on remote project (code ${e.statusCode}) : delete it locally anyway")
dbHelper.deletePaymentMode(pmToDel.id)
} else {
throw e
}
} catch (_: Exception) {
}
}
val categories = dbHelper.getCategories(project.id)
val categoryIdToRemoteId = categories.associate { it.id to it.remoteId }.toMutableMap()
// Map hardcoded constants to themselves if not in DB
categoryIdToRemoteId[DBBill.CATEGORY_REIMBURSEMENT] = DBBill.CATEGORY_REIMBURSEMENT
categories.filter { it.remoteId < 0 }.forEach { categoryIdToRemoteId[it.remoteId] = it.remoteId }
val paymentModes = dbHelper.getPaymentModes(project.id)
val paymentModeIdToRemoteId = paymentModes.associate { it.id to it.remoteId }.toMutableMap()
paymentModes.filter { it.remoteId < 0 }.forEach { paymentModeIdToRemoteId[it.remoteId] = it.remoteId }
val toDelete = dbHelper.getBillsOfProjectWithState(project.id, DBBill.STATE_DELETED)
for (bToDel in toDelete) {
try {
@@ -293,12 +408,16 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
val toEdit = dbHelper.getBillsOfProjectWithState(project.id, DBBill.STATE_EDITED)
for (bToEdit in toEdit) {
try {
val editRemoteBillResponse = client!!.editRemoteBill(project, bToEdit, memberIdToRemoteId)
if (editRemoteBillResponse.stringContent == bToEdit.remoteId.toString()) {
val editRemoteBillResponse = client!!.editRemoteBill(project, bToEdit, memberIdToRemoteId, categoryIdToRemoteId, paymentModeIdToRemoteId)
val returnedRemoteId = editRemoteBillResponse.remoteBillId
if (returnedRemoteId == bToEdit.remoteId || (returnedRemoteId == 0L && !project.getRequestBaseUrl(true).contains("/ocs/v2.php"))) {
dbHelper.setBillState(bToEdit.id, DBBill.STATE_OK)
Log.d(TAG, "SUCCESSFUL remote bill edition (${editRemoteBillResponse.stringContent})")
Log.d(TAG, "SUCCESSFUL remote bill edition ($returnedRemoteId)")
} else if (returnedRemoteId > 0) {
dbHelper.setBillState(bToEdit.id, DBBill.STATE_OK)
Log.d(TAG, "SUCCESSFUL remote bill edition ($returnedRemoteId)")
} else {
Log.d(TAG, "FAILED to edit remote bill (${editRemoteBillResponse.stringContent})")
Log.d(TAG, "FAILED to edit remote bill ($returnedRemoteId)")
}
} catch (_: Exception) {
Log.d(TAG, "FAILED to edit remote bill: it probably does not exist remotely")
@@ -307,8 +426,8 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
val toAdd = dbHelper.getBillsOfProjectWithState(project.id, DBBill.STATE_ADDED)
for (bToAdd in toAdd) {
val createRemoteBillResponse = client!!.createRemoteBill(project, bToAdd, memberIdToRemoteId)
val newRemoteId = createRemoteBillResponse.stringContent.toLong()
val createRemoteBillResponse = client!!.createRemoteBill(project, bToAdd, memberIdToRemoteId, categoryIdToRemoteId, paymentModeIdToRemoteId)
val newRemoteId = createRemoteBillResponse.remoteBillId
if (newRemoteId > 0) {
dbHelper.updateBill(
bToAdd.id, newRemoteId, null,
@@ -320,6 +439,7 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
}
}
if (project.type == ProjectType.COSPEND) {
val currenciesToDelete = dbHelper.getCurrenciesOfProjectWithState(project.id, DBBill.STATE_DELETED)
for (cToDel in currenciesToDelete) {
try {
@@ -337,7 +457,14 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
}
}
}
} else {
val currenciesToDelete = dbHelper.getCurrenciesOfProjectWithState(project.id, DBBill.STATE_DELETED)
for (cToDel in currenciesToDelete) {
dbHelper.deleteCurrency(cToDel.id)
}
}
if (project.type == ProjectType.COSPEND) {
val currenciesToEdit = dbHelper.getCurrenciesOfProjectWithState(project.id, DBBill.STATE_EDITED)
for (cToEdit in currenciesToEdit) {
try {
@@ -356,15 +483,28 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
}
}
}
} else {
val currenciesToEdit = dbHelper.getCurrenciesOfProjectWithState(project.id, DBBill.STATE_EDITED)
for (cToEdit in currenciesToEdit) {
dbHelper.setCurrencyState(cToEdit.id, DBBill.STATE_OK)
}
}
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()
val newRemoteId = createRemoteCurrencyResponse.remoteCurrencyId
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)
}
}
LoginStatus.OK
} catch (_: ServerResponse.NotModifiedException) {
@@ -450,7 +590,7 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
val localPaymentModes = dbHelper.getPaymentModes(project.id)
for (localPaymentMode in localPaymentModes) {
if (!remotePaymentModesByRemoteId.containsKey(localPaymentMode.remoteId)) {
if (localPaymentMode.state == DBBill.STATE_OK && !remotePaymentModesByRemoteId.containsKey(localPaymentMode.remoteId)) {
dbHelper.deletePaymentMode(localPaymentMode.id)
Log.d(TAG, "Delete local pm : $localPaymentMode")
}
@@ -460,6 +600,7 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
val remoteCategoriesByRemoteId = remoteCategories.associateBy { it.remoteId }
for (c in remoteCategories) {
if (c.remoteId == DBBill.CATEGORY_REIMBURSEMENT) continue
val localCategory = dbHelper.getCategory(c.remoteId, project.id)
if (localCategory == null) {
Log.d(TAG, "Add local category : $c")
@@ -479,7 +620,7 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
val localCategories = dbHelper.getCategories(project.id)
for (localCategory in localCategories) {
if (!remoteCategoriesByRemoteId.containsKey(localCategory.remoteId)) {
if (localCategory.state == DBBill.STATE_OK && !remoteCategoriesByRemoteId.containsKey(localCategory.remoteId)) {
dbHelper.deleteCategory(localCategory.id)
Log.d(TAG, "Delete local category : $localCategory")
}
@@ -507,7 +648,7 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
val localCurrencies = dbHelper.getCurrencies(project.id)
for (localCurrency in localCurrencies) {
if (!remoteCurrenciesByRemoteId.containsKey(localCurrency.remoteId)) {
if (localCurrency.state == DBBill.STATE_OK && !remoteCurrenciesByRemoteId.containsKey(localCurrency.remoteId)) {
dbHelper.deleteCurrency(localCurrency.id)
Log.d(TAG, "Delete local currency : $localCurrencies")
}
@@ -574,13 +715,23 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
val dbMembers = dbHelper.getMembersOfProject(project.id, null)
val memberRemoteIdToId = dbMembers.associate { it.remoteId to it.id }
val dbCategories = dbHelper.getCategories(project.id)
val categoriesRemoteIdToId = dbCategories.associate { it.remoteId to it.id }.toMutableMap()
// Map hardcoded constants to their local IDs if they exist in DB, else to themselves
categoriesRemoteIdToId[DBBill.CATEGORY_REIMBURSEMENT] = DBBill.CATEGORY_REIMBURSEMENT
dbCategories.filter { it.remoteId < 0 }.forEach { categoriesRemoteIdToId[it.remoteId] = it.id }
val dbPaymentModes = dbHelper.getPaymentModes(project.id)
val paymentModesRemoteIdToId = dbPaymentModes.associate { it.remoteId to it.id }.toMutableMap()
dbPaymentModes.filter { it.remoteId < 0 }.forEach { paymentModesRemoteIdToId[it.remoteId] = it.id }
val billsResponse = client!!.getBills(project)
val isIHM = project.type == ProjectType.IHATEMONEY
val serverSyncTimestamp = if (isIHM) 0L else billsResponse.syncTimestamp
val remoteBills: List<DBBill> = if (isIHM) {
billsResponse.getBillsIHM(project.id, memberRemoteIdToId)
billsResponse.getBillsIHM(project.id, memberRemoteIdToId, categoriesRemoteIdToId, paymentModesRemoteIdToId)
} else {
billsResponse.getBillsCospend(project.id, memberRemoteIdToId)
billsResponse.getBillsCospend(project.id, memberRemoteIdToId, categoriesRemoteIdToId, paymentModesRemoteIdToId)
}
val remoteAllBillIds: List<Long> = if (isIHM) {
remoteBills.map { it.remoteId }
@@ -597,7 +748,6 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
dbHelper.addBill(remoteBill)
nbPulledNewBills++
newBillsDialogText += "+ ${remoteBill.what}\n"
Log.d(TAG, "Add local bill : $remoteBill")
} else {
val localBill = localBillsByRemoteId[remoteBill.remoteId]!!
if (hasChanged(localBill, remoteBill)) {
@@ -605,12 +755,11 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
localBill.id, null, remoteBill.payerId,
remoteBill.amount, remoteBill.timestamp,
remoteBill.what, DBBill.STATE_OK, remoteBill.repeat,
remoteBill.paymentMode, remoteBill.paymentModeRemoteId,
remoteBill.categoryRemoteId, remoteBill.comment
remoteBill.paymentMode, remoteBill.paymentModeId,
remoteBill.categoryId, remoteBill.comment
)
nbPulledUpdatedBills++
updatedBillsDialogText += "${remoteBill.what}\n"
Log.d(TAG, "Update local bill : $remoteBill")
} else {
Log.d(TAG, "Nothing to do for bill : $localBill")
}
@@ -1063,8 +1212,8 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
localBill.what == remoteBill.what &&
localBill.comment == remoteBill.comment &&
localBill.paymentMode == remoteBill.paymentMode &&
localBill.paymentModeRemoteId == remoteBill.paymentModeRemoteId &&
localBill.categoryRemoteId == remoteBill.categoryRemoteId
localBill.paymentModeId == remoteBill.paymentModeId &&
localBill.categoryId == remoteBill.categoryId
) {
val localRepeat = localBill.repeat ?: DBBill.NON_REPEATED
val remoteRepeat = remoteBill.repeat ?: DBBill.NON_REPEATED
@@ -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()
}
}
@@ -14,11 +14,11 @@ object BillFormatter {
) {
for (bill in bills) {
var whatPrefix = ""
val pm = paymentModesMap[bill.paymentModeRemoteId.toLong()]
val pm = paymentModesMap[bill.paymentModeId]
if (pm != null) {
whatPrefix += pm.icon + " "
} else {
when (bill.paymentModeRemoteId) {
when (bill.paymentModeId) {
DBBill.PAYMODE_ID_CARD -> whatPrefix += "\uD83D\uDCB3 "
DBBill.PAYMODE_ID_CASH -> whatPrefix += "\uD83D\uDCB5 "
DBBill.PAYMODE_ID_CHECK -> whatPrefix += "\uD83C\uDFAB "
@@ -27,11 +27,11 @@ object BillFormatter {
}
}
val cat = categoriesMap[bill.categoryRemoteId.toLong()]
val cat = categoriesMap[bill.categoryId]
if (cat != null) {
whatPrefix += cat.icon + " "
} else {
when (bill.categoryRemoteId) {
when (bill.categoryId) {
DBBill.CATEGORY_GROCERIES -> whatPrefix += "\uD83D\uDED2 "
DBBill.CATEGORY_LEISURE -> whatPrefix += "\uD83C\uDF89 "
DBBill.CATEGORY_RENT -> whatPrefix += "\uD83C\uDFE0 "
@@ -10,28 +10,57 @@ object CategoryUtils {
fun getDefaultCategories(context: Context, projectId: Long): List<DBCategory> {
return listOf(
DBCategory(0, DBBill.CATEGORY_GROCERIES.toLong(), projectId, context.getString(R.string.category_groceries), "\uD83D\uDED2", "#ffaa00"),
DBCategory(0, DBBill.CATEGORY_LEISURE.toLong(), projectId, context.getString(R.string.category_leisure), "\uD83C\uDF89", "#aa55ff"),
DBCategory(0, DBBill.CATEGORY_RENT.toLong(), projectId, context.getString(R.string.category_rent), "\uD83C\uDFE0", "#da8733"),
DBCategory(0, DBBill.CATEGORY_BILLS.toLong(), projectId, context.getString(R.string.category_bills), "\uD83C\uDF29", "#4aa6b0"),
DBCategory(0, DBBill.CATEGORY_CULTURE.toLong(), projectId, context.getString(R.string.category_excursion), "\uD83D\uDEB8", "#0055ff"),
DBCategory(0, DBBill.CATEGORY_HEALTH.toLong(), projectId, context.getString(R.string.category_health), "\uD83D\uDC9A", "#bf090c"),
DBCategory(0, DBBill.CATEGORY_SHOPPING.toLong(), projectId, context.getString(R.string.category_shopping), "\uD83D\uDECD", "#e167d1"),
DBCategory(0, DBBill.CATEGORY_REIMBURSEMENT.toLong(), projectId, context.getString(R.string.category_reimbursement), "\uD83D\uDCB0", "#00ced1"),
DBCategory(0, DBBill.CATEGORY_RESTAURANT.toLong(), projectId, context.getString(R.string.category_restaurant), "\uD83C\uDF74", "#d0d5e1"),
DBCategory(0, DBBill.CATEGORY_ACCOMMODATION.toLong(), projectId, context.getString(R.string.category_accomodation), "\uD83D\uDECC", "#5de1a3"),
DBCategory(0, DBBill.CATEGORY_TRANSPORT.toLong(), projectId, context.getString(R.string.category_transport), "\uD83D\uDE8C", "#6f2ee1"),
DBCategory(0, DBBill.CATEGORY_SPORT.toLong(), projectId, context.getString(R.string.category_sport), "\uD83C\uDFBE", "#69e177")
DBCategory(DBBill.CATEGORY_GROCERIES,
DBBill.CATEGORY_GROCERIES, projectId, context.getString(R.string.category_groceries), "\uD83D\uDED2", "#ffaa00"),
DBCategory(DBBill.CATEGORY_LEISURE,
DBBill.CATEGORY_LEISURE, projectId, context.getString(R.string.category_leisure), "\uD83C\uDF89", "#aa55ff"),
DBCategory(DBBill.CATEGORY_RENT,
DBBill.CATEGORY_RENT, projectId, context.getString(R.string.category_rent), "\uD83C\uDFE0", "#da8733"),
DBCategory(DBBill.CATEGORY_BILLS,
DBBill.CATEGORY_BILLS, projectId, context.getString(R.string.category_bills), "\uD83C\uDF29", "#4aa6b0"),
DBCategory(DBBill.CATEGORY_CULTURE,
DBBill.CATEGORY_CULTURE, projectId, context.getString(R.string.category_excursion), "\uD83D\uDEB8", "#0055ff"),
DBCategory(DBBill.CATEGORY_HEALTH,
DBBill.CATEGORY_HEALTH, projectId, context.getString(R.string.category_health), "\uD83D\uDC9A", "#bf090c"),
DBCategory(DBBill.CATEGORY_SHOPPING,
DBBill.CATEGORY_SHOPPING, projectId, context.getString(R.string.category_shopping), "\uD83D\uDECD", "#e167d1"),
DBCategory(DBBill.CATEGORY_REIMBURSEMENT,
DBBill.CATEGORY_REIMBURSEMENT, projectId, context.getString(R.string.category_reimbursement), "\uD83D\uDCB0", "#00ced1"),
DBCategory(DBBill.CATEGORY_RESTAURANT,
DBBill.CATEGORY_RESTAURANT, projectId, context.getString(R.string.category_restaurant), "\uD83C\uDF74", "#d0d5e1"),
DBCategory(DBBill.CATEGORY_ACCOMMODATION,
DBBill.CATEGORY_ACCOMMODATION, projectId, context.getString(R.string.category_accomodation), "\uD83D\uDECC", "#5de1a3"),
DBCategory(DBBill.CATEGORY_TRANSPORT,
DBBill.CATEGORY_TRANSPORT, projectId, context.getString(R.string.category_transport), "\uD83D\uDE8C", "#6f2ee1"),
DBCategory(DBBill.CATEGORY_SPORT,
DBBill.CATEGORY_SPORT, projectId, context.getString(R.string.category_sport), "\uD83C\uDFBE", "#69e177")
)
}
fun getDefaultPaymentModes(context: Context, projectId: Long): List<DBPaymentMode> {
return listOf(
DBPaymentMode(0, DBBill.PAYMODE_ID_CARD.toLong(), projectId, context.getString(R.string.payment_mode_credit_card), "\uD83D\uDCB3", "#ff7f50"),
DBPaymentMode(0, DBBill.PAYMODE_ID_CASH.toLong(), projectId, context.getString(R.string.payment_mode_cash), "\uD83D\uDCB5", "#556b2f"),
DBPaymentMode(0, DBBill.PAYMODE_ID_CHECK.toLong(), projectId, context.getString(R.string.payment_mode_check), "\uD83C\uDFAB", "#a9a9a9"),
DBPaymentMode(0, DBBill.PAYMODE_ID_TRANSFER.toLong(), projectId, context.getString(R.string.payment_mode_transfer), "", "#00ced1"),
DBPaymentMode(0, DBBill.PAYMODE_ID_ONLINE_SERVICE.toLong(), projectId, context.getString(R.string.payment_mode_online), "\uD83C\uDF0E", "#9932cc")
DBPaymentMode(DBBill.PAYMODE_ID_CARD, DBBill.PAYMODE_ID_CARD, projectId, context.getString(R.string.payment_mode_credit_card), "\uD83D\uDCB3", "#ff7f50"),
DBPaymentMode(DBBill.PAYMODE_ID_CASH, DBBill.PAYMODE_ID_CASH, projectId, context.getString(R.string.payment_mode_cash), "\uD83D\uDCB5", "#556b2f"),
DBPaymentMode(DBBill.PAYMODE_ID_CHECK, DBBill.PAYMODE_ID_CHECK, projectId, context.getString(R.string.payment_mode_check), "\uD83C\uDFAB", "#a9a9a9"),
DBPaymentMode(DBBill.PAYMODE_ID_TRANSFER, DBBill.PAYMODE_ID_TRANSFER, projectId, context.getString(R.string.payment_mode_transfer), "", "#00ced1"),
DBPaymentMode(DBBill.PAYMODE_ID_ONLINE_SERVICE, DBBill.PAYMODE_ID_ONLINE_SERVICE, projectId, context.getString(R.string.payment_mode_online), "\uD83C\uDF0E", "#9932cc")
)
}
fun getReimbursementCategoryId(categories: List<DBCategory>, projectId: Long, projectRemoteId: String? = null): Long {
return categories.find {
it.projectId == projectId && (
it.remoteId == DBBill.CATEGORY_REIMBURSEMENT ||
(projectRemoteId != null && it.remoteId.toString() == projectRemoteId)
)
}?.id ?: DBBill.CATEGORY_REIMBURSEMENT
}
fun getCategoryById(context: Context, id: Long, projectId: Long = 0): DBCategory? {
return getDefaultCategories(context, projectId).find { it.id == id }
}
fun getPaymentModeById(context: Context, id: Long, projectId: Long = 0): DBPaymentMode? {
return getDefaultPaymentModes(context, projectId).find { it.id == id }
}
}
@@ -17,7 +17,7 @@ object CospendClientUtil {
enum class LoginStatus(@param:StringRes val str: Int) {
OK(0),
AUTH_FAILED(R.string.error_username_password_invalid),
AUTH_FAILED(R.string.error_auth),
CONNECTION_FAILED(R.string.error_io),
NO_NETWORK(R.string.error_no_network),
JSON_FAILED(R.string.error_json),
@@ -48,7 +48,7 @@ object ExportUtil {
}
owersTxt = owersTxt.replace(",$".toRegex(), "")
fileContent += "\"${b.what}\",${b.amount},${b.date},${b.timestamp},\"$payerName\"," +
"$payerWeight,$payerActive,\"$owersTxt\",${b.repeat},${b.categoryRemoteId}," +
"$payerWeight,$payerActive,\"$owersTxt\",${b.repeat},${b.categoryId}," +
"${b.paymentMode}\n"
}
@@ -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
@@ -93,6 +93,10 @@ class NextcloudClient(
params: Collection<QueryParam>?,
isOCSRequest: Boolean
): VersatileProjectSyncClient.ResponseData {
var finalTarget = target
if (finalTarget.contains("/ocs/v2.php") && !finalTarget.contains("format=json")) {
finalTarget += if (finalTarget.contains("?")) "&format=json" else "?format=json"
}
val result = StringBuilder()
val headers: MutableMap<String, List<String>> = HashMap()
if (isOCSRequest) {
@@ -103,13 +107,13 @@ class NextcloudClient(
val nextcloudRequest: NextcloudRequest = if (params == null) {
NextcloudRequest.Builder()
.setMethod(method)
.setUrl(target)
.setUrl(finalTarget)
.setHeader(headers)
.build()
} else {
NextcloudRequest.Builder()
.setMethod(method)
.setUrl(target)
.setUrl(finalTarget)
.setParameter(params)
.setHeader(headers)
.build()
@@ -182,9 +186,13 @@ class NextcloudClient(
target: String,
method: String, params: JSONObject?, lastETag: String?, needLogin: Boolean, isOCSRequest: Boolean
): VersatileProjectSyncClient.ResponseData {
var finalTarget = target
if (finalTarget.contains("/ocs/v2.php") && !finalTarget.contains("format=json")) {
finalTarget += if (finalTarget.contains("?")) "&format=json" else "?format=json"
}
val result = StringBuilder()
val targetURL = url + target.replace("^/".toRegex(), "")
Log.d(javaClass.simpleName, "method and target URL: $method $targetURL")
val targetURL = url + finalTarget.replace("^/".toRegex(), "")
// Log.d(javaClass.simpleName, "method and target URL: $method $targetURL")
val httpCon = SupportUtil.getHttpURLConnection(targetURL)
httpCon.requestMethod = method
if (needLogin) {
@@ -194,7 +202,7 @@ class NextcloudClient(
)
}
httpCon.setRequestProperty("Connection", "Close")
httpCon.setRequestProperty("User-Agent", "cowspent-android/" + SupportUtil.getAppVersionName(context))
httpCon.setRequestProperty("User-Agent", "Cowspent-android/" + SupportUtil.getAppVersionName(context))
if (lastETag != null && METHOD_GET == method) {
httpCon.setRequestProperty("If-None-Match", lastETag)
}
@@ -206,7 +214,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
@@ -244,8 +252,12 @@ class NextcloudClient(
target: String,
method: String, params: JSONObject?, lastETag: String?, needLogin: Boolean, isOCSRequest: Boolean
): VersatileProjectSyncClient.ResponseData {
var finalTarget = target
if (finalTarget.contains("/ocs/v2.php") && !finalTarget.contains("format=json")) {
finalTarget += if (finalTarget.contains("?")) "&format=json" else "?format=json"
}
var strBase64: String
val targetURL = url + target.replace("^/".toRegex(), "")
val targetURL = url + finalTarget.replace("^/".toRegex(), "")
val httpCon = SupportUtil.getHttpURLConnection( targetURL)
httpCon.requestMethod = method
if (needLogin) {
@@ -263,11 +275,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)
}
}
@@ -137,6 +137,90 @@ open class ServerResponse(
getResponseStringData().toLong()
}
class CreateRemoteCategoryResponse(
response: VersatileProjectSyncClient.ResponseData,
isOcsResponse: Boolean
) : ServerResponse(response, isOcsResponse) {
@get:Throws(JSONException::class)
val stringContent: String
get() = getResponseStringData()
@get:Throws(JSONException::class)
val remoteCategoryId: Long
get() {
val dataStr = getResponseStringData()
return try {
dataStr.toLong()
} catch (_: NumberFormatException) {
val obj = JSONObject(dataStr)
obj.optLong("id", obj.optLong("remoteId", 0L))
}
}
}
class EditRemoteCategoryResponse(
response: VersatileProjectSyncClient.ResponseData,
isOcsResponse: Boolean
) : ServerResponse(response, isOcsResponse) {
@get:Throws(JSONException::class)
val stringContent: String
get() = getResponseStringData()
}
class DeleteRemoteCategoryResponse(
response: VersatileProjectSyncClient.ResponseData,
isOcsResponse: Boolean
) : ServerResponse(response, isOcsResponse) {
@get:Throws(JSONException::class)
val stringContent: String
get() = getResponseStringData()
}
class CreateRemotePaymentModeResponse(
response: VersatileProjectSyncClient.ResponseData,
isOcsResponse: Boolean
) : ServerResponse(response, isOcsResponse) {
@get:Throws(JSONException::class)
val stringContent: String
get() = getResponseStringData()
@get:Throws(JSONException::class)
val remotePaymentModeId: Long
get() {
val dataStr = getResponseStringData()
return try {
dataStr.toLong()
} catch (_: NumberFormatException) {
val obj = JSONObject(dataStr)
obj.optLong("id", obj.optLong("remoteId", 0L))
}
}
}
class EditRemotePaymentModeResponse(
response: VersatileProjectSyncClient.ResponseData,
isOcsResponse: Boolean
) : ServerResponse(response, isOcsResponse) {
@get:Throws(JSONException::class)
val stringContent: String
get() = getResponseStringData()
}
class DeleteRemotePaymentModeResponse(
response: VersatileProjectSyncClient.ResponseData,
isOcsResponse: Boolean
) : ServerResponse(response, isOcsResponse) {
@get:Throws(JSONException::class)
val stringContent: String
get() = getResponseStringData()
}
class CreateRemoteCurrencyResponse(
response: VersatileProjectSyncClient.ResponseData,
isOcsResponse: Boolean
@@ -145,6 +229,18 @@ open class ServerResponse(
@get:Throws(JSONException::class)
val stringContent: String
get() = getResponseStringData()
@get:Throws(JSONException::class)
val remoteCurrencyId: Long
get() {
val dataStr = getResponseStringData()
return try {
dataStr.toLong()
} catch (_: NumberFormatException) {
val obj = JSONObject(dataStr)
obj.optLong("id", obj.optLong("remoteId", 0L))
}
}
}
class EditRemoteCurrencyResponse(
@@ -196,6 +292,18 @@ open class ServerResponse(
@get:Throws(JSONException::class)
val stringContent: String
get() = getResponseStringData()
@get:Throws(JSONException::class)
val remoteBillId: Long
get() {
val dataStr = getResponseStringData()
return try {
dataStr.toLong()
} catch (_: NumberFormatException) {
val obj = JSONObject(dataStr)
obj.optLong("id", obj.optLong("remoteId", 0L))
}
}
}
class CreateRemoteBillResponse(
@@ -206,6 +314,18 @@ open class ServerResponse(
@get:Throws(JSONException::class)
val stringContent: String
get() = getResponseStringData()
@get:Throws(JSONException::class)
val remoteBillId: Long
get() {
val dataStr = getResponseStringData()
return try {
dataStr.toLong()
} catch (_: NumberFormatException) {
val obj = JSONObject(dataStr)
obj.optLong("id", obj.optLong("remoteId", 0L))
}
}
}
class DeleteRemoteBillResponse(
@@ -242,13 +362,23 @@ open class ServerResponse(
ServerResponse(response, isOcsResponse) {
@Throws(JSONException::class)
fun getBillsCospend(projId: Long, memberRemoteIdToId: Map<Long, Long>): List<DBBill> {
return getBillsFromJSONObject(getResponseObjectData(), projId, memberRemoteIdToId)
fun getBillsCospend(
projId: Long,
memberRemoteIdToId: Map<Long, Long>,
catRemoteIdToId: Map<Long, Long>,
pmRemoteIdToId: Map<Long, Long>
): List<DBBill> {
return getBillsFromJSONObject(projId, memberRemoteIdToId, catRemoteIdToId, pmRemoteIdToId)
}
@Throws(JSONException::class)
fun getBillsIHM(projId: Long, memberRemoteIdToId: Map<Long, Long>): List<DBBill> {
return getBillsFromJSONArray(JSONArray(content), projId, memberRemoteIdToId)
fun getBillsIHM(
projId: Long,
memberRemoteIdToId: Map<Long, Long>,
catRemoteIdToId: Map<Long, Long>,
pmRemoteIdToId: Map<Long, Long>
): List<DBBill> {
return getBillsFromJSONArray(JSONArray(content), projId, memberRemoteIdToId, catRemoteIdToId, pmRemoteIdToId)
}
@@ -270,6 +400,32 @@ open class ServerResponse(
}
}
class CategoriesResponse(response: VersatileProjectSyncClient.ResponseData, isOcsResponse: Boolean) :
ServerResponse(response, isOcsResponse) {
@Throws(JSONException::class)
fun getCategories(projId: Long): List<DBCategory> {
return if (isOcsResponse) {
getCategoriesFromJSON(getResponseObjectData(), projId)
} else {
getCategoriesFromJSONArray(getResponseArrayData(), projId)
}
}
}
class PaymentModesResponse(response: VersatileProjectSyncClient.ResponseData, isOcsResponse: Boolean) :
ServerResponse(response, isOcsResponse) {
@Throws(JSONException::class)
fun getPaymentModes(projId: Long): List<DBPaymentMode> {
return if (isOcsResponse) {
getPaymentModesFromJSON(getResponseObjectData(), projId)
} else {
getPaymentModesFromJSONArray(getResponseArrayData(), projId)
}
}
}
class AccountProjectsResponse(
response: VersatileProjectSyncClient.ResponseData,
isOcsResponse: Boolean
@@ -366,6 +522,26 @@ open class ServerResponse(
return members
}
@Throws(JSONException::class)
protected fun getCategoriesFromJSONArray(jsonCats: JSONArray, projId: Long): List<DBCategory> {
val categories: MutableList<DBCategory> = ArrayList()
for (i in 0 until jsonCats.length()) {
val jsonCat = jsonCats.getJSONObject(i)
categories.add(getCategoryFromJSON(jsonCat, projId))
}
return categories
}
@Throws(JSONException::class)
protected fun getPaymentModesFromJSONArray(jsonPms: JSONArray, projId: Long): List<DBPaymentMode> {
val paymentModes: MutableList<DBPaymentMode> = ArrayList()
for (i in 0 until jsonPms.length()) {
val jsonPm = jsonPms.getJSONObject(i)
paymentModes.add(getPaymentModeFromJSON(jsonPm, projId))
}
return paymentModes
}
@Throws(JSONException::class)
protected fun getCategoriesFromJSON(json: JSONObject, projId: Long): List<DBCategory> {
val categories: MutableList<DBCategory> = ArrayList()
@@ -382,6 +558,27 @@ open class ServerResponse(
return categories
}
@Throws(JSONException::class)
protected fun getCategoryFromJSON(json: JSONObject, projId: Long): DBCategory {
var remoteId: Long = 0
if (json.has("id") && !json.isNull("id")) {
remoteId = json.getLong("id")
}
var name = ""
var color = ""
var icon = ""
if (json.has("color") && !json.isNull("color")) {
color = json.getString("color")
}
if (json.has("icon") && !json.isNull("icon")) {
icon = json.getString("icon")
}
if (json.has("name") && !json.isNull("name")) {
name = json.getString("name")
}
return DBCategory(0, remoteId, projId, name, icon, color)
}
@Throws(JSONException::class)
protected fun getCategoryFromJSON(json: JSONObject, remoteIdStr: String, projId: Long): DBCategory {
val remoteId = remoteIdStr.toLong()
@@ -416,6 +613,27 @@ open class ServerResponse(
return paymentModes
}
@Throws(JSONException::class)
protected fun getPaymentModeFromJSON(json: JSONObject, projId: Long): DBPaymentMode {
var remoteId: Long = 0
if (json.has("id") && !json.isNull("id")) {
remoteId = json.getLong("id")
}
var name = ""
var color = ""
var icon = ""
if (json.has("color") && !json.isNull("color")) {
color = json.getString("color")
}
if (json.has("icon") && !json.isNull("icon")) {
icon = json.getString("icon")
}
if (json.has("name") && !json.isNull("name")) {
name = json.getString("name")
}
return DBPaymentMode(0, remoteId, projId, name, icon, color)
}
@Throws(JSONException::class)
protected fun getPaymentModeFromJSON(json: JSONObject, remoteIdStr: String, projId: Long): DBPaymentMode {
val remoteId = remoteIdStr.toLong()
@@ -556,26 +774,30 @@ open class ServerResponse(
protected fun getBillsFromJSONArray(
json: JSONArray,
projId: Long,
memberRemoteIdToId: Map<Long, Long>
memberRemoteIdToId: Map<Long, Long>,
catRemoteIdToId: Map<Long, Long>,
pmRemoteIdToId: Map<Long, Long>
): List<DBBill> {
val bills: MutableList<DBBill> = ArrayList()
for (i in 0 until json.length()) {
val jsonBill = json.getJSONObject(i)
bills.add(getBillFromJSON(jsonBill, projId, memberRemoteIdToId))
bills.add(getBillFromJSON(jsonBill, projId, memberRemoteIdToId, catRemoteIdToId, pmRemoteIdToId))
}
return bills
}
@Throws(JSONException::class)
protected fun getBillsFromJSONObject(
json: JSONObject,
projId: Long,
memberRemoteIdToId: Map<Long, Long>
memberRemoteIdToId: Map<Long, Long>,
catRemoteIdToId: Map<Long, Long>,
pmRemoteIdToId: Map<Long, Long>
): List<DBBill> {
val bills: List<DBBill>
val json = getResponseObjectData()
if (json.has("bills") && !json.isNull("bills")) {
val jsonBills = json.getJSONArray("bills")
bills = getBillsFromJSONArray(jsonBills, projId, memberRemoteIdToId)
bills = getBillsFromJSONArray(jsonBills, projId, memberRemoteIdToId, catRemoteIdToId, pmRemoteIdToId)
} else {
bills = ArrayList()
}
@@ -586,7 +808,9 @@ open class ServerResponse(
protected fun getBillFromJSON(
json: JSONObject,
projId: Long,
memberRemoteIdToId: Map<Long, Long>
memberRemoteIdToId: Map<Long, Long>,
catRemoteIdToId: Map<Long, Long>,
pmRemoteIdToId: Map<Long, Long>
): DBBill {
var remoteId: Long = 0
var payerRemoteId: Long
@@ -600,7 +824,7 @@ open class ServerResponse(
var repeat = DBBill.NON_REPEATED
var paymentMode = DBBill.PAYMODE_NONE
var paymentModeRemoteId = DBBill.PAYMODE_ID_NONE
var categoryId = DBBill.CATEGORY_NONE
var categoryRemoteId = DBBill.CATEGORY_NONE
if (!json.isNull("id")) {
remoteId = json.getLong("id")
}
@@ -639,23 +863,29 @@ open class ServerResponse(
}
if (json.has("paymentmode") && !json.isNull("paymentmode")) {
paymentMode = json.getString("paymentmode")
} else if (json.has("paymentMode") && !json.isNull("paymentMode")) {
paymentMode = json.getString("paymentMode")
}
if (json.has("categoryid") && !json.isNull("categoryid")) {
categoryId = json.getInt("categoryid")
Log.d("PLOP", "LOADED CATTTTTTTTTTTT $categoryId")
categoryRemoteId = json.getLong("categoryid")
} else if (json.has("categoryId") && !json.isNull("categoryId")) {
categoryRemoteId = json.getLong("categoryId")
}
if (json.has("paymentmodeid") && !json.isNull("paymentmodeid")) {
paymentModeRemoteId = json.getInt("paymentmodeid")
paymentModeRemoteId = json.getLong("paymentmodeid")
} else if (json.has("paymentModeId") && !json.isNull("paymentModeId")) {
paymentModeRemoteId = json.getLong("paymentModeId")
}
// old MB, new Cospend is ok as Cospend provides the old pm ID
// new MB, old Cospend => set payment mode ID from old one
if (DBBill.PAYMODE_NONE != paymentMode && "" != paymentMode && paymentModeRemoteId == DBBill.PAYMODE_ID_NONE) {
Log.d("PaymentMode", "old: $paymentMode and new: 0")
paymentModeRemoteId = DBBill.oldPmIdToNew[paymentMode] ?: DBBill.PAYMODE_ID_NONE
}
val categoryId = catRemoteIdToId[categoryRemoteId] ?: 0L
val paymentModeId = pmRemoteIdToId[paymentModeRemoteId] ?: 0L
val bill = DBBill(
0, remoteId, projId, payerId, amount, timestamp, what,
DBBill.STATE_OK, repeat, paymentMode, categoryId, comment, paymentModeRemoteId
DBBill.STATE_OK, repeat, paymentMode, categoryId, comment, paymentModeId
)
bill.billOwers = getBillOwersFromJson(json, memberRemoteIdToId)
return bill
@@ -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
@@ -86,14 +87,17 @@ object SupportUtil {
membersBalance: MutableMap<Long, Double>,
membersPaid: MutableMap<Long, Double>,
membersSpent: MutableMap<Long, Double>,
catId: Int, paymentModeId: Int,
catId: Long, paymentModeId: Long,
dateMin: String?, dateMax: String?
): Int {
val proj = db.getProject(projId)
val categories = db.getCategories(projId)
val reimbursementCategoryId = CategoryUtils.getReimbursementCategoryId(categories, projId, proj?.remoteId)
return getStats(
db.getMembersOfProject(projId, null),
db.getBillsOfProject(projId),
membersNbBills, membersBalance, membersPaid, membersSpent,
catId, paymentModeId, dateMin, dateMax
catId, paymentModeId, reimbursementCategoryId, dateMin, dateMax
)
}
@@ -105,7 +109,8 @@ object SupportUtil {
membersBalance: MutableMap<Long, Double>,
membersPaid: MutableMap<Long, Double>,
membersSpent: MutableMap<Long, Double>,
catId: Int, paymentModeId: Int,
catId: Long, paymentModeId: Long,
reimbursementCategoryId: Long,
dateMin: String?, dateMax: String?
): Int {
val nbBillsTotal = 0
@@ -123,9 +128,9 @@ object SupportUtil {
for (b in dbBills) {
// don't take deleted bills and respect category filter
if (b.state != DBBill.STATE_DELETED &&
((catId == -1000 || catId == -100 || b.categoryRemoteId == catId) &&
(catId != -100 || b.categoryRemoteId != DBBill.CATEGORY_REIMBURSEMENT) &&
(paymentModeId == -1000 || b.paymentModeRemoteId == paymentModeId)) &&
((catId == -1000L || catId == -100L || b.categoryId == catId) &&
(catId != -100L || b.categoryId != reimbursementCategoryId) &&
(paymentModeId == -1000L || b.paymentModeId == paymentModeId)) &&
(dateMin == null || b.date >= dateMin) &&
(dateMax == null || b.date <= dateMax)
) {
@@ -284,18 +289,6 @@ object SupportUtil {
return reduceBalance(crediters, debiters, results)
}
@JvmStatic
fun getVersionName(context: Context): String {
var versionName = "0.0.0"
try {
val pInfo = context.packageManager.getPackageInfo(context.packageName, 0)
versionName = pInfo.versionName ?: "0.0.0"
} catch (e: PackageManager.NameNotFoundException) {
e.printStackTrace()
}
return versionName
}
@JvmStatic
fun getJsonObject(text: String?): JSONObject? {
if (text == null) return null
@@ -11,8 +11,10 @@ import com.nextcloud.android.sso.exceptions.NextcloudHttpRequestFailedException
import com.nextcloud.android.sso.exceptions.TokenMismatchException
import com.nextcloud.android.sso.model.SingleSignOnAccount
import net.helcel.cowspent.model.DBBill
import net.helcel.cowspent.model.DBCategory
import net.helcel.cowspent.model.DBCurrency
import net.helcel.cowspent.model.DBMember
import net.helcel.cowspent.model.DBPaymentMode
import net.helcel.cowspent.model.DBProject
import net.helcel.cowspent.model.ProjectType
import org.json.JSONException
@@ -81,25 +83,18 @@ 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
ServerResponse.ProjectResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_GET, null, null, false), false)
}
target = if (cospendVersionGT161)
"/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId
else
"/index.php/apps/cospend/api-priv/projects/" + project.remoteId
return ServerResponse.ProjectResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_GET, null, null, cospendVersionGT161), cospendVersionGT161)
} else {
useOcsApiRequest = cospendVersionGT161
target = if (cospendVersionGT161)
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
@@ -275,7 +260,13 @@ class VersatileProjectSyncClient(
}
@Throws(IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class)
fun editRemoteBill(project: DBProject, bill: DBBill, memberIdToRemoteId: Map<Long, Long>): ServerResponse.EditRemoteBillResponse {
fun editRemoteBill(
project: DBProject,
bill: DBBill,
memberIdToRemoteId: Map<Long, Long>,
categoryIdToRemoteId: Map<Long, Long>,
paymentModeIdToRemoteId: Map<Long, Long>
): ServerResponse.EditRemoteBillResponse {
val paramKeys: MutableList<String> = ArrayList()
val paramValues: MutableList<String> = ArrayList()
paramKeys.add("date")
@@ -322,8 +313,8 @@ class VersatileProjectSyncClient(
payedFor = payedFor.replace(",$".toRegex(), "")
paramValues.add(payedFor)
paramValues.add(bill.paymentMode ?: "")
paramValues.add(bill.categoryRemoteId.toString())
paramValues.add(bill.paymentModeRemoteId.toString())
paramValues.add((categoryIdToRemoteId[bill.categoryId] ?: 0L).toString())
paramValues.add((paymentModeIdToRemoteId[bill.paymentModeId] ?: 0L).toString())
if (canAccessProjectWithNCLogin(project)) {
username = this.username
@@ -333,9 +324,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 +339,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 +376,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 +391,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 +422,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 +437,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 +507,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,
@@ -541,7 +518,13 @@ class VersatileProjectSyncClient(
}
@Throws(IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class)
fun createRemoteBill(project: DBProject, bill: DBBill, memberIdToRemoteId: Map<Long, Long>): ServerResponse.CreateRemoteBillResponse {
fun createRemoteBill(
project: DBProject,
bill: DBBill,
memberIdToRemoteId: Map<Long, Long>,
categoryIdToRemoteId: Map<Long, Long>,
paymentModeIdToRemoteId: Map<Long, Long>
): ServerResponse.CreateRemoteBillResponse {
val paramKeys: MutableList<String> = ArrayList()
val paramValues: MutableList<String> = ArrayList()
paramKeys.add("date")
@@ -583,8 +566,8 @@ class VersatileProjectSyncClient(
payedFor = payedFor.replace(",$".toRegex(), "")
paramValues.add(payedFor)
paramValues.add(bill.paymentMode ?: "")
paramValues.add(bill.categoryRemoteId.toString())
paramValues.add(bill.paymentModeRemoteId.toString())
paramValues.add((categoryIdToRemoteId[bill.categoryId] ?: 0L).toString())
paramValues.add((paymentModeIdToRemoteId[bill.paymentModeId] ?: 0L).toString())
if (canAccessProjectWithNCLogin(project)) {
username = this.username
@@ -594,9 +577,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 +592,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,13 +643,9 @@ 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"
Log.i(TAG, "using new API for createRemoteBill")
ServerResponse.CreateRemoteMemberResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_POST, paramKeys, paramValues, true), isOcsResponse=true, isJsonMember=true)
} else {
target = "/index.php/apps/cospend/api-priv/projects/" + project.remoteId + "/members"
@@ -682,7 +657,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 +691,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,
@@ -738,7 +709,6 @@ class VersatileProjectSyncClient(
paramValues.add(tsLastSync.toString())
return if (cospendVersionGT161) {
target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/bills"
Log.i(TAG, "using new API for getBills")
ServerResponse.BillsResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_GET, paramKeys, paramValues, true), true)
} else {
target = "/index.php/apps/cospend/api-priv/projects/" + project.remoteId + "/bills"
@@ -750,7 +720,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,
@@ -774,6 +743,92 @@ class VersatileProjectSyncClient(
}
}
@Throws(JSONException::class, IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class)
fun getCategories(project: DBProject): ServerResponse.CategoriesResponse {
var target: String
var username: String? = null
var password: String? = null
var bearerToken: String? = null
var useOcsApiRequest = false
if (ProjectType.COSPEND == project.type) {
if (canAccessProjectWithNCLogin(project)) {
username = this.username
password = this.password
target = if (cospendVersionGT161)
project.getRequestBaseUrl(true) + "/api/v1/projects/" + project.remoteId
else
project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/categories"
useOcsApiRequest = cospendVersionGT161
} else if (canAccessProjectWithSSO(project)) {
target = if (cospendVersionGT161)
"/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId
else
"/index.php/apps/cospend/api-priv/projects/" + project.remoteId + "/categories"
return ServerResponse.CategoriesResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_GET, null, null, cospendVersionGT161), cospendVersionGT161)
} else {
useOcsApiRequest = cospendVersionGT161
target = if (cospendVersionGT161)
project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password)
else
project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/categories"
}
} else {
target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/categories"
username = project.remoteId
password = project.password
bearerToken = project.bearerToken
}
return ServerResponse.CategoriesResponse(
requestServer(
target, METHOD_GET, null, null,
null, username, password, bearerToken, useOcsApiRequest
), useOcsApiRequest
)
}
@Throws(JSONException::class, IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class)
fun getPaymentModes(project: DBProject): ServerResponse.PaymentModesResponse {
var target: String
var username: String? = null
var password: String? = null
var bearerToken: String? = null
var useOcsApiRequest = false
if (ProjectType.COSPEND == project.type) {
if (canAccessProjectWithNCLogin(project)) {
username = this.username
password = this.password
target = if (cospendVersionGT161)
project.getRequestBaseUrl(true) + "/api/v1/projects/" + project.remoteId
else
project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/paymentmodes"
useOcsApiRequest = cospendVersionGT161
} else if (canAccessProjectWithSSO(project)) {
target = if (cospendVersionGT161)
"/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId
else
"/index.php/apps/cospend/api-priv/projects/" + project.remoteId + "/paymentmodes"
return ServerResponse.PaymentModesResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_GET, null, null, cospendVersionGT161), cospendVersionGT161)
} else {
useOcsApiRequest = cospendVersionGT161
target = if (cospendVersionGT161)
project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password)
else
project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/paymentmodes"
}
} else {
target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/paymentmodes"
username = project.remoteId
password = project.password
bearerToken = project.bearerToken
}
return ServerResponse.PaymentModesResponse(
requestServer(
target, METHOD_GET, null, null,
null, username, password, bearerToken, useOcsApiRequest
), useOcsApiRequest
)
}
@Throws(JSONException::class, IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class)
fun getMembers(project: DBProject): ServerResponse.MembersResponse {
var target: String
@@ -790,9 +845,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 +860,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"
@@ -824,6 +875,305 @@ class VersatileProjectSyncClient(
)
}
@Throws(IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class)
fun createRemoteCategory(project: DBProject, category: DBCategory): ServerResponse.CreateRemoteCategoryResponse {
val paramKeys: MutableList<String> = ArrayList()
val paramValues: MutableList<String> = ArrayList()
paramKeys.add("name")
paramValues.add(category.name ?: "")
paramKeys.add("icon")
paramValues.add(category.icon)
paramKeys.add("color")
paramValues.add(category.color)
var target: String
var username: String? = null
var password: String? = null
var bearerToken: String? = null
var useOcsApiRequest = false
if (ProjectType.COSPEND == project.type) {
if (canAccessProjectWithNCLogin(project)) {
username = this.username
password = this.password
target = if (cospendVersionGT161)
project.getRequestBaseUrl(true) + "/api/v1/projects/" + project.remoteId + "/category"
else
project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/categories"
useOcsApiRequest = cospendVersionGT161
} else if (canAccessProjectWithSSO(project)) {
target = if (cospendVersionGT161)
"/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/category"
else
"/index.php/apps/cospend/api-priv/projects/" + project.remoteId + "/categories"
return ServerResponse.CreateRemoteCategoryResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_POST, paramKeys, paramValues, cospendVersionGT161), cospendVersionGT161)
} else {
useOcsApiRequest = cospendVersionGT161
target = if (cospendVersionGT161)
project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/category"
else
project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/categories"
}
} else {
target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/categories"
username = project.remoteId
password = project.password
bearerToken = project.bearerToken
}
val response = requestServer(
target, METHOD_POST, paramKeys, paramValues, null,
username, password, bearerToken, useOcsApiRequest
)
return ServerResponse.CreateRemoteCategoryResponse(response, useOcsApiRequest)
}
@Throws(IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class)
fun editRemoteCategory(project: DBProject, category: DBCategory): ServerResponse.EditRemoteCategoryResponse {
val paramKeys: MutableList<String> = ArrayList()
val paramValues: MutableList<String> = ArrayList()
paramKeys.add("name")
paramValues.add(category.name ?: "")
paramKeys.add("icon")
paramValues.add(category.icon)
paramKeys.add("color")
paramValues.add(category.color)
var target: String
var username: String? = null
var password: String? = null
var bearerToken: String? = null
var useOcsApiRequest = false
if (ProjectType.COSPEND == project.type) {
if (canAccessProjectWithNCLogin(project)) {
username = this.username
password = this.password
target = if (cospendVersionGT161)
project.getRequestBaseUrl(true) + "/api/v1/projects/" + project.remoteId + "/category/" + category.remoteId
else
project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/categories/" + category.remoteId
useOcsApiRequest = cospendVersionGT161
} else if (canAccessProjectWithSSO(project)) {
target = if (cospendVersionGT161)
"/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/category/" + category.remoteId
else
"/index.php/apps/cospend/api-priv/projects/" + project.remoteId + "/categories/" + category.remoteId
return ServerResponse.EditRemoteCategoryResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_PUT, paramKeys, paramValues, cospendVersionGT161), cospendVersionGT161)
} else {
useOcsApiRequest = cospendVersionGT161
target = if (cospendVersionGT161)
project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/category/" + category.remoteId
else
project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/categories/" + category.remoteId
}
} else {
target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/categories/" + category.remoteId
username = project.remoteId
password = project.password
bearerToken = project.bearerToken
}
return ServerResponse.EditRemoteCategoryResponse(
requestServer(
target, METHOD_PUT, paramKeys, paramValues, null,
username, password, bearerToken, useOcsApiRequest
), useOcsApiRequest
)
}
@Throws(IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class)
fun deleteRemoteCategory(project: DBProject, categoryRemoteId: Long): ServerResponse.DeleteRemoteCategoryResponse {
var target: String
var username: String? = null
var password: String? = null
var bearerToken: String? = null
var useOcsApiRequest = false
if (ProjectType.COSPEND == project.type) {
if (canAccessProjectWithNCLogin(project)) {
username = this.username
password = this.password
target = if (cospendVersionGT161)
project.getRequestBaseUrl(true) + "/api/v1/projects/" + project.remoteId + "/category/" + categoryRemoteId
else
project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/categories/" + categoryRemoteId
useOcsApiRequest = cospendVersionGT161
} else if (canAccessProjectWithSSO(project)) {
target = if (cospendVersionGT161)
"/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/category/" + categoryRemoteId
else
"/index.php/apps/cospend/api-priv/projects/" + project.remoteId + "/categories/" + categoryRemoteId
return ServerResponse.DeleteRemoteCategoryResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_DELETE, null, null, cospendVersionGT161), cospendVersionGT161)
} else {
useOcsApiRequest = cospendVersionGT161
target = if (cospendVersionGT161)
project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/category/" + categoryRemoteId
else
project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/categories/" + categoryRemoteId
}
} else {
target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/categories/" + categoryRemoteId
username = project.remoteId
password = project.password
bearerToken = project.bearerToken
}
return ServerResponse.DeleteRemoteCategoryResponse(
requestServer(
target, METHOD_DELETE, null, null,
null, username, password, bearerToken, useOcsApiRequest
), useOcsApiRequest
)
}
@Throws(IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class)
fun createRemotePaymentMode(project: DBProject, paymentMode: DBPaymentMode): ServerResponse.CreateRemotePaymentModeResponse {
val paramKeys: MutableList<String> = ArrayList()
val paramValues: MutableList<String> = ArrayList()
paramKeys.add("name")
paramValues.add(paymentMode.name ?: "")
paramKeys.add("icon")
paramValues.add(paymentMode.icon)
paramKeys.add("color")
paramValues.add(paymentMode.color)
var target: String
var username: String? = null
var password: String? = null
var bearerToken: String? = null
var useOcsApiRequest = false
if (ProjectType.COSPEND == project.type) {
if (canAccessProjectWithNCLogin(project)) {
username = this.username
password = this.password
target = if (cospendVersionGT161)
project.getRequestBaseUrl(true) + "/api/v1/projects/" + project.remoteId + "/paymentmode"
else
project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/paymentmodes"
useOcsApiRequest = cospendVersionGT161
} else if (canAccessProjectWithSSO(project)) {
target = if (cospendVersionGT161)
"/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/paymentmode"
else
"/index.php/apps/cospend/api-priv/projects/" + project.remoteId + "/paymentmodes"
return ServerResponse.CreateRemotePaymentModeResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_POST, paramKeys, paramValues, cospendVersionGT161), cospendVersionGT161)
} else {
useOcsApiRequest = cospendVersionGT161
target = if (cospendVersionGT161)
project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/paymentmode"
else
project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/paymentmodes"
}
} else {
target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/paymentmodes"
username = project.remoteId
password = project.password
bearerToken = project.bearerToken
}
return ServerResponse.CreateRemotePaymentModeResponse(
requestServer(
target, METHOD_POST, paramKeys, paramValues, null,
username, password, bearerToken, useOcsApiRequest
), useOcsApiRequest
)
}
@Throws(IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class)
fun editRemotePaymentMode(project: DBProject, paymentMode: DBPaymentMode): ServerResponse.EditRemotePaymentModeResponse {
val paramKeys: MutableList<String> = ArrayList()
val paramValues: MutableList<String> = ArrayList()
paramKeys.add("name")
paramValues.add(paymentMode.name ?: "")
paramKeys.add("icon")
paramValues.add(paymentMode.icon)
paramKeys.add("color")
paramValues.add(paymentMode.color)
var target: String
var username: String? = null
var password: String? = null
var bearerToken: String? = null
var useOcsApiRequest = false
if (ProjectType.COSPEND == project.type) {
if (canAccessProjectWithNCLogin(project)) {
username = this.username
password = this.password
target = if (cospendVersionGT161)
project.getRequestBaseUrl(true) + "/api/v1/projects/" + project.remoteId + "/paymentmode/" + paymentMode.remoteId
else
project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/paymentmodes/" + paymentMode.remoteId
useOcsApiRequest = cospendVersionGT161
} else if (canAccessProjectWithSSO(project)) {
target = if (cospendVersionGT161)
"/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/paymentmode/" + paymentMode.remoteId
else
"/index.php/apps/cospend/api-priv/projects/" + project.remoteId + "/paymentmodes/" + paymentMode.remoteId
return ServerResponse.EditRemotePaymentModeResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_PUT, paramKeys, paramValues, cospendVersionGT161), cospendVersionGT161)
} else {
useOcsApiRequest = cospendVersionGT161
target = if (cospendVersionGT161)
project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/paymentmode/" + paymentMode.remoteId
else
project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/paymentmodes/" + paymentMode.remoteId
}
} else {
target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/paymentmodes/" + paymentMode.remoteId
username = project.remoteId
password = project.password
bearerToken = project.bearerToken
}
return ServerResponse.EditRemotePaymentModeResponse(
requestServer(
target, METHOD_PUT, paramKeys, paramValues, null,
username, password, bearerToken, useOcsApiRequest
), useOcsApiRequest
)
}
@Throws(IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class)
fun deleteRemotePaymentMode(project: DBProject, paymentModeRemoteId: Long): ServerResponse.DeleteRemotePaymentModeResponse {
var target: String
var username: String? = null
var password: String? = null
var bearerToken: String? = null
var useOcsApiRequest = false
if (ProjectType.COSPEND == project.type) {
if (canAccessProjectWithNCLogin(project)) {
username = this.username
password = this.password
target = if (cospendVersionGT161)
project.getRequestBaseUrl(true) + "/api/v1/projects/" + project.remoteId + "/paymentmode/" + paymentModeRemoteId
else
project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/paymentmodes/" + paymentModeRemoteId
useOcsApiRequest = cospendVersionGT161
} else if (canAccessProjectWithSSO(project)) {
target = if (cospendVersionGT161)
"/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/paymentmode/" + paymentModeRemoteId
else
"/index.php/apps/cospend/api-priv/projects/" + project.remoteId + "/paymentmodes/" + paymentModeRemoteId
return ServerResponse.DeleteRemotePaymentModeResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_DELETE, null, null, cospendVersionGT161), cospendVersionGT161)
} else {
useOcsApiRequest = cospendVersionGT161
target = if (cospendVersionGT161)
project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/paymentmode/" + paymentModeRemoteId
else
project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/paymentmodes/" + paymentModeRemoteId
}
} else {
target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/paymentmodes/" + paymentModeRemoteId
username = project.remoteId
password = project.password
bearerToken = project.bearerToken
}
return ServerResponse.DeleteRemotePaymentModeResponse(
requestServer(
target, METHOD_DELETE, null, null,
null, username, password, bearerToken, useOcsApiRequest
), useOcsApiRequest
)
}
@Throws(IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class)
fun createRemoteCurrency(project: DBProject, currency: DBCurrency): ServerResponse.CreateRemoteCurrencyResponse {
val paramKeys: MutableList<String> = ArrayList()
@@ -847,9 +1197,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 +1212,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 +1250,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 +1265,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 +1296,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 +1311,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
@@ -993,6 +1331,10 @@ class VersatileProjectSyncClient(
nextcloudAPI: NextcloudAPI, target: String, method: String,
paramKeys: List<String>?, paramValues: List<String>?, isOCSRequest: Boolean
): ResponseData {
var finalTarget = target
if (finalTarget.contains("/ocs/v2.php") && !finalTarget.contains("format=json")) {
finalTarget += if (finalTarget.contains("?")) "&format=json" else "?format=json"
}
val result = StringBuilder()
var params: MutableList<QueryParam>? = null
if (paramKeys != null && paramValues != null) {
@@ -1010,13 +1352,13 @@ class VersatileProjectSyncClient(
val nextcloudRequest: NextcloudRequest = if (params == null) {
NextcloudRequest.Builder()
.setMethod(method)
.setUrl(target)
.setUrl(finalTarget)
.setHeader(headers)
.build()
} else {
NextcloudRequest.Builder()
.setMethod(method)
.setUrl(target)
.setUrl(finalTarget)
.setParameter(params)
.setHeader(headers)
.build()
@@ -1050,8 +1392,12 @@ class VersatileProjectSyncClient(
lastETag: String?, username: String?, password: String?,
bearerToken: String?, isOCSRequest: Boolean
): ResponseData {
var finalTarget = target
if (finalTarget.contains("/ocs/v2.php") && !finalTarget.contains("format=json")) {
finalTarget += if (finalTarget.contains("?")) "&format=json" else "?format=json"
}
val result = StringBuilder()
val httpCon = SupportUtil.getHttpURLConnection(target)
val httpCon = SupportUtil.getHttpURLConnection(finalTarget)
httpCon.requestMethod = method
if (bearerToken != null) {
httpCon.setRequestProperty("Authorization", "Bearer $bearerToken")
@@ -1062,7 +1408,7 @@ class VersatileProjectSyncClient(
)
}
httpCon.setRequestProperty("Connection", "Close")
httpCon.setRequestProperty("User-Agent", "Cowspent/" + SupportUtil.getAppVersionName(context))
httpCon.setRequestProperty("User-Agent", "Cowspent-android/" + SupportUtil.getAppVersionName(context))
if (lastETag != null && METHOD_GET == method) {
httpCon.setRequestProperty("If-None-Match", lastETag)
}
@@ -1071,7 +1417,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 +1429,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())
@@ -2,10 +2,39 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="100"
android:viewportHeight="100">
<!-- Matches #0F172A Base -->
android:viewportWidth="72"
android:viewportHeight="72">
<group android:translateY="-0.33333334">
<group android:scaleX="0.63461536"
android:scaleY="0.63461536"
android:translateX="13.153846"
android:translateY="13.153846">
<path
android:pathData="M0,0h100v100h-100z"
android:fillColor="#0F172A"/>
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" />
<!-- 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"/>
</group>
</group>
</vector>
@@ -9,27 +9,12 @@
android:scaleY="0.63461536"
android:translateX="13.153846"
android:translateY="13.153846">
<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"/>
@@ -44,17 +29,6 @@
</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"/>
</group>
</group>
</group>
@@ -0,0 +1,31 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="72"
android:viewportHeight="72">
<group
android:scaleX="0.63461536"
android:scaleY="0.63461536"
android:translateX="13.153846"
android:translateY="13.153846">
<!-- Cow Outlines -->
<group android:name="color">
<path android:fillColor="#000" android:pathData="m15,12.0211h0c0,.6116.3056,1.1827.8145,1.5219l4.2449,2.83c.5764.3843,1.774.8721,1.688,1.5596l-1.7051,6.7284c-.028.2241-.0143.4514.0405.6705l1.8343,7.3371c.0548.2191.0685.4464.0405.6705l-.9152,7.322c-.028.2241-.0143.4514.0405.6705l1.7456,6.9825c.1104.4414.3813.8259.7599,1.0783l1.7312,1.1541c.433.2887.7225.7481.7961,1.2632l-.2598,3.7979s-.2916,3.1088,2.3012,6.051c1.6332,1.8533,5.7148,3.8846,8.2656,3.8847,3.3409,0,5.9547-1.9378,7.3949-3.6465.8914-1.0576,2.5398-2.4978,3.2821-5.8244.2014-.9023-.0996-3.6967-.0996-3.6967,0-.2477.4009-1.311.4985-1.5386l1.3063-2.5077c.0158-.0369.0264-.0595.0389-.0795.5394-.8642.7885-1.8786.8851-2.8927,0,0,.9043-4.9747,1.1585-6.6406.005-.0325-.4996-5.5158-.4953-5.5485l1.5496-6.8172c.0386-.2315.0323-.4683-.0187-.6975l-1.5364-6.9139c-.2151-.9678.3801-1.9308,1.3419-2.1712l1.6416-.4104c.4075-.1019.7676-.341,1.0196-.677l2.2445-2.9927c.2375-.3166.3658-.7017.3658-1.0974v-.1102c0-1.1701-1.1152-1.1434-2.2575-.8896l-6.191.4868c-.3593.0798-.7343.0494-1.0761-.0873l-4.3602-1.7441c-.0767-.0307-.1555-.0562-.2357-.0762l-7.3628-1.8407c-.3375-.0844-.692-.0708-1.022.0393l-5.3714,1.7905c-.0821.0274-.1622.0605-.2396.0992l-3.4104,1.7052c-.3092.1546-.656.2184-1,.184l-7.4623-.7462c-1.0768-.1077-2.0111.7379-2.0111,1.82Z"/>
<path android:fillColor="#000" android:pathData="m65.1818,16.5746l-3.5658.2422c-.1304.0089-.2591.0347-.3828.077l-7.081,2.42c-.043.0147-.0854.0314-.1269.0499l-2.6409,1.1807c-.1171.0524-.2341.0883-.3607.1091-1.3737.2264-10.1731,1.8874-10.0194,7.2658,0,0-.4646,3.6003,1.951,6.1473,1.8803,1.9826,2.9889,4.6478,2.5192,7.3395-.3101,1.7768-1.2515,3.5306-3.4702,4.594h0l2.9104,1.0217c.4295.1508.767.4887.9174.9183l1.4763,4.2192,1.6616-1.7712c1.1136-1.1945,1.9689-5.8742,1.9689-5.8742.4855-3.6065.5032-7.264.0031-10.8685-.0148-.1064-.0287-.2031-.0417-.2889h0s1.2606-.965,1.2606-.965c.1608-.1231.3449-.2123.5411-.2622l5.5633-1.4149c.1716-.0436.3341-.1174.4799-.2177l4.3017-2.9608c.1811-.1247.3326-.2877.4436-.4775l2.6005-4.4465c.1016-.1737.1671-.3661.1927-.5658l.4849-3.7874c.1198-.9359-.6453-1.748-1.5867-1.6841Z"/>
<path android:fillColor="#000" android:pathData="m6.867,16.5746l3.5658.2422c.1304.0089.2591.0347.3828.077l7.081,2.42c.043.0147.0854.0314.1269.0499l2.6409,1.1807c.1171.0524.2341.0883.3607.1091,1.3737.2264,10.1731,1.8874,10.0194,7.2658,0,0,.4645,3.6003-1.951,6.1473-1.8803,1.9826-2.9889,4.6478-2.5192,7.3395.3101,1.7768,1.2515,3.5306,3.4702,4.594h0l-2.9104,1.0217c-.4295.1508-.767.4887-.9174.9183l-1.4763,4.2192-1.6616-1.7712c-1.1136-1.1945-2.0412-6.9083-2.0412-6.9083-.3413-3.1673-.1698-6.3689.5077-9.4817h0c.1234-.5672-.092-1.1546-.553-1.5075l-.4665-.3571c-.1858-.1422-.4024-.2389-.6323-.2824l-6.0587-1.1441c-.2049-.0387-.3994-.1197-.5711-.2379l-4.2616-2.9332c-.1811-.1247-.3326-.2877-.4436-.4775l-2.6005-4.4465c-.1016-.1737-.1671-.3661-.1927-.5657l-.485-3.7875c-.1198-.9359.6453-1.7481,1.5867-1.6841Z"/>
<path android:fillColor="#000" android:pathData="m27.3197,47.9945l-.6598,1.1318c-.7217,2.1691-.9617,4.4695-.7033,6.7408h0c.1194,1.0495.9592,1.871,2.0111,1.9674l.0811.0074,6.4963-.5146c1.249-.0989,2.5045-.0796,3.7499.0577l5.9262.6536c.7598.0838,1.5171-.1772,2.064-.7112h0c.4669-.4559.7436-1.0718.7746-1.7237l.0484-1.0203c.1133-2.3893-.4008-4.7667-1.4912-6.8958h0c-.4317-.843-1.2874-1.3848-2.234-1.4148l-8.6286-.2728h-3.9632c-1.4296,0-2.7515.7595-3.4715,1.9945Z"/>
</group>
<group android:name="line">
<path android:fillColor="#000" android:pathData="m26.1616,31.0595s-3.0474-.219-3.8284-1-.781-2.0474,0-2.8284,2.0474-.781,2.8284,0c.7811.781,1,3.8284,1,3.8284Z"/>
<path android:strokeColor="#000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2" android:pathData="m19.6191,26.2353l2.0377-9.248s-7.6806-.627-6.505-7.4454c0,0,9.9534,3.2917,12.7748.4702,0,0,6.2375-6.6642,16.1956-.0429,3.3226,2.2093,12.7748-.4702,12.7748-.4702,1.1756,6.8184-6.505,7.4454-6.505,7.4454l2.0377,9.248"/>
<path android:strokeColor="#000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2" android:pathData="m28.4825,46h15.7265c.272,0,.5324.1138.7082.3213.8467.9992,3.1442,4.3471,2.0943,10.2966-.1836,1.0404-1.1647,1.7418-2.2037,1.5503-3.4759-.6406-11.324-1.7893-16.7592-.3263-1.1058.2976-2.2065-.454-2.3432-1.5909-.302-2.5115-.3157-6.6284,2.0103-9.8682.1751-.2438.4667-.3828.7668-.3828Z"/>
<path android:strokeColor="#000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2" android:pathData="m29.8671,50.277s-.6865,2.855,3.1538,2.926"/>
<path android:strokeColor="#000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2" android:pathData="m29.1276,61.4663s6.3482,7.9157,13.7936.3135"/>
<path android:strokeColor="#000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2" android:pathData="m17.5177,20.1544c-3.0642-1.9576-7.3791-4.5873-12.4353-3.3426,0,0-1.4891,14.0288,15.3611,15.5179l1.2133.881s-2.2906,8.9177,1.2618,16.1018"/>
<path android:fillColor="#000" android:pathData="m45.8871,31.0139s3.0474-.219,3.8284-1c.7811-.781.7811-2.0474,0-2.8284-.781-.781-2.0474-.781-2.8284,0-.781.781-1,3.8284-1,3.8284Z"/>
<path android:strokeColor="#000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2" android:pathData="m43.1817,50.2341s.6865,2.855-3.1538,2.926"/>
<path android:strokeColor="#000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2" android:pathData="m54.7743,19.9585c3.0474-1.888,7.2683-4.4017,12.1921-3.1896,0,0,1.4891,14.0288-15.3611,15.5179l-1.2133.881s2.5382,7.9772-1.0141,15.1613"/>
</group>
</group>
</vector>
-52
View File
@@ -1,52 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="128dp"
android:height="128dp"
android:viewportWidth="72"
android:viewportHeight="72">
<path
android:fillColor="#FFD700"
android:pathData="M36,2 C17.22,2 2,17.22 2,36 C2,54.78 17.22,70 36,70 C54.78,70 70,54.78 70,36 C70,17.22 54.78,2 36,2 Z" />
<!-- Inner Face (Expanded path) -->
<path
android:fillColor="#DAA520"
android:pathData="M36,8 C20.54,8 8,20.54 8,36 C8,51.46 20.54,64 36,64 C51.46,64 64,51.46 64,36 C64,20.54 51.46,8 36,8 Z" />
<!-- Center Detail (Pure path replacement for the center circle) -->
<path
android:fillColor="#FFD700"
android:pathData="M36,22 C28.27,22 22,28.27 22,36 C22,43.73 28.27,50 36,50 C43.73,50 50,43.73 50,36 C50,28.27 43.73,22 36,22 Z" />
<group android:name="color">
<path android:fillColor="#fff" android:pathData="m15,12.0211h0c0,.6116.3056,1.1827.8145,1.5219l4.2449,2.83c.5764.3843,1.774.8721,1.688,1.5596l-1.7051,6.7284c-.028.2241-.0143.4514.0405.6705l1.8343,7.3371c.0548.2191.0685.4464.0405.6705l-.9152,7.322c-.028.2241-.0143.4514.0405.6705l1.7456,6.9825c.1104.4414.3813.8259.7599,1.0783l1.7312,1.1541c.433.2887.7225.7481.7961,1.2632l-.2598,3.7979s-.2916,3.1088,2.3012,6.051c1.6332,1.8533,5.7148,3.8846,8.2656,3.8847,3.3409,0,5.9547-1.9378,7.3949-3.6465.8914-1.0576,2.5398-2.4978,3.2821-5.8244.2014-.9023-.0996-3.6967-.0996-3.6967,0-.2477.4009-1.311.4985-1.5386l1.3063-2.5077c.0158-.0369.0264-.0595.0389-.0795.5394-.8642.7885-1.8786.8851-2.8927,0,0,.9043-4.9747,1.1585-6.6406.005-.0325-.4996-5.5158-.4953-5.5485l1.5496-6.8172c.0386-.2315.0323-.4683-.0187-.6975l-1.5364-6.9139c-.2151-.9678.3801-1.9308,1.3419-2.1712l1.6416-.4104c.4075-.1019.7676-.341,1.0196-.677l2.2445-2.9927c.2375-.3166.3658-.7017.3658-1.0974v-.1102c0-1.1701-1.1152-1.1434-2.2575-.8896l-6.191.4868c-.3593.0798-.7343.0494-1.0761-.0873l-4.3602-1.7441c-.0767-.0307-.1555-.0562-.2357-.0762l-7.3628-1.8407c-.3375-.0844-.692-.0708-1.022.0393l-5.3714,1.7905c-.0821.0274-.1622.0605-.2396.0992l-3.4104,1.7052c-.3092.1546-.656.2184-1,.184l-7.4623-.7462c-1.0768-.1077-2.0111.7379-2.0111,1.82Z"/>
<path android:fillColor="#a57939" android:pathData="m65.1818,16.5746l-3.5658.2422c-.1304.0089-.2591.0347-.3828.077l-7.081,2.42c-.043.0147-.0854.0314-.1269.0499l-2.6409,1.1807c-.1171.0524-.2341.0883-.3607.1091-1.3737.2264-10.1731,1.8874-10.0194,7.2658,0,0-.4646,3.6003,1.951,6.1473,1.8803,1.9826,2.9889,4.6478,2.5192,7.3395-.3101,1.7768-1.2515,3.5306-3.4702,4.594h0l2.9104,1.0217c.4295.1508.767.4887.9174.9183l1.4763,4.2192,1.6616-1.7712c1.1136-1.1945,1.9689-5.8742,1.9689-5.8742.4855-3.6065.5032-7.264.0031-10.8685-.0148-.1064-.0287-.2031-.0417-.2889h0s1.2606-.965,1.2606-.965c.1608-.1231.3449-.2123.5411-.2622l5.5633-1.4149c.1716-.0436.3341-.1174.4799-.2177l4.3017-2.9608c.1811-.1247.3326-.2877.4436-.4775l2.6005-4.4465c.1016-.1737.1671-.3661.1927-.5658l.4849-3.7874c.1198-.9359-.6453-1.748-1.5867-1.6841Z"/>
<path android:fillColor="#a57939" android:pathData="m6.867,16.5746l3.5658.2422c.1304.0089.2591.0347.3828.077l7.081,2.42c.043.0147.0854.0314.1269.0499l2.6409,1.1807c.1171.0524.2341.0883.3607.1091,1.3737.2264,10.1731,1.8874,10.0194,7.2658,0,0,.4645,3.6003-1.951,6.1473-1.8803,1.9826-2.9889,4.6478-2.5192,7.3395.3101,1.7768,1.2515,3.5306,3.4702,4.594h0l-2.9104,1.0217c-.4295.1508-.767.4887-.9174.9183l-1.4763,4.2192-1.6616-1.7712c-1.1136-1.1945-2.0412-6.9083-2.0412-6.9083-.3413-3.1673-.1698-6.3689.5077-9.4817h0c.1234-.5672-.092-1.1546-.553-1.5075l-.4665-.3571c-.1858-.1422-.4024-.2389-.6323-.2824l-6.0587-1.1441c-.2049-.0387-.3994-.1197-.5711-.2379l-4.2616-2.9332c-.1811-.1247-.3326-.2877-.4436-.4775l-2.6005-4.4465c-.1016-.1737-.1671-.3661-.1927-.5657l-.485-3.7875c-.1198-.9359.6453-1.7481,1.5867-1.6841Z"/>
<path android:fillColor="#f4aa41" android:pathData="m27.3197,47.9945l-.6598,1.1318c-.7217,2.1691-.9617,4.4695-.7033,6.7408h0c.1194,1.0495.9592,1.871,2.0111,1.9674l.0811.0074,6.4963-.5146c1.249-.0989,2.5045-.0796,3.7499.0577l5.9262.6536c.7598.0838,1.5171-.1772,2.064-.7112h0c.4669-.4559.7436-1.0718.7746-1.7237l.0484-1.0203c.1133-2.3893-.4008-4.7667-1.4912-6.8958h0c-.4317-.843-1.2874-1.3848-2.234-1.4148l-8.6286-.2728h-3.9632c-1.4296,0-2.7515.7595-3.4715,1.9945Z"/>
</group>
<group android:name="line">
<path android:fillColor="#000" android:pathData="m26.1616,31.0595s-3.0474-.219-3.8284-1-.781-2.0474,0-2.8284,2.0474-.781,2.8284,0c.7811.781,1,3.8284,1,3.8284Z"/>
<path android:strokeColor="#000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2" android:pathData="m19.6191,26.2353l2.0377-9.248s-7.6806-.627-6.505-7.4454c0,0,9.9534,3.2917,12.7748.4702,0,0,6.2375-6.6642,16.1956-.0429,3.3226,2.2093,12.7748-.4702,12.7748-.4702,1.1756,6.8184-6.505,7.4454-6.505,7.4454l2.0377,9.248"/>
<path android:strokeColor="#000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2" android:pathData="m28.4825,46h15.7265c.272,0,.5324.1138.7082.3213.8467.9992,3.1442,4.3471,2.0943,10.2966-.1836,1.0404-1.1647,1.7418-2.2037,1.5503-3.4759-.6406-11.324-1.7893-16.7592-.3263-1.1058.2976-2.2065-.454-2.3432-1.5909-.302-2.5115-.3157-6.6284,2.0103-9.8682.1751-.2438.4667-.3828.7668-.3828Z"/>
<path android:strokeColor="#000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2" android:pathData="m29.8671,50.277s-.6865,2.855,3.1538,2.926"/>
<path android:strokeColor="#000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2" android:pathData="m29.1276,61.4663s6.3482,7.9157,13.7936.3135"/>
<path android:strokeColor="#000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2" android:pathData="m17.5177,20.1544c-3.0642-1.9576-7.3791-4.5873-12.4353-3.3426,0,0-1.4891,14.0288,15.3611,15.5179l1.2133.881s-2.2906,8.9177,1.2618,16.1018"/>
<path android:fillColor="#000" android:pathData="m45.8871,31.0139s3.0474-.219,3.8284-1c.7811-.781.7811-2.0474,0-2.8284-.781-.781-2.0474-.781-2.8284,0-.781.781-1,3.8284-1,3.8284Z"/>
<path android:strokeColor="#000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2" android:pathData="m43.1817,50.2341s.6865,2.855-3.1538,2.926"/>
<path android:strokeColor="#000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2" android:pathData="m54.7743,19.9585c3.0474-1.888,7.2683-4.4017,12.1921-3.1896,0,0,1.4891,14.0288-15.3611,15.5179l-1.2133.881s2.5382,7.9772-1.0141,15.1613"/>
</group>
<!-- Massive Background Coin Base -->
<path
android:pathData="M 256,76 A 180,180 0 1,1 255.9,76 Z"
android:fillColor="#FBBF24"/>
<!-- Coin Decorative Dotted Inner Rim -->
<path
android:pathData="M 256,101 A 155,155 0 1,1 255.9,101 Z"
android:strokeColor="#D97706"
android:strokeWidth="4"/>
</vector>
@@ -2,5 +2,5 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<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>
@@ -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>
+219 -223
View File
@@ -2,231 +2,152 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="app_name">Cowspent</string>
<string name="label_all_bills">All bills</string>
<string name="action_create_bill">New bill</string>
<string name="action_settings">App settings</string>
<string name="action_scan_qrcode">Scan QRCode from Cospend/Cowspent</string>
<!-- Actions -->
<string name="action_new_bill">New bill</string>
<string name="action_add_project">Add project</string>
<string name="action_save_bill">Save bill</string>
<string name="action_edit_project">Edit project</string>
<string name="action_share_project">Share project</string>
<string name="setting_what">What?</string>
<string name="setting_payer">Who paid?</string>
<string name="setting_all">All</string>
<string name="setting_none">None</string>
<string name="save_or_discard_bill_dialog_title">Bill edition</string>
<string name="save_or_discard_bill_dialog_message">Bill values have been changed. What do you want to do?</string>
<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>
<string name="setting_project_repetition">Repeat this bill every</string>
<string name="setting_payment_mode">Mode</string>
<string name="setting_category">Category</string>
<string name="action_label_bills">Label missing categories</string>
<string name="label_bills_title">Label Bills</string>
<string name="label_bills_no_more">No more bills to label</string>
<string name="label_bills_skip">Skip</string>
<string name="label_bills_suggested">Suggested</string>
<string name="label_bills_no_suggestions">No suggestions for this bill</string>
<string name="new_project_what_todo">What do you want to do?</string>
<string name="new_project_where">Where?</string>
<string name="where_local_short">Local</string>
<string name="where_cospend_short">Cospend</string>
<string name="where_ihatemoney_short">ihatemoney</string>
<string name="setting_ihatemoney_project_url">ihatemoney address</string>
<string name="todo_join_label">Join</string>
<string name="todo_create_label">Create</string>
<string name="setting_cospend_project_url">Nextcloud address</string>
<string name="setting_password">Password (local)</string>
<string name="setting_new_project_password">Password</string>
<string name="setting_new_project_name">Project title</string>
<string name="setting_new_project_email">E-mail</string>
<string name="warning_auth_project_creation">Warning: creating a Cospend project using Nextcloud account works with Cospend v0.3.4 and higher.</string>
<string name="auth_project_creation_title">Authenticated project creation</string>
<string name="project_edition_no_change">Nothing was changed</string>
<string name="project_added_success">Project successfully added to Cowspent</string>
<string name="action_save">Save</string>
<string name="action_edit">Edit</string>
<string name="action_share">Share</string>
<string name="action_search">Search</string>
<string name="action_delete">Delete</string>
<string name="simple_error">Error</string>
<string name="simple_close">Close</string>
<string name="simple_loading">Loading</string>
<string name="sync_error_dialog_title">Project sync failed</string>
<string name="sync_error_dialog_full_content">Synchronization with project %1$s failed. Error message:\n\n%2$s\n\nThe associated remote project might not exist anymore. If so, you could remove this project from Cowspent.</string>
<string name="error_maintenance_mode">The Nextcloud instance is probably in maintenance mode</string>
<string name="action_archive">Archive</string>
<string name="action_unarchive">Unarchive</string>
<string name="action_export">Export</string>
<string name="action_stats">Stats</string>
<string name="action_settle">Settle</string>
<string name="action_scan_qrcode">Scan QR Code</string>
<string name="action_settings">Settings</string>
<string name="action_label_bills">Label missing categories</string>
<string name="action_logout">Logout</string>
<string name="action_connect">Connect</string>
<string name="action_discard">Discard</string>
<string name="action_members">Members</string>
<string name="action_labels">Labels</string>
<string name="action_currencies">Currencies</string>
<!-- Titles -->
<string name="title_stats">Statistics</string>
<string name="title_edit_project">Edit project</string>
<string name="title_label_bills">Label Bills</string>
<string name="title_labels">Manage Labels</string>
<string name="title_about">About</string>
<string name="title_settle">Settle Project</string>
<string name="title_share">Share Project</string>
<string name="title_add_project">Add Project</string>
<string name="title_add_category">Add Category</string>
<string name="title_add_payment_mode">Add Payment Mode</string>
<string name="title_account">Nextcloud Account</string>
<string name="title_share_web">Web link</string>
<string name="title_share_qr">Cowspent link</string>
<string name="title_confirm">Are you sure?</string>
<!-- Labels and Fields -->
<string name="label_all_bills">All bills</string>
<string name="label_categories">Categories</string>
<string name="label_payment_modes">Payment Modes</string>
<string name="label_name">Name</string>
<string name="label_icon">Icon / Emoji</string>
<string name="label_color">Color</string>
<string name="label_weight">Weight</string>
<string name="label_activated">Activated</string>
<string name="label_password">Password</string>
<string name="label_email">E-mail</string>
<string name="label_url">Server address</string>
<string name="label_username">Username</string>
<string name="label_comment">Comment</string>
<string name="label_what">What?</string>
<string name="label_payer">Who paid?</string>
<string name="label_owers">For whom?</string>
<string name="label_repeat">Repeat every</string>
<string name="label_mode">Mode</string>
<string name="label_category">Category</string>
<string name="label_project_id">Project ID/name</string>
<string name="label_project_title">Project title</string>
<string name="label_use_sso">Use Nextcloud App Account</string>
<!-- Dialogs and Messages -->
<string name="dialog_unsaved_changes_title">Unsaved changes</string>
<string name="dialog_unsaved_changes_msg">Save changes before leaving?</string>
<string name="dialog_confirm_remove_project_msg">The remote project will not be deleted.</string>
<string name="dialog_sync_error_title">Sync error</string>
<string name="dialog_sync_error_msg">Sync failed for %1$s.\n\n%2$s</string>
<string name="dialog_balanced_msg">Expenses are already balanced.</string>
<string name="msg_project_added">Project %1$s added</string>
<string name="msg_bill_labeled_done">All bills labeled</string>
<string name="msg_no_suggestions">No suggestions</string>
<string name="msg_auth_warning">Requires Cospend v0.3.4+.</string>
<string name="msg_link_copied">Link copied to clipboard</string>
<string name="msg_share_qr">Scan QR code or share the link to join.</string>
<string name="msg_share_web">Link for web browser access.</string>
<string name="msg_share_qr_warn">Share this link with a Cowspent user.</string>
<string name="msg_settle_intro">Settlement for %1$s:</string>
<string name="msg_settle_sentence">%1$s owes %3$.2f to %2$s</string>
<string name="msg_stats_intro">Stats for %1$s:</string>
<string name="msg_stats_header">Member (Paid | Spent | Balance)</string>
<string name="msg_logged_in_as">Logged in as %1$s</string>
<!-- Errors -->
<string name="error_generic">Error</string>
<string name="error_loading">Loading</string>
<string name="error_no_projects">No projects found</string>
<string name="error_no_members">No members found</string>
<string name="error_no_bills">No bills found</string>
<string name="error_no_member">At least one member required</string>
<string name="error_maintenance_mode">Server is in maintenance mode</string>
<string name="error_400">400 Bad request</string>
<string name="error_401">401 Unauthorized</string>
<string name="error_403">403 Forbidden</string>
<string name="error_404">404 Not Found</string>
<string name="creating_remote_project">Creating remote project</string>
<string name="error_sync">Sync failed: %1$s</string>
<string name="error_invalid_login">Invalid login: %1$s</string>
<string name="error_auth">Wrong username or password</string>
<string name="error_json">Invalid server response</string>
<string name="error_req_failed">Request failed</string>
<string name="error_invalid_email">Invalid e-mail</string>
<string name="error_invalid_project_id">Invalid project ID</string>
<string name="error_invalid_project_name">Invalid project title</string>
<string name="error_invalid_bill_name">Invalid bill name</string>
<string name="error_invalid_bill_date">Invalid bill date</string>
<string name="error_invalid_bill_payer">Payer required</string>
<string name="error_invalid_bill_owers">Owers required</string>
<string name="error_no_network">No network connection</string>
<string name="error_server">Server error</string>
<string name="error_io">Server connection broken</string>
<string name="error_share_impossible">Cannot share this project</string>
<!-- Drawer / Common UI -->
<string name="drawer_no_account">Connect to Nextcloud account</string>
<string name="drawer_last_sync">Last sync: %1$02d:%2$02d</string>
<string name="simple_cancel">Cancel</string>
<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>
<string name="simple_edit_project">Edit project</string>
<string name="simple_about">About</string>
<string name="share_impossible">Impossible to share this project</string>
<string name="simple_settle_share">Share</string>
<string name="simple_create_bills">Create bills</string>
<string name="settle_bill_what">Settlement bill</string>
<string name="simple_share_share">Share Cowspent link</string>
<string name="menu_delete_project_remote">Delete project on server</string>
<string name="menu_save_project">Save project</string>
<string name="fab_add_member">Add a member</string>
<string name="fab_rm_project">Remove Project</string>
<string name="remove_project_confirmation">Project %1$s has been removed</string>
<string name="choose_project_management_action">Manage project</string>
<string name="fab_manage_members">Manage members</string>
<string name="fab_export_project">Export project</string>
<string name="drawer_last_sync_text">Last sync: %1$02d:%2$02d</string>
<string name="scan_qrcode">Scan QR code</string>
<string name="new_project_from_nextcloud_tooltip">Choose from Nextcloud account</string>
<string name="import_tooltip">Import from CSV file</string>
<string name="import_error_owers">Malformed CSV, bad owers on line %1$d</string>
<string name="import_error_date">Malformed CSV, bad date on line %1$d</string>
<string name="import_error_header">Malformed CSV, bad column names on line %1$d</string>
<string name="member_already_exists">This member already exists in this project</string>
<string name="confirm_remove_project_dialog_title">Are you sure?</string>
<string name="confirm_delete_project_dialog_title">Are you sure you want to delete remote project?</string>
<string name="confirm_remove_project_dialog_message">The remote project will NOT be deleted</string>
<string name="member_edit_toggle">Activated</string>
<string name="member_edit_name">Name</string>
<string name="member_edit_color">Color</string>
<string name="member_edit_weight">Weight</string>
<string name="member_edit_delete">Delete member</string>
<string name="remote_project_operation_no_network">Remote project operations require network connectivity</string>
<string name="insufficient_access_level">You are not allowed to perform this action</string>
<string name="simple_close">Close</string>
<!-- Settings -->
<string name="settings_appearance_category">Appearance</string>
<string name="settings_network_category">Network</string>
<string name="settings_other_category">Other</string>
<string name="settings_about">About Cowspent</string>
<string name="settings_server_settings">Nextcloud account</string>
<string name="account_logged_in_as">Logged in as %1$s</string>
<string name="account_logout">Logout</string>
<string name="settings_url">Server address</string>
<string name="settings_url_warn_http">WARNING: "http" is unsafe. Please use "https".</string>
<string name="settings_username">Username</string>
<string name="settings_password">Password</string>
<string name="settings_submit">Connect</string>
<string name="settings_night_mode_title">Theme</string>
<string name="settings_offline_mode_title">Offline mode</string>
<string name="settings_appearance">Appearance</string>
<string name="settings_network">Network</string>
<string name="settings_other">Other</string>
<string name="settings_night_mode">Theme</string>
<string name="settings_offline_mode">Offline mode</string>
<string name="settings_offline_mode_summary">Only sync manually.</string>
<string name="settings_color_title">Custom color</string>
<string name="settings_color_summary">UI Accent color</string>
<string name="settings_colorpicker_title">Choose color</string>
<string name="settings_color_mode_title">Color Selection</string>
<string name="settings_color_custom">Custom color</string>
<string name="settings_color_mode">Color Selection</string>
<string name="settings_show_archived">Show archived projects</string>
<string name="settings_beta_features">Beta Features</string>
<string name="settings_beta_features_summary">Enable experimental features. Use at your own risk.</string>
<string name="settings_url_warn_http">WARNING: "http" is unsafe. Use "https".</string>
<string name="settings_colorpicker_title">Choose Color</string>
<string name="pref_value_color_system">System</string>
<string name="pref_value_color_server">Nextcloud</string>
<string name="pref_value_color_manual">Manual</string>
<string name="pref_value_theme_light">Light</string>
<string name="pref_value_theme_dark">Dark</string>
<string name="pref_value_theme_system">Follow system</string>
<string name="currency_saved_success">Main currency saved</string>
<string name="file_saved_success">Project saved in %s</string>
<string name="add_member_dialog_title">New member name</string>
<string name="edit_member_dialog_title">Edit member</string>
<string name="center_none">Optimal</string>
<string name="settle_who">Who pays?</string>
<string name="settle_to_whom">To whom?</string>
<string name="settle_how_much">How much?</string>
<string name="total">Total Spent: %s</string>
<string name="stats_date_min">From</string>
<string name="stats_date_max">To</string>
<string name="stats_who">Who</string>
<string name="stats_paid">Paid</string>
<string name="stats_spent">Spent</string>
<string name="stats_balance">Balance</string>
<string name="fab_statistics">Statistics</string>
<string name="fab_settle">Settle</string>
<string name="project_add_success_message">Project %1$s was successfully added</string>
<string name="project_create_success_message">Project %1$s was successfully created</string>
<string name="project_add_success_title">Project added</string>
<string name="project_create_success_title">Project created</string>
<string name="statistic_title">Statistics</string>
<string name="settle_dialog_title">Settle the project</string>
<string name="settle_dialog_balanced">The expenses in this project are already balanced. There is nothing to settle!</string>
<string name="share_dialog_title">Share the project</string>
<string name="share_project_dialog_message">Scan this QRCode with Cowspent or a scanner app on an Android device where Cowspent is installed. You can also simply send/share the Cowspent link above.</string>
<string name="share_project_public_url_dialog_message">Share this web link to let others access the project with a web browser.</string>
<string name="share_project_public_url_title">Web link</string>
<string name="share_project_public_qrcode_title">Cowspent link</string>
<string name="qrcode_link_open_attempt_warning">Share this link to a Cowspent user.</string>
<string name="share_share_intent_title">Cowspent link of %1$s</string>
<string name="share_share_chooser_title">Share Cowspent link of %1$s</string>
<string name="share_settle_intro">Here is how to settle project %1$s :</string>
<string name="share_settle_sentence">%1$s owes %3$.2f to %2$s</string>
<string name="share_settle_title">Settle the project %1$s</string>
<string name="share_stats_intro">Statistics of project %1$s :</string>
<string name="share_stats_header">MEMBER_NAME (PAID | SPENT | BALANCE)</string>
<string name="drawer_no_account">Click here to connect to a Nextcloud account</string>
<string name="add_bill_impossible_no_member">At least one activated member is required to add a bill</string>
<string name="edit_project_local_impossible">Impossible for local projects</string>
<string name="choose_account_project_dialog_title">Choose a project from your Nextcloud account</string>
<string name="choose_account_project_dialog_impossible">There is no Cospend project in your Nextcloud account</string>
<string name="activity_dialog_title">Activity in project %1$s</string>
<!-- Bills list corner cases -->
<string name="no_projects_title">No Projects</string>
<string name="no_projects_text">You don\'t have any projects yet. If you have Cospend installed on a Nextcloud server, you can configure your Nextcloud account. Alternatively, you can manually add projects.</string>
<string name="configure_account_choice">Configure Nextcloud account</string>
<string name="add_project_choice">Manually add a project (Cospend or local)</string>
<string name="no_members_title">No Members</string>
<string name="no_members_text">This project doesn\'t have any members. In order to create bills, you need to add at least one member (better two or more).</string>
<string name="no_bills_title">No Bills</string>
<string name="no_bills_text">This project doesn\'t have any bills. Click the button below to add your first bill.</string>
<!-- Certificates -->
<!-- Network -->
<!-- Error -->
<string name="error_project_connect_check">Impossible to reach the remote project:\n\n%1$s</string>
<string name="error_sync">Project sync failed: %1$s</string>
<string name="error_invalid_login">Invalid login: %1$s</string>
<string name="error_json">is the Cospend app activated on the server?</string>
<string name="error_io">server connection is broken</string>
<string name="error_no_network">no network connection</string>
<string name="error_server">server error</string>
<string name="error_username_password_invalid">wrong username or password</string>
<string name="error_invalid_email">Invalid e-mail address</string>
<string name="error_invalid_project_remote_id">Invalid project ID/name</string>
<string name="error_invalid_project_name">Invalid project title</string>
<string name="error_invalid_bill_what">Invalid bill name</string>
<string name="error_invalid_bill_date">Invalid bill date</string>
<string name="error_invalid_bill_payerid">Bill should have a payer</string>
<string name="error_invalid_bill_owers">Bill should have at least one ower</string>
<string name="error_create_remote_project_helper">Error at remote project creation : %s</string>
<string name="error_edit_remote_project_helper">Error at remote project edition : %s</string>
<string name="error_token_mismatch">Please reauthenticate your SSO account in settings</string>
<string name="error_req_failed">Request failed</string>
<!-- About -->
<string name="about_version">Version %1$s</string>
<string name="about_maintainer_title">Maintainer</string>
<string name="about_maintainer" translatable="false">Helcel.net</string>
<string name="about_license_title">License</string>
<string name="about_license" translatable="false">GNU GPL3x / CC BY-SA 4.0</string>
<string name="about_source_title">Source code</string>
<string name="about_source" translatable="false">https://github.com/helcel-net/cowspent</string>
<!-- Key / Values for preferences -->
<!-- Constants (Do not translate) -->
<string name="pref_key_night_mode" translatable="false">nightMode</string>
<string name="pref_key_color" translatable="false">appColor</string>
<string name="pref_key_server_color" translatable="false">serverColor</string>
@@ -236,36 +157,29 @@
<string name="pref_key_color_mode" translatable="false">colorMode</string>
<string name="pref_key_offline_mode" translatable="false">offlineMode</string>
<string name="pref_key_show_archived" translatable="false">showArchived</string>
<string name="pref_value_theme_light">Light</string>
<string name="pref_value_theme_dark">Dark</string>
<string name="pref_value_theme_system">Follow system</string>
<string name="pref_key_beta_features" translatable="false">betaFeatures</string>
<string name="pref_value_night_mode_no" translatable="false">1</string>
<string name="pref_value_night_mode_yes" translatable="false">2</string>
<string name="pref_value_night_mode_system" translatable="false">-1</string>
<string name="use_sso_toggle">Use Nextcloud app account</string>
<!-- QR Code Scanner -->
<string name="error_scanning_bill_qr_code">QR code could not be scanned.</string>
<string name="fab_manage_currencies">Manage currencies</string>
<string name="setting_show_archived">Show archived projects</string>
<!-- Enums and Lists -->
<string name="repeat_no">No repeat</string>
<string name="repeat_day">Daily</string>
<string name="repeat_week">Weekly</string>
<string name="repeat_fortnight">Fortnightly</string>
<string name="repeat_month">Monthly</string>
<string name="repeat_year">Yearly</string>
<string name="payment_mode_none">No payment mode</string>
<string name="payment_mode_all">All payment modes</string>
<string name="payment_mode_none">None</string>
<string name="payment_mode_all">All</string>
<string name="payment_mode_credit_card">Credit card</string>
<string name="payment_mode_cash">Cash</string>
<string name="payment_mode_check">Check</string>
<string name="payment_mode_online">Online service</string>
<string name="payment_mode_online">Online</string>
<string name="payment_mode_transfer">Transfer</string>
<string name="category_none">No category</string>
<string name="category_all">All categories</string>
<string name="category_none">None</string>
<string name="category_all">All</string>
<string name="category_all_except_reimbursement">All except reimbursement</string>
<string name="category_groceries">Grocery</string>
<string name="category_leisure">Bar/Party</string>
@@ -279,4 +193,86 @@
<string name="category_accomodation">Accommodation</string>
<string name="category_transport">Transport</string>
<string name="category_sport">Sport</string>
<!-- Project specific -->
<string name="new_project_action">What</string>
<string name="new_project_where">Where</string>
<string name="where_local">Local only</string>
<string name="where_cospend">Cospend</string>
<string name="where_ihatemoney">IHateMoney</string>
<string name="todo_join">Join existing project</string>
<string name="todo_create">Create new project</string>
<string name="import_tooltip">Import from file</string>
<string name="choose_account_project_dialog_title">Choose project</string>
<string name="choose_account_project_dialog_impossible">No projects found on this account.</string>
<string name="choose_project_management_action">Project</string>
<string name="project_added_success">Project added successfully.</string>
<string name="no_projects_text">You have no projects yet.</string>
<string name="configure_account_choice">Configure Nextcloud account</string>
<string name="add_project_choice">Add project manually</string>
<string name="no_members_text">No members in this project.</string>
<string name="no_bills_text">No bills found.</string>
<string name="member_already_exists">Member already exists.</string>
<string name="activity_dialog_title">Project: %1$s</string>
<string name="remove_project_confirmation">Project %1$s removed.</string>
<string name="file_saved_success">File saved: %1$s</string>
<string name="import_error_header">Import failed at row %d</string>
<string name="import_error_date">Invalid date format at row %d</string>
<string name="import_error_owers">Invalid owers at row %d</string>
<string name="add_member_dialog_title">Add Member</string>
<string name="edit_member_dialog_title">Edit Member</string>
<string name="member_edit_delete">Delete</string>
<string name="project_edition_no_change">No changes to save.</string>
<!-- Settlement -->
<string name="center_none">None (Optimal)</string>
<string name="settle_who">Who pays</string>
<string name="settle_to_whom">To whom</string>
<string name="settle_how_much">Amount</string>
<string name="simple_settle_share">Share</string>
<string name="simple_create_bills">Create bills</string>
<string name="settle_bill_what">Settlement</string>
<!-- Currencies -->
<string name="currency_dialog_title">Choose Currency (%s)</string>
<string name="setting_none">None</string>
<string name="setting_all">All</string>
<string name="currency_saved_success">Currency settings saved.</string>
<string name="main_currency">Main Currency</string>
<!-- Statistics -->
<string name="label_bills_suggested">Suggested Categories</string>
<string name="label_bills_skip">Skip</string>
<string name="stats_date_min">From</string>
<string name="stats_date_max">To</string>
<string name="stats_who">Member</string>
<string name="stats_paid">Paid</string>
<string name="stats_spent">Spent</string>
<string name="stats_balance">Balance</string>
<string name="total">Total: %1$s</string>
<!-- Errors Extra -->
<string name="error_project_connect_check">Connection failed: %1$s</string>
<string name="error_create_remote_project_helper">Creation failed: %1$s</string>
<string name="error_edit_remote_project_helper">Error updating remote project: %1$s</string>
<string name="remote_project_operation_no_network">Network unavailable for remote operation.</string>
<string name="error_scanning_bill_qr_code">Failed to parse QR code.</string>
<string name="error_token_mismatch">Authentication token mismatch. Please log in again.</string>
<string name="insufficient_access_level">You don\'t have permission to perform this action.</string>
<string name="delete_label_confirmation_title">Delete Label</string>
<string name="delete_label_confirmation_message">Are you sure you want to delete this label?</string>
<!-- About -->
<string name="about_version">Version %1$s</string>
<string name="about_maintainer_title">Maintainer</string>
<string name="about_maintainer" translatable="false">Helcel.net</string>
<string name="about_license_title">License</string>
<string name="about_license" translatable="false">GNU GPL3x / CC BY-SA 4.0</string>
<string name="about_source_title">Source code</string>
<string name="about_source" translatable="false">https://github.com/helcel-net/cowspent</string>
<!-- New constants for backward compatibility or shared use -->
<string name="share_intent_title">Project %1$s</string>
<string name="share_chooser_title">Share %1$s</string>
</resources>
+1 -1
View File
@@ -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
-2
View File
@@ -19,8 +19,6 @@ This means you can choose where your data is going and preserve your privacy.
* Dark theme and customizable main app color
* Share/import projects with link/QRCode
* Connect to a Nextcloud account to automatically add projects
* Background sync service with notifications on bills events
* Multi-lingual user-interface (translated on Crowdin: https://crowdin.com/project/cowspent)
# Requirements
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB