diff --git a/.github/images/apk.png b/.github/images/apk.png
new file mode 100644
index 0000000..d7850af
Binary files /dev/null and b/.github/images/apk.png differ
diff --git a/.github/images/izzy.png b/.github/images/izzy.png
new file mode 100644
index 0000000..af5bf5b
Binary files /dev/null and b/.github/images/izzy.png differ
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 82c5eaa..50903f1 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -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
diff --git a/README.md b/README.md
index e2a45dc..8017486 100644
--- a/README.md
+++ b/README.md
@@ -1,9 +1,10 @@
Cowspent
-

+
+
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).
diff --git a/app/build.gradle b/app/build.gradle
index 9a1a5aa..d195e9e 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -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"
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 67a0b67..2e082dc 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -20,11 +20,14 @@
android:supportsRtl="true"
android:theme="@style/AppTheme">
-
+
@@ -43,9 +46,9 @@
@@ -64,9 +67,9 @@
@@ -91,36 +94,48 @@
+
+
+
+
diff --git a/app/src/main/java/net/helcel/cowspent/android/about/AboutActivity.kt b/app/src/main/java/net/helcel/cowspent/android/about/AboutActivity.kt
index a0606b1..a8c09d6 100644
--- a/app/src/main/java/net/helcel/cowspent/android/about/AboutActivity.kt
+++ b/app/src/main/java/net/helcel/cowspent/android/about/AboutActivity.kt
@@ -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 {
diff --git a/app/src/main/java/net/helcel/cowspent/android/about/AboutScreen.kt b/app/src/main/java/net/helcel/cowspent/android/about/AboutScreen.kt
index e9ae939..aa34814 100644
--- a/app/src/main/java/net/helcel/cowspent/android/about/AboutScreen.kt
+++ b/app/src/main/java/net/helcel/cowspent/android/about/AboutScreen.kt
@@ -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)
diff --git a/app/src/main/java/net/helcel/cowspent/android/account/AccountActivity.kt b/app/src/main/java/net/helcel/cowspent/android/account/AccountActivity.kt
index efe64de..e02bcbc 100644
--- a/app/src/main/java/net/helcel/cowspent/android/account/AccountActivity.kt
+++ b/app/src/main/java/net/helcel/cowspent/android/account/AccountActivity.kt
@@ -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)
}
diff --git a/app/src/main/java/net/helcel/cowspent/android/account/AccountScreen.kt b/app/src/main/java/net/helcel/cowspent/android/account/AccountScreen.kt
index 9c7c213..c6e25d0 100644
--- a/app/src/main/java/net/helcel/cowspent/android/account/AccountScreen.kt
+++ b/app/src/main/java/net/helcel/cowspent/android/account/AccountScreen.kt
@@ -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(
- text = serverUrl,
- style = MaterialTheme.typography.body2,
- color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
+ 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.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))
}
}
}
diff --git a/app/src/main/java/net/helcel/cowspent/android/account/AccountViewModel.kt b/app/src/main/java/net/helcel/cowspent/android/account/AccountViewModel.kt
index 8966d0c..c08b63f 100644
--- a/app/src/main/java/net/helcel/cowspent/android/account/AccountViewModel.kt
+++ b/app/src/main/java/net/helcel/cowspent/android/account/AccountViewModel.kt
@@ -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)
diff --git a/app/src/main/java/net/helcel/cowspent/android/account/LoginDialog.kt b/app/src/main/java/net/helcel/cowspent/android/account/LoginDialog.kt
index c630148..1b45f1c 100644
--- a/app/src/main/java/net/helcel/cowspent/android/account/LoginDialog.kt
+++ b/app/src/main/java/net/helcel/cowspent/android/account/LoginDialog.kt
@@ -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)
)
diff --git a/app/src/main/java/net/helcel/cowspent/android/bill_edit/EditBillActivity.kt b/app/src/main/java/net/helcel/cowspent/android/bill_edit/EditBillActivity.kt
index 4681bfc..e29c411 100644
--- a/app/src/main/java/net/helcel/cowspent/android/bill_edit/EditBillActivity.kt
+++ b/app/src/main/java/net/helcel/cowspent/android/bill_edit/EditBillActivity.kt
@@ -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()
@@ -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 = 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)
diff --git a/app/src/main/java/net/helcel/cowspent/android/bill_edit/EditBillScreen.kt b/app/src/main/java/net/helcel/cowspent/android/bill_edit/EditBillScreen.kt
index 242356c..42fddcc 100644
--- a/app/src/main/java/net/helcel/cowspent/android/bill_edit/EditBillScreen.kt
+++ b/app/src/main/java/net/helcel/cowspent/android/bill_edit/EditBillScreen.kt
@@ -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()) {
- viewModel.showDialog(
- title = currencyDialogTitle,
- items = viewModel.currencies.map { "${it.name} (${it.exchangeRate})" },
- onItemSelected = { index ->
- viewModel.convertCurrency(viewModel.currencies[index])
- }
- )
- } else {
- showToast(context, noCurrencyError)
+ val mainLabel = viewModel.mainCurrencyName.ifEmpty { "$" }
+ val options = listOf("$mainLabel | Base") + viewModel.currencies.map {
+ "${it.name} | 1 $mainLabel = ${it.exchangeRate} ${it.name}"
}
+ viewModel.showDialog(
+ title = currencyDialogTitle,
+ items = options,
+ onItemSelected = { index ->
+ if (index == 0) {
+ viewModel.resetCurrency()
+ } else {
+ viewModel.convertCurrency(viewModel.currencies[index - 1])
+ }
+ }
+ )
}
) {
- Icon(Icons.Default.CurrencyExchange, contentDescription = null)
+ Icon(Icons.Default.SwapHoriz, contentDescription = "Change Currency")
}
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
@@ -221,7 +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,
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.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.categoryRemoteId = category.remoteId.toInt()
+ 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)
diff --git a/app/src/main/java/net/helcel/cowspent/android/bill_edit/EditBillViewModel.kt b/app/src/main/java/net/helcel/cowspent/android/bill_edit/EditBillViewModel.kt
index 34f0592..16918d8 100644
--- a/app/src/main/java/net/helcel/cowspent/android/bill_edit/EditBillViewModel.kt
+++ b/app/src/main/java/net/helcel/cowspent/android/bill_edit/EditBillViewModel.kt
@@ -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>(emptyList())
var mainCurrencyName by mutableStateOf("")
+ var selectedCurrencyName by mutableStateOf("")
+ var selectedCurrencyRate by mutableDoubleStateOf(1.0)
var members by mutableStateOf>(emptyList())
var owersSelection = mutableStateMapOf()
@@ -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
-
- val newAmount = originalAmount / currency.exchangeRate
- amount = SupportUtil.round2(newAmount).toString()
-
- val currencyLabel = currency.name ?: ""
- val conversionNote = "($originalAmountStr $currencyLabel)"
- if (!comment.contains(conversionNote)) {
- if (comment.isNotEmpty() && !comment.endsWith(" ")) {
- comment += " "
- }
- comment += conversionNote
- }
-
- if (isCustomSplit) {
- owersCustomSplit.keys.toList().forEach { id ->
- val value = owersCustomSplit[id] ?: ""
- val partAmount = value.replace(',', '.').toDoubleOrNull() ?: 0.0
- if (partAmount != 0.0) {
- val newPartAmount = partAmount / currency.exchangeRate
- owersCustomSplit[id] = SupportUtil.round2(newPartAmount).toString()
- }
- }
- }
-
+ selectedCurrencyName = currency.name ?: ""
+ selectedCurrencyRate = currency.exchangeRate
+
+ // We don't change 'amount' here anymore as per request.
+ // It stays as the original amount.
+
updateSplits()
}
+ fun resetCurrency() {
+ selectedCurrencyName = ""
+ selectedCurrencyRate = 1.0
+ updateSplits()
+ }
+
+ fun getFinalAmount(): Double {
+ return SupportUtil.round2(amountAsDouble / selectedCurrencyRate)
+ }
+
+ fun getFinalComment(): String {
+ var baseComment = comment
+ val regex = "\\s*#[^:]+:[\\d.,]+@[\\d.,]+".toRegex()
+ baseComment = baseComment.replace(regex, "").trim()
+
+ return if (selectedCurrencyName.isNotEmpty() && selectedCurrencyRate != 1.0) {
+ val metadata = "#$selectedCurrencyName:$amount@$selectedCurrencyRate"
+ if (baseComment.isEmpty()) metadata else "$baseComment $metadata"
+ } else {
+ baseComment
+ }
+ }
+
fun initFromBill(bill: DBBill, members: List, customSplits: Map? = 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) {
diff --git a/app/src/main/java/net/helcel/cowspent/android/bill_label/LabelBillsActivity.kt b/app/src/main/java/net/helcel/cowspent/android/bill_label/LabelBillsActivity.kt
index 6345092..681fd8e 100644
--- a/app/src/main/java/net/helcel/cowspent/android/bill_label/LabelBillsActivity.kt
+++ b/app/src/main/java/net/helcel/cowspent/android/bill_label/LabelBillsActivity.kt
@@ -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()
diff --git a/app/src/main/java/net/helcel/cowspent/android/bill_label/LabelBillsScreen.kt b/app/src/main/java/net/helcel/cowspent/android/bill_label/LabelBillsScreen.kt
index cf1263a..4e1889b 100644
--- a/app/src/main/java/net/helcel/cowspent/android/bill_label/LabelBillsScreen.kt
+++ b/app/src/main/java/net/helcel/cowspent/android/bill_label/LabelBillsScreen.kt
@@ -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),
diff --git a/app/src/main/java/net/helcel/cowspent/android/bill_label/LabelBillsViewModel.kt b/app/src/main/java/net/helcel/cowspent/android/bill_label/LabelBillsViewModel.kt
index af27cc4..194f575 100644
--- a/app/src/main/java/net/helcel/cowspent/android/bill_label/LabelBillsViewModel.kt
+++ b/app/src/main/java/net/helcel/cowspent/android/bill_label/LabelBillsViewModel.kt
@@ -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
diff --git a/app/src/main/java/net/helcel/cowspent/android/currencies/ManageCurrenciesActivity.kt b/app/src/main/java/net/helcel/cowspent/android/currencies/ManageCurrenciesActivity.kt
index a0c711b..a49f441 100644
--- a/app/src/main/java/net/helcel/cowspent/android/currencies/ManageCurrenciesActivity.kt
+++ b/app/src/main/java/net/helcel/cowspent/android/currencies/ManageCurrenciesActivity.kt
@@ -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,11 +91,17 @@ class ManageCurrenciesActivity : AppCompatActivity() {
val project = db!!.getProject(selectedProjectID)
if (project != null) {
db!!.syncIfRemote(project)
- withContext(Dispatchers.Main) {
- if (!db!!.cowspentServerSyncHelper
- .editRemoteProject(selectedProjectID, project.name, null, null, newMainCurrencyName, editMainCurrencyCallBack)
- ) {
- showToast(this@ManageCurrenciesActivity, getString(R.string.remote_project_operation_no_network), Toast.LENGTH_LONG)
+ if (project.type == ProjectType.COSPEND) {
+ withContext(Dispatchers.Main) {
+ if (!db!!.cowspentServerSyncHelper
+ .editRemoteProject(selectedProjectID, project.name, null, null, newMainCurrencyName, editMainCurrencyCallBack)
+ ) {
+ showToast(this@ManageCurrenciesActivity, getString(R.string.remote_project_operation_no_network), Toast.LENGTH_LONG)
+ }
+ }
+ } else {
+ withContext(Dispatchers.Main) {
+ showToast(this@ManageCurrenciesActivity, getString(R.string.currency_saved_success), Toast.LENGTH_LONG)
}
}
}
@@ -96,22 +109,44 @@ class ManageCurrenciesActivity : AppCompatActivity() {
}
}
- private fun addCurrency() {
+ private fun addOrUpdateCurrency() {
val exchangeRate = try { viewModel.newCurrencyRate.toDouble() } catch (_: Exception) { 0.0 }
- val newCurrency = DBCurrency(
- 0, 0, selectedProjectID,
- viewModel.newCurrencyName, exchangeRate, DBBill.STATE_ADDED
- )
+ val currencyName = viewModel.newCurrencyName
+ val editingId = viewModel.editingCurrencyId
+
lifecycleScope.launch {
withContext(Dispatchers.IO) {
- db!!.addCurrencyAndSync(newCurrency)
+ if (editingId != null) {
+ db!!.updateCurrency(editingId, currencyName, exchangeRate)
+ val currency = db!!.getCurrency(editingId)
+ if (currency != null) {
+ db!!.setCurrencyStateSync(editingId, DBBill.STATE_EDITED)
+ }
+ } else {
+ val newCurrency = DBCurrency(
+ 0, 0, selectedProjectID,
+ currencyName, exchangeRate, DBBill.STATE_ADDED
+ )
+ db!!.addCurrencyAndSync(newCurrency)
+ }
}
- viewModel.newCurrencyName = ""
- viewModel.newCurrencyRate = ""
+ cancelEditing()
updateCurrenciesList()
}
}
+ private fun startEditing(currency: DBCurrency) {
+ viewModel.editingCurrencyId = currency.id
+ viewModel.newCurrencyName = currency.name ?: ""
+ viewModel.newCurrencyRate = currency.exchangeRate.toString()
+ }
+
+ private fun cancelEditing() {
+ viewModel.editingCurrencyId = null
+ viewModel.newCurrencyName = ""
+ viewModel.newCurrencyRate = ""
+ }
+
private fun deleteCurrency(currency: DBCurrency) {
lifecycleScope.launch {
withContext(Dispatchers.IO) {
diff --git a/app/src/main/java/net/helcel/cowspent/android/currencies/ManageCurrenciesScreen.kt b/app/src/main/java/net/helcel/cowspent/android/currencies/ManageCurrenciesScreen.kt
index 346fe2a..030477a 100644
--- a/app/src/main/java/net/helcel/cowspent/android/currencies/ManageCurrenciesScreen.kt
+++ b/app/src/main/java/net/helcel/cowspent/android/currencies/ManageCurrenciesScreen.kt
@@ -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))
) {
- Text(stringResource(R.string.main_currency), style = MaterialTheme.typography.h6)
- Row(verticalAlignment = Alignment.CenterVertically) {
- OutlinedTextField(
- value = viewModel.mainCurrencyName,
- onValueChange = { viewModel.mainCurrencyName = it },
- label = { Text(stringResource(R.string.currency_edit_name)) },
- modifier = Modifier.weight(1f)
- )
- Spacer(modifier = Modifier.width(8.dp))
- Button(onClick = onSaveMain, enabled = viewModel.mainCurrencyName.isNotEmpty()) {
- Text(stringResource(R.string.save_or_discard_bill_dialog_save))
+ LaunchedEffect(viewModel.mainCurrencyName) {
+ if (viewModel.mainCurrencyName.isNotEmpty()) {
+ delay(500.milliseconds)
+ onSaveMain()
}
}
- Spacer(modifier = Modifier.height(24.dp))
-
- Text(stringResource(R.string.add_currency_title), style = MaterialTheme.typography.h6)
- Row(verticalAlignment = Alignment.CenterVertically) {
- OutlinedTextField(
- value = viewModel.newCurrencyName,
- onValueChange = { viewModel.newCurrencyName = it },
- label = { Text(stringResource(R.string.currency_edit_name)) },
- modifier = Modifier.weight(1f)
+ Column(modifier = Modifier.padding(16.dp)) {
+ // Section 1: Main Currency
+ Text(
+ text = stringResource(R.string.main_currency).uppercase(),
+ style = MaterialTheme.typography.subtitle1,
+ color = MaterialTheme.colors.onSurface,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.padding(bottom = 8.dp)
)
- Spacer(modifier = Modifier.width(8.dp))
- OutlinedTextField(
- value = viewModel.newCurrencyRate,
- onValueChange = { viewModel.newCurrencyRate = it },
- label = { Text(stringResource(R.string.currency_rate)) },
- modifier = Modifier.weight(1f)
+ Card(
+ shape = RoundedCornerShape(12.dp),
+ elevation = 2.dp,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ OutlinedTextField(
+ value = viewModel.mainCurrencyName,
+ onValueChange = { viewModel.mainCurrencyName = it },
+ modifier = Modifier.fillMaxWidth(),
+ placeholder = { Text("e.g. EUR", fontSize = 14.sp) },
+ colors = TextFieldDefaults.outlinedTextFieldColors(
+ focusedBorderColor = Color.Transparent,
+ unfocusedBorderColor = Color.Transparent
+ )
+ )
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // Section 2: Exchange Rates
+ val isEditing = viewModel.editingCurrencyId != null
+ Text(
+ text = (if (isEditing) "Edit exchange rate" else "Add exchange rate").uppercase(),
+ style = MaterialTheme.typography.subtitle1,
+ color = MaterialTheme.colors.onSurface,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.padding(bottom = 8.dp)
+ )
+
+ // Integrated Add/Edit Ribbon
+ Surface(
+ color = MaterialTheme.colors.surface,
+ shape = RoundedCornerShape(12.dp),
+ modifier = Modifier.fillMaxWidth(),
+ elevation = 2.dp
+ ) {
+ Row(
+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text("1 ", style = MaterialTheme.typography.body2, color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f))
+ Text(viewModel.mainCurrencyName.ifEmpty { "$" }, fontWeight = FontWeight.Bold)
+ Text(
+ text = " = ",
+ style = MaterialTheme.typography.h6,
+ color = if (isEditing) MaterialTheme.colors.secondary else MaterialTheme.colors.primary,
+ modifier = Modifier.padding(horizontal = 4.dp)
+ )
+
+ OutlinedTextField(
+ value = viewModel.newCurrencyRate,
+ onValueChange = { viewModel.newCurrencyRate = it },
+ modifier = Modifier.weight(1.2f).height(52.dp),
+ placeholder = { Text("1", fontSize = 12.sp) },
+ singleLine = true,
+ textStyle = LocalTextStyle.current.copy(fontWeight = FontWeight.ExtraBold, fontSize = 14.sp),
+ shape = RoundedCornerShape(8.dp),
+ colors = TextFieldDefaults.outlinedTextFieldColors(
+ focusedBorderColor = if (isEditing) MaterialTheme.colors.secondary else MaterialTheme.colors.primary,
+ unfocusedBorderColor = MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
+ backgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.02f)
+ )
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ OutlinedTextField(
+ value = viewModel.newCurrencyName,
+ onValueChange = { viewModel.newCurrencyName = it },
+ modifier = Modifier.weight(1f).height(52.dp),
+ placeholder = { Text(viewModel.mainCurrencyName.ifEmpty { "$" }, fontSize = 12.sp) },
+ singleLine = true,
+ textStyle = LocalTextStyle.current.copy(fontWeight = FontWeight.Bold, fontSize = 14.sp),
+ shape = RoundedCornerShape(8.dp),
+ colors = TextFieldDefaults.outlinedTextFieldColors(
+ focusedBorderColor = if (isEditing) MaterialTheme.colors.secondary else MaterialTheme.colors.primary,
+ unfocusedBorderColor = MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
+ backgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.02f)
+ )
+ )
+
+ Row(modifier = Modifier.padding(start = 4.dp)) {
+ IconButton(
+ onClick = onAdd,
+ enabled = viewModel.isAddEnabled()
+ ) {
+ Icon(
+ imageVector = if (isEditing) Icons.Default.Done else Icons.Default.Add,
+ contentDescription = null,
+ tint = if (viewModel.isAddEnabled()) {
+ if (isEditing) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
+ } else MaterialTheme.colors.onSurface.copy(alpha = 0.2f)
+ )
+ }
+
+ if (isEditing) {
+ IconButton(onClick = onCancelEdit) {
+ Icon(Icons.Default.Close, contentDescription = null, tint = MaterialTheme.colors.error)
+ }
+ }
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Text(
+ text = "SAVED RATES",
+ style = MaterialTheme.typography.subtitle1,
+ color = MaterialTheme.colors.onSurface,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.padding(bottom = 8.dp)
)
}
- Spacer(modifier = Modifier.height(8.dp))
- Button(
- onClick = onAdd,
- modifier = Modifier.fillMaxWidth(),
- enabled = viewModel.isAddEnabled()
+
+ LazyColumn(
+ modifier = Modifier.weight(1f).fillMaxWidth(),
+ contentPadding = PaddingValues(16.dp, 0.dp, 16.dp, 16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
) {
- Text(stringResource(R.string.simple_add))
- }
-
- Spacer(modifier = Modifier.height(24.dp))
-
- LazyColumn(modifier = Modifier.weight(1f)) {
items(viewModel.currencies) { currency ->
- CurrencyRow(currency, onDelete = { onDelete(currency) })
+ CurrencyRow(
+ currency = currency,
+ mainCurrencyName = viewModel.mainCurrencyName,
+ isEditing = viewModel.editingCurrencyId == currency.id,
+ onEdit = { onEdit(currency) },
+ onDelete = { onDelete(currency) }
+ )
}
}
}
@@ -129,20 +263,70 @@ fun ManageCurrenciesScreen(
}
@Composable
-fun CurrencyRow(currency: DBCurrency, onDelete: () -> Unit) {
- Row(
- modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
- verticalAlignment = Alignment.CenterVertically
+fun CurrencyRow(
+ currency: DBCurrency,
+ mainCurrencyName: String,
+ isEditing: Boolean,
+ onEdit: () -> Unit,
+ onDelete: () -> Unit
+) {
+ Card(
+ shape = RoundedCornerShape(12.dp),
+ elevation = if (isEditing) 4.dp else 1.dp,
+ border = if (isEditing) BorderStroke(1.dp, MaterialTheme.colors.secondary.copy(alpha = 0.5f)) else null,
+ modifier = Modifier.fillMaxWidth()
) {
- Column(modifier = Modifier.weight(1f)) {
- Text(currency.name ?: "", style = MaterialTheme.typography.subtitle1)
- Text("Rate: ${currency.exchangeRate}", style = MaterialTheme.typography.caption)
- }
- IconButton(onClick = onDelete) {
- Icon(Icons.Default.Delete, contentDescription = null, tint = MaterialTheme.colors.error)
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onEdit)
+ .padding(16.dp, 12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Row(
+ modifier = Modifier.weight(1f),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = "1 ",
+ style = MaterialTheme.typography.body2,
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
+ )
+ Text(
+ text = mainCurrencyName.ifEmpty { "$" },
+ style = MaterialTheme.typography.body1,
+ fontWeight = FontWeight.Bold
+ )
+ Text(
+ text = " = ",
+ style = MaterialTheme.typography.h6,
+ color = if (isEditing) MaterialTheme.colors.secondary else MaterialTheme.colors.primary,
+ modifier = Modifier.padding(horizontal = 8.dp)
+ )
+ Text(
+ text = formatAmount(currency.exchangeRate),
+ style = MaterialTheme.typography.body1,
+ fontWeight = FontWeight.ExtraBold,
+ color = if (isEditing) MaterialTheme.colors.secondary else MaterialTheme.colors.onSurface
+ )
+ Text(
+ text = " ${currency.name ?: ""}",
+ style = MaterialTheme.typography.body1,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
+ )
+ }
+
+ IconButton(onClick = onDelete) {
+ Icon(
+ imageVector = Icons.Default.Delete,
+ contentDescription = null,
+ tint = MaterialTheme.colors.error.copy(alpha = 0.7f),
+ modifier = Modifier.size(20.dp)
+ )
+ }
}
}
- Divider()
}
@Preview(showBackground = true)
@@ -151,6 +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 = {}
)
}
}
diff --git a/app/src/main/java/net/helcel/cowspent/android/currencies/ManageCurrenciesViewModel.kt b/app/src/main/java/net/helcel/cowspent/android/currencies/ManageCurrenciesViewModel.kt
index f4e0002..9d80694 100644
--- a/app/src/main/java/net/helcel/cowspent/android/currencies/ManageCurrenciesViewModel.kt
+++ b/app/src/main/java/net/helcel/cowspent/android/currencies/ManageCurrenciesViewModel.kt
@@ -13,6 +13,8 @@ class ManageCurrenciesViewModel : ViewModel() {
var newCurrencyName by mutableStateOf("")
var newCurrencyRate by mutableStateOf("")
+ var editingCurrencyId by mutableStateOf(null)
+
var currencies by mutableStateOf>(emptyList())
var dialogState by mutableStateOf(null)
diff --git a/app/src/main/java/net/helcel/cowspent/android/drawer/Drawer.kt b/app/src/main/java/net/helcel/cowspent/android/drawer/Drawer.kt
index a50188d..24e5bb0 100644
--- a/app/src/main/java/net/helcel/cowspent/android/drawer/Drawer.kt
+++ b/app/src/main/java/net/helcel/cowspent/android/drawer/Drawer.kt
@@ -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 {
+ 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) {
- Image(
- painter = painterResource(id = R.drawable.ic_launcher_foreground),
- contentDescription = null,
- modifier = Modifier.size(48.dp)
- )
+ 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.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(
@@ -294,6 +320,17 @@ fun DrawerItem(
modifier = Modifier.padding(end = 8.dp)
)
}
+
+ if (secondaryText != null) {
+ Box(modifier = Modifier
+ .padding(end = 8.dp)
+ ) {
+ TextIconDisplay(
+ textIcon = TextIcon.Symbol(secondaryText),
+ tint = contentColor.copy(alpha = 0.6f)
+ )
+ }
+ }
if (onSecondaryClick != null) {
IconButton(onClick = onSecondaryClick) {
@@ -340,6 +377,7 @@ fun DrawerPreview() {
selectedProjectId = 1,
selectedMemberId = null,
lastSyncText = "Last sync: 5 mins ago",
+ mainCurrency = "EUR",
onProjectClick = {},
onProjectOptionsClick = {},
onMemberClick = {},
diff --git a/app/src/main/java/net/helcel/cowspent/android/helper/AlertDialog.kt b/app/src/main/java/net/helcel/cowspent/android/helper/AlertDialog.kt
index 55ae77f..99fb016 100644
--- a/app/src/main/java/net/helcel/cowspent/android/helper/AlertDialog.kt
+++ b/app/src/main/java/net/helcel/cowspent/android/helper/AlertDialog.kt
@@ -11,6 +11,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -110,28 +111,55 @@ fun AlertDialog(
if (message != null) Spacer(Modifier.height(8.dp))
LazyColumn {
itemsIndexed(items) { index, item ->
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .clickable {
- onItemSelected?.invoke(index)
+ Column {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable {
+ onItemSelected?.invoke(index)
+ }
+ .padding(vertical = 16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ if (itemIcons != null && index < itemIcons.size) {
+ Icon(
+ itemIcons[index],
+ contentDescription = null,
+ modifier = Modifier.size(24.dp),
+ tint = MaterialTheme.colors.primary.copy(alpha = 0.7f)
+ )
+ Spacer(Modifier.width(16.dp))
+ }
+
+ val text = item.toString()
+ if (text.contains("|")) {
+ val parts = text.split("|")
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = parts[0].trim(),
+ style = MaterialTheme.typography.body1,
+ fontWeight = FontWeight.Bold
+ )
+ Text(
+ text = parts[1].trim(),
+ style = MaterialTheme.typography.body2,
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
+ )
+ }
+ } else {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.body1
+ )
}
- .padding(vertical = 12.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- if (itemIcons != null && index < itemIcons.size) {
- Icon(
- itemIcons[index],
- contentDescription = null,
- modifier = Modifier.size(24.dp),
- tint = MaterialTheme.colors.primary.copy(alpha = 0.7f)
- )
- Spacer(Modifier.width(16.dp))
}
- Text(
- text = item.toString(),
- style = MaterialTheme.typography.body1
- )
+ if (index < items.size - 1) {
+ Divider(color = MaterialTheme.colors.onSurface.copy(alpha = 0.08f))
+ }
}
}
}
@@ -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,28 +249,55 @@ fun AlertDialogContent(
if (message != null) Spacer(Modifier.height(8.dp))
LazyColumn {
itemsIndexed(items) { index, item ->
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .clickable {
- onItemSelected?.invoke(index)
+ Column {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable {
+ onItemSelected?.invoke(index)
+ }
+ .padding(vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ if (itemIcons != null && index < itemIcons.size) {
+ Icon(
+ itemIcons[index],
+ contentDescription = null,
+ modifier = Modifier.size(24.dp),
+ tint = MaterialTheme.colors.primary.copy(alpha = 0.7f)
+ )
+ Spacer(Modifier.width(16.dp))
+ }
+
+ val text = item.toString()
+ if (text.contains("|")) {
+ val parts = text.split("|")
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = parts[0].trim(),
+ style = MaterialTheme.typography.body1,
+ fontWeight = FontWeight.Bold
+ )
+ Text(
+ text = parts[1].trim(),
+ style = MaterialTheme.typography.body2,
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
+ )
+ }
+ } else {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.body1
+ )
}
- .padding(vertical = 8.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- if (itemIcons != null && index < itemIcons.size) {
- Icon(
- itemIcons[index],
- contentDescription = null,
- modifier = Modifier.size(24.dp),
- tint = MaterialTheme.colors.primary.copy(alpha = 0.7f)
- )
- Spacer(Modifier.width(16.dp))
}
- Text(
- text = item.toString(),
- style = MaterialTheme.typography.body1
- )
+ if (index < items.size - 1) {
+ Divider(color = MaterialTheme.colors.onSurface.copy(alpha = 0.08f))
+ }
}
}
}
diff --git a/app/src/main/java/net/helcel/cowspent/android/helper/ColorPicker.kt b/app/src/main/java/net/helcel/cowspent/android/helper/ColorPicker.kt
index 12855ad..0c9ec90 100644
--- a/app/src/main/java/net/helcel/cowspent/android/helper/ColorPicker.kt
+++ b/app/src/main/java/net/helcel/cowspent/android/helper/ColorPicker.kt
@@ -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",
diff --git a/app/src/main/java/net/helcel/cowspent/android/helper/FormatUtils.kt b/app/src/main/java/net/helcel/cowspent/android/helper/FormatUtils.kt
index ae57271..9d46c80 100644
--- a/app/src/main/java/net/helcel/cowspent/android/helper/FormatUtils.kt
+++ b/app/src/main/java/net/helcel/cowspent/android/helper/FormatUtils.kt
@@ -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()
+ }
+ }
}
diff --git a/app/src/main/java/net/helcel/cowspent/android/helper/IconUtils.kt b/app/src/main/java/net/helcel/cowspent/android/helper/IconUtils.kt
new file mode 100644
index 0000000..e0a2908
--- /dev/null
+++ b/app/src/main/java/net/helcel/cowspent/android/helper/IconUtils.kt
@@ -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
+ )
+ }
+}
diff --git a/app/src/main/java/net/helcel/cowspent/android/helper/QrCodeScannerActivity.kt b/app/src/main/java/net/helcel/cowspent/android/helper/QrCodeScannerActivity.kt
index 2c87df7..3837685 100644
--- a/app/src/main/java/net/helcel/cowspent/android/helper/QrCodeScannerActivity.kt
+++ b/app/src/main/java/net/helcel/cowspent/android/helper/QrCodeScannerActivity.kt
@@ -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)
diff --git a/app/src/main/java/net/helcel/cowspent/android/helper/UserAvatar.kt b/app/src/main/java/net/helcel/cowspent/android/helper/UserAvatar.kt
index 30bd1c8..3a61358 100644
--- a/app/src/main/java/net/helcel/cowspent/android/helper/UserAvatar.kt
+++ b/app/src/main/java/net/helcel/cowspent/android/helper/UserAvatar.kt
@@ -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() {
diff --git a/app/src/main/java/net/helcel/cowspent/android/label/LabelManagementActivity.kt b/app/src/main/java/net/helcel/cowspent/android/label/LabelManagementActivity.kt
new file mode 100644
index 0000000..d6bfad0
--- /dev/null
+++ b/app/src/main/java/net/helcel/cowspent/android/label/LabelManagementActivity.kt
@@ -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)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/net/helcel/cowspent/android/label/LabelManagementScreen.kt b/app/src/main/java/net/helcel/cowspent/android/label/LabelManagementScreen.kt
new file mode 100644
index 0000000..dcfedf8
--- /dev/null
+++ b/app/src/main/java/net/helcel/cowspent/android/label/LabelManagementScreen.kt
@@ -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,
+ paymentModes: List,
+ 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(null) }
+ var editingPaymentMode by remember { mutableStateOf(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,
+ 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,
+ 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
+ )
+ }
+}
diff --git a/app/src/main/java/net/helcel/cowspent/android/label/LabelManagementViewModel.kt b/app/src/main/java/net/helcel/cowspent/android/label/LabelManagementViewModel.kt
new file mode 100644
index 0000000..733eeed
--- /dev/null
+++ b/app/src/main/java/net/helcel/cowspent/android/label/LabelManagementViewModel.kt
@@ -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>(emptyList())
+ var paymentModes by mutableStateOf>(emptyList())
+
+ var dialogState by mutableStateOf(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)
+ }
+ }
+}
diff --git a/app/src/main/java/net/helcel/cowspent/android/main/BillsListComponents.kt b/app/src/main/java/net/helcel/cowspent/android/main/BillsListComponents.kt
index cb855b2..5935ecc 100644
--- a/app/src/main/java/net/helcel/cowspent/android/main/BillsListComponents.kt
+++ b/app/src/main/java/net/helcel/cowspent/android/main/BillsListComponents.kt
@@ -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,13 +188,16 @@ fun SectionHeader(title: String) {
color = MaterialTheme.colors.background,
modifier = Modifier.fillMaxWidth()
) {
- Divider(thickness = 2.dp)
- Text(
- text = title,
- modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
- style = MaterialTheme.typography.caption.copy(fontWeight = FontWeight.Bold),
- color = MaterialTheme.colors.primary
- )
+ Column {
+ Divider(thickness = 1.dp, color = MaterialTheme.colors.onSurface.copy(alpha = 0.08f))
+ Text(
+ text = title.uppercase(),
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
+ style = MaterialTheme.typography.subtitle1,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colors.onSurface
+ )
+ }
}
}
@@ -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)
+ )
}
}
diff --git a/app/src/main/java/net/helcel/cowspent/android/main/BillsListScreen.kt b/app/src/main/java/net/helcel/cowspent/android/main/BillsListScreen.kt
index 283f037..bf4d881 100644
--- a/app/src/main/java/net/helcel/cowspent/android/main/BillsListScreen.kt
+++ b/app/src/main/java/net/helcel/cowspent/android/main/BillsListScreen.kt
@@ -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 }
- )
- }
+ 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
- }
- )
- }
+ viewModel.showAddMemberDialogByProjectId = null
}
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
- }
- )
- }
- }
+ viewModel.showEditMemberDialogByProjectId = null
}
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
diff --git a/app/src/main/java/net/helcel/cowspent/android/main/BillsListUtils.kt b/app/src/main/java/net/helcel/cowspent/android/main/BillsListUtils.kt
index b0eee62..05f6177 100644
--- a/app/src/main/java/net/helcel/cowspent/android/main/BillsListUtils.kt
+++ b/app/src/main/java/net/helcel/cowspent/android/main/BillsListUtils.kt
@@ -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
) {
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)
diff --git a/app/src/main/java/net/helcel/cowspent/android/main/BillsListViewActivity.kt b/app/src/main/java/net/helcel/cowspent/android/main/BillsListViewActivity.kt
index 06df8f1..c089426 100644
--- a/app/src/main/java/net/helcel/cowspent/android/main/BillsListViewActivity.kt
+++ b/app/src/main/java/net/helcel/cowspent/android/main/BillsListViewActivity.kt
@@ -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))
- }
+ val intent = Intent(applicationContext, EditProjectActivity::class.java).apply {
+ putExtra(EditProjectActivity.PARAM_PROJECT_ID, projectId)
}
+ editProjectLauncher.launch(intent)
}
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()
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
diff --git a/app/src/main/java/net/helcel/cowspent/android/project/ProjectImportHelper.kt b/app/src/main/java/net/helcel/cowspent/android/project/ProjectImportHelper.kt
index 9ef41cc..e63f12e 100644
--- a/app/src/main/java/net/helcel/cowspent/android/project/ProjectImportHelper.kt
+++ b/app/src/main/java/net/helcel/cowspent/android/project/ProjectImportHelper.kt
@@ -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
@@ -149,9 +149,17 @@ object ProjectImportHelper {
val memberNameToId = mutableMapOf()
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()
+ paymentModes.forEach {
+ val newId = db.addPaymentMode(DBPaymentMode(0, it.remoteId, pid, it.name, it.icon, it.color))
+ pmRemoteToLocal[it.remoteId] = newId
+ }
+ val catRemoteToLocal = mutableMapOf()
+ 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) }
}
diff --git a/app/src/main/java/net/helcel/cowspent/android/project/ProjectOptionsDialog.kt b/app/src/main/java/net/helcel/cowspent/android/project/ProjectOptionsDialog.kt
index 9d39a8b..13e2cfb 100644
--- a/app/src/main/java/net/helcel/cowspent/android/project/ProjectOptionsDialog.kt
+++ b/app/src/main/java/net/helcel/cowspent/android/project/ProjectOptionsDialog.kt
@@ -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()
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()
+ val row2 = mutableListOf()
+ val row3 = mutableListOf()
+
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))
+ }
+ 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 {
+ row1.add(ProjectOption(stringResource(R.string.action_delete), Icons.Default.Delete, onRemoveProject))
}
- 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) {
+ // 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 = options[i + 1],
+ option = option,
modifier = Modifier.weight(1f)
)
- } else {
- Spacer(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
)
}
}
diff --git a/app/src/main/java/net/helcel/cowspent/android/project/ProjectShareDialog.kt b/app/src/main/java/net/helcel/cowspent/android/project/ProjectShareDialog.kt
index ade833d..299bff8 100644
--- a/app/src/main/java/net/helcel/cowspent/android/project/ProjectShareDialog.kt
+++ b/app/src/main/java/net/helcel/cowspent/android/project/ProjectShareDialog.kt
@@ -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(
+ 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,
- fontWeight = FontWeight.Bold,
- color = MaterialTheme.colors.onSurface.copy(alpha = 0.8f)
- )
- }
+ Text(
+ text = title.uppercase(),
+ style = MaterialTheme.typography.subtitle1,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colors.onSurface,
+ modifier = Modifier.padding(bottom = 8.dp)
+ )
- if (qrBitmap != null) {
- Spacer(modifier = Modifier.height(8.dp))
+ if (qrBitmap != null) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 12.dp),
+ contentAlignment = Alignment.Center
+ ) {
Box(
- modifier = Modifier.size(128.dp)
+ modifier = Modifier
+ .size(128.dp)
.clip(MaterialTheme.shapes.small)
.background(Color.White),
contentAlignment = Alignment.Center
@@ -209,55 +182,53 @@ 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,
- modifier = Modifier.fillMaxWidth()
+ Surface(
+ shape = MaterialTheme.shapes.small,
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.05f),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Row(
+ modifier = Modifier
+ .clickable { onUrlClick() }
+ .padding(start = 12.dp, end = 4.dp),
+ verticalAlignment = Alignment.CenterVertically
) {
- Row(
- modifier = Modifier
- .clickable { onUrlClick() }
- .padding(start = 12.dp, end = 4.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- Text(
- text = url,
- modifier = Modifier.weight(1f),
- color = MaterialTheme.colors.primary,
- style = MaterialTheme.typography.body2,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- fontWeight = FontWeight.Medium
+ Text(
+ text = url,
+ modifier = Modifier.weight(1f),
+ color = MaterialTheme.colors.primary,
+ style = MaterialTheme.typography.body2,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ fontWeight = FontWeight.Medium
+ )
+ IconButton(onClick = onCopyClick) {
+ Icon(
+ Icons.Default.ContentCopy,
+ contentDescription = "Copy",
+ modifier = Modifier.size(18.dp),
+ tint = MaterialTheme.colors.onSurface.copy(alpha = 0.4f)
)
- IconButton(onClick = onCopyClick) {
- Icon(
- Icons.Default.ContentCopy,
- contentDescription = "Copy",
- modifier = Modifier.size(18.dp),
- tint = colorResource(R.color.fg_default_low)
- )
- }
}
}
+ }
- 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,
- lineHeight = 16.sp,
- modifier = Modifier.padding(horizontal = 8.dp)
- )
- }
+ CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
+ Text(
+ text = description,
+ style = MaterialTheme.typography.caption,
+ textAlign = TextAlign.Start,
+ lineHeight = 16.sp,
+ modifier = Modifier.padding(horizontal = 4.dp)
+ )
}
}
}
@@ -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
)
diff --git a/app/src/main/java/net/helcel/cowspent/android/project/create/NewProjectActivity.kt b/app/src/main/java/net/helcel/cowspent/android/project/create/NewProjectActivity.kt
index 92f58a6..b5be7bc 100644
--- a/app/src/main/java/net/helcel/cowspent/android/project/create/NewProjectActivity.kt
+++ b/app/src/main/java/net/helcel/cowspent/android/project/create/NewProjectActivity.kt
@@ -5,6 +5,7 @@ import android.net.Uri
import android.os.Bundle
import android.util.Patterns
import android.widget.Toast
+import androidx.activity.enableEdgeToEdge
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
@@ -20,6 +21,7 @@ import net.helcel.cowspent.model.*
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.theme.ThemeUtils
import net.helcel.cowspent.util.*
+import java.util.Locale
import androidx.core.net.toUri
import androidx.core.content.edit
import net.helcel.cowspent.model.ProjectType
@@ -33,6 +35,7 @@ class NewProjectActivity : AppCompatActivity() {
private lateinit var db: CowspentSQLiteOpenHelper
override fun onCreate(savedInstanceState: Bundle?) {
+ enableEdgeToEdge()
super.onCreate(savedInstanceState)
db = CowspentSQLiteOpenHelper.getInstance(this)
@@ -58,7 +61,8 @@ class NewProjectActivity : AppCompatActivity() {
chooseFromNextcloud()
},
onOkPressed = { onPressOk() },
- onBack = { finish() }
+ onBack = { finish() },
+ onFieldsChanged = { updateAuthStatus() }
)
}
}
@@ -83,6 +87,8 @@ class NewProjectActivity : AppCompatActivity() {
viewModel.projectUrl = defaultNcUrl
}
}
+
+ updateAuthStatus()
val defaultProjectId = intent.getStringExtra(PARAM_DEFAULT_PROJECT_ID)
if (defaultProjectId != null) {
@@ -137,6 +143,17 @@ class NewProjectActivity : AppCompatActivity() {
}
}
+ private fun updateAuthStatus() {
+ val url = getFormattedUrl()
+ val fakeProj = DBProject(
+ 0, "", "", "", url,
+ "", 0L, viewModel.projectType, 0L,
+ null, false, DBProject.ACCESS_LEVEL_UNKNOWN,
+ ""
+ )
+ viewModel.isAuthenticatedAccount = db.cowspentServerSyncHelper.canCreateAuthenticatedProject(fakeProj)
+ }
+
private fun onPressOk() {
val type = viewModel.projectType
val todoCreate = viewModel.whatTodoIsCreate
@@ -184,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
}
diff --git a/app/src/main/java/net/helcel/cowspent/android/project/create/NewProjectScreen.kt b/app/src/main/java/net/helcel/cowspent/android/project/create/NewProjectScreen.kt
index bceb66b..cfe97d7 100644
--- a/app/src/main/java/net/helcel/cowspent/android/project/create/NewProjectScreen.kt
+++ b/app/src/main/java/net/helcel/cowspent/android/project/create/NewProjectScreen.kt
@@ -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,42 +158,47 @@ fun NewProjectScreen(
Spacer(modifier = Modifier.height(8.dp))
}
- OutlinedTextField(
- value = viewModel.projectId,
- onValueChange = { viewModel.projectId = it },
- label = { Text(stringResource(R.string.setting_project_id)) },
- modifier = Modifier.fillMaxWidth(),
- leadingIcon = { Icon(Icons.AutoMirrored.Filled.LibraryBooks, contentDescription = null) }
- )
+ if (!viewModel.whatTodoIsCreate || !viewModel.isAuthenticatedAccount || viewModel.projectType != ProjectType.COSPEND) {
+ OutlinedTextField(
+ value = viewModel.projectId,
+ onValueChange = { viewModel.projectId = it },
+ 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) }
)
- Spacer(modifier = Modifier.height(8.dp))
- OutlinedTextField(
- value = viewModel.projectEmail,
- onValueChange = { viewModel.projectEmail = it },
- label = { Text(stringResource(R.string.setting_new_project_email)) },
- modifier = Modifier.fillMaxWidth(),
- leadingIcon = { Icon(Icons.Default.Email, contentDescription = null) }
- )
+
+ if (!viewModel.isAuthenticatedAccount || viewModel.projectType == ProjectType.IHATEMONEY) {
+ Spacer(modifier = Modifier.height(8.dp))
+ OutlinedTextField(
+ value = viewModel.projectEmail,
+ onValueChange = { viewModel.projectEmail = it },
+ placeholder = { Text(stringResource(R.string.label_email)) },
+ modifier = Modifier.fillMaxWidth(),
+ leadingIcon = { Icon(Icons.Default.Email, contentDescription = null) }
+ )
+ }
}
}
}
@@ -205,8 +206,8 @@ fun NewProjectScreen(
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,11 +292,15 @@ fun SectionRow(
label: String,
content: @Composable () -> Unit
) {
- Row(verticalAlignment = Alignment.Top) {
- Column {
- Text(label, fontSize = 12.sp, color = MaterialTheme.colors.onSurface)
- content()
- }
+ 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()
}
}
@@ -334,6 +339,7 @@ fun NewProjectScreenPreview() {
onImportFile = {},
onChooseFromNextcloud = {},
onOkPressed = {},
- onBack = {}
+ onBack = {},
+ onFieldsChanged = {}
)
}
diff --git a/app/src/main/java/net/helcel/cowspent/android/project/create/NewProjectViewModel.kt b/app/src/main/java/net/helcel/cowspent/android/project/create/NewProjectViewModel.kt
index b0060c0..16a8b82 100644
--- a/app/src/main/java/net/helcel/cowspent/android/project/create/NewProjectViewModel.kt
+++ b/app/src/main/java/net/helcel/cowspent/android/project/create/NewProjectViewModel.kt
@@ -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>(emptyList())
@@ -39,12 +41,23 @@ class NewProjectViewModel : ViewModel() {
fun isFormValid(): Boolean {
if (whatTodoIsCreate) {
- if (projectId.isEmpty()) return false
- if (projectType != ProjectType.LOCAL) {
+ if (projectType == ProjectType.LOCAL) {
+ return projectId.isNotEmpty()
+ } else {
if (projectUrl.isEmpty()) return false
if (projectName.isEmpty()) return false
- if (projectEmail.isEmpty()) return false
- if (projectType == ProjectType.IHATEMONEY && projectPassword.isEmpty()) return false
+
+ if (projectType == ProjectType.COSPEND && isAuthenticatedAccount) {
+ return true
+ }
+
+ if (projectId.isEmpty()) return false
+ if (projectType == ProjectType.IHATEMONEY) {
+ if (projectEmail.isEmpty()) return false
+ if (projectPassword.isEmpty()) return false
+ } else {
+ if (projectEmail.isEmpty()) return false
+ }
}
} else {
// Join
diff --git a/app/src/main/java/net/helcel/cowspent/android/project/edit/EditProjectActivity.kt b/app/src/main/java/net/helcel/cowspent/android/project/edit/EditProjectActivity.kt
index a105eb8..4afcf0c 100644
--- a/app/src/main/java/net/helcel/cowspent/android/project/edit/EditProjectActivity.kt
+++ b/app/src/main/java/net/helcel/cowspent/android/project/edit/EditProjectActivity.kt
@@ -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)) {
diff --git a/app/src/main/java/net/helcel/cowspent/android/project/edit/EditProjectScreen.kt b/app/src/main/java/net/helcel/cowspent/android/project/edit/EditProjectScreen.kt
index 350568b..f9e97f6 100644
--- a/app/src/main/java/net/helcel/cowspent/android/project/edit/EditProjectScreen.kt
+++ b/app/src/main/java/net/helcel/cowspent/android/project/edit/EditProjectScreen.kt
@@ -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,70 +62,91 @@ 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 = {
- IconButton(onClick = onDeleteRemote) {
- Icon(Icons.Default.DeleteForever, contentDescription = stringResource(R.string.menu_delete_project_remote))
+ if (!viewModel.isLocal) {
+ IconButton(onClick = onDeleteRemote) {
+ 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) }
)
- Spacer(modifier = Modifier.height(8.dp))
+ if (!viewModel.isLocal) {
+ Spacer(modifier = Modifier.height(8.dp))
- OutlinedTextField(
- value = viewModel.password,
- onValueChange = { viewModel.password = it },
- label = { Text(stringResource(R.string.setting_password)) },
- modifier = Modifier.fillMaxWidth(),
- leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) }
- )
+ 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) }
+ )
- Spacer(modifier = Modifier.height(8.dp))
+ 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.newPassword,
- onValueChange = { viewModel.newPassword = it },
- label = { Text(stringResource(R.string.setting_new_project_password)) },
- modifier = Modifier.fillMaxWidth(),
- leadingIcon = { Icon(Icons.Default.LockOpen, contentDescription = null) }
- )
+ OutlinedTextField(
+ value = viewModel.password,
+ onValueChange = { viewModel.password = it },
+ placeholder = { Text("Old " + stringResource(R.string.label_password)) },
+ modifier = Modifier.fillMaxWidth(),
+ leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) }
+ )
- Spacer(modifier = Modifier.height(8.dp))
+ 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) }
- )
+ OutlinedTextField(
+ value = viewModel.newPassword,
+ onValueChange = { viewModel.newPassword = it },
+ placeholder = { Text("New " + stringResource(R.string.label_password)) },
+ modifier = Modifier.fillMaxWidth(),
+ leadingIcon = { Icon(Icons.Default.LockOpen, contentDescription = null) }
+ )
+ }
}
}
}
diff --git a/app/src/main/java/net/helcel/cowspent/android/project/edit/EditProjectViewModel.kt b/app/src/main/java/net/helcel/cowspent/android/project/edit/EditProjectViewModel.kt
index fac868d..8b7d26e 100644
--- a/app/src/main/java/net/helcel/cowspent/android/project/edit/EditProjectViewModel.kt
+++ b/app/src/main/java/net/helcel/cowspent/android/project/edit/EditProjectViewModel.kt
@@ -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(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
}
}
diff --git a/app/src/main/java/net/helcel/cowspent/android/project/member/MemberAddDialog.kt b/app/src/main/java/net/helcel/cowspent/android/project/member/MemberAddDialog.kt
index 0cfd11d..b1b4a0b 100644
--- a/app/src/main/java/net/helcel/cowspent/android/project/member/MemberAddDialog.kt
+++ b/app/src/main/java/net/helcel/cowspent/android/project/member/MemberAddDialog.kt
@@ -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
diff --git a/app/src/main/java/net/helcel/cowspent/android/project/member/MemberEditDialog.kt b/app/src/main/java/net/helcel/cowspent/android/project/member/MemberEditDialog.kt
index 656bf80..642a841 100644
--- a/app/src/main/java/net/helcel/cowspent/android/project/member/MemberEditDialog.kt
+++ b/app/src/main/java/net/helcel/cowspent/android/project/member/MemberEditDialog.kt
@@ -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)
diff --git a/app/src/main/java/net/helcel/cowspent/android/project/member/MemberManagementActivity.kt b/app/src/main/java/net/helcel/cowspent/android/project/member/MemberManagementActivity.kt
new file mode 100644
index 0000000..5dba687
--- /dev/null
+++ b/app/src/main/java/net/helcel/cowspent/android/project/member/MemberManagementActivity.kt
@@ -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(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)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/net/helcel/cowspent/android/project/member/MemberManagementDialog.kt b/app/src/main/java/net/helcel/cowspent/android/project/member/MemberManagementDialog.kt
deleted file mode 100644
index d2121f3..0000000
--- a/app/src/main/java/net/helcel/cowspent/android/project/member/MemberManagementDialog.kt
+++ /dev/null
@@ -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,
- 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 = {}
- )
- }
-}
-
diff --git a/app/src/main/java/net/helcel/cowspent/android/project/member/MemberManagementScreen.kt b/app/src/main/java/net/helcel/cowspent/android/project/member/MemberManagementScreen.kt
new file mode 100644
index 0000000..51aaf1c
--- /dev/null
+++ b/app/src/main/java/net/helcel/cowspent/android/project/member/MemberManagementScreen.kt
@@ -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,
+ 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 = {}
+ )
+ }
+}
diff --git a/app/src/main/java/net/helcel/cowspent/android/project/member/MemberManagementViewModel.kt b/app/src/main/java/net/helcel/cowspent/android/project/member/MemberManagementViewModel.kt
new file mode 100644
index 0000000..e061930
--- /dev/null
+++ b/app/src/main/java/net/helcel/cowspent/android/project/member/MemberManagementViewModel.kt
@@ -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>(emptyList())
+
+ var dialogState by mutableStateOf(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)
+ }
+ }
+}
diff --git a/app/src/main/java/net/helcel/cowspent/android/project/settle/ProjectSettlementScreen.kt b/app/src/main/java/net/helcel/cowspent/android/project/settle/ProjectSettlementScreen.kt
index bcb6f04..364df84 100644
--- a/app/src/main/java/net/helcel/cowspent/android/project/settle/ProjectSettlementScreen.kt
+++ b/app/src/main/java/net/helcel/cowspent/android/project/settle/ProjectSettlementScreen.kt
@@ -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
)
diff --git a/app/src/main/java/net/helcel/cowspent/android/settings/PreferencesActivity.kt b/app/src/main/java/net/helcel/cowspent/android/settings/PreferencesActivity.kt
index 854f23f..623c2fd 100644
--- a/app/src/main/java/net/helcel/cowspent/android/settings/PreferencesActivity.kt
+++ b/app/src/main/java/net/helcel/cowspent/android/settings/PreferencesActivity.kt
@@ -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) {
diff --git a/app/src/main/java/net/helcel/cowspent/android/settings/SettingsScreen.kt b/app/src/main/java/net/helcel/cowspent/android/settings/SettingsScreen.kt
index b84290b..dc3ffa1 100644
--- a/app/src/main/java/net/helcel/cowspent/android/settings/SettingsScreen.kt
+++ b/app/src/main/java/net/helcel/cowspent/android/settings/SettingsScreen.kt
@@ -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
)
}
diff --git a/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectSankeyDiagram.kt b/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectSankeyDiagram.kt
index 19410a1..58f4d9e 100644
--- a/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectSankeyDiagram.kt
+++ b/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectSankeyDiagram.kt
@@ -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,
allBills: List,
customCategories: List,
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()
- val catMap = mutableMapOf()
- membersMap.keys.forEach { spentMap[it] = 0.0 }
+ val spentMap = mutableMapOf().apply { membersMap.keys.forEach { put(it, 0.0) } }
+ val catMap = mutableMapOf()
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 middleNode = if (selectedMemberId == -1L) "Total" else "Spent"
-
- displayMemberSpendings.forEach { (memberId, amount) ->
- val name = membersMap[memberId]?.name ?: "???"
- statsText.append("$name [${formatShortValue(amount)}] $middleNode\n")
- }
-
- statsText.append("\n")
-
- displayCategorySpendings.forEach { (catRemoteId, amount) ->
- val name = categoriesMap[catRemoteId]?.name ?: "Other"
- statsText.append("$middleNode [${formatShortValue(amount)}] $name\n")
- }
-
- statsText.append("\n")
-
- displayMemberSpendings.forEach { (memberId, _) ->
- val member = membersMap[memberId]
- if (member != null) {
- val hexColor = String.format("#%02x%02x%02x", member.r ?: 128, member.g ?: 128, member.b ?: 128)
- statsText.append(":${member.name} $hexColor\n")
+ val statsText = StringBuilder().apply {
+ append("// ").append(shareStatsIntro.replace("\n", "\n// ")).append("\n\n")
+ val middleNode = if (selectedMemberId == -1L) "Total" else "Spent"
+ displayMemberSpendings.forEach { (id, amount) -> append("${membersMap[id]?.name ?: "???"} [${formatShortValue(amount)}] $middleNode\n") }
+ append("\n")
+ displayCategorySpendings.forEach { (id, amount) -> append("$middleNode [${formatShortValue(amount)}] ${categoriesMap[id]?.name ?: "Other"}\n") }
+ append("\n")
+ displayMemberSpendings.forEach { (id, _) ->
+ membersMap[id]?.let { append(":${it.name} ${String.format("#%02x%02x%02x", it.r ?: 128, it.g ?: 128, it.b ?: 128)}\n") }
}
+ displayCategorySpendings.forEach { (id, _) -> categoriesMap[id]?.let { append(":${it.name ?: "Other"} ${it.color}\n") } }
}
-
- displayCategorySpendings.forEach { (catRemoteId, _) ->
- val category = categoriesMap[catRemoteId]
- if (category != null) {
- statsText.append(":${category.name ?: "Other"} ${category.color}\n")
- }
- }
-
onShareReady(statsText.toString())
}
- Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
- val selectedMember = membersMap[selectedMemberId]
- EditableExposedDropdownMenu(
- value = selectedMember?.name ?: "All Members",
- placeholder = "Filter by member",
- expanded = expanded,
- onExpandedChange = { expanded = it },
- onDismissRequest = { expanded = false },
- modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
- leadingIcon = {
- Box(modifier = Modifier.padding(start = 12.dp)) {
- if (selectedMember != null) {
- UserAvatar(
- name = selectedMember.name,
- r = selectedMember.r,
- g = selectedMember.g,
- b = selectedMember.b,
- disabled = !selectedMember.isActivated,
- size = 24.dp
- )
- } else {
- Icon(Icons.Default.Group, contentDescription = null)
- }
- }
- },
- content = {
- DropdownMenuItem(onClick = {
- selectedMemberId = -1L
- expanded = false
- }) {
- Icon(Icons.Default.Group, contentDescription = null)
- Spacer(modifier = Modifier.width(12.dp))
- Text("All Members")
- }
- allMembers.forEach { member ->
- DropdownMenuItem(onClick = {
- selectedMemberId = member.id
- expanded = false
- }) {
- UserAvatar(
- name = member.name,
- r = member.r,
- g = member.g,
- b = member.b,
- disabled = !member.isActivated,
- size = 24.dp
- )
- Spacer(modifier = Modifier.width(12.dp))
- Text(member.name)
+ Column(modifier = Modifier.fillMaxSize()) {
+ Box(modifier = Modifier.padding(16.dp)) {
+ val selectedMember = membersMap[selectedMemberId]
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ EditableExposedDropdownMenu(
+ value = selectedMember?.name ?: "All Members",
+ placeholder = "Filter by member",
+ expanded = expanded,
+ onExpandedChange = { expanded = it },
+ onDismissRequest = { expanded = false },
+ modifier = Modifier.weight(1f).padding(bottom = 16.dp),
+ leadingIcon = {
+ Box(modifier = Modifier.padding(start = 12.dp)) {
+ if (selectedMember != null) {
+ MemberAvatar(member = selectedMember, size = 24.dp)
+ } else Icon(Icons.Default.Group, contentDescription = null)
+ }
+ },
+ content = {
+ DropdownMenuItem(onClick = { selectedMemberId = -1L; expanded = false }) {
+ Icon(Icons.Default.Group, contentDescription = null)
+ Spacer(modifier = Modifier.width(12.dp))
+ Text("All Members")
+ }
+ allMembers.forEach { member ->
+ DropdownMenuItem(onClick = { selectedMemberId = member.id; expanded = false }) {
+ MemberAvatar(member = member, size = 24.dp)
+ Spacer(modifier = Modifier.width(12.dp))
+ Text(member.name)
+ }
+ }
}
+ )
+ IconButton(onClick = { isSpecialMode = !isSpecialMode }, modifier = Modifier.padding(bottom = 16.dp)) {
+ Icon(Icons.Default.ViewColumn, contentDescription = "Focus Mode", tint = if (isSpecialMode) MaterialTheme.colors.primary else LocalContentColor.current)
}
}
- )
+ }
if (totalAmount <= 0) {
Box(modifier = Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
Text("No spending data for selected filter", style = MaterialTheme.typography.body1)
}
} else {
- BoxWithConstraints(modifier = Modifier.weight(1f).fillMaxWidth()) {
- val nodeHeightMember = 76.dp
- val nodeHeightCategory = 76.dp
- val nodeHeightTotal = 42.dp
- val horizontalGap = 8.dp
-
- val memberCount = displayMemberSpendings.size
- val categoryCount = displayCategorySpendings.size
-
- val maxGaps = maxOf(memberCount - 1, categoryCount - 1, 0)
- val usableWidth = (maxWidth.value - horizontalGap.value * maxGaps).coerceAtLeast(0f)
- val moneyScale = if (totalAmount > 0) usableWidth / totalAmount.toFloat() else 0f
-
- Canvas(modifier = Modifier.fillMaxSize()) {
- val nodeHeightMemberPx = nodeHeightMember.toPx()
- val nodeHeightCategoryPx = nodeHeightCategory.toPx()
- val nodeHeightTotalPx = nodeHeightTotal.toPx()
- val gapPx = horizontalGap.toPx()
-
- val totalWidthPx = size.width
- val usableWidthPx = totalWidthPx - gapPx * maxGaps
- val moneyScalePx = if (totalAmount > 0) usableWidthPx / totalAmount else 0.0
-
- val totalNodeWidthPx = (totalAmount * moneyScalePx).toFloat()
- val totalNodeXPx = (totalWidthPx - totalNodeWidthPx) / 2
-
- val topY = 0f
- val middleY = (size.height - nodeHeightTotalPx) / 2
- val bottomY = size.height - nodeHeightCategoryPx
-
- val totalNodeColor = Color(0xFF333333)
-
- // Flows
- var currentXTop = (totalWidthPx - (totalAmount * moneyScalePx + (memberCount - 1).coerceAtLeast(0) * gapPx).toFloat()) / 2
- var currentXInTotalTop = totalNodeXPx
- displayMemberSpendings.forEach { (memberId, amount) ->
- val member = membersMap[memberId]
- val boxWidth = (amount * moneyScalePx).toFloat()
- val color = member?.let {
- Color(it.r ?: 128, it.g ?: 128, it.b ?: 128)
- } ?: Color.Gray
-
- if (boxWidth > 0.5f) {
- drawSankeyFlow(
- startX = currentXTop,
- startY = topY + nodeHeightMemberPx * 0.5f,
- startWidth = boxWidth,
- endX = currentXInTotalTop,
- endY = middleY + nodeHeightTotalPx * 0.5f,
- endWidth = boxWidth,
- startColor = color.copy(alpha = 0.5f),
- endColor = totalNodeColor.copy(alpha = 0.35f)
- )
- }
- currentXTop += boxWidth + gapPx
- currentXInTotalTop += boxWidth
- }
-
- var currentXBottom = (totalWidthPx - (totalAmount * moneyScalePx + (categoryCount - 1).coerceAtLeast(0) * gapPx).toFloat()) / 2
- var currentXInTotalBottom = totalNodeXPx
- displayCategorySpendings.forEach { (catRemoteId, amount) ->
- val category = categoriesMap[catRemoteId]
- val boxWidth = (amount * moneyScalePx).toFloat()
- val color = category?.color?.let {
- try { Color(it.toColorInt()) } catch (_: Exception) { Color(0xFF999999) }
- } ?: Color(0xFF999999)
-
- if (boxWidth > 0.5f) {
- drawSankeyFlow(
- startX = currentXInTotalBottom,
- startY = middleY + nodeHeightTotalPx * 0.5f,
- startWidth = boxWidth,
- endX = currentXBottom,
- endY = bottomY + nodeHeightCategoryPx * 0.5f,
- endWidth = boxWidth,
- startColor = totalNodeColor.copy(alpha = 0.35f),
- endColor = color.copy(alpha = 0.5f)
- )
- }
- currentXInTotalBottom += boxWidth
- currentXBottom += boxWidth + gapPx
- }
-
- // Nodes
- currentXTop = (totalWidthPx - (totalAmount * moneyScalePx + (memberCount - 1).coerceAtLeast(0) * gapPx).toFloat()) / 2
- displayMemberSpendings.forEach { (memberId, amount) ->
- val member = membersMap[memberId]
- val width = (amount * moneyScalePx).toFloat()
- val color = member?.let {
- Color(it.r ?: 128, it.g ?: 128, it.b ?: 128)
- } ?: Color.Gray
-
- if (width > 0.5f) {
- drawRoundRect(
- color = color,
- topLeft = Offset(currentXTop, topY),
- size = Size(width, nodeHeightMemberPx),
- cornerRadius = CornerRadius(10.dp.toPx())
- )
- }
- currentXTop += width + gapPx
- }
-
- drawRect(
- color = totalNodeColor,
- topLeft = Offset(totalNodeXPx, middleY),
- size = Size(totalNodeWidthPx, nodeHeightTotalPx)
- )
-
- currentXBottom = (totalWidthPx - (totalAmount * moneyScalePx + (categoryCount - 1).coerceAtLeast(0) * gapPx).toFloat()) / 2
- displayCategorySpendings.forEach { (catRemoteId, amount) ->
- val category = categoriesMap[catRemoteId]
- val width = (amount * moneyScalePx).toFloat()
- val color = category?.color?.let {
- try { Color(it.toColorInt()) } catch (_: Exception) { Color(0xFF999999) }
- } ?: Color(0xFF999999)
-
- if (width > 0.5f) {
- drawRoundRect(
- color = color,
- topLeft = Offset(currentXBottom, bottomY),
- size = Size(width, nodeHeightCategoryPx),
- cornerRadius = CornerRadius(10.dp.toPx())
- )
- }
- currentXBottom += width + gapPx
- }
- }
-
- // Member labels
- var currentXTopLabel = (maxWidth.value - (totalAmount.toFloat() * moneyScale + (memberCount - 1).coerceAtLeast(0) * horizontalGap.value)) / 2
- displayMemberSpendings.forEach { (memberId, amount) ->
- val member = membersMap[memberId]
- val widthValue = amount.toFloat() * moneyScale
- val width = widthValue.dp
- if (widthValue > 12f) {
- Box(
- modifier = Modifier
- .offset(x = currentXTopLabel.dp, y = 0.dp)
- .size(width, nodeHeightMember),
- contentAlignment = Alignment.Center
- ) {
- if (width >= 36.dp) {
- Column(
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- Text(
- text = member?.name ?: "???",
- fontSize = 16.sp,
- color = Color.White,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- fontWeight = FontWeight.Bold
- )
- Text(
- text = formatShortValue(amount),
- fontSize = 14.sp,
- color = Color.White.copy(alpha = 0.9f)
- )
- }
- }
- }
- }
- currentXTopLabel += widthValue + horizontalGap.value
- }
-
- // Total label
- Box(
- modifier = Modifier
- .align(Alignment.Center)
- .fillMaxWidth()
- .height(nodeHeightTotal),
- contentAlignment = Alignment.Center
- ) {
- Text(
- text = if (selectedMemberId == -1L) formatShortValue(totalAmount) else "SPENT: ${formatShortValue(totalAmount)}",
- fontSize = 16.sp,
- color = Color.White,
- textAlign = TextAlign.Center,
- fontWeight = FontWeight.ExtraBold
- )
- }
-
- // Category labels
- var currentXBottomLabel = (maxWidth.value - (totalAmount.toFloat() * moneyScale + (categoryCount - 1).coerceAtLeast(0) * horizontalGap.value)) / 2
- displayCategorySpendings.forEach { (catRemoteId, amount) ->
- val category = categoriesMap[catRemoteId]
- val widthValue = amount.toFloat() * moneyScale
- val width = widthValue.dp
- if (widthValue > 12f) {
- Box(
- modifier = Modifier
- .align(Alignment.BottomStart)
- .offset(x = currentXBottomLabel.dp)
- .size(width, nodeHeightCategory),
- contentAlignment = Alignment.Center
- ) {
- Column(
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- Text(
- text = category?.icon ?: "❔",
- fontSize = 20.sp
- )
- if (width >= 32.dp) {
- Text(
- text = formatShortValue(amount),
- fontSize = 14.sp,
- color = Color.White.copy(alpha = 0.9f)
- )
- }
- }
- }
- }
- currentXBottomLabel += widthValue + horizontalGap.value
- }
- }
+ SankeyContent(isSpecialMode, selectedMemberId, totalAmount, displayMemberSpendings, displayCategorySpendings, membersMap, categoriesMap)
}
Spacer(Modifier.fillMaxWidth().height(32.dp))
}
}
-private fun DrawScope.drawSankeyFlow(
- startX: Float,
- startY: Float,
- startWidth: Float,
- endX: Float,
- endY: Float,
- endWidth: Float,
- startColor: Color,
- endColor: Color
+private data class NodeLayout(val centerX: Float, val visualWidth: Float)
+
+@SuppressLint("FrequentlyChangingValue")
+@Composable
+private fun SankeyContent(
+ isSpecialMode: Boolean,
+ selectedMemberId: Long,
+ totalAmount: Double,
+ displayMemberSpendings: List>,
+ displayCategorySpendings: List>,
+ membersMap: Map,
+ categoriesMap: Map
) {
+ val density = LocalDensity.current
+ val scope = rememberCoroutineScope()
+
+ val topFocalIndex = remember { Animatable(0f) }
+ val bottomFocalIndex = remember { Animatable(0f) }
+
+ var diagramAreaCoordinates by remember { mutableStateOf(null) }
+ var totalNodeCoordinates by remember { mutableStateOf(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>, focalIndex: Float): List {
+ 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()
+ 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()) {} }
diff --git a/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectSpendingGraph.kt b/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectSpendingGraph.kt
index 1db969f..8c69aaf 100644
--- a/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectSpendingGraph.kt
+++ b/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectSpendingGraph.kt
@@ -43,10 +43,10 @@ fun ProjectSpendingGraph(
allBills: List,
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
}
diff --git a/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectStatisticsActivity.kt b/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectStatisticsActivity.kt
index 8ff5c6a..5677935 100644
--- a/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectStatisticsActivity.kt
+++ b/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectStatisticsActivity.kt
@@ -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)
diff --git a/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectStatisticsScreen.kt b/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectStatisticsScreen.kt
index 7e19785..b231c7c 100644
--- a/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectStatisticsScreen.kt
+++ b/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectStatisticsScreen.kt
@@ -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(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,
diff --git a/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectStatisticsTable.kt b/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectStatisticsTable.kt
index b157c1e..71f21a1 100644
--- a/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectStatisticsTable.kt
+++ b/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectStatisticsTable.kt
@@ -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(null) }
var dateMax by remember { mutableStateOf(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>()
- list.add(Triple(-1000, "📋", categoryAll))
- list.add(Triple(-100, "🧾", categoryAllExceptReimbursement))
- list.add(Triple(0, "❌", categoryNone))
+ val list = mutableListOf>()
+ 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>()
- list.add(Triple(-1000, "💳", paymentModeAll))
- list.add(Triple(0, "❌", paymentModeNone))
+ val list = mutableListOf>()
+ 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()
val membersSpent = HashMap()
+ 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)
}
}
diff --git a/app/src/main/java/net/helcel/cowspent/model/DBAccountProject.kt b/app/src/main/java/net/helcel/cowspent/model/DBAccountProject.kt
index b53ccd0..e295969 100644
--- a/app/src/main/java/net/helcel/cowspent/model/DBAccountProject.kt
+++ b/app/src/main/java/net/helcel/cowspent/model/DBAccountProject.kt
@@ -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"
}
}
diff --git a/app/src/main/java/net/helcel/cowspent/model/DBBill.kt b/app/src/main/java/net/helcel/cowspent/model/DBBill.kt
index 2ca7d20..66b85c4 100644
--- a/app/src/main/java/net/helcel/cowspent/model/DBBill.kt
+++ b/app/src/main/java/net/helcel/cowspent/model/DBBill.kt
@@ -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 = object : HashMap() {
+ val oldPmIdToNew: Map = object : HashMap() {
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
diff --git a/app/src/main/java/net/helcel/cowspent/model/DBCategory.kt b/app/src/main/java/net/helcel/cowspent/model/DBCategory.kt
index ff71c93..bca3ddd 100644
--- a/app/src/main/java/net/helcel/cowspent/model/DBCategory.kt
+++ b/app/src/main/java/net/helcel/cowspent/model/DBCategory.kt
@@ -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 {
diff --git a/app/src/main/java/net/helcel/cowspent/model/DBPaymentMode.kt b/app/src/main/java/net/helcel/cowspent/model/DBPaymentMode.kt
index a35345d..0b14c6e 100644
--- a/app/src/main/java/net/helcel/cowspent/model/DBPaymentMode.kt
+++ b/app/src/main/java/net/helcel/cowspent/model/DBPaymentMode.kt
@@ -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 {
diff --git a/app/src/main/java/net/helcel/cowspent/model/GroupedBill.kt b/app/src/main/java/net/helcel/cowspent/model/GroupedBill.kt
index 51947a4..99847c4 100644
--- a/app/src/main/java/net/helcel/cowspent/model/GroupedBill.kt
+++ b/app/src/main/java/net/helcel/cowspent/model/GroupedBill.kt
@@ -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
diff --git a/app/src/main/java/net/helcel/cowspent/persistence/CowspentSQLiteOpenHelper.kt b/app/src/main/java/net/helcel/cowspent/persistence/CowspentSQLiteOpenHelper.kt
index cc73377..e4b082e 100644
--- a/app/src/main/java/net/helcel/cowspent/persistence/CowspentSQLiteOpenHelper.kt
+++ b/app/src/main/java/net/helcel/cowspent/persistence/CowspentSQLiteOpenHelper.kt
@@ -7,15 +7,22 @@ import android.database.Cursor
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
import android.text.TextUtils
-import android.util.Log
import androidx.annotation.WorkerThread
import androidx.preference.PreferenceManager
import net.helcel.cowspent.R
-import net.helcel.cowspent.android.main.BillsListViewActivity
-import net.helcel.cowspent.model.*
+import net.helcel.cowspent.model.DBAccountProject
+import net.helcel.cowspent.model.DBBill
+import net.helcel.cowspent.model.DBBillOwer
+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 net.helcel.cowspent.util.CategoryUtils
+import net.helcel.cowspent.util.SecureStorage
import net.helcel.cowspent.util.SupportUtil
-import java.text.SimpleDateFormat
-import java.util.*
+import java.util.Locale
/**
* Helps to add, get, update and delete bills, members, projects with the option to trigger a sync with the server.
@@ -124,7 +131,8 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
"$key_projectid INTEGER, " +
"$key_name TEXT, " +
"$key_icon TEXT, " +
- "$key_color TEXT)"
+ "$key_color TEXT, " +
+ "$key_state INTEGER DEFAULT 0)"
)
}
@@ -136,7 +144,8 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
"$key_projectid INTEGER, " +
"$key_name TEXT, " +
"$key_icon TEXT, " +
- "$key_color TEXT)"
+ "$key_color TEXT, " +
+ "$key_state INTEGER DEFAULT 0)"
)
}
@@ -153,123 +162,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
}
@SuppressLint("Range")
- override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
- if (oldVersion < 2) {
- db.execSQL("ALTER TABLE $table_projects ADD COLUMN $key_lastPayerId INTEGER DEFAULT 0")
- }
- if (oldVersion < 3) {
- db.execSQL("ALTER TABLE $table_bills ADD COLUMN $key_repeat TEXT")
- }
- if (oldVersion < 4) {
- db.execSQL("ALTER TABLE $table_projects ADD COLUMN $key_type TEXT")
- val projects = getProjectsCustom("", arrayOf(), default_order, db)
- for (project in projects) {
- val url = project.serverUrl
- project.type = if (url == null) ProjectType.LOCAL else ProjectType.COSPEND
- updateProject(
- project.id, project.name, project.email,
- project.password, project.lastPayerId, project.type,
- project.lastSyncedTimestamp, project.currencyName,
- project.isDeletionDisabled, project.myAccessLevel, project.bearerToken,
- project.archivedTs, db
- )
- }
- }
- if (oldVersion < 5) {
- createTableAccountProjects(db)
- createIndex(db, table_account_projects)
- }
- if (oldVersion < 6) {
- db.execSQL("ALTER TABLE $table_bills ADD COLUMN $key_payment_mode TEXT DEFAULT \"n\"")
- db.execSQL("ALTER TABLE $table_bills ADD COLUMN $key_category_id INTEGER DEFAULT 0")
- }
- if (oldVersion < 7) {
- db.execSQL("ALTER TABLE $table_members ADD COLUMN $key_r INTEGER DEFAULT NULL")
- db.execSQL("ALTER TABLE $table_members ADD COLUMN $key_g INTEGER DEFAULT NULL")
- db.execSQL("ALTER TABLE $table_members ADD COLUMN $key_b INTEGER DEFAULT NULL")
- }
- if (oldVersion < 8) {
- db.execSQL("ALTER TABLE $table_projects ADD COLUMN $key_lastSyncTimestamp INTEGER DEFAULT 0")
- }
- if (oldVersion < 9) {
- createTableCategories(db)
- createIndex(db, table_categories)
- }
- if (oldVersion < 10) {
- db.execSQL("ALTER TABLE $table_projects ADD COLUMN $key_currencyName TEXT")
- createTableCurrencies(db)
- createIndex(db, table_currencies)
- }
- if (oldVersion < 11) {
- db.execSQL("ALTER TABLE $table_bills ADD COLUMN $key_timestamp INTEGER")
- val idToTs: MutableMap = HashMap()
- val sdfDate = SimpleDateFormat("yyyy-MM-dd", Locale.ROOT)
- val cursor = db.query(table_bills, arrayOf(key_id, "DATE"), "", arrayOf(), null, null, null)
- val dateNow = Date()
- while (cursor.moveToNext()) {
- val id = cursor.getLong(cursor.getColumnIndex(key_id))
- val dateStr = cursor.getString(cursor.getColumnIndex("DATE"))
- val date = try {
- sdfDate.parse(dateStr)
- } catch (_: Exception) {
- dateNow
- }
- val timestamp = (date?.time ?: System.currentTimeMillis()) / 1000
- idToTs[id] = timestamp
- }
- cursor.close()
- for (billId in idToTs.keys) {
- val timestamp = idToTs[billId]!!
- val values = ContentValues()
- values.put(key_timestamp, timestamp)
- db.update(table_bills, values, "$key_id = ?", arrayOf(billId.toString()))
- }
- }
- if (oldVersion < 12) {
- db.execSQL("ALTER TABLE $table_members ADD COLUMN $key_nc_userid TEXT DEFAULT NULL")
- db.execSQL("ALTER TABLE $table_members ADD COLUMN $key_avatar TEXT DEFAULT NULL")
- }
- if (oldVersion < 13) {
- db.execSQL("ALTER TABLE $table_bills ADD COLUMN $key_comment TEXT DEFAULT \"\"")
- }
- if (oldVersion < 14) {
- val projects = getProjectsCustom("", arrayOf(), default_order, db)
- for (project in projects) {
- updateProject(
- project.id, project.name, project.email,
- project.password, project.lastPayerId, project.type,
- 0L, project.currencyName, project.isDeletionDisabled, project.myAccessLevel, project.bearerToken,
- project.archivedTs, db
- )
- }
- }
- if (oldVersion < 15) {
- db.execSQL("ALTER TABLE $table_bills ADD COLUMN $key_payment_mode_id INTEGER DEFAULT 0")
- for (key in DBBill.oldPmIdToNew.keys) {
- val values = ContentValues()
- values.put(key_payment_mode_id, DBBill.oldPmIdToNew[key])
- db.update(table_bills, values, "$key_payment_mode = ?", arrayOf(key))
- }
- createTablePaymentModes(db)
- createIndex(db, table_payment_modes)
- }
- if (oldVersion < 16) {
- db.execSQL("ALTER TABLE $table_currencies ADD COLUMN $key_state INTEGER DEFAULT ${DBBill.STATE_OK}")
- }
- if (oldVersion < 17) {
- db.execSQL("ALTER TABLE $table_projects ADD COLUMN $key_deletionDisabled INTEGER")
- }
- if (oldVersion < 18) {
- db.execSQL("ALTER TABLE $table_projects ADD COLUMN $key_myAccessLevel INTEGER DEFAULT ${DBProject.ACCESS_LEVEL_UNKNOWN}")
- }
- if (oldVersion < 19) {
- db.execSQL("ALTER TABLE $table_projects ADD COLUMN $key_bearer_token TEXT")
- }
- if (oldVersion < 20) {
- db.execSQL("ALTER TABLE $table_projects ADD COLUMN $key_archived INTEGER DEFAULT 0")
- db.execSQL("ALTER TABLE $table_account_projects ADD COLUMN $key_archived INTEGER DEFAULT 0")
- }
- }
+ override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {}
override fun onDowngrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
recreateDatabase(db)
@@ -312,306 +205,21 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
db.execSQL("CREATE INDEX IF NOT EXISTS $indexName ON $table($key_id)")
}
- fun addAccountProject(accountProject: DBAccountProject): Long {
- val db = writableDatabase
- val values = ContentValues()
- values.put(key_remoteId, accountProject.remoteId)
- values.put(key_password, accountProject.password)
- values.put(key_ncUrl, accountProject.ncUrl)
- values.put(key_name, accountProject.name)
- values.put(key_archived, accountProject.archivedTs ?: 0L)
- return db.insert(table_account_projects, null, values)
- }
-
- val accountProjects: List
- get() = getAccountProjectsCustom("", arrayOf(), default_order)
-
- @WorkerThread
- private fun getAccountProjectsCustom(selection: String, selectionArgs: Array, orderBy: String?): List {
- return getAccountProjectsCustom(selection, selectionArgs, orderBy, readableDatabase)
- }
-
- @WorkerThread
- private fun getAccountProjectsCustom(selection: String, selectionArgs: Array, orderBy: String?, db: SQLiteDatabase): List {
- val cursor = db.query(table_account_projects, columnsAccountProjects, selection, selectionArgs, null, null, orderBy)
- val accountProjects: MutableList = ArrayList()
- while (cursor.moveToNext()) {
- accountProjects.add(getAccountProjectFromCursor(cursor))
- }
- cursor.close()
- return accountProjects
- }
-
- @SuppressLint("Range")
- private fun getAccountProjectFromCursor(cursor: Cursor): DBAccountProject {
- val archivedTs = cursor.getLong(cursor.getColumnIndex(key_archived))
- return DBAccountProject(
- cursor.getLong(cursor.getColumnIndex(key_id)),
- cursor.getString(cursor.getColumnIndex(key_remoteId)),
- cursor.getString(cursor.getColumnIndex(key_password)),
- cursor.getString(cursor.getColumnIndex(key_name)),
- cursor.getString(cursor.getColumnIndex(key_ncUrl)),
- if (archivedTs > 0) archivedTs else null
- )
- }
-
- fun clearAccountProjects() {
- val db = writableDatabase
- db.delete(table_account_projects, null, null)
- }
-
- fun addPaymentMode(paymentMode: DBPaymentMode): Long {
- val db = writableDatabase
- val values = ContentValues()
- values.put(key_remoteId, paymentMode.remoteId)
- values.put(key_projectid, paymentMode.projectId)
- values.put(key_name, paymentMode.name)
- values.put(key_icon, paymentMode.icon)
- values.put(key_color, paymentMode.color)
- return db.insert(table_payment_modes, null, values)
- }
-
- fun getPaymentMode(remoteId: Long, projectId: Long): DBPaymentMode? {
- val paymentModes = getPaymentModesCustom(
- "$key_remoteId = ? AND $key_projectid = ?",
- arrayOf(remoteId.toString(), projectId.toString()),
- null
- )
- return if (paymentModes.isEmpty()) null else paymentModes[0]
- }
-
- fun getPaymentModes(projectId: Long): List {
- return getPaymentModesCustom("$key_projectid = ?", arrayOf(projectId.toString()), null)
- }
-
- @WorkerThread
- private fun getPaymentModesCustom(selection: String, selectionArgs: Array, orderBy: String?): List {
- return getPaymentModesCustom(selection, selectionArgs, orderBy, readableDatabase)
- }
-
- @WorkerThread
- private fun getPaymentModesCustom(selection: String, selectionArgs: Array, orderBy: String?, db: SQLiteDatabase): List {
- val cursor = db.query(table_payment_modes, columnsPaymentModes, selection, selectionArgs, null, null, orderBy)
- val paymentModes: MutableList = ArrayList()
- while (cursor.moveToNext()) {
- paymentModes.add(getPaymentModeFromCursor(cursor))
- }
- cursor.close()
- return paymentModes
- }
-
- @SuppressLint("Range")
- private fun getPaymentModeFromCursor(cursor: Cursor): DBPaymentMode {
- return DBPaymentMode(
- cursor.getLong(cursor.getColumnIndex(key_id)),
- cursor.getLong(cursor.getColumnIndex(key_remoteId)),
- cursor.getLong(cursor.getColumnIndex(key_projectid)),
- cursor.getString(cursor.getColumnIndex(key_name)),
- cursor.getString(cursor.getColumnIndex(key_icon)),
- cursor.getString(cursor.getColumnIndex(key_color))
- )
- }
-
- fun updatePaymentMode(id: Long, name: String?, icon: String?, color: String?) {
- val db = writableDatabase
- val values = ContentValues()
- if (name != null) values.put(key_name, name)
- if (icon != null) values.put(key_icon, icon)
- if (color != null) values.put(key_color, color)
- if (values.size() > 0) {
- db.update(table_payment_modes, values, "$key_id = ?", arrayOf(id.toString()))
- }
- }
-
- fun deletePaymentMode(id: Long) {
- val db = writableDatabase
- db.delete(table_payment_modes, "$key_id = ?", arrayOf(id.toString()))
- }
-
- fun addCategory(category: DBCategory): Long {
- val db = writableDatabase
- val values = ContentValues()
- values.put(key_remoteId, category.remoteId)
- values.put(key_projectid, category.projectId)
- values.put(key_name, category.name)
- values.put(key_icon, category.icon)
- values.put(key_color, category.color)
- return db.insert(table_categories, null, values)
- }
-
- fun getCategory(remoteId: Long, projectId: Long): DBCategory? {
- val categories = getCategoriesCustom(
- "$key_remoteId = ? AND $key_projectid = ?",
- arrayOf(remoteId.toString(), projectId.toString()),
- null
- )
- return if (categories.isEmpty()) null else categories[0]
- }
-
- fun getCategories(projectId: Long): List {
- return getCategoriesCustom("$key_projectid = ?", arrayOf(projectId.toString()), null)
- }
-
- @WorkerThread
- private fun getCategoriesCustom(selection: String, selectionArgs: Array, orderBy: String?): List {
- return getCategoriesCustom(selection, selectionArgs, orderBy, readableDatabase)
- }
-
- @WorkerThread
- private fun getCategoriesCustom(selection: String, selectionArgs: Array, orderBy: String?, db: SQLiteDatabase): List {
- val cursor = db.query(table_categories, columnsCategories, selection, selectionArgs, null, null, orderBy)
- val categories: MutableList = ArrayList()
- while (cursor.moveToNext()) {
- categories.add(getCategoryFromCursor(cursor))
- }
- cursor.close()
- return categories
- }
-
- @SuppressLint("Range")
- private fun getCategoryFromCursor(cursor: Cursor): DBCategory {
- return DBCategory(
- cursor.getLong(cursor.getColumnIndex(key_id)),
- cursor.getLong(cursor.getColumnIndex(key_remoteId)),
- cursor.getLong(cursor.getColumnIndex(key_projectid)),
- cursor.getString(cursor.getColumnIndex(key_name)),
- cursor.getString(cursor.getColumnIndex(key_icon)),
- cursor.getString(cursor.getColumnIndex(key_color))
- )
- }
-
- fun updateCategory(id: Long, name: String?, icon: String?, color: String?) {
- val db = writableDatabase
- val values = ContentValues()
- if (name != null) values.put(key_name, name)
- if (icon != null) values.put(key_icon, icon)
- if (color != null) values.put(key_color, color)
- if (values.size() > 0) {
- db.update(table_categories, values, "$key_id = ?", arrayOf(id.toString()))
- }
- }
-
- fun deleteCategory(id: Long) {
- val db = writableDatabase
- db.delete(table_categories, "$key_id = ?", arrayOf(id.toString()))
- }
-
- fun addCurrency(currency: DBCurrency): Long {
- val db = writableDatabase
- val values = ContentValues()
- values.put(key_remoteId, currency.remoteId)
- values.put(key_projectid, currency.projectId)
- values.put(key_name, currency.name)
- values.put(key_exchangeRate, currency.exchangeRate)
- values.put(key_state, currency.state)
- return db.insert(table_currencies, null, values)
- }
-
- fun addCurrencyAndSync(m: DBCurrency) {
- addCurrency(m)
- val proj = getProject(m.projectId)
- if (proj != null) syncIfRemote(proj)
- }
-
- fun syncIfRemote(proj: DBProject) {
- if (!proj.isLocal) {
- val preferences = PreferenceManager.getDefaultSharedPreferences(context)
- val offlineMode = preferences.getBoolean(context.getString(R.string.pref_key_offline_mode), false)
- if (!offlineMode) {
- cowspentServerSyncHelper.scheduleSync(true, proj.id)
- }
- }
- }
-
- fun getCurrency(remoteId: Long, projectId: Long): DBCurrency? {
- val currencies = getCurrenciesCustom(
- "$key_remoteId = ? AND $key_projectid = ?",
- arrayOf(remoteId.toString(), projectId.toString()),
- null
- )
- return if (currencies.isEmpty()) null else currencies[0]
- }
-
- fun getCurrency(id: Long): DBCurrency? {
- val currencies = getCurrenciesCustom("$key_id = ?", arrayOf(id.toString()), null)
- return if (currencies.isEmpty()) null else currencies[0]
- }
-
- fun updateCurrency(id: Long, name: String?, exchangeRate: Double?) {
- val db = writableDatabase
- val values = ContentValues()
- if (name != null) values.put(key_name, name)
- if (exchangeRate != null) values.put(key_exchangeRate, exchangeRate)
- if (values.size() > 0) {
- db.update(table_currencies, values, "$key_id = ?", arrayOf(id.toString()))
- }
- }
-
- fun deleteCurrency(id: Long) {
- val db = writableDatabase
- db.delete(table_currencies, "$key_id = ?", arrayOf(id.toString()))
- }
-
- fun getCurrencies(projectId: Long): List {
- return getCurrenciesCustom("$key_projectid = ?", arrayOf(projectId.toString()), null)
- }
-
- @WorkerThread
- private fun getCurrenciesCustom(selection: String, selectionArgs: Array, orderBy: String?): List {
- return getCurrenciesCustom(selection, selectionArgs, orderBy, readableDatabase)
- }
-
- @WorkerThread
- private fun getCurrenciesCustom(selection: String, selectionArgs: Array, orderBy: String?, db: SQLiteDatabase): List {
- val cursor = db.query(table_currencies, columnsCurrencies, selection, selectionArgs, null, null, orderBy)
- val currencies: MutableList = ArrayList()
- while (cursor.moveToNext()) {
- currencies.add(getCurrencyFromCursor(cursor))
- }
- cursor.close()
- return currencies
- }
-
- @SuppressLint("Range")
- private fun getCurrencyFromCursor(cursor: Cursor): DBCurrency {
- return DBCurrency(
- cursor.getLong(cursor.getColumnIndex(key_id)),
- cursor.getLong(cursor.getColumnIndex(key_remoteId)),
- cursor.getLong(cursor.getColumnIndex(key_projectid)),
- cursor.getString(cursor.getColumnIndex(key_name)),
- cursor.getDouble(cursor.getColumnIndex(key_exchangeRate)),
- cursor.getInt(cursor.getColumnIndex(key_state))
- )
- }
-
- fun setCurrencyStateSync(currencyId: Long, state: Int) {
- setCurrencyState(currencyId, state)
- val currency = getCurrency(currencyId)
- if (currency != null) {
- val project = getProject(currency.projectId)
- if (project != null) syncIfRemote(project)
- }
- }
-
- fun setCurrencyState(currencyId: Long, state: Int) {
- val db = writableDatabase
- val values = ContentValues()
- values.put(key_state, state)
- db.update(table_currencies, values, "$key_id = ?", arrayOf(currencyId.toString()))
- }
+ // --- Projects logic ---
fun addProject(project: DBProject): Long {
val db = writableDatabase
val values = ContentValues()
values.put(key_remoteId, project.remoteId)
- values.put(key_password, project.password)
values.put(key_bearer_token, project.bearerToken)
values.put(key_email, project.email)
values.put(key_name, project.name)
values.put(key_ihmUrl, project.serverUrl)
values.put(key_type, project.type.id)
values.put(key_archived, project.archivedTs ?: 0L)
- return db.insert(table_projects, null, values)
+ val id = db.insert(table_projects, null, values)
+ SecureStorage.savePasswordSync(context, "ProjectPassword_$id", project.password)
+ return id
}
fun getProject(id: Long): DBProject? {
@@ -640,11 +248,13 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
@SuppressLint("Range")
private fun getProjectFromCursor(cursor: Cursor): DBProject {
+ val id = cursor.getLong(cursor.getColumnIndex(key_id))
val archivedTs = cursor.getLong(cursor.getColumnIndex(key_archived))
+ val password = SecureStorage.getPasswordSync(context, "ProjectPassword_$id") ?: ""
return DBProject(
- cursor.getLong(cursor.getColumnIndex(key_id)),
+ id,
cursor.getString(cursor.getColumnIndex(key_remoteId)),
- cursor.getString(cursor.getColumnIndex(key_password)),
+ password,
cursor.getString(cursor.getColumnIndex(key_name)),
cursor.getString(cursor.getColumnIndex(key_ihmUrl)),
cursor.getString(cursor.getColumnIndex(key_email)),
@@ -660,15 +270,6 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
)
}
- fun deleteProject(id: Long) {
- val db = writableDatabase
- for (b in getBillsOfProject(id)) {
- deleteBill(b.id)
- }
- db.delete(table_members, "$key_projectid = ?", arrayOf(id.toString()))
- db.delete(table_projects, "$key_id = ?", arrayOf(id.toString()))
- }
-
fun updateProject(
projId: Long, newName: String?, newEmail: String?,
newPassword: String?, newLastPayerId: Long?,
@@ -681,7 +282,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
val values = ContentValues()
if (newName != null) values.put(key_name, newName)
if (newEmail != null) values.put(key_email, newEmail)
- if (newPassword != null) values.put(key_password, newPassword)
+ if (newPassword != null) SecureStorage.savePasswordSync(context, "ProjectPassword_$projId", newPassword)
if (newBearerToken != null) values.put(key_bearer_token, newBearerToken)
if (newLastPayerId != null) values.put(key_lastPayerId, newLastPayerId)
if (newLastSyncedTimestamp != null) values.put(key_lastSyncTimestamp, newLastSyncedTimestamp)
@@ -721,7 +322,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
val values = ContentValues()
if (newName != null) values.put(key_name, newName)
if (newEmail != null) values.put(key_email, newEmail)
- if (newPassword != null) values.put(key_password, newPassword)
+ if (newPassword != null) SecureStorage.savePasswordSync(context, "ProjectPassword_$projId", newPassword)
if (newBearerToken != null) values.put(key_bearer_token, newBearerToken)
if (newLastPayerId != null) values.put(key_lastPayerId, newLastPayerId)
if (newLastSyncedTimestamp != null) values.put(key_lastSyncTimestamp, newLastSyncedTimestamp)
@@ -735,8 +336,19 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
}
}
+ fun deleteProject(id: Long) {
+ val db = writableDatabase
+ for (b in getBillsOfProject(id)) {
+ deleteBill(b.id)
+ }
+ db.delete(table_members, "$key_projectid = ?", arrayOf(id.toString()))
+ db.delete(table_projects, "$key_id = ?", arrayOf(id.toString()))
+ SecureStorage.removePasswordSync(context, "ProjectPassword_$id")
+ }
+
+ // --- Members logic ---
+
fun addMember(m: DBMember): Long {
- if (BillsListViewActivity.DEBUG) { Log.d(TAG, "[add member]") }
val db = writableDatabase
val values = ContentValues()
values.put(key_remoteId, m.remoteId)
@@ -793,7 +405,6 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
m.id, newName, newWeight, newActivated, newState, null,
newR, newG, newB, newNcUserId, newAvatar
)
- Log.v(TAG, "UPDATE MEMBER AND SYNC")
val proj = getProject(m.projectId)
if (proj != null) syncIfRemote(proj)
}
@@ -868,8 +479,9 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
db.delete(table_members, "$key_id = ?", arrayOf(id.toString()))
}
+ // --- Bills logic ---
+
fun addBill(b: DBBill): Long {
- if (BillsListViewActivity.DEBUG) { Log.d(TAG, "[add bill]") }
val db = writableDatabase
val values = ContentValues()
values.put(key_remoteId, b.remoteId)
@@ -881,8 +493,8 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
values.put(key_state, b.state)
values.put(key_repeat, b.repeat)
values.put(key_payment_mode, b.paymentMode)
- values.put(key_payment_mode_id, b.paymentModeRemoteId)
- values.put(key_category_id, b.categoryRemoteId)
+ values.put(key_payment_mode_id, b.paymentModeId)
+ values.put(key_category_id, b.categoryId)
values.put(key_comment, b.comment)
val billId = db.insert(table_bills, null, values)
for (bo in b.billOwers) {
@@ -903,8 +515,8 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
newAmount: Double?, newTimestamp: Long?,
newWhat: String?, newState: Int?,
newRepeat: String?,
- newPaymentMode: String?, newPaymentModeRemoteId: Int?,
- newCategoryId: Int?, newComment: String?
+ newPaymentMode: String?, newPaymentModeId: Long?,
+ newCategoryId: Long?, newComment: String?
) {
val db = writableDatabase
val values = ContentValues()
@@ -916,7 +528,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
if (newState != null) values.put(key_state, newState)
if (newRepeat != null) values.put(key_repeat, newRepeat)
if (newPaymentMode != null) values.put(key_payment_mode, newPaymentMode)
- if (newPaymentModeRemoteId != null) values.put(key_payment_mode_id, newPaymentModeRemoteId)
+ if (newPaymentModeId != null) values.put(key_payment_mode_id, newPaymentModeId)
if (newCategoryId != null) values.put(key_category_id, newCategoryId)
if (newComment != null) values.put(key_comment, newComment)
if (values.size() > 0) {
@@ -928,14 +540,14 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
bill: DBBill, newPayerId: Long, newAmount: Double,
newTimestamp: Long?, newWhat: String?,
newOwersIds: List?, newRepeat: String?,
- newPaymentMode: String?, newPaymentModeRemoteId: Int?,
- newCategoryId: Int?,
+ newPaymentMode: String?, newPaymentModeId: Long?,
+ newCategoryId: Long?,
newComment: String?
) {
val newState = if (bill.state == DBBill.STATE_ADDED) DBBill.STATE_ADDED else DBBill.STATE_EDITED
updateBill(
bill.id, null, newPayerId, newAmount, newTimestamp, newWhat, newState,
- newRepeat, newPaymentMode, newPaymentModeRemoteId, newCategoryId, newComment
+ newRepeat, newPaymentMode, newPaymentModeId, newCategoryId, newComment
)
val dbBillOwers = getBillowersOfBill(bill.id)
val dbBillOwersByMemberId: MutableMap = HashMap()
@@ -954,7 +566,6 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
}
}
}
- Log.v(TAG, "UPDATE BILL AND SYNC")
val proj = getProject(bill.projectId)
if (proj != null) syncIfRemote(proj)
}
@@ -975,29 +586,11 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
return getBillsCustom("$key_payer_id = ?", arrayOf(memberId.toString()), "$key_timestamp ASC")
}
- @Suppress("unused")
- fun getBill(remoteId: Long, projId: Long): DBBill? {
- val bills = getBillsCustom(
- "$key_remoteId = ? AND $key_projectid = ?",
- arrayOf(remoteId.toString(), projId.toString()),
- null
- )
+ fun getBill(id: Long): DBBill? {
+ val bills = getBillsCustom("$key_id = ?", arrayOf(id.toString()), null)
return if (bills.isEmpty()) null else bills[0]
}
- fun getBill(billId: Long): DBBill? {
- val bills = getBillsCustom("$key_id = ?", arrayOf(billId.toString()), null)
- return if (bills.isEmpty()) null else bills[0]
- }
-
- fun getCurrenciesOfProjectWithState(projId: Long, state: Int): List {
- return getCurrenciesCustom(
- "$key_projectid = ? AND $key_state = ?",
- arrayOf(projId.toString(), state.toString()),
- null
- )
- }
-
@WorkerThread
fun searchBills(query: CharSequence?, projectId: Long): List {
val andWhere: MutableList = ArrayList()
@@ -1095,9 +688,9 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
cursor.getInt(cursor.getColumnIndex(key_state)),
cursor.getString(cursor.getColumnIndex(key_repeat)),
cursor.getString(cursor.getColumnIndex(key_payment_mode)),
- cursor.getInt(cursor.getColumnIndex(key_category_id)),
+ cursor.getLong(cursor.getColumnIndex(key_category_id)),
cursor.getString(cursor.getColumnIndex(key_comment)),
- cursor.getInt(cursor.getColumnIndex(key_payment_mode_id))
+ cursor.getLong(cursor.getColumnIndex(key_payment_mode_id))
)
}
@@ -1107,8 +700,9 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
db.delete(table_bills, "$key_id = ?", arrayOf(id.toString()))
}
+ // --- Billowers logic ---
+
fun addBillower(billId: Long, memberId: Long) {
- if (BillsListViewActivity.DEBUG) { Log.d(TAG, "[add billower]") }
val db = writableDatabase
val values = ContentValues()
values.put(key_billId, billId)
@@ -1150,10 +744,446 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
db.delete(table_billowers, "$key_id = ?", arrayOf(id.toString()))
}
+ // --- Categories logic ---
+
+ fun addCategory(category: DBCategory): Long {
+ val db = writableDatabase
+ val values = ContentValues()
+ values.put(key_remoteId, category.remoteId)
+ values.put(key_projectid, category.projectId)
+ values.put(key_name, category.name)
+ values.put(key_icon, category.icon)
+ values.put(key_color, category.color)
+ values.put(key_state, category.state)
+ return db.insert(table_categories, null, values)
+ }
+
+ fun addCategoryAndSync(cat: DBCategory) {
+ addCategory(cat)
+ val proj = getProject(cat.projectId)
+ if (proj != null) syncIfRemote(proj)
+ }
+
+ fun getCategory(remoteId: Long, projectId: Long): DBCategory? {
+ val categories = getCategoriesCustom(
+ "$key_remoteId = ? AND $key_projectid = ?",
+ arrayOf(remoteId.toString(), projectId.toString()),
+ null
+ )
+ return if (categories.isEmpty()) null else categories[0]
+ }
+
+ fun getCategory(id: Long): DBCategory? {
+ val categories = getCategoriesCustom("$key_id = ?", arrayOf(id.toString()), null)
+ return if (categories.isEmpty()) null else categories[0]
+ }
+
+ fun getCategories(projectId: Long, includeDeleted: Boolean = false): List {
+ val selection = if (includeDeleted) "$key_projectid = ?" else "$key_projectid = ? AND $key_state != ?"
+ val selectionArgs = if (includeDeleted) arrayOf(projectId.toString()) else arrayOf(projectId.toString(), DBBill.STATE_DELETED.toString())
+ return getCategoriesCustom(selection, selectionArgs, null)
+ }
+
+ fun getCategoriesOfProjectWithState(projId: Long, state: Int): List {
+ return getCategoriesCustom(
+ "$key_projectid = ? AND $key_state = ?",
+ arrayOf(projId.toString(), state.toString()),
+ null
+ )
+ }
+
+ @WorkerThread
+ private fun getCategoriesCustom(selection: String, selectionArgs: Array, orderBy: String?): List {
+ val db = readableDatabase
+ val cursor = db.query(table_categories, columnsCategories, selection, selectionArgs, null, null, orderBy)
+ val categories: MutableList = ArrayList()
+ while (cursor.moveToNext()) {
+ categories.add(getCategoryFromCursor(cursor))
+ }
+ cursor.close()
+ return categories
+ }
+
+ @SuppressLint("Range")
+ private fun getCategoryFromCursor(cursor: Cursor): DBCategory {
+ return DBCategory(
+ cursor.getLong(cursor.getColumnIndex(key_id)),
+ cursor.getLong(cursor.getColumnIndex(key_remoteId)),
+ cursor.getLong(cursor.getColumnIndex(key_projectid)),
+ cursor.getString(cursor.getColumnIndex(key_name)),
+ cursor.getString(cursor.getColumnIndex(key_icon)),
+ cursor.getString(cursor.getColumnIndex(key_color)),
+ cursor.getInt(cursor.getColumnIndex(key_state))
+ )
+ }
+
+ fun updateCategory(id: Long, name: String?, icon: String?, color: String?, state: Int? = null, remoteId: Long? = null) {
+ val db = writableDatabase
+ val values = ContentValues()
+ if (name != null) values.put(key_name, name)
+ if (icon != null) values.put(key_icon, icon)
+ if (color != null) values.put(key_color, color)
+ if (state != null) values.put(key_state, state)
+ if (remoteId != null) values.put(key_remoteId, remoteId)
+ if (values.size() > 0) {
+ db.update(table_categories, values, "$key_id = ?", arrayOf(id.toString()))
+ }
+ }
+
+ fun updateCategoryAndSync(cat: DBCategory, name: String?, icon: String?, color: String?, remoteId: Long? = null) {
+ val newState = if (cat.state == DBBill.STATE_ADDED) DBBill.STATE_ADDED else DBBill.STATE_EDITED
+ updateCategory(cat.id, name, icon, color, newState, remoteId)
+ val proj = getProject(cat.projectId)
+ if (proj != null) syncIfRemote(proj)
+ }
+
+ fun deleteCategoryAndSync(catId: Long) {
+ val cat = getCategory(catId)
+ if (cat != null) {
+ if (cat.state == DBBill.STATE_ADDED) {
+ deleteCategory(catId)
+ } else {
+ updateCategory(catId, null, null, null, DBBill.STATE_DELETED)
+ }
+ val proj = getProject(cat.projectId)
+ if (proj != null) syncIfRemote(proj)
+ }
+ }
+
+ fun deleteCategory(id: Long) {
+ val db = writableDatabase
+ db.delete(table_categories, "$key_id = ?", arrayOf(id.toString()))
+ }
+
+ // --- Payment Modes logic ---
+
+ fun addPaymentMode(paymentMode: DBPaymentMode): Long {
+ val db = writableDatabase
+ val values = ContentValues()
+ values.put(key_remoteId, paymentMode.remoteId)
+ values.put(key_projectid, paymentMode.projectId)
+ values.put(key_name, paymentMode.name)
+ values.put(key_icon, paymentMode.icon)
+ values.put(key_color, paymentMode.color)
+ values.put(key_state, paymentMode.state)
+ return db.insert(table_payment_modes, null, values)
+ }
+
+ fun addPaymentModeAndSync(pm: DBPaymentMode) {
+ addPaymentMode(pm)
+ val proj = getProject(pm.projectId)
+ if (proj != null) syncIfRemote(proj)
+ }
+
+ fun getPaymentMode(remoteId: Long, projectId: Long): DBPaymentMode? {
+ val paymentModes = getPaymentModesCustom(
+ "$key_remoteId = ? AND $key_projectid = ?",
+ arrayOf(remoteId.toString(), projectId.toString()),
+ null
+ )
+ return if (paymentModes.isEmpty()) null else paymentModes[0]
+ }
+
+ fun getPaymentMode(id: Long): DBPaymentMode? {
+ val pms = getPaymentModesCustom("$key_id = ?", arrayOf(id.toString()), null)
+ return if (pms.isEmpty()) null else pms[0]
+ }
+
+ fun getPaymentModes(projectId: Long, includeDeleted: Boolean = false): List {
+ val selection = if (includeDeleted) "$key_projectid = ?" else "$key_projectid = ? AND $key_state != ?"
+ val selectionArgs = if (includeDeleted) arrayOf(projectId.toString()) else arrayOf(projectId.toString(), DBBill.STATE_DELETED.toString())
+ return getPaymentModesCustom(selection, selectionArgs, null)
+ }
+
+ fun getPaymentModesOfProjectWithState(projId: Long, state: Int): List {
+ return getPaymentModesCustom(
+ "$key_projectid = ? AND $key_state = ?",
+ arrayOf(projId.toString(), state.toString()),
+ null
+ )
+ }
+
+ @WorkerThread
+ private fun getPaymentModesCustom(selection: String, selectionArgs: Array, orderBy: String?): List {
+ return getPaymentModesCustom(selection, selectionArgs, orderBy, readableDatabase)
+ }
+
+ @WorkerThread
+ private fun getPaymentModesCustom(selection: String, selectionArgs: Array, orderBy: String?, db: SQLiteDatabase): List {
+ val cursor = db.query(table_payment_modes, columnsPaymentModes, selection, selectionArgs, null, null, orderBy)
+ val paymentModes: MutableList = ArrayList()
+ while (cursor.moveToNext()) {
+ paymentModes.add(getPaymentModeFromCursor(cursor))
+ }
+ cursor.close()
+ return paymentModes
+ }
+
+ @SuppressLint("Range")
+ private fun getPaymentModeFromCursor(cursor: Cursor): DBPaymentMode {
+ return DBPaymentMode(
+ cursor.getLong(cursor.getColumnIndex(key_id)),
+ cursor.getLong(cursor.getColumnIndex(key_remoteId)),
+ cursor.getLong(cursor.getColumnIndex(key_projectid)),
+ cursor.getString(cursor.getColumnIndex(key_name)),
+ cursor.getString(cursor.getColumnIndex(key_icon)),
+ cursor.getString(cursor.getColumnIndex(key_color)),
+ cursor.getInt(cursor.getColumnIndex(key_state))
+ )
+ }
+
+ fun updatePaymentMode(id: Long, name: String?, icon: String?, color: String?, state: Int? = null, remoteId: Long? = null) {
+ val db = writableDatabase
+ val values = ContentValues()
+ if (name != null) values.put(key_name, name)
+ if (icon != null) values.put(key_icon, icon)
+ if (color != null) values.put(key_color, color)
+ if (state != null) values.put(key_state, state)
+ if (remoteId != null) values.put(key_remoteId, remoteId)
+ if (values.size() > 0) {
+ db.update(table_payment_modes, values, "$key_id = ?", arrayOf(id.toString()))
+ }
+ }
+
+ fun updatePaymentModeAndSync(pm: DBPaymentMode, name: String?, icon: String?, color: String?, remoteId: Long? = null) {
+ val newState = if (pm.state == DBBill.STATE_ADDED) DBBill.STATE_ADDED else DBBill.STATE_EDITED
+ updatePaymentMode(pm.id, name, icon, color, newState, remoteId)
+ val proj = getProject(pm.projectId)
+ if (proj != null) syncIfRemote(proj)
+ }
+
+ fun deletePaymentModeAndSync(pmId: Long) {
+ val pm = getPaymentMode(pmId)
+ if (pm != null) {
+ if (pm.state == DBBill.STATE_ADDED) {
+ deletePaymentMode(pmId)
+ } else {
+ updatePaymentMode(pmId, null, null, null, DBBill.STATE_DELETED)
+ }
+ val proj = getProject(pm.projectId)
+ if (proj != null) syncIfRemote(proj)
+ }
+ }
+
+ fun deletePaymentMode(id: Long) {
+ val db = writableDatabase
+ db.delete(table_payment_modes, "$key_id = ?", arrayOf(id.toString()))
+ }
+
+ // --- Currencies logic ---
+
+ fun addCurrency(currency: DBCurrency): Long {
+ val db = writableDatabase
+ val values = ContentValues()
+ values.put(key_remoteId, currency.remoteId)
+ values.put(key_projectid, currency.projectId)
+ values.put(key_name, currency.name)
+ values.put(key_exchangeRate, currency.exchangeRate)
+ values.put(key_state, currency.state)
+ return db.insert(table_currencies, null, values)
+ }
+
+ fun addCurrencyAndSync(m: DBCurrency) {
+ addCurrency(m)
+ val proj = getProject(m.projectId)
+ if (proj != null) syncIfRemote(proj)
+ }
+
+ fun getCurrency(id: Long): DBCurrency? {
+ val currencies = getCurrenciesCustom("$key_id = ?", arrayOf(id.toString()), null)
+ return if (currencies.isEmpty()) null else currencies[0]
+ }
+
+ fun getCurrency(remoteId: Long, projectId: Long): DBCurrency? {
+ val currencies = getCurrenciesCustom(
+ "$key_remoteId = ? AND $key_projectid = ?",
+ arrayOf(remoteId.toString(), projectId.toString()),
+ null
+ )
+ return if (currencies.isEmpty()) null else currencies[0]
+ }
+
+ fun getCurrencies(projectId: Long): List {
+ return getCurrenciesCustom(
+ "$key_projectid = ? AND $key_state != ?",
+ arrayOf(projectId.toString(), DBBill.STATE_DELETED.toString()),
+ null
+ )
+ }
+
+ fun getCurrenciesOfProjectWithState(projId: Long, state: Int): List {
+ return getCurrenciesCustom(
+ "$key_projectid = ? AND $key_state = ?",
+ arrayOf(projId.toString(), state.toString()),
+ null
+ )
+ }
+
+ @WorkerThread
+ private fun getCurrenciesCustom(selection: String, selectionArgs: Array, orderBy: String?): List {
+ return getCurrenciesCustom(selection, selectionArgs, orderBy, readableDatabase)
+ }
+
+ @WorkerThread
+ private fun getCurrenciesCustom(selection: String, selectionArgs: Array, orderBy: String?, db: SQLiteDatabase): List {
+ val cursor = db.query(table_currencies, columnsCurrencies, selection, selectionArgs, null, null, orderBy)
+ val currencies: MutableList = ArrayList()
+ while (cursor.moveToNext()) {
+ currencies.add(getCurrencyFromCursor(cursor))
+ }
+ cursor.close()
+ return currencies
+ }
+
+ @SuppressLint("Range")
+ private fun getCurrencyFromCursor(cursor: Cursor): DBCurrency {
+ return DBCurrency(
+ cursor.getLong(cursor.getColumnIndex(key_id)),
+ cursor.getLong(cursor.getColumnIndex(key_remoteId)),
+ cursor.getLong(cursor.getColumnIndex(key_projectid)),
+ cursor.getString(cursor.getColumnIndex(key_name)),
+ cursor.getDouble(cursor.getColumnIndex(key_exchangeRate)),
+ cursor.getInt(cursor.getColumnIndex(key_state))
+ )
+ }
+
+ fun updateCurrency(id: Long, name: String?, exchangeRate: Double?) {
+ val db = writableDatabase
+ val values = ContentValues()
+ if (name != null) values.put(key_name, name)
+ if (exchangeRate != null) values.put(key_exchangeRate, exchangeRate)
+ if (values.size() > 0) {
+ db.update(table_currencies, values, "$key_id = ?", arrayOf(id.toString()))
+ }
+ }
+
+ fun setCurrencyState(currencyId: Long, state: Int) {
+ val db = writableDatabase
+ val values = ContentValues()
+ values.put(key_state, state)
+ db.update(table_currencies, values, "$key_id = ?", arrayOf(currencyId.toString()))
+ }
+
+ fun setCurrencyStateSync(currencyId: Long, state: Int) {
+ setCurrencyState(currencyId, state)
+ val currency = getCurrency(currencyId)
+ if (currency != null) {
+ val project = getProject(currency.projectId)
+ if (project != null) syncIfRemote(project)
+ }
+ }
+
+ fun deleteCurrency(id: Long) {
+ val db = writableDatabase
+ db.delete(table_currencies, "$key_id = ?", arrayOf(id.toString()))
+ }
+
+ // --- Common Helpers ---
+
+ fun syncIfRemote(proj: DBProject) {
+ if (!proj.isLocal) {
+ val preferences = PreferenceManager.getDefaultSharedPreferences(context)
+ val offlineMode = preferences.getBoolean(context.getString(R.string.pref_key_offline_mode), false)
+ if (!offlineMode) {
+ cowspentServerSyncHelper.scheduleSync(true, proj.id)
+ }
+ }
+ }
+
+ fun ensureDefaultLabels(projectId: Long, projectType: ProjectType) {
+ val allCats = getCategories(projectId, includeDeleted = true)
+ val defaultCats = CategoryUtils.getDefaultCategories(context, projectId)
+ val isLocal = projectType == ProjectType.LOCAL
+
+ // Cleanup existing "fake" Reimbursement categories from DB to avoid duplicates
+ // and migrate bills to use the virtual constant directly.
+ allCats.filter { it.remoteId == DBBill.CATEGORY_REIMBURSEMENT }.forEach { cat ->
+ val db = writableDatabase
+ val values = ContentValues()
+ values.put(key_category_id, DBBill.CATEGORY_REIMBURSEMENT)
+ db.update(table_bills, values, "$key_category_id = ?", arrayOf(cat.id.toString()))
+ db.delete(table_categories, "$key_id = ?", arrayOf(cat.id.toString()))
+ }
+
+ // Only ensure other default categories for local projects.
+ // Reimbursement is now virtual and handled in UI/Sync.
+ val catsToEnsure = if (isLocal) defaultCats.filter { it.remoteId != DBBill.CATEGORY_REIMBURSEMENT } else emptyList()
+
+ catsToEnsure.forEach { def ->
+ if (allCats.none { it.remoteId == def.remoteId }) {
+ if (!isLocal) def.state = DBBill.STATE_ADDED
+ addCategory(def)
+ }
+ }
+
+ val allPms = getPaymentModes(projectId, includeDeleted = true)
+ val defaultPms = CategoryUtils.getDefaultPaymentModes(context, projectId)
+ val pmsToEnsure = if (isLocal) defaultPms else emptyList()
+
+ pmsToEnsure.forEach { def ->
+ if (allPms.none { it.remoteId == def.remoteId }) {
+ if (!isLocal) def.state = DBBill.STATE_ADDED
+ addPaymentMode(def)
+ }
+ }
+ }
+
+ // --- AccountProjects logic ---
+
+ fun addAccountProject(accountProject: DBAccountProject): Long {
+ val db = writableDatabase
+ val values = ContentValues()
+ values.put(key_remoteId, accountProject.remoteId)
+ values.put(key_ncUrl, accountProject.ncUrl)
+ values.put(key_name, accountProject.name)
+ values.put(key_archived, accountProject.archivedTs ?: 0L)
+ val id = db.insert(table_account_projects, null, values)
+ SecureStorage.savePasswordSync(context, "AccountProjectPassword_$id", accountProject.password)
+ return id
+ }
+
+ val accountProjects: List
+ get() = getAccountProjectsCustom("", arrayOf(), default_order)
+
+ @WorkerThread
+ private fun getAccountProjectsCustom(selection: String, selectionArgs: Array, orderBy: String?): List {
+ return getAccountProjectsCustom(selection, selectionArgs, orderBy, readableDatabase)
+ }
+
+ @WorkerThread
+ private fun getAccountProjectsCustom(selection: String, selectionArgs: Array, orderBy: String?, db: SQLiteDatabase): List {
+ val cursor = db.query(table_account_projects, columnsAccountProjects, selection, selectionArgs, null, null, orderBy)
+ val accountProjects: MutableList = ArrayList()
+ while (cursor.moveToNext()) {
+ accountProjects.add(getAccountProjectFromCursor(cursor))
+ }
+ cursor.close()
+ return accountProjects
+ }
+
+ @SuppressLint("Range")
+ private fun getAccountProjectFromCursor(cursor: Cursor): DBAccountProject {
+ val id = cursor.getLong(cursor.getColumnIndex(key_id))
+ val archivedTs = cursor.getLong(cursor.getColumnIndex(key_archived))
+ val password = SecureStorage.getPasswordSync(context, "AccountProjectPassword_$id")
+ return DBAccountProject(
+ id,
+ cursor.getString(cursor.getColumnIndex(key_remoteId)),
+ password,
+ cursor.getString(cursor.getColumnIndex(key_name)),
+ cursor.getString(cursor.getColumnIndex(key_ncUrl)),
+ if (archivedTs > 0) archivedTs else null
+ )
+ }
+
+ fun clearAccountProjects() {
+ val db = writableDatabase
+ db.delete(table_account_projects, null, null)
+ }
+
@Suppress("ConstPropertyName")
companion object {
- private val TAG = CowspentSQLiteOpenHelper::class.java.simpleName
- private const val database_version = 20
+ private const val database_version = 1
private const val database_name = "COWSPENT"
private const val table_members = "MEMBERS"
const val key_id = "ID"
@@ -1188,16 +1218,16 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
private const val key_repeat = "REPEAT"
private const val key_payment_mode = "PAYMENTMODE"
private const val key_payment_mode_id = "PAYMENTMODEID"
- private const val key_category_id = "CATEGORYID"
- private const val key_comment = "COMMENT"
+ const val key_category_id = "CATEGORYID"
+ const val key_comment = "COMMENT"
private const val table_billowers = "BILLOWERS"
private const val key_billId = "BILLID"
private const val key_member_id = "MEMBERID"
private const val table_account_projects = "ACCOUNTPROJECTS"
private const val key_ncUrl = "NCURL"
private const val table_categories = "CATEGORIES"
- private const val key_icon = "ICON"
- private const val key_color = "COLOR"
+ const val key_icon = "ICON"
+ const val key_color = "COLOR"
private const val table_payment_modes = "PAYMENTMODES"
private const val key_latest_bill_ts = "LATEST_BILL_TS"
private const val table_currencies = "CURRENCIES"
@@ -1225,10 +1255,10 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
key_id, key_remoteId, key_password, key_name, key_ncUrl, key_archived
)
private val columnsCategories = arrayOf(
- key_id, key_remoteId, key_projectid, key_name, key_icon, key_color
+ key_id, key_remoteId, key_projectid, key_name, key_icon, key_color, key_state
)
private val columnsPaymentModes = arrayOf(
- key_id, key_remoteId, key_projectid, key_name, key_icon, key_color
+ key_id, key_remoteId, key_projectid, key_name, key_icon, key_color, key_state
)
private val columnsCurrencies = arrayOf(
key_id, key_remoteId, key_projectid, key_name, key_exchangeRate, key_state
diff --git a/app/src/main/java/net/helcel/cowspent/persistence/CowspentServerSyncHelper.kt b/app/src/main/java/net/helcel/cowspent/persistence/CowspentServerSyncHelper.kt
index fab690b..d6272ca 100644
--- a/app/src/main/java/net/helcel/cowspent/persistence/CowspentServerSyncHelper.kt
+++ b/app/src/main/java/net/helcel/cowspent/persistence/CowspentServerSyncHelper.kt
@@ -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,48 +439,69 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
}
}
- val currenciesToDelete = dbHelper.getCurrenciesOfProjectWithState(project.id, DBBill.STATE_DELETED)
- for (cToDel in currenciesToDelete) {
- try {
- val deleteRemoteCurrencyResponse = client!!.deleteRemoteCurrency(project, cToDel.remoteId)
- if (deleteRemoteCurrencyResponse.stringContent == "OK") {
- Log.d(TAG, "successfully deleted currency on remote project : delete it locally")
- dbHelper.deleteCurrency(cToDel.id)
- }
- } catch (e: IOException) {
- if (e.message == "\"Not Found\"") {
- Log.d(TAG, "failed to delete currency on remote project : delete it locally anyway")
- dbHelper.deleteCurrency(cToDel.id)
- } else {
- throw e
+ if (project.type == ProjectType.COSPEND) {
+ val currenciesToDelete = dbHelper.getCurrenciesOfProjectWithState(project.id, DBBill.STATE_DELETED)
+ for (cToDel in currenciesToDelete) {
+ try {
+ val deleteRemoteCurrencyResponse = client!!.deleteRemoteCurrency(project, cToDel.remoteId)
+ if (deleteRemoteCurrencyResponse.stringContent == "OK") {
+ Log.d(TAG, "successfully deleted currency on remote project : delete it locally")
+ dbHelper.deleteCurrency(cToDel.id)
+ }
+ } catch (e: IOException) {
+ if (e.message == "\"Not Found\"") {
+ Log.d(TAG, "failed to delete currency on remote project : delete it locally anyway")
+ dbHelper.deleteCurrency(cToDel.id)
+ } else {
+ throw e
+ }
}
}
+ } else {
+ val currenciesToDelete = dbHelper.getCurrenciesOfProjectWithState(project.id, DBBill.STATE_DELETED)
+ for (cToDel in currenciesToDelete) {
+ dbHelper.deleteCurrency(cToDel.id)
+ }
}
- val currenciesToEdit = dbHelper.getCurrenciesOfProjectWithState(project.id, DBBill.STATE_EDITED)
- for (cToEdit in currenciesToEdit) {
- try {
- val editRemoteCurrencyResponse = client!!.editRemoteCurrency(project, cToEdit)
- if (editRemoteCurrencyResponse.stringContent == cToEdit.remoteId.toString()) {
- dbHelper.setCurrencyState(cToEdit.id, DBBill.STATE_OK)
- Log.d(TAG, "SUCCESSFUL remote currency edition (${editRemoteCurrencyResponse.stringContent})")
- } else {
- Log.d(TAG, "FAILED to edit remote currency (${editRemoteCurrencyResponse.stringContent})")
- }
- } catch (e: IOException) {
- if (e.message == "{\"message\": \"Internal Server Error\"}") {
- Log.d(TAG, "FAILED to edit remote currency : it does not exist remotely")
- } else {
- throw e
+ if (project.type == ProjectType.COSPEND) {
+ val currenciesToEdit = dbHelper.getCurrenciesOfProjectWithState(project.id, DBBill.STATE_EDITED)
+ for (cToEdit in currenciesToEdit) {
+ try {
+ val editRemoteCurrencyResponse = client!!.editRemoteCurrency(project, cToEdit)
+ if (editRemoteCurrencyResponse.stringContent == cToEdit.remoteId.toString()) {
+ dbHelper.setCurrencyState(cToEdit.id, DBBill.STATE_OK)
+ Log.d(TAG, "SUCCESSFUL remote currency edition (${editRemoteCurrencyResponse.stringContent})")
+ } else {
+ Log.d(TAG, "FAILED to edit remote currency (${editRemoteCurrencyResponse.stringContent})")
+ }
+ } catch (e: IOException) {
+ if (e.message == "{\"message\": \"Internal Server Error\"}") {
+ Log.d(TAG, "FAILED to edit remote currency : it does not exist remotely")
+ } else {
+ throw e
+ }
}
}
+ } else {
+ val currenciesToEdit = dbHelper.getCurrenciesOfProjectWithState(project.id, DBBill.STATE_EDITED)
+ for (cToEdit in currenciesToEdit) {
+ dbHelper.setCurrencyState(cToEdit.id, DBBill.STATE_OK)
+ }
}
- val currencyToAdd = dbHelper.getCurrenciesOfProjectWithState(project.id, DBBill.STATE_ADDED)
- for (cToAdd in currencyToAdd) {
- val createRemoteCurrencyResponse = client!!.createRemoteCurrency(project, cToAdd)
- val newRemoteId = createRemoteCurrencyResponse.stringContent.toLong()
- if (newRemoteId > 0) {
+ if (project.type == ProjectType.COSPEND) {
+ val currencyToAdd = dbHelper.getCurrenciesOfProjectWithState(project.id, DBBill.STATE_ADDED)
+ for (cToAdd in currencyToAdd) {
+ val createRemoteCurrencyResponse = client!!.createRemoteCurrency(project, cToAdd)
+ val newRemoteId = createRemoteCurrencyResponse.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)
}
}
@@ -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 = 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 = 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
diff --git a/app/src/main/java/net/helcel/cowspent/theme/ThemeUtils.kt b/app/src/main/java/net/helcel/cowspent/theme/ThemeUtils.kt
index dfdcb6c..cb42bc9 100644
--- a/app/src/main/java/net/helcel/cowspent/theme/ThemeUtils.kt
+++ b/app/src/main/java/net/helcel/cowspent/theme/ThemeUtils.kt
@@ -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()
}
}
diff --git a/app/src/main/java/net/helcel/cowspent/util/BillFormatter.kt b/app/src/main/java/net/helcel/cowspent/util/BillFormatter.kt
index 3af97c3..965fb93 100644
--- a/app/src/main/java/net/helcel/cowspent/util/BillFormatter.kt
+++ b/app/src/main/java/net/helcel/cowspent/util/BillFormatter.kt
@@ -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 "
diff --git a/app/src/main/java/net/helcel/cowspent/util/CategoryUtils.kt b/app/src/main/java/net/helcel/cowspent/util/CategoryUtils.kt
index 7711be2..618d700 100644
--- a/app/src/main/java/net/helcel/cowspent/util/CategoryUtils.kt
+++ b/app/src/main/java/net/helcel/cowspent/util/CategoryUtils.kt
@@ -10,28 +10,57 @@ object CategoryUtils {
fun getDefaultCategories(context: Context, projectId: Long): List {
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 {
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, 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 }
+ }
}
diff --git a/app/src/main/java/net/helcel/cowspent/util/CospendClientUtil.kt b/app/src/main/java/net/helcel/cowspent/util/CospendClientUtil.kt
index dabbdd0..e5485d7 100644
--- a/app/src/main/java/net/helcel/cowspent/util/CospendClientUtil.kt
+++ b/app/src/main/java/net/helcel/cowspent/util/CospendClientUtil.kt
@@ -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),
diff --git a/app/src/main/java/net/helcel/cowspent/util/ExportUtil.kt b/app/src/main/java/net/helcel/cowspent/util/ExportUtil.kt
index c3bfec4..a9df23f 100644
--- a/app/src/main/java/net/helcel/cowspent/util/ExportUtil.kt
+++ b/app/src/main/java/net/helcel/cowspent/util/ExportUtil.kt
@@ -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"
}
diff --git a/app/src/main/java/net/helcel/cowspent/util/NextcloudClient.kt b/app/src/main/java/net/helcel/cowspent/util/NextcloudClient.kt
index 2ac2d64..5d0e244 100644
--- a/app/src/main/java/net/helcel/cowspent/util/NextcloudClient.kt
+++ b/app/src/main/java/net/helcel/cowspent/util/NextcloudClient.kt
@@ -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?,
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> = 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
diff --git a/app/src/main/java/net/helcel/cowspent/util/SecureStorage.kt b/app/src/main/java/net/helcel/cowspent/util/SecureStorage.kt
new file mode 100644
index 0000000..233e26d
--- /dev/null
+++ b/app/src/main/java/net/helcel/cowspent/util/SecureStorage.kt
@@ -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 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)
+ }
+}
diff --git a/app/src/main/java/net/helcel/cowspent/util/ServerResponse.kt b/app/src/main/java/net/helcel/cowspent/util/ServerResponse.kt
index c8416ee..d1feac3 100644
--- a/app/src/main/java/net/helcel/cowspent/util/ServerResponse.kt
+++ b/app/src/main/java/net/helcel/cowspent/util/ServerResponse.kt
@@ -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): List {
- return getBillsFromJSONObject(getResponseObjectData(), projId, memberRemoteIdToId)
+ fun getBillsCospend(
+ projId: Long,
+ memberRemoteIdToId: Map,
+ catRemoteIdToId: Map,
+ pmRemoteIdToId: Map
+ ): List {
+ return getBillsFromJSONObject(projId, memberRemoteIdToId, catRemoteIdToId, pmRemoteIdToId)
}
@Throws(JSONException::class)
- fun getBillsIHM(projId: Long, memberRemoteIdToId: Map): List {
- return getBillsFromJSONArray(JSONArray(content), projId, memberRemoteIdToId)
+ fun getBillsIHM(
+ projId: Long,
+ memberRemoteIdToId: Map,
+ catRemoteIdToId: Map,
+ pmRemoteIdToId: Map
+ ): List {
+ 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 {
+ 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 {
+ 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 {
+ val categories: MutableList = 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 {
+ val paymentModes: MutableList = 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 {
val categories: MutableList = 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
+ memberRemoteIdToId: Map,
+ catRemoteIdToId: Map,
+ pmRemoteIdToId: Map
): List {
val bills: MutableList = 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
+ memberRemoteIdToId: Map,
+ catRemoteIdToId: Map,
+ pmRemoteIdToId: Map
): List {
val bills: List
+ 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
+ memberRemoteIdToId: Map,
+ catRemoteIdToId: Map,
+ pmRemoteIdToId: Map
): 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
diff --git a/app/src/main/java/net/helcel/cowspent/util/SupportUtil.kt b/app/src/main/java/net/helcel/cowspent/util/SupportUtil.kt
index 00766a4..c2b0cf9 100644
--- a/app/src/main/java/net/helcel/cowspent/util/SupportUtil.kt
+++ b/app/src/main/java/net/helcel/cowspent/util/SupportUtil.kt
@@ -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,
membersPaid: MutableMap,
membersSpent: MutableMap,
- 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,
membersPaid: MutableMap,
membersSpent: MutableMap,
- 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
diff --git a/app/src/main/java/net/helcel/cowspent/util/VersatileProjectSyncClient.kt b/app/src/main/java/net/helcel/cowspent/util/VersatileProjectSyncClient.kt
index ac0a4df..1918ecb 100644
--- a/app/src/main/java/net/helcel/cowspent/util/VersatileProjectSyncClient.kt
+++ b/app/src/main/java/net/helcel/cowspent/util/VersatileProjectSyncClient.kt
@@ -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): ServerResponse.EditRemoteBillResponse {
+ fun editRemoteBill(
+ project: DBProject,
+ bill: DBBill,
+ memberIdToRemoteId: Map,
+ categoryIdToRemoteId: Map,
+ paymentModeIdToRemoteId: Map
+ ): ServerResponse.EditRemoteBillResponse {
val paramKeys: MutableList = ArrayList()
val paramValues: MutableList = 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): ServerResponse.CreateRemoteBillResponse {
+ fun createRemoteBill(
+ project: DBProject,
+ bill: DBBill,
+ memberIdToRemoteId: Map,
+ categoryIdToRemoteId: Map,
+ paymentModeIdToRemoteId: Map
+ ): ServerResponse.CreateRemoteBillResponse {
val paramKeys: MutableList = ArrayList()
val paramValues: MutableList = 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 = ArrayList()
+ val paramValues: MutableList = 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 = ArrayList()
+ val paramValues: MutableList = 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 = ArrayList()
+ val paramValues: MutableList = 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 = ArrayList()
+ val paramValues: MutableList = 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 = 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?, paramValues: List?, 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? = 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())
diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml
index 724b0df..036b7f1 100644
--- a/app/src/main/res/drawable/ic_launcher_background.xml
+++ b/app/src/main/res/drawable/ic_launcher_background.xml
@@ -2,10 +2,39 @@
-
-
+ android:viewportWidth="72"
+ android:viewportHeight="72">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml
index 030fd29..074841e 100644
--- a/app/src/main/res/drawable/ic_launcher_foreground.xml
+++ b/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -9,27 +9,12 @@
android:scaleY="0.63461536"
android:translateX="13.153846"
android:translateY="13.153846">
-
-
-
-
-
-
-
-
-
@@ -44,17 +29,6 @@
-
-
-
-
-
-
diff --git a/app/src/main/res/drawable/ic_launcher_monochrome.xml b/app/src/main/res/drawable/ic_launcher_monochrome.xml
new file mode 100644
index 0000000..6b7c66f
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_monochrome.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/test.xml b/app/src/main/res/drawable/test.xml
deleted file mode 100644
index 1828a39..0000000
--- a/app/src/main/res/drawable/test.xml
+++ /dev/null
@@ -1,52 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml
similarity index 79%
rename from app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
rename to app/src/main/res/mipmap-anydpi/ic_launcher.xml
index 50ec886..04091bd 100644
--- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
+++ b/app/src/main/res/mipmap-anydpi/ic_launcher.xml
@@ -2,5 +2,5 @@
-
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
similarity index 79%
rename from app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
rename to app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
index 50ec886..04091bd 100644
--- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
+++ b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
@@ -2,5 +2,5 @@
-
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 00ff0c4..c11e725 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -2,231 +2,152 @@
Cowspent
- All bills
- New bill
- App settings
- Scan QRCode from Cospend/Cowspent
+
+
+ New bill
Add project
- Save bill
- Edit project
- Share project
- What?
- Who paid?
- All
- None
-
- Bill edition
- Bill values have been changed. What do you want to do?
- Save
- Discard
- Currency manager
- Add currency
- Currency management is only available for Cospend projects
- Comment
- Convert current amount from another currency into %s
- There is no additional currency
- Main currency
- Name
- Exchange rate
- For whom?
- Project ID/name
-
- Repeat this bill every
- Mode
- Category
-
- Label missing categories
- Label Bills
- No more bills to label
- Skip
- Suggested
- No suggestions for this bill
- What do you want to do?
- Where?
- Local
- Cospend
- ihatemoney
- ihatemoney address
- Join
- Create
- Nextcloud address
- Password (local)
- Password
- Project title
- E-mail
- Warning: creating a Cospend project using Nextcloud account works with Cospend v0.3.4 and higher.
- Authenticated project creation
- Nothing was changed
- Project successfully added to Cowspent
+ Save
+ Edit
+ Share
Search
Delete
- Error
- Close
- Loading
- Project sync failed
- 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.
- The Nextcloud instance is probably in maintenance mode
+ Archive
+ Unarchive
+ Export
+ Stats
+ Settle
+ Scan QR Code
+ Settings
+ Label missing categories
+ Logout
+ Connect
+ Discard
+ Members
+ Labels
+ Currencies
+
+
+ Statistics
+ Edit project
+ Label Bills
+ Manage Labels
+ About
+ Settle Project
+ Share Project
+ Add Project
+ Add Category
+ Add Payment Mode
+ Nextcloud Account
+ Web link
+ Cowspent link
+ Are you sure?
+
+
+ All bills
+ Categories
+ Payment Modes
+ Name
+ Icon / Emoji
+ Color
+ Weight
+ Activated
+ Password
+ E-mail
+ Server address
+ Username
+ Comment
+ What?
+ Who paid?
+ For whom?
+ Repeat every
+ Mode
+ Category
+ Project ID/name
+ Project title
+ Use Nextcloud App Account
+
+
+ Unsaved changes
+ Save changes before leaving?
+ The remote project will not be deleted.
+ Sync error
+ Sync failed for %1$s.\n\n%2$s
+ Expenses are already balanced.
+ Project %1$s added
+ All bills labeled
+ No suggestions
+ Requires Cospend v0.3.4+.
+ Link copied to clipboard
+ Scan QR code or share the link to join.
+ Link for web browser access.
+ Share this link with a Cowspent user.
+ Settlement for %1$s:
+ %1$s owes %3$.2f to %2$s
+ Stats for %1$s:
+ Member (Paid | Spent | Balance)
+ Logged in as %1$s
+
+
+ Error
+ Loading
+ No projects found
+ No members found
+ No bills found
+ At least one member required
+ Server is in maintenance mode
400 Bad request
401 Unauthorized
403 Forbidden
404 Not Found
- Creating remote project
+ Sync failed: %1$s
+ Invalid login: %1$s
+ Wrong username or password
+ Invalid server response
+ Request failed
+ Invalid e-mail
+ Invalid project ID
+ Invalid project title
+ Invalid bill name
+ Invalid bill date
+ Payer required
+ Owers required
+ No network connection
+ Server error
+ Server connection broken
+ Cannot share this project
+
+
+ Connect to Nextcloud account
+ Last sync: %1$02d:%2$02d
Cancel
Ok
Yes
No
- Add
- Edit bill
- New bill
- Add project in Cowspent
- Edit project
- About
- Impossible to share this project
- Share
- Create bills
- Settlement bill
- Share Cowspent link
- Delete project on server
- Save project
- Add a member
- Remove Project
- Project %1$s has been removed
- Manage project
- Manage members
- Export project
- Last sync: %1$02d:%2$02d
- Scan QR code
- Choose from Nextcloud account
- Import from CSV file
- Malformed CSV, bad owers on line %1$d
- Malformed CSV, bad date on line %1$d
- Malformed CSV, bad column names on line %1$d
- This member already exists in this project
- Are you sure?
- Are you sure you want to delete remote project?
- The remote project will NOT be deleted
- Activated
- Name
- Color
- Weight
- Delete member
- Remote project operations require network connectivity
- You are not allowed to perform this action
+ Close
- Appearance
- Network
- Other
- About Cowspent
- Nextcloud account
- Logged in as %1$s
- Logout
- Server address
- WARNING: "http" is unsafe. Please use "https".
- Username
- Password
- Connect
- Theme
- Offline mode
+ Appearance
+ Network
+ Other
+ Theme
+ Offline mode
Only sync manually.
- Custom color
- UI Accent color
- Choose color
- Color Selection
+ Custom color
+ Color Selection
+ Show archived projects
+ Beta Features
+ Enable experimental features. Use at your own risk.
+ WARNING: "http" is unsafe. Use "https".
+ Choose Color
+
System
Nextcloud
Manual
+ Light
+ Dark
+ Follow system
-
- Main currency saved
- Project saved in %s
- New member name
- Edit member
- Optimal
- Who pays?
- To whom?
- How much?
- Total Spent: %s
- From
- To
- Who
- Paid
- Spent
- Balance
- Statistics
- Settle
- Project %1$s was successfully added
- Project %1$s was successfully created
- Project added
- Project created
- Statistics
- Settle the project
- The expenses in this project are already balanced. There is nothing to settle!
- Share the project
- 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.
- Share this web link to let others access the project with a web browser.
- Web link
- Cowspent link
- Share this link to a Cowspent user.
- Cowspent link of %1$s
- Share Cowspent link of %1$s
- Here is how to settle project %1$s :
- %1$s owes %3$.2f to %2$s
- Settle the project %1$s
- Statistics of project %1$s :
- MEMBER_NAME (PAID | SPENT | BALANCE)
- Click here to connect to a Nextcloud account
- At least one activated member is required to add a bill
- Impossible for local projects
- Choose a project from your Nextcloud account
- There is no Cospend project in your Nextcloud account
- Activity in project %1$s
-
-
- No Projects
- 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.
- Configure Nextcloud account
- Manually add a project (Cospend or local)
- No Members
- This project doesn\'t have any members. In order to create bills, you need to add at least one member (better two or more).
- No Bills
- This project doesn\'t have any bills. Click the button below to add your first bill.
-
-
-
-
-
-
- Impossible to reach the remote project:\n\n%1$s
- Project sync failed: %1$s
- Invalid login: %1$s
- is the Cospend app activated on the server?
- server connection is broken
- no network connection
- server error
- wrong username or password
- Invalid e-mail address
- Invalid project ID/name
- Invalid project title
- Invalid bill name
- Invalid bill date
- Bill should have a payer
- Bill should have at least one ower
- Error at remote project creation : %s
- Error at remote project edition : %s
- Please reauthenticate your SSO account in settings
- Request failed
-
-
- Version %1$s
- Maintainer
- Helcel.net
- License
- GNU GPL3x / CC BY-SA 4.0
- Source code
- https://github.com/helcel-net/cowspent
-
-
+
nightMode
appColor
serverColor
@@ -236,36 +157,29 @@
colorMode
offlineMode
showArchived
- Light
- Dark
- Follow system
+ betaFeatures
1
2
-1
- Use Nextcloud app account
-
-
- QR code could not be scanned.
- Manage currencies
- Show archived projects
-
-
+
No repeat
Daily
Weekly
Fortnightly
Monthly
Yearly
- No payment mode
- All payment modes
+
+ None
+ All
Credit card
Cash
Check
- Online service
+ Online
Transfer
- No category
- All categories
+
+ None
+ All
All except reimbursement
Grocery
Bar/Party
@@ -279,4 +193,86 @@
Accommodation
Transport
Sport
+
+
+ What
+ Where
+ Local only
+ Cospend
+ IHateMoney
+ Join existing project
+ Create new project
+ Import from file
+ Choose project
+ No projects found on this account.
+ Project
+ Project added successfully.
+ You have no projects yet.
+ Configure Nextcloud account
+ Add project manually
+ No members in this project.
+ No bills found.
+ Member already exists.
+ Project: %1$s
+ Project %1$s removed.
+ File saved: %1$s
+ Import failed at row %d
+ Invalid date format at row %d
+ Invalid owers at row %d
+ Add Member
+ Edit Member
+ Delete
+ No changes to save.
+
+
+ None (Optimal)
+ Who pays
+ To whom
+ Amount
+ Share
+ Create bills
+ Settlement
+
+
+ Choose Currency (%s)
+ None
+ All
+ Currency settings saved.
+ Main Currency
+
+
+ Suggested Categories
+ Skip
+ From
+ To
+ Member
+ Paid
+ Spent
+ Balance
+ Total: %1$s
+
+
+ Connection failed: %1$s
+ Creation failed: %1$s
+ Error updating remote project: %1$s
+ Network unavailable for remote operation.
+ Failed to parse QR code.
+ Authentication token mismatch. Please log in again.
+ You don\'t have permission to perform this action.
+ Delete Label
+ Are you sure you want to delete this label?
+
+
+ Version %1$s
+ Maintainer
+ Helcel.net
+ License
+ GNU GPL3x / CC BY-SA 4.0
+ Source code
+ https://github.com/helcel-net/cowspent
+
+
+ Project %1$s
+ Share %1$s
+
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index df6a6ad..a9db115 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -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
diff --git a/metadata/en-US/full_description.txt b/metadata/en-US/full_description.txt
index d045db8..0207b25 100644
--- a/metadata/en-US/full_description.txt
+++ b/metadata/en-US/full_description.txt
@@ -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
diff --git a/metadata/en-US/images/icon.png b/metadata/en-US/images/icon.png
new file mode 100644
index 0000000..14b4dab
Binary files /dev/null and b/metadata/en-US/images/icon.png differ
diff --git a/metadata/en-US/images/icon.webp b/metadata/en-US/images/icon.webp
deleted file mode 100644
index 975d76a..0000000
Binary files a/metadata/en-US/images/icon.webp and /dev/null differ