From 2967dbd001180674b6887e127b98067750bacc16 Mon Sep 17 00:00:00 2001 From: soraefir Date: Mon, 8 Jun 2026 22:21:22 +0200 Subject: [PATCH 01/31] fix ui --- app/build.gradle | 6 +- app/src/main/AndroidManifest.xml | 13 +- .../cowspent/android/about/AboutActivity.kt | 2 + .../android/account/AccountActivity.kt | 2 + .../android/bill_edit/EditBillActivity.kt | 2 + .../android/bill_label/LabelBillsActivity.kt | 2 + .../currencies/ManageCurrenciesActivity.kt | 2 + .../android/helper/QrCodeScannerActivity.kt | 2 + .../android/main/BillsListViewActivity.kt | 2 + .../project/create/NewProjectActivity.kt | 2 + .../project/edit/EditProjectActivity.kt | 2 + .../android/settings/PreferencesActivity.kt | 2 + .../statistics/ProjectSankeyDiagram.kt | 665 ++++++++---------- .../statistics/ProjectStatisticsActivity.kt | 2 + .../net/helcel/cowspent/theme/ThemeUtils.kt | 8 +- 15 files changed, 338 insertions(+), 376 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 9a1a5aa..2ad6f2d 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 "1.0a" versionCode project.hasProperty('VERSION_CODE') ? project.property('VERSION_CODE').toInteger() : 1 } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 67a0b67..778d94d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -20,11 +20,14 @@ android:supportsRtl="true" android:theme="@style/AppTheme"> - + @@ -45,7 +48,7 @@ android:name="net.helcel.cowspent.android.account.AccountActivity" android:label="@string/settings_server_settings" android:parentActivityName="net.helcel.cowspent.android.settings.PreferencesActivity" - android:windowSoftInputMode="stateVisible" + android:windowSoftInputMode="adjustResize|stateVisible" android:launchMode="standard" /> @@ -66,7 +69,7 @@ android:name="net.helcel.cowspent.android.project.create.NewProjectActivity" android:label="@string/simple_add_project" android:parentActivityName="net.helcel.cowspent.android.main.BillsListViewActivity" - android:windowSoftInputMode="stateHidden" + android:windowSoftInputMode="adjustResize" android:launchMode="standard" android:exported="true"> @@ -93,7 +96,7 @@ android:name="net.helcel.cowspent.android.project.edit.EditProjectActivity" android:label="@string/simple_edit_project" android:parentActivityName="net.helcel.cowspent.android.main.BillsListViewActivity" - android:windowSoftInputMode="stateHidden" + android:windowSoftInputMode="adjustResize" android:launchMode="standard"> @@ -101,7 +104,7 @@ android:name="net.helcel.cowspent.android.bill_edit.EditBillActivity" android:label="@string/simple_edit_bill" android:parentActivityName="net.helcel.cowspent.android.main.BillsListViewActivity" - android:windowSoftInputMode="stateHidden" + android:windowSoftInputMode="adjustResize" android:launchMode="standard"> 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/account/AccountActivity.kt b/app/src/main/java/net/helcel/cowspent/android/account/AccountActivity.kt index efe64de..3250acf 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 @@ -13,6 +13,7 @@ import android.webkit.WebView import android.webkit.WebViewClient import android.widget.Toast import androidx.activity.OnBackPressedCallback +import androidx.activity.enableEdgeToEdge import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity @@ -73,6 +74,7 @@ class AccountActivity : AppCompatActivity() { private var showLoginDialog by mutableStateOf(false) override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() super.onCreate(savedInstanceState) preferences = PreferenceManager.getDefaultSharedPreferences(applicationContext) 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..8f3898e 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 @@ -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) 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..07545c1 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 @@ -20,6 +21,7 @@ class LabelBillsActivity : AppCompatActivity() { private val viewModel: LabelBillsViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() super.onCreate(savedInstanceState) val projectId = intent.getLongExtra(EXTRA_PROJECT_ID, -1L) 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..be736c5 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 @@ -40,6 +41,7 @@ class ManageCurrenciesActivity : AppCompatActivity() { } override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() super.onCreate(savedInstanceState) intent.extras?.let { 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..f5ba6fd 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 @@ -7,6 +7,7 @@ import android.os.Bundle import android.os.VibrationEffect import android.os.Vibrator import android.util.Log +import androidx.activity.enableEdgeToEdge import androidx.activity.compose.setContent import androidx.annotation.OptIn import androidx.appcompat.app.AppCompatActivity @@ -42,6 +43,7 @@ class QrCodeScannerActivity : AppCompatActivity() { private var isScanning = true override fun onCreate(state: Bundle?) { + enableEdgeToEdge() super.onCreate(state) cameraExecutor = Executors.newSingleThreadExecutor() 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..e479865 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 @@ -17,6 +17,7 @@ import android.util.Log import android.widget.Toast import androidx.activity.OnBackPressedCallback import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity @@ -193,6 +194,7 @@ class BillsListViewActivity : } override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() super.onCreate(savedInstanceState) isActivityVisible = true if (savedInstanceState != null) { 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..7bdf5d5 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 @@ -33,6 +34,7 @@ class NewProjectActivity : AppCompatActivity() { private lateinit var db: CowspentSQLiteOpenHelper override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() super.onCreate(savedInstanceState) db = CowspentSQLiteOpenHelper.getInstance(this) 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..4e39ac1 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) 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/statistics/ProjectSankeyDiagram.kt b/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectSankeyDiagram.kt index 19410a1..416a5cc 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,34 +1,86 @@ package net.helcel.cowspent.android.statistics import android.annotation.SuppressLint +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring import androidx.compose.foundation.Canvas -import androidx.compose.foundation.layout.* -import androidx.compose.material.* +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.LocalContentColor +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Group -import androidx.compose.runtime.* +import androidx.compose.material.icons.filled.ViewColumn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.CornerRadius -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.res.stringResource +import androidx.core.graphics.toColorInt +import kotlinx.coroutines.launch import net.helcel.cowspent.R -import net.helcel.cowspent.android.helper.* +import net.helcel.cowspent.android.helper.EditableExposedDropdownMenu +import net.helcel.cowspent.android.helper.UserAvatar +import net.helcel.cowspent.android.helper.formatShortValue import net.helcel.cowspent.model.DBBill import net.helcel.cowspent.model.DBCategory import net.helcel.cowspent.model.DBMember -import androidx.core.graphics.toColorInt +import kotlin.math.abs +import kotlin.math.roundToInt + +private object SankeyDimens { + val NodeHeight = 76.dp + val TotalNodeHeight = 42.dp + val NormalGap = 8.dp + val FocusGap = 16.dp + val ActiveMinWidth = 160.dp +} @OptIn(ExperimentalMaterialApi::class) @SuppressLint("UseKtx") @@ -41,15 +93,11 @@ fun ProjectSankeyDiagram( onShareReady: (String) -> Unit ) { val shareStatsIntro = stringResource(R.string.share_stats_intro, projectName) - var selectedMemberId by remember { mutableLongStateOf(-1L) } var expanded by remember { mutableStateOf(false) } val activeBills = remember(allBills) { - allBills.filter { - it.state != DBBill.STATE_DELETED && - it.categoryRemoteId != DBBill.CATEGORY_REIMBURSEMENT - } + allBills.filter { it.state != DBBill.STATE_DELETED && it.categoryRemoteId != DBBill.CATEGORY_REIMBURSEMENT } } val membersMap = remember(allMembers) { allMembers.associateBy { it.id } } @@ -62,408 +110,293 @@ fun ProjectSankeyDiagram( return } - // Consolidated spending calculation val spendings = remember(activeBills, selectedMemberId, membersMap) { - val spentMap = mutableMapOf() + val spentMap = mutableMapOf().apply { membersMap.keys.forEach { put(it, 0.0) } } val catMap = mutableMapOf() - membersMap.keys.forEach { spentMap[it] = 0.0 } activeBills.forEach { bill -> - val totalWeight = bill.billOwers.sumOf { bo -> - membersMap[bo.memberId]?.weight ?: 1.0 - } + val totalWeight = bill.billOwers.sumOf { membersMap[it.memberId]?.weight ?: 1.0 } if (totalWeight > 0) { if (selectedMemberId == -1L) { catMap[bill.categoryRemoteId] = (catMap[bill.categoryRemoteId] ?: 0.0) + bill.amount bill.billOwers.forEach { bo -> val weight = membersMap[bo.memberId]?.weight ?: 1.0 - val share = (bill.amount / totalWeight) * weight - spentMap[bo.memberId] = (spentMap[bo.memberId] ?: 0.0) + share + spentMap[bo.memberId] = (spentMap[bo.memberId] ?: 0.0) + (bill.amount / totalWeight) * weight } } else { bill.billOwers.find { it.memberId == selectedMemberId }?.let { bo -> val weight = membersMap[bo.memberId]?.weight ?: 1.0 - val share = (bill.amount / totalWeight) * weight - catMap[bill.categoryRemoteId] = (catMap[bill.categoryRemoteId] ?: 0.0) + share - spentMap[selectedMemberId] = (spentMap[selectedMemberId] ?: 0.0) + share + catMap[bill.categoryRemoteId] = (catMap[bill.categoryRemoteId] ?: 0.0) + (bill.amount / totalWeight) * weight + spentMap[selectedMemberId] = (spentMap[selectedMemberId] ?: 0.0) + (bill.amount / totalWeight) * weight } } } } - - val memberList = spentMap.toList().filter { it.second > 0 }.sortedByDescending { it.second } - val categoryList = catMap.toList().sortedByDescending { it.second } - memberList to categoryList + spentMap.toList().filter { it.second > 0 }.sortedByDescending { it.second } to catMap.toList().sortedByDescending { it.second } } val displayMemberSpendings = spendings.first val displayCategorySpendings = spendings.second val totalAmount = remember(displayMemberSpendings) { displayMemberSpendings.sumOf { it.second } } + var isSpecialMode by remember { mutableStateOf(false) } LaunchedEffect(displayMemberSpendings, displayCategorySpendings, totalAmount, projectName) { - val statsText = StringBuilder() - statsText.append("// ").append(shareStatsIntro.replace("\n", "\n// ")).append("\n\n") - - val middleNode = if (selectedMemberId == -1L) "Total" else "Spent" - - displayMemberSpendings.forEach { (memberId, amount) -> - val name = membersMap[memberId]?.name ?: "???" - statsText.append("$name [${formatShortValue(amount)}] $middleNode\n") - } - - statsText.append("\n") - - displayCategorySpendings.forEach { (catRemoteId, amount) -> - val name = categoriesMap[catRemoteId]?.name ?: "Other" - statsText.append("$middleNode [${formatShortValue(amount)}] $name\n") - } - - statsText.append("\n") - - displayMemberSpendings.forEach { (memberId, _) -> - val member = membersMap[memberId] - if (member != null) { - val hexColor = String.format("#%02x%02x%02x", member.r ?: 128, member.g ?: 128, member.b ?: 128) - statsText.append(":${member.name} $hexColor\n") + val statsText = StringBuilder().apply { + append("// ").append(shareStatsIntro.replace("\n", "\n// ")).append("\n\n") + val middleNode = if (selectedMemberId == -1L) "Total" else "Spent" + displayMemberSpendings.forEach { (id, amount) -> append("${membersMap[id]?.name ?: "???"} [${formatShortValue(amount)}] $middleNode\n") } + append("\n") + displayCategorySpendings.forEach { (id, amount) -> append("$middleNode [${formatShortValue(amount)}] ${categoriesMap[id]?.name ?: "Other"}\n") } + append("\n") + displayMemberSpendings.forEach { (id, _) -> + membersMap[id]?.let { append(":${it.name} ${String.format("#%02x%02x%02x", it.r ?: 128, it.g ?: 128, it.b ?: 128)}\n") } } + displayCategorySpendings.forEach { (id, _) -> categoriesMap[id]?.let { append(":${it.name ?: "Other"} ${it.color}\n") } } } - - displayCategorySpendings.forEach { (catRemoteId, _) -> - val category = categoriesMap[catRemoteId] - if (category != null) { - statsText.append(":${category.name ?: "Other"} ${category.color}\n") - } - } - onShareReady(statsText.toString()) } - Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { - val selectedMember = membersMap[selectedMemberId] - EditableExposedDropdownMenu( - value = selectedMember?.name ?: "All Members", - placeholder = "Filter by member", - expanded = expanded, - onExpandedChange = { expanded = it }, - onDismissRequest = { expanded = false }, - modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), - leadingIcon = { - Box(modifier = Modifier.padding(start = 12.dp)) { - if (selectedMember != null) { - UserAvatar( - name = selectedMember.name, - r = selectedMember.r, - g = selectedMember.g, - b = selectedMember.b, - disabled = !selectedMember.isActivated, - size = 24.dp - ) - } else { - Icon(Icons.Default.Group, contentDescription = null) - } - } - }, - content = { - DropdownMenuItem(onClick = { - selectedMemberId = -1L - expanded = false - }) { - Icon(Icons.Default.Group, contentDescription = null) - Spacer(modifier = Modifier.width(12.dp)) - Text("All Members") - } - allMembers.forEach { member -> - DropdownMenuItem(onClick = { - selectedMemberId = member.id - expanded = false - }) { - UserAvatar( - name = member.name, - r = member.r, - g = member.g, - b = member.b, - disabled = !member.isActivated, - size = 24.dp - ) - Spacer(modifier = Modifier.width(12.dp)) - Text(member.name) + Column(modifier = Modifier.fillMaxSize()) { + Box(modifier = Modifier.padding(16.dp)) { + val selectedMember = membersMap[selectedMemberId] + Row(verticalAlignment = Alignment.CenterVertically) { + EditableExposedDropdownMenu( + value = selectedMember?.name ?: "All Members", + placeholder = "Filter by member", + expanded = expanded, + onExpandedChange = { expanded = it }, + onDismissRequest = { expanded = false }, + modifier = Modifier.weight(1f).padding(bottom = 16.dp), + leadingIcon = { + Box(modifier = Modifier.padding(start = 12.dp)) { + if (selectedMember != null) { + 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) + } + } } + ) + 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", StatisticsMockData.members, StatisticsMockData.bills, emptyList()) {} } 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/theme/ThemeUtils.kt b/app/src/main/java/net/helcel/cowspent/theme/ThemeUtils.kt index dfdcb6c..2b371fa 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,18 @@ 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() + ) { content() } } From 65799496f9e2125c50c7be812b60ea919bd3f710 Mon Sep 17 00:00:00 2001 From: sora Date: Tue, 9 Jun 2026 08:51:43 +0200 Subject: [PATCH 02/31] Update app/build.gradle --- app/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle b/app/build.gradle index 2ad6f2d..ec8a48d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -14,7 +14,7 @@ android { applicationId "net.helcel.cowspent" minSdk = 26 targetSdk = 37 - versionName "1.0a" + versionName "1.0b" versionCode project.hasProperty('VERSION_CODE') ? project.property('VERSION_CODE').toInteger() : 1 } From 3675e87651dc05943b6c34a457903e40df487f35 Mon Sep 17 00:00:00 2001 From: sora Date: Tue, 9 Jun 2026 19:18:02 +0200 Subject: [PATCH 03/31] Upload files to ".github/images" --- .github/images/apk.png | Bin 0 -> 6538 bytes .github/images/izzy.png | Bin 0 -> 20993 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 .github/images/apk.png create mode 100644 .github/images/izzy.png diff --git a/.github/images/apk.png b/.github/images/apk.png new file mode 100644 index 0000000000000000000000000000000000000000..d7850af9fe6cf1d3ac35d4f08d6fd9dd398e2b45 GIT binary patch literal 6538 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 Y%|lTp0i7XQuvsH=ifC(5X6AeCFS(pD;{X5v literal 0 HcmV?d00001 From 110ab397b899fee59f65edcfa28cc9f831c236ca Mon Sep 17 00:00:00 2001 From: sora Date: Tue, 9 Jun 2026 19:19:24 +0200 Subject: [PATCH 04/31] Update README.md --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index e2a45dc..3be6673 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ Logo

+ 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). From a9a303478c350544016d12343dd6ca5ab9d3b934 Mon Sep 17 00:00:00 2001 From: soraefir Date: Sun, 14 Jun 2026 21:33:08 +0200 Subject: [PATCH 05/31] better currency handling --- .../android/bill_edit/EditBillActivity.kt | 45 ++++++-- .../android/bill_edit/EditBillScreen.kt | 46 +++++--- .../android/bill_edit/EditBillViewModel.kt | 104 +++++++++++++----- .../currencies/ManageCurrenciesActivity.kt | 17 ++- .../currencies/ManageCurrenciesScreen.kt | 74 +++++++++++-- .../helcel/cowspent/android/drawer/Drawer.kt | 36 +++++- .../cowspent/android/helper/AlertDialog.kt | 61 ++++++++-- .../cowspent/android/helper/IconUtils.kt | 33 ++++++ .../android/main/BillsListViewActivity.kt | 4 +- .../persistence/CowspentSQLiteOpenHelper.kt | 6 +- 10 files changed, 344 insertions(+), 82 deletions(-) create mode 100644 app/src/main/java/net/helcel/cowspent/android/helper/IconUtils.kt 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 8f3898e..611dd12 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 @@ -27,6 +27,7 @@ import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper import net.helcel.cowspent.theme.ThemeUtils import net.helcel.cowspent.util.BillParser import net.helcel.cowspent.util.CategoryUtils +import net.helcel.cowspent.util.SupportUtil import java.text.ParseException import java.time.ZoneId import java.util.Calendar @@ -107,7 +108,8 @@ class EditBillActivity : AppCompatActivity() { val createIntent = Intent(this@EditBillActivity, QrCodeScannerActivity::class.java) scanQRCodeLauncher.launch(createIntent) }, - onDelete = if (bill.id > 0) { { deleteBillAsked() } } else null, + onDuplicate = if (!viewModel.isNewBill) { { duplicateCurrentBill() } } else null, + onDelete = if (!viewModel.isNewBill) { { deleteBillAsked() } } else null, accessLevel = db.getProject(bill.projectId)?.myAccessLevel ?: DBProject.ACCESS_LEVEL_ADMIN ) } @@ -191,6 +193,7 @@ class EditBillActivity : AppCompatActivity() { withContext(Dispatchers.Main) { viewModel.currencies = currencies viewModel.mainCurrencyName = project?.currencyName ?: "" + viewModel.isNewBill = (bill.id == 0L) viewModel.initFromBill(bill, members, customSplits) } } @@ -225,6 +228,19 @@ class EditBillActivity : AppCompatActivity() { } } + private fun duplicateCurrentBill() { + bill = DBBill( + 0, 0, bill.projectId, viewModel.payerId, viewModel.amountAsDouble, + System.currentTimeMillis() / 1000, viewModel.what, DBBill.STATE_ADDED, + viewModel.repeat, bill.paymentMode, viewModel.categoryRemoteId, + viewModel.getFinalComment(), viewModel.paymentModeRemoteId + ) + viewModel.timestamp = bill.timestamp + viewModel.isNewBill = true + + showToast(this, "Duplicating bill...") + } + private fun onBack() { if (!valuesHaveChanged()) { finish() @@ -298,9 +314,9 @@ class EditBillActivity : AppCompatActivity() { return !(bill.what == viewModel.what && bill.timestamp == viewModel.timestamp && - bill.amount == viewModel.amountAsDouble && + bill.amount == viewModel.getFinalAmount() && bill.payerId == viewModel.payerId && - bill.comment == viewModel.comment && + bill.comment == viewModel.getFinalComment() && bill.repeat == viewModel.repeat && bill.categoryRemoteId == viewModel.categoryRemoteId && bill.paymentModeRemoteId == viewModel.paymentModeRemoteId && @@ -312,13 +328,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 @@ -360,7 +380,7 @@ class EditBillActivity : AppCompatActivity() { existingBill.paymentMode, viewModel.paymentModeRemoteId, viewModel.categoryRemoteId, - viewModel.comment + finalComment ) if (firstSavedId == 0L) firstSavedId = billToUseId } else { @@ -368,7 +388,7 @@ class EditBillActivity : AppCompatActivity() { val newBill = DBBill( 0, 0, bill.projectId, viewModel.payerId, amount, viewModel.timestamp, viewModel.what, DBBill.STATE_ADDED, viewModel.repeat, - bill.paymentMode, viewModel.categoryRemoteId, viewModel.comment, viewModel.paymentModeRemoteId + bill.paymentMode, viewModel.categoryRemoteId, finalComment, viewModel.paymentModeRemoteId ) newBill.billOwers = listOf(DBBillOwer(0, 0, memberId)) val newId = db.addBill(newBill) @@ -398,7 +418,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) { @@ -414,7 +435,7 @@ class EditBillActivity : AppCompatActivity() { bill.paymentMode, viewModel.paymentModeRemoteId, viewModel.categoryRemoteId, - viewModel.comment + finalComment ) if (groupedBillIds != null) { for (id in groupedBillIds) { @@ -431,7 +452,7 @@ class EditBillActivity : AppCompatActivity() { val newBill = DBBill( 0, 0, bill.projectId, viewModel.payerId, newAmount, viewModel.timestamp, viewModel.what, DBBill.STATE_ADDED, viewModel.repeat, - bill.paymentMode, viewModel.categoryRemoteId, viewModel.comment, viewModel.paymentModeRemoteId + bill.paymentMode, viewModel.categoryRemoteId, finalComment, viewModel.paymentModeRemoteId ) newOwersIds.forEach { newBill.billOwers += DBBillOwer(0, 0, it) } val newBillId = db.addBill(newBill) 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..212e41f 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 @@ -45,6 +45,7 @@ fun EditBillScreen( onDateClick: () -> Unit, onTimeClick: () -> Unit, onScan: () -> Unit, + onDuplicate: (() -> Unit)? = null, onDelete: (() -> Unit)? = null, accessLevel: Int = DBProject.ACCESS_LEVEL_ADMIN ) { @@ -59,7 +60,7 @@ fun EditBillScreen( Scaffold( topBar = { TopAppBar( - title = { Text(stringResource(if (viewModel.what.isEmpty()) R.string.simple_new_bill else R.string.simple_edit_bill)) }, + title = { Text(stringResource(if (viewModel.isNewBill) R.string.simple_new_bill else R.string.simple_edit_bill)) }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) @@ -70,6 +71,11 @@ fun EditBillScreen( IconButton(onClick = onScan) { Icon(Icons.Default.QrCodeScanner, contentDescription = null) } + if (onDuplicate != null) { + IconButton(onClick = onDuplicate) { + Icon(Icons.Default.ContentCopy, contentDescription = "Duplicate") + } + } if (onDelete != null) { IconButton(onClick = onDelete) { Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.action_delete)) @@ -160,7 +166,6 @@ fun BillBasicInfoSection( val context = LocalContext.current val currencyDialogTitle = stringResource(R.string.currency_dialog_title, viewModel.mainCurrencyName) - val noCurrencyError = stringResource(R.string.no_currency_error) OutlinedTextField( value = viewModel.what, @@ -182,25 +187,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 } + 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 { "Main" } + 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.selectedCurrencyName = "" + viewModel.selectedCurrencyRate = 1.0 + viewModel.updateSplits() + } else { + viewModel.convertCurrency(viewModel.currencies[index - 1]) + } + } + ) } ) { - Icon(Icons.Default.CurrencyExchange, contentDescription = null) + Icon(Icons.Default.SwapHoriz, contentDescription = null) } }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) @@ -587,6 +604,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..7e71cbe 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 @@ -25,9 +25,12 @@ class EditBillViewModel : ViewModel() { var repeat by mutableStateOf(DBBill.NON_REPEATED) var paymentModeRemoteId by mutableIntStateOf(0) var categoryRemoteId by mutableIntStateOf(0) + var isNewBill by mutableStateOf(false) var currencies by mutableStateOf>(emptyList()) var mainCurrencyName by mutableStateOf("") + var selectedCurrencyName by mutableStateOf("") + var selectedCurrencyRate by mutableStateOf(1.0) var members by mutableStateOf>(emptyList()) var owersSelection = mutableStateMapOf() @@ -128,46 +131,77 @@ 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() - } - } - } + if (amountAsDouble == 0.0) return + selectedCurrencyName = currency.name ?: "" + selectedCurrencyRate = currency.exchangeRate + + // We don't change 'amount' here anymore as per request. + // It stays as the original amount. + 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 + + 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 +212,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/currencies/ManageCurrenciesActivity.kt b/app/src/main/java/net/helcel/cowspent/android/currencies/ManageCurrenciesActivity.kt index be736c5..bca038e 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 @@ -15,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 @@ -86,11 +87,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) } } } 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..abf976d 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 @@ -4,6 +4,7 @@ import android.annotation.SuppressLint import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack @@ -12,6 +13,7 @@ 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 @@ -93,19 +95,42 @@ fun ManageCurrenciesScreen( Spacer(modifier = Modifier.height(24.dp)) Text(stringResource(R.string.add_currency_title), style = MaterialTheme.typography.h6) + + // Visual relationship indicator + Surface( + color = MaterialTheme.colors.primary.copy(alpha = 0.05f), + shape = RoundedCornerShape(8.dp), + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp) + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Text("1 ", style = MaterialTheme.typography.body1) + Text(viewModel.mainCurrencyName.ifEmpty { "Base" }, fontWeight = FontWeight.Bold) + Text(" = ", style = MaterialTheme.typography.h6) + Text(viewModel.newCurrencyRate.ifEmpty { "0.0" }, fontWeight = FontWeight.Bold, color = MaterialTheme.colors.primary) + Text(" ") + Text(viewModel.newCurrencyName.ifEmpty { "Currency" }, fontWeight = FontWeight.Bold) + } + } + Row(verticalAlignment = Alignment.CenterVertically) { OutlinedTextField( value = viewModel.newCurrencyName, onValueChange = { viewModel.newCurrencyName = it }, label = { Text(stringResource(R.string.currency_edit_name)) }, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), + placeholder = { Text("e.g. USD") } ) 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) + modifier = Modifier.weight(1f), + placeholder = { Text("1.0") } ) } Spacer(modifier = Modifier.height(8.dp)) @@ -121,7 +146,7 @@ fun ManageCurrenciesScreen( LazyColumn(modifier = Modifier.weight(1f)) { items(viewModel.currencies) { currency -> - CurrencyRow(currency, onDelete = { onDelete(currency) }) + CurrencyRow(currency, viewModel.mainCurrencyName, onDelete = { onDelete(currency) }) } } } @@ -129,15 +154,47 @@ fun ManageCurrenciesScreen( } @Composable -fun CurrencyRow(currency: DBCurrency, onDelete: () -> Unit) { +fun CurrencyRow(currency: DBCurrency, mainCurrencyName: String, onDelete: () -> Unit) { Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), verticalAlignment = Alignment.CenterVertically ) { - Column(modifier = Modifier.weight(1f)) { - Text(currency.name ?: "", style = MaterialTheme.typography.subtitle1) - Text("Rate: ${currency.exchangeRate}", style = MaterialTheme.typography.caption) + // Table-like layout + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "1 ", + style = MaterialTheme.typography.body1, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f) + ) + Text( + text = mainCurrencyName, + style = MaterialTheme.typography.body1, + fontWeight = FontWeight.Bold + ) + Text( + text = " = ", + style = MaterialTheme.typography.h6, + color = MaterialTheme.colors.primary, + modifier = Modifier.padding(horizontal = 8.dp) + ) + Column { + Text( + text = currency.exchangeRate.toString(), + style = MaterialTheme.typography.body1, + fontWeight = FontWeight.ExtraBold + ) + Text( + text = currency.name ?: "", + style = MaterialTheme.typography.caption, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f) + ) + } } + IconButton(onClick = onDelete) { Icon(Icons.Default.Delete, contentDescription = null, tint = MaterialTheme.colors.error) } @@ -151,6 +208,7 @@ fun CurrencyRowPreview() { MaterialTheme { CurrencyRow( currency = DBCurrency(1, 0, 0, "USD", 1.0, 0), + mainCurrencyName = "EUR", onDelete = {} ) } 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..f46b967 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 @@ -26,6 +27,8 @@ import net.helcel.cowspent.R import net.helcel.cowspent.android.helper.UserAvatar 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,37 @@ fun Drawer( // Members Section val membersState = rememberLazyListState() + val sortedMembers = remember(members, memberBalances) { + members.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, @@ -241,6 +260,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, @@ -294,6 +314,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 +371,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..284d1af 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 @@ -128,10 +129,32 @@ fun AlertDialog( ) Spacer(Modifier.width(16.dp)) } - Text( - text = item.toString(), - style = MaterialTheme.typography.body1 - ) + + 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 + ) + } } } } @@ -234,10 +257,32 @@ fun AlertDialogContent( ) Spacer(Modifier.width(16.dp)) } - Text( - text = item.toString(), - style = MaterialTheme.typography.body1 - ) + + 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 + ) + } } } } 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/main/BillsListViewActivity.kt b/app/src/main/java/net/helcel/cowspent/android/main/BillsListViewActivity.kt index e479865..799a8f5 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 @@ -444,11 +444,11 @@ class BillsListViewActivity : fun onManageCurrenciesClick(projectId: Long) { lifecycleScope.launch { val proj = withContext(Dispatchers.IO) { db.getProject(projectId) } - if (proj != null && proj.type == ProjectType.COSPEND) { + if (proj != null) { startActivity(Intent(applicationContext, ManageCurrenciesActivity::class.java).apply { putExtra(ManageCurrenciesActivity.EXTRA_PROJECT_ID, projectId) }) - } else showToast(this@BillsListViewActivity, getString(R.string.currency_management_unavailable)) + } } } 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..ca107fd 100644 --- a/app/src/main/java/net/helcel/cowspent/persistence/CowspentSQLiteOpenHelper.kt +++ b/app/src/main/java/net/helcel/cowspent/persistence/CowspentSQLiteOpenHelper.kt @@ -553,7 +553,11 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) : } fun getCurrencies(projectId: Long): List { - return getCurrenciesCustom("$key_projectid = ?", arrayOf(projectId.toString()), null) + return getCurrenciesCustom( + "$key_projectid = ? AND $key_state != ?", + arrayOf(projectId.toString(), DBBill.STATE_DELETED.toString()), + null + ) } @WorkerThread From d617c5c7be434c7be2145e1618a71782dcdf2385 Mon Sep 17 00:00:00 2001 From: soraefir Date: Sun, 14 Jun 2026 21:34:58 +0200 Subject: [PATCH 06/31] project creation and currency db --- .../cowspent/android/main/BillsListScreen.kt | 2 + .../project/create/NewProjectActivity.kt | 43 +++++++-- .../project/create/NewProjectScreen.kt | 49 ++++++---- .../project/create/NewProjectViewModel.kt | 21 ++++- .../persistence/CowspentServerSyncHelper.kt | 89 ++++++++++++------- 5 files changed, 142 insertions(+), 62 deletions(-) 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..f1a9e70 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 @@ -430,6 +430,7 @@ fun BillsListScreen( } }, drawerContent = { + val selectedProject = viewModel.projects.find { it.id == viewModel.selectedProjectId } Drawer( projects = viewModel.projects, members = viewModel.members, @@ -437,6 +438,7 @@ fun BillsListScreen( selectedProjectId = viewModel.selectedProjectId, selectedMemberId = viewModel.selectedMemberId, lastSyncText = viewModel.lastSyncText, + mainCurrency = selectedProject?.currencyName, showArchived = showArchived, onProjectClick = { viewModel.selectedMemberId = null 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 7bdf5d5..d348563 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 @@ -21,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 @@ -60,7 +61,8 @@ class NewProjectActivity : AppCompatActivity() { chooseFromNextcloud() }, onOkPressed = { onPressOk() }, - onBack = { finish() } + onBack = { finish() }, + onFieldsChanged = { updateAuthStatus() } ) } } @@ -85,6 +87,8 @@ class NewProjectActivity : AppCompatActivity() { viewModel.projectUrl = defaultNcUrl } } + + updateAuthStatus() val defaultProjectId = intent.getStringExtra(PARAM_DEFAULT_PROJECT_ID) if (defaultProjectId != null) { @@ -139,6 +143,17 @@ class NewProjectActivity : AppCompatActivity() { } } + private fun updateAuthStatus() { + val url = getFormattedUrl() + val fakeProj = DBProject( + 0, "", "", "", url, + "", 0L, viewModel.projectType, 0L, + null, false, DBProject.ACCESS_LEVEL_UNKNOWN, + "" + ) + viewModel.isAuthenticatedAccount = db.cowspentServerSyncHelper.canCreateAuthenticatedProject(fakeProj) + } + private fun onPressOk() { val type = viewModel.projectType val todoCreate = viewModel.whatTodoIsCreate @@ -186,7 +201,16 @@ class NewProjectActivity : AppCompatActivity() { } private fun createProject() { - val isCospendScheme = isCospendSchemeLink(getFormattedUrl()) + val url = getFormattedUrl() + val isCospendScheme = isCospendSchemeLink(url) + + if (viewModel.whatTodoIsCreate && viewModel.projectId.isEmpty() && viewModel.projectName.isNotEmpty()) { + viewModel.projectId = viewModel.projectName.lowercase(Locale.ROOT) + .replace("[^a-z0-9]".toRegex(), "-") + .replace("-+".toRegex(), "-") + .trim('-') + } + val rid = viewModel.projectId if (!isCospendScheme && (rid == "" || rid.contains(",") || rid.contains("/"))) { showToast(getString(R.string.error_invalid_project_remote_id), Toast.LENGTH_LONG) @@ -194,7 +218,7 @@ class NewProjectActivity : AppCompatActivity() { } if (viewModel.projectType != ProjectType.LOCAL && !isCospendScheme) { - if (!isValidUrl(getFormattedUrl())) { + if (!isValidUrl(url)) { showToast("Invalid URL", Toast.LENGTH_SHORT) return } @@ -217,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 } @@ -226,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 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..fa12f85 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 @@ -32,8 +32,13 @@ fun NewProjectScreen( onImportFile: () -> Unit, onChooseFromNextcloud: () -> Unit, onOkPressed: () -> Unit, - onBack: () -> Unit + onBack: () -> Unit, + onFieldsChanged: () -> Unit ) { + LaunchedEffect(viewModel.projectUrl, viewModel.projectType) { + onFieldsChanged() + } + Scaffold( topBar = { TopAppBar( @@ -162,16 +167,18 @@ fun NewProjectScreen( Spacer(modifier = Modifier.height(8.dp)) } - OutlinedTextField( - value = viewModel.projectId, - onValueChange = { viewModel.projectId = it }, - label = { Text(stringResource(R.string.setting_project_id)) }, - modifier = Modifier.fillMaxWidth(), - leadingIcon = { Icon(Icons.AutoMirrored.Filled.LibraryBooks, contentDescription = null) } - ) + if (!viewModel.whatTodoIsCreate || !viewModel.isAuthenticatedAccount || viewModel.projectType != ProjectType.COSPEND) { + OutlinedTextField( + value = viewModel.projectId, + onValueChange = { viewModel.projectId = it }, + label = { Text(stringResource(R.string.setting_project_id)) }, + modifier = Modifier.fillMaxWidth(), + leadingIcon = { Icon(Icons.AutoMirrored.Filled.LibraryBooks, contentDescription = null) } + ) + Spacer(modifier = Modifier.height(8.dp)) + } if (viewModel.projectType != ProjectType.LOCAL && (!viewModel.whatTodoIsCreate || viewModel.projectType == ProjectType.IHATEMONEY)) { - Spacer(modifier = Modifier.height(8.dp)) OutlinedTextField( value = viewModel.projectPassword, onValueChange = { viewModel.projectPassword = it }, @@ -179,10 +186,10 @@ fun NewProjectScreen( modifier = Modifier.fillMaxWidth(), leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) } ) + Spacer(modifier = Modifier.height(8.dp)) } if (viewModel.whatTodoIsCreate && viewModel.projectType != ProjectType.LOCAL) { - Spacer(modifier = Modifier.height(8.dp)) OutlinedTextField( value = viewModel.projectName, onValueChange = { viewModel.projectName = it }, @@ -190,14 +197,17 @@ fun NewProjectScreen( modifier = Modifier.fillMaxWidth(), leadingIcon = { Icon(Icons.Default.Title, contentDescription = null) } ) - Spacer(modifier = Modifier.height(8.dp)) - OutlinedTextField( - value = viewModel.projectEmail, - onValueChange = { viewModel.projectEmail = it }, - label = { Text(stringResource(R.string.setting_new_project_email)) }, - modifier = Modifier.fillMaxWidth(), - leadingIcon = { Icon(Icons.Default.Email, contentDescription = null) } - ) + + if (!viewModel.isAuthenticatedAccount || viewModel.projectType == ProjectType.IHATEMONEY) { + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = viewModel.projectEmail, + onValueChange = { viewModel.projectEmail = it }, + label = { Text(stringResource(R.string.setting_new_project_email)) }, + modifier = Modifier.fillMaxWidth(), + leadingIcon = { Icon(Icons.Default.Email, contentDescription = null) } + ) + } } } } @@ -334,6 +344,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/persistence/CowspentServerSyncHelper.kt b/app/src/main/java/net/helcel/cowspent/persistence/CowspentServerSyncHelper.kt index fab690b..457b6a6 100644 --- a/app/src/main/java/net/helcel/cowspent/persistence/CowspentServerSyncHelper.kt +++ b/app/src/main/java/net/helcel/cowspent/persistence/CowspentServerSyncHelper.kt @@ -320,48 +320,69 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen } } - val currenciesToDelete = dbHelper.getCurrenciesOfProjectWithState(project.id, DBBill.STATE_DELETED) - for (cToDel in currenciesToDelete) { - try { - val deleteRemoteCurrencyResponse = client!!.deleteRemoteCurrency(project, cToDel.remoteId) - if (deleteRemoteCurrencyResponse.stringContent == "OK") { - Log.d(TAG, "successfully deleted currency on remote project : delete it locally") - dbHelper.deleteCurrency(cToDel.id) - } - } catch (e: IOException) { - if (e.message == "\"Not Found\"") { - Log.d(TAG, "failed to delete currency on remote project : delete it locally anyway") - dbHelper.deleteCurrency(cToDel.id) - } else { - throw e + if (project.type == ProjectType.COSPEND) { + val currenciesToDelete = dbHelper.getCurrenciesOfProjectWithState(project.id, DBBill.STATE_DELETED) + for (cToDel in currenciesToDelete) { + try { + val deleteRemoteCurrencyResponse = client!!.deleteRemoteCurrency(project, cToDel.remoteId) + if (deleteRemoteCurrencyResponse.stringContent == "OK") { + Log.d(TAG, "successfully deleted currency on remote project : delete it locally") + dbHelper.deleteCurrency(cToDel.id) + } + } catch (e: IOException) { + if (e.message == "\"Not Found\"") { + Log.d(TAG, "failed to delete currency on remote project : delete it locally anyway") + dbHelper.deleteCurrency(cToDel.id) + } else { + throw e + } } } + } else { + val currenciesToDelete = dbHelper.getCurrenciesOfProjectWithState(project.id, DBBill.STATE_DELETED) + for (cToDel in currenciesToDelete) { + dbHelper.deleteCurrency(cToDel.id) + } } - val currenciesToEdit = dbHelper.getCurrenciesOfProjectWithState(project.id, DBBill.STATE_EDITED) - for (cToEdit in currenciesToEdit) { - try { - val editRemoteCurrencyResponse = client!!.editRemoteCurrency(project, cToEdit) - if (editRemoteCurrencyResponse.stringContent == cToEdit.remoteId.toString()) { - dbHelper.setCurrencyState(cToEdit.id, DBBill.STATE_OK) - Log.d(TAG, "SUCCESSFUL remote currency edition (${editRemoteCurrencyResponse.stringContent})") - } else { - Log.d(TAG, "FAILED to edit remote currency (${editRemoteCurrencyResponse.stringContent})") - } - } catch (e: IOException) { - if (e.message == "{\"message\": \"Internal Server Error\"}") { - Log.d(TAG, "FAILED to edit remote currency : it does not exist remotely") - } else { - throw e + if (project.type == ProjectType.COSPEND) { + val currenciesToEdit = dbHelper.getCurrenciesOfProjectWithState(project.id, DBBill.STATE_EDITED) + for (cToEdit in currenciesToEdit) { + try { + val editRemoteCurrencyResponse = client!!.editRemoteCurrency(project, cToEdit) + if (editRemoteCurrencyResponse.stringContent == cToEdit.remoteId.toString()) { + dbHelper.setCurrencyState(cToEdit.id, DBBill.STATE_OK) + Log.d(TAG, "SUCCESSFUL remote currency edition (${editRemoteCurrencyResponse.stringContent})") + } else { + Log.d(TAG, "FAILED to edit remote currency (${editRemoteCurrencyResponse.stringContent})") + } + } catch (e: IOException) { + if (e.message == "{\"message\": \"Internal Server Error\"}") { + Log.d(TAG, "FAILED to edit remote currency : it does not exist remotely") + } else { + throw e + } } } + } else { + val currenciesToEdit = dbHelper.getCurrenciesOfProjectWithState(project.id, DBBill.STATE_EDITED) + for (cToEdit in currenciesToEdit) { + dbHelper.setCurrencyState(cToEdit.id, DBBill.STATE_OK) + } } - val currencyToAdd = dbHelper.getCurrenciesOfProjectWithState(project.id, DBBill.STATE_ADDED) - for (cToAdd in currencyToAdd) { - val createRemoteCurrencyResponse = client!!.createRemoteCurrency(project, cToAdd) - val newRemoteId = createRemoteCurrencyResponse.stringContent.toLong() - if (newRemoteId > 0) { + if (project.type == ProjectType.COSPEND) { + val currencyToAdd = dbHelper.getCurrenciesOfProjectWithState(project.id, DBBill.STATE_ADDED) + for (cToAdd in currencyToAdd) { + val createRemoteCurrencyResponse = client!!.createRemoteCurrency(project, cToAdd) + val newRemoteId = createRemoteCurrencyResponse.stringContent.toLong() + if (newRemoteId > 0) { + dbHelper.setCurrencyState(cToAdd.id, DBBill.STATE_OK) + } + } + } else { + val currencyToAdd = dbHelper.getCurrenciesOfProjectWithState(project.id, DBBill.STATE_ADDED) + for (cToAdd in currencyToAdd) { dbHelper.setCurrencyState(cToAdd.id, DBBill.STATE_OK) } } From 5c74336c10aa62dc7b244b333f9a11a93c4cbc09 Mon Sep 17 00:00:00 2001 From: soraefir Date: Sat, 27 Jun 2026 12:18:33 +0200 Subject: [PATCH 07/31] Fixes for currency, logo, render and more --- .../android/bill_edit/EditBillActivity.kt | 5 +- .../android/bill_edit/EditBillScreen.kt | 34 +--- .../android/bill_edit/EditBillViewModel.kt | 8 +- .../helcel/cowspent/android/drawer/Drawer.kt | 11 +- .../cowspent/android/helper/AlertDialog.kt | 174 +++++++++--------- .../cowspent/android/helper/UserAvatar.kt | 21 +++ .../android/main/BillsListComponents.kt | 11 +- .../project/member/MemberManagementDialog.kt | 11 +- .../statistics/ProjectSankeyDiagram.kt | 6 +- .../res/drawable/ic_launcher_foreground.xml | 1 - .../res/drawable/ic_launcher_monochrome.xml | 31 ++++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 2 +- .../mipmap-anydpi-v26/ic_launcher_round.xml | 2 +- 13 files changed, 178 insertions(+), 139 deletions(-) create mode 100644 app/src/main/res/drawable/ic_launcher_monochrome.xml 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 611dd12..a088640 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 @@ -209,6 +209,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) { } @@ -220,6 +221,7 @@ class EditBillActivity : AppCompatActivity() { viewModel.timestamp = calendar.timeInMillis / 1000 } viewModel.amount = croatianBill.amount.toString() + viewModel.updateSplits() return@registerForActivityResult } catch (_: ParseException) { } @@ -235,7 +237,8 @@ class EditBillActivity : AppCompatActivity() { viewModel.repeat, bill.paymentMode, viewModel.categoryRemoteId, viewModel.getFinalComment(), viewModel.paymentModeRemoteId ) - viewModel.timestamp = bill.timestamp + calendar.timeInMillis = System.currentTimeMillis() + viewModel.timestamp = calendar.timeInMillis / 1000 viewModel.isNewBill = true showToast(this, "Duplicating bill...") 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 212e41f..bccaa90 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 @@ -207,9 +207,7 @@ fun BillBasicInfoSection( items = options, onItemSelected = { index -> if (index == 0) { - viewModel.selectedCurrencyName = "" - viewModel.selectedCurrencyRate = 1.0 - viewModel.updateSplits() + viewModel.resetCurrency() } else { viewModel.convertCurrency(viewModel.currencies[index - 1]) } @@ -217,7 +215,7 @@ fun BillBasicInfoSection( ) } ) { - Icon(Icons.Default.SwapHoriz, contentDescription = null) + Icon(Icons.Default.SwapHoriz, contentDescription = "Change Currency") } }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) @@ -238,7 +236,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( @@ -246,7 +244,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") } ) } } @@ -269,12 +267,8 @@ fun PayerSection( leadingIcon = { Box(modifier = Modifier.padding(start = 12.dp)) { if (selectedPayer != null) { - UserAvatar( - name = selectedPayer.name, - r = selectedPayer.r, - g = selectedPayer.g, - b = selectedPayer.b, - disabled = !selectedPayer.isActivated, + MemberAvatar( + member = selectedPayer, size = 24.dp ) } else { @@ -288,12 +282,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)) @@ -398,12 +388,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)) 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 7e71cbe..defdb8c 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 @@ -131,8 +131,6 @@ class EditBillViewModel : ViewModel() { } fun convertCurrency(currency: DBCurrency) { - if (amountAsDouble == 0.0) return - selectedCurrencyName = currency.name ?: "" selectedCurrencyRate = currency.exchangeRate @@ -142,6 +140,12 @@ class EditBillViewModel : ViewModel() { updateSplits() } + fun resetCurrency() { + selectedCurrencyName = "" + selectedCurrencyRate = 1.0 + updateSplits() + } + fun getFinalAmount(): Double { return SupportUtil.round2(amountAsDouble / selectedCurrencyRate) } 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 f46b967..d2bc0c3 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 @@ -24,7 +24,7 @@ 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 @@ -279,13 +279,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 ) 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 284d1af..421a38c 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 @@ -111,49 +111,54 @@ 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)) } - - 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 - ) + if (index < items.size - 1) { + Divider(color = MaterialTheme.colors.onSurface.copy(alpha = 0.08f)) } } } @@ -239,49 +244,54 @@ 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)) } - - 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 - ) + 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/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/main/BillsListComponents.kt b/app/src/main/java/net/helcel/cowspent/android/main/BillsListComponents.kt index cb855b2..8c20bc1 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 @@ -16,7 +16,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import net.helcel.cowspent.R -import net.helcel.cowspent.android.helper.UserAvatar +import net.helcel.cowspent.android.helper.MemberAvatar import net.helcel.cowspent.model.DBBill import net.helcel.cowspent.model.DBMember import net.helcel.cowspent.util.SupportUtil @@ -83,13 +83,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) { 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 index d2121f3..aa476f4 100644 --- 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 @@ -30,7 +30,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import net.helcel.cowspent.R -import net.helcel.cowspent.android.helper.UserAvatar +import net.helcel.cowspent.android.helper.MemberAvatar import net.helcel.cowspent.android.helper.lazyVerticalScrollbar import net.helcel.cowspent.model.DBMember @@ -84,13 +84,8 @@ fun MemberManagementDialogContent( .padding(vertical = 12.dp), verticalAlignment = Alignment.CenterVertically ) { - UserAvatar( - name = member.name, - r = member.r, - g = member.g, - b = member.b, - avatar = member.avatar, - disabled = !member.isActivated, + MemberAvatar( + member = member, size = 40.dp ) Spacer(modifier = Modifier.width(16.dp)) 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 416a5cc..351234f 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 @@ -66,7 +66,7 @@ import androidx.core.graphics.toColorInt import kotlinx.coroutines.launch import net.helcel.cowspent.R import net.helcel.cowspent.android.helper.EditableExposedDropdownMenu -import net.helcel.cowspent.android.helper.UserAvatar +import net.helcel.cowspent.android.helper.MemberAvatar import net.helcel.cowspent.android.helper.formatShortValue import net.helcel.cowspent.model.DBBill import net.helcel.cowspent.model.DBCategory @@ -170,7 +170,7 @@ fun ProjectSankeyDiagram( 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) + MemberAvatar(member = selectedMember, size = 24.dp) } else Icon(Icons.Default.Group, contentDescription = null) } }, @@ -182,7 +182,7 @@ fun ProjectSankeyDiagram( } 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) + MemberAvatar(member = member, size = 24.dp) Spacer(modifier = Modifier.width(12.dp)) Text(member.name) } diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml index 030fd29..e0c14c5 100644 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -29,7 +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..2979fd6 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/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-v26/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-v26/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-v26/ic_launcher_round.xml @@ -2,5 +2,5 @@ - + \ No newline at end of file From ec4d60897aaf10821223ebee74b0864044cb7e5d Mon Sep 17 00:00:00 2001 From: soraefir Date: Mon, 29 Jun 2026 21:30:35 +0200 Subject: [PATCH 08/31] Currency parsing and formating # Conflicts: # app/src/main/java/net/helcel/cowspent/android/main/BillsListComponents.kt --- .../android/bill_edit/EditBillActivity.kt | 7 +- .../android/bill_edit/EditBillScreen.kt | 6 +- .../android/bill_edit/EditBillViewModel.kt | 10 +-- .../currencies/ManageCurrenciesScreen.kt | 7 +- .../cowspent/android/helper/FormatUtils.kt | 70 +++++++++++++++++-- .../android/main/BillsListComponents.kt | 1 + 6 files changed, 83 insertions(+), 18 deletions(-) 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 a088640..fa6af65 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 @@ -187,7 +187,12 @@ class EditBillActivity : AppCompatActivity() { } } calendar.timeInMillis = bill.timestamp * 1000 - val members = db.getMembersOfProject(bill.projectId, null) + val allMembers = db.getMembersOfProject(bill.projectId, null) + val billOwerIds = bill.billOwersIds + val members = allMembers.filter { m -> + m.isActivated || m.id == bill.payerId || billOwerIds.contains(m.id) + } + val project = db.getProject(bill.projectId) val currencies = db.getCurrencies(bill.projectId) withContext(Dispatchers.Main) { 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 bccaa90..3cf7784 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 @@ -188,7 +188,9 @@ fun BillBasicInfoSection( placeholder = { Text("0") }, modifier = Modifier.fillMaxWidth(), leadingIcon = { - val currencyToShow = viewModel.selectedCurrencyName.ifEmpty { viewModel.mainCurrencyName } + val currencyToShow = viewModel.selectedCurrencyName.ifEmpty { + viewModel.mainCurrencyName.ifEmpty { "$" } + } TextIconDisplay( textIcon = TextIcon.Symbol(currencyToShow), tint = MaterialTheme.colors.onSurface.copy(alpha = 0.6f) @@ -198,7 +200,7 @@ fun BillBasicInfoSection( IconButton( enabled = canEdit, onClick = { - val mainLabel = viewModel.mainCurrencyName.ifEmpty { "Main" } + val mainLabel = viewModel.mainCurrencyName.ifEmpty { "$" } val options = listOf("$mainLabel | Base") + viewModel.currencies.map { "${it.name} | 1 $mainLabel = ${it.exchangeRate} ${it.name}" } 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 defdb8c..14545ec 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 @@ -10,6 +10,7 @@ import androidx.lifecycle.ViewModel import net.helcel.cowspent.model.DBBill import net.helcel.cowspent.model.DBMember import net.helcel.cowspent.android.helper.DialogState +import net.helcel.cowspent.android.helper.parseAmount import net.helcel.cowspent.util.SupportUtil import net.helcel.cowspent.util.evalMath @@ -41,13 +42,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 } 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 abf976d..ef2261c 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 @@ -18,6 +18,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp 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 @Composable @@ -108,7 +109,7 @@ fun ManageCurrenciesScreen( horizontalArrangement = Arrangement.Center ) { Text("1 ", style = MaterialTheme.typography.body1) - Text(viewModel.mainCurrencyName.ifEmpty { "Base" }, fontWeight = FontWeight.Bold) + Text(viewModel.mainCurrencyName.ifEmpty { "$" }, fontWeight = FontWeight.Bold) Text(" = ", style = MaterialTheme.typography.h6) Text(viewModel.newCurrencyRate.ifEmpty { "0.0" }, fontWeight = FontWeight.Bold, color = MaterialTheme.colors.primary) Text(" ") @@ -170,7 +171,7 @@ fun CurrencyRow(currency: DBCurrency, mainCurrencyName: String, onDelete: () -> color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f) ) Text( - text = mainCurrencyName, + text = mainCurrencyName.ifEmpty { "$" }, style = MaterialTheme.typography.body1, fontWeight = FontWeight.Bold ) @@ -182,7 +183,7 @@ fun CurrencyRow(currency: DBCurrency, mainCurrencyName: String, onDelete: () -> ) Column { Text( - text = currency.exchangeRate.toString(), + text = formatAmount(currency.exchangeRate), style = MaterialTheme.typography.body1, fontWeight = FontWeight.ExtraBold ) 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/main/BillsListComponents.kt b/app/src/main/java/net/helcel/cowspent/android/main/BillsListComponents.kt index 8c20bc1..0d30465 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 @@ -16,6 +16,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import net.helcel.cowspent.R +import net.helcel.cowspent.android.helper.formatAmount import net.helcel.cowspent.android.helper.MemberAvatar import net.helcel.cowspent.model.DBBill import net.helcel.cowspent.model.DBMember From 7e00b14d272fcd444afbc676c47fde1f1aafb8a2 Mon Sep 17 00:00:00 2001 From: soraefir Date: Mon, 29 Jun 2026 21:27:27 +0200 Subject: [PATCH 09/31] Fix keyboard padding and better currency management --- .../android/bill_edit/EditBillScreen.kt | 1 + .../currencies/ManageCurrenciesActivity.kt | 44 ++- .../currencies/ManageCurrenciesScreen.kt | 298 ++++++++++++------ .../currencies/ManageCurrenciesViewModel.kt | 2 + .../project/create/NewProjectScreen.kt | 1 + .../android/project/edit/EditProjectScreen.kt | 5 + .../net/helcel/cowspent/theme/ThemeUtils.kt | 1 + 7 files changed, 248 insertions(+), 104 deletions(-) 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 3cf7784..1aeeb30 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 @@ -117,6 +117,7 @@ fun EditBillScreen( Column( modifier = Modifier .padding(padding) + .imePadding() .padding(16.dp) .fillMaxSize() .verticalScroll(scrollState) 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 bca038e..41dda19 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 @@ -67,8 +67,10 @@ class ManageCurrenciesActivity : AppCompatActivity() { viewModel = viewModel, onBack = { finish() }, onSaveMain = { saveMainCurrency() }, - onAdd = { addCurrency() }, - onDelete = { deleteCurrency(it) } + onAdd = { addOrUpdateCurrency() }, + onDelete = { deleteCurrency(it) }, + onEdit = { startEditing(it) }, + onCancelEdit = { cancelEditing() } ) } } @@ -105,22 +107,44 @@ class ManageCurrenciesActivity : AppCompatActivity() { } } - private fun addCurrency() { + private fun addOrUpdateCurrency() { val exchangeRate = try { viewModel.newCurrencyRate.toDouble() } catch (_: Exception) { 0.0 } - val newCurrency = DBCurrency( - 0, 0, selectedProjectID, - viewModel.newCurrencyName, exchangeRate, DBBill.STATE_ADDED - ) + val currencyName = viewModel.newCurrencyName + val editingId = viewModel.editingCurrencyId + lifecycleScope.launch { withContext(Dispatchers.IO) { - db!!.addCurrencyAndSync(newCurrency) + if (editingId != null) { + db!!.updateCurrency(editingId, currencyName, exchangeRate) + val currency = db!!.getCurrency(editingId) + if (currency != null) { + db!!.setCurrencyStateSync(editingId, DBBill.STATE_EDITED) + } + } else { + val newCurrency = DBCurrency( + 0, 0, selectedProjectID, + currencyName, exchangeRate, DBBill.STATE_ADDED + ) + db!!.addCurrencyAndSync(newCurrency) + } } - viewModel.newCurrencyName = "" - viewModel.newCurrencyRate = "" + cancelEditing() updateCurrenciesList() } } + private fun startEditing(currency: DBCurrency) { + viewModel.editingCurrencyId = currency.id + viewModel.newCurrencyName = currency.name ?: "" + viewModel.newCurrencyRate = currency.exchangeRate.toString() + } + + private fun cancelEditing() { + viewModel.editingCurrencyId = null + viewModel.newCurrencyName = "" + viewModel.newCurrencyRate = "" + } + private fun deleteCurrency(currency: DBCurrency) { lifecycleScope.launch { withContext(Dispatchers.IO) { 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 ef2261c..15d428d 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,6 +1,9 @@ package net.helcel.cowspent.android.currencies import android.annotation.SuppressLint +import androidx.compose.foundation.BorderStroke +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 @@ -8,16 +11,25 @@ import androidx.compose.foundation.shape.RoundedCornerShape 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.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.TextIcon +import net.helcel.cowspent.android.helper.TextIconDisplay import net.helcel.cowspent.android.helper.formatAmount import net.helcel.cowspent.model.DBCurrency @@ -27,7 +39,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) { @@ -76,78 +90,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) + onSaveMain() } } - Spacer(modifier = Modifier.height(24.dp)) - - Text(stringResource(R.string.add_currency_title), style = MaterialTheme.typography.h6) - - // Visual relationship indicator - Surface( - color = MaterialTheme.colors.primary.copy(alpha = 0.05f), - shape = RoundedCornerShape(8.dp), - modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp) - ) { - Row( - modifier = Modifier.padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center + Column(modifier = Modifier.padding(16.dp)) { + // Section 1: Main Currency + Text( + text = stringResource(R.string.main_currency).uppercase(), + style = MaterialTheme.typography.overline, + color = MaterialTheme.colors.primary, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(8.dp)) + Card( + shape = RoundedCornerShape(12.dp), + elevation = 2.dp, + modifier = Modifier.fillMaxWidth() ) { - Text("1 ", style = MaterialTheme.typography.body1) - Text(viewModel.mainCurrencyName.ifEmpty { "$" }, fontWeight = FontWeight.Bold) - Text(" = ", style = MaterialTheme.typography.h6) - Text(viewModel.newCurrencyRate.ifEmpty { "0.0" }, fontWeight = FontWeight.Bold, color = MaterialTheme.colors.primary) - Text(" ") - Text(viewModel.newCurrencyName.ifEmpty { "Currency" }, fontWeight = FontWeight.Bold) + OutlinedTextField( + value = viewModel.mainCurrencyName, + onValueChange = { viewModel.mainCurrencyName = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("e.g. EUR", fontSize = 14.sp) }, + colors = TextFieldDefaults.outlinedTextFieldColors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent + ) + ) } + + Spacer(modifier = Modifier.height(24.dp)) + + // Section 2: Exchange Rates + val isEditing = viewModel.editingCurrencyId != null + Text( + text = (if (isEditing) "Edit exchange rate" else "Add exchange rate").uppercase(), + style = MaterialTheme.typography.overline, + color = if (isEditing) MaterialTheme.colors.secondary else MaterialTheme.colors.primary, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(8.dp)) + + // Integrated Add/Edit Ribbon + Surface( + color = if (isEditing) MaterialTheme.colors.secondary.copy(alpha = 0.08f) else MaterialTheme.colors.surface, + shape = RoundedCornerShape(12.dp), + modifier = Modifier.fillMaxWidth(), + elevation = if (isEditing) 4.dp else 2.dp, + border = if (isEditing) BorderStroke(2.dp, MaterialTheme.colors.secondary) else null + ) { + 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("Rate", 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("Code", fontSize = 12.sp) }, + singleLine = true, + textStyle = LocalTextStyle.current.copy(fontWeight = FontWeight.Bold, fontSize = 14.sp), + shape = RoundedCornerShape(8.dp), + colors = TextFieldDefaults.outlinedTextFieldColors( + focusedBorderColor = if (isEditing) MaterialTheme.colors.secondary else MaterialTheme.colors.primary, + unfocusedBorderColor = MaterialTheme.colors.onSurface.copy(alpha = 0.12f), + backgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.02f) + ) + ) + + Row(modifier = Modifier.padding(start = 4.dp)) { + IconButton( + onClick = onAdd, + enabled = viewModel.isAddEnabled() + ) { + Icon( + imageVector = if (isEditing) Icons.Default.Done else Icons.Default.Add, + contentDescription = null, + tint = if (viewModel.isAddEnabled()) { + if (isEditing) MaterialTheme.colors.secondary else MaterialTheme.colors.primary + } else MaterialTheme.colors.onSurface.copy(alpha = 0.2f) + ) + } + + if (isEditing) { + IconButton(onClick = onCancelEdit) { + Icon(Icons.Default.Close, contentDescription = null, tint = MaterialTheme.colors.error) + } + } + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "SAVED RATES", + style = MaterialTheme.typography.overline, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.4f), + fontWeight = FontWeight.Bold + ) } - Row(verticalAlignment = Alignment.CenterVertically) { - OutlinedTextField( - value = viewModel.newCurrencyName, - onValueChange = { viewModel.newCurrencyName = it }, - label = { Text(stringResource(R.string.currency_edit_name)) }, - modifier = Modifier.weight(1f), - placeholder = { Text("e.g. USD") } - ) - 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), - placeholder = { Text("1.0") } - ) - } - 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, viewModel.mainCurrencyName, onDelete = { onDelete(currency) }) + CurrencyRow( + currency = currency, + mainCurrencyName = viewModel.mainCurrencyName, + isEditing = viewModel.editingCurrencyId == currency.id, + onEdit = { onEdit(currency) }, + onDelete = { onDelete(currency) } + ) } } } @@ -155,52 +243,70 @@ fun ManageCurrenciesScreen( } @Composable -fun CurrencyRow(currency: DBCurrency, mainCurrencyName: String, onDelete: () -> Unit) { - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 12.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() ) { - // Table-like layout Row( - modifier = Modifier.weight(1f), + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onEdit) + .padding(16.dp, 12.dp), verticalAlignment = Alignment.CenterVertically ) { - Text( - text = "1 ", - style = MaterialTheme.typography.body1, - 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 = MaterialTheme.colors.primary, - modifier = Modifier.padding(horizontal = 8.dp) - ) - Column { + 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 + fontWeight = FontWeight.ExtraBold, + color = if (isEditing) MaterialTheme.colors.secondary else MaterialTheme.colors.onSurface ) Text( - text = currency.name ?: "", - style = MaterialTheme.typography.caption, + text = " ${currency.name ?: ""}", + style = MaterialTheme.typography.body1, fontWeight = FontWeight.Bold, color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f) ) } - } - - IconButton(onClick = onDelete) { - Icon(Icons.Default.Delete, contentDescription = null, tint = MaterialTheme.colors.error) + + 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) @@ -210,6 +316,8 @@ fun CurrencyRowPreview() { CurrencyRow( currency = DBCurrency(1, 0, 0, "USD", 1.0, 0), mainCurrencyName = "EUR", + isEditing = false, + onEdit = {}, onDelete = {} ) } @@ -231,7 +339,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/project/create/NewProjectScreen.kt b/app/src/main/java/net/helcel/cowspent/android/project/create/NewProjectScreen.kt index fa12f85..ab0efce 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 @@ -63,6 +63,7 @@ fun NewProjectScreen( Column( modifier = Modifier .padding(padding) + .imePadding() .padding(16.dp) .verticalScroll(rememberScrollState()) .fillMaxSize() 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..2b9ad31 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 @@ -80,11 +82,14 @@ fun EditProjectScreen( } } ) { padding -> + val scrollState = rememberScrollState() Column( modifier = Modifier .padding(padding) + .imePadding() .padding(16.dp) .fillMaxSize() + .verticalScroll(scrollState) ) { OutlinedTextField( value = viewModel.name, 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 2b371fa..cb42bc9 100644 --- a/app/src/main/java/net/helcel/cowspent/theme/ThemeUtils.kt +++ b/app/src/main/java/net/helcel/cowspent/theme/ThemeUtils.kt @@ -111,6 +111,7 @@ object ThemeUtils { modifier = Modifier .weight(1f) .navigationBarsPadding() + .imePadding() ) { content() } From 84432fe68d7ad6e45491e58b36f29098013e8c8f Mon Sep 17 00:00:00 2001 From: soraefir Date: Mon, 29 Jun 2026 21:38:01 +0200 Subject: [PATCH 10/31] Better formating and placeholder --- .../android/currencies/ManageCurrenciesScreen.kt | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) 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 15d428d..962f2d8 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 @@ -141,11 +141,10 @@ fun ManageCurrenciesScreen( // Integrated Add/Edit Ribbon Surface( - color = if (isEditing) MaterialTheme.colors.secondary.copy(alpha = 0.08f) else MaterialTheme.colors.surface, + color = MaterialTheme.colors.surface, shape = RoundedCornerShape(12.dp), modifier = Modifier.fillMaxWidth(), - elevation = if (isEditing) 4.dp else 2.dp, - border = if (isEditing) BorderStroke(2.dp, MaterialTheme.colors.secondary) else null + elevation = 2.dp ) { Row( modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), @@ -164,7 +163,7 @@ fun ManageCurrenciesScreen( value = viewModel.newCurrencyRate, onValueChange = { viewModel.newCurrencyRate = it }, modifier = Modifier.weight(1.2f).height(52.dp), - placeholder = { Text("Rate", fontSize = 12.sp) }, + placeholder = { Text("1", fontSize = 12.sp) }, singleLine = true, textStyle = LocalTextStyle.current.copy(fontWeight = FontWeight.ExtraBold, fontSize = 14.sp), shape = RoundedCornerShape(8.dp), @@ -179,7 +178,7 @@ fun ManageCurrenciesScreen( value = viewModel.newCurrencyName, onValueChange = { viewModel.newCurrencyName = it }, modifier = Modifier.weight(1f).height(52.dp), - placeholder = { Text("Code", fontSize = 12.sp) }, + placeholder = { Text(viewModel.mainCurrencyName.ifEmpty { "$" }, fontSize = 12.sp) }, singleLine = true, textStyle = LocalTextStyle.current.copy(fontWeight = FontWeight.Bold, fontSize = 14.sp), shape = RoundedCornerShape(8.dp), From 1bee1dabbec92068b172680850ee58cda338ec00 Mon Sep 17 00:00:00 2001 From: soraefir Date: Tue, 30 Jun 2026 07:17:57 +0200 Subject: [PATCH 11/31] Disable dependency tree, logo to png --- README.md | 2 +- app/build.gradle | 15 +++++++++++---- metadata/en-US/images/icon.png | Bin 0 -> 23811 bytes metadata/en-US/images/icon.webp | Bin 15418 -> 0 bytes 4 files changed, 12 insertions(+), 5 deletions(-) create mode 100644 metadata/en-US/images/icon.png delete mode 100644 metadata/en-US/images/icon.webp diff --git a/README.md b/README.md index 3be6673..8017486 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@

Cowspent

- Logo + Logo

diff --git a/app/build.gradle b/app/build.gradle index ec8a48d..9f49880 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -82,11 +82,18 @@ android { androidResources { generateLocaleConfig = true } + + dependenciesInfo { + // Disables dependency metadata when building APKs (for IzzyOnDroid/F-Droid) + includeInApk = false + // Disables dependency metadata when building Android App Bundles (for Google Play) + includeInBundle = false + } } dependencies { - implementation 'androidx.compose.foundation:foundation:1.11.2' - implementation 'androidx.compose.runtime:runtime:1.11.2' + implementation 'androidx.compose.foundation:foundation:1.11.3' + implementation 'androidx.compose.runtime:runtime:1.11.3' coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.5' implementation 'androidx.preference:preference-ktx:1.2.1' @@ -99,7 +106,7 @@ dependencies { implementation 'com.github.nextcloud:Android-SingleSignOn:1.1.0' implementation 'com.opencsv:opencsv:5.12.0' - implementation platform('androidx.compose:compose-bom:2026.05.01') + implementation platform('androidx.compose:compose-bom:2026.06.00') implementation 'androidx.compose.ui:ui' implementation 'androidx.compose.material:material' implementation 'androidx.compose.material:material-icons-extended' @@ -107,5 +114,5 @@ 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.lifecycle:lifecycle-viewmodel-compose:2.11.0' } diff --git a/metadata/en-US/images/icon.png b/metadata/en-US/images/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..14b4dababd274457d6e12cb8a216c28cde70c486 GIT binary patch literal 23811 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 literal 0 HcmV?d00001 diff --git a/metadata/en-US/images/icon.webp b/metadata/en-US/images/icon.webp deleted file mode 100644 index 975d76a9eaaf18e3cc5acd56e84e067aaf86ebae..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15418 zcmV-AJjKIONk&F8JOBV!MM6+kP&iB_JOBVMzrZgLRfmGMZKRk#?E4QvL`*=3j#p`~ z|LrK#;dIhfeUDq+mRlb9tQM{v8eFO3%)$vxv1b)j40g)DX~MSu*d;q#Wpx+VdY^?d zdKKD6p>5mdE~~4ny1UfnYTIQs(p^g1W_7Ku*tU~)2mj~!>shNa(mRONIi6S}JLy?Q zwl`Mn72CFL+jd4y{;}=!I*!=3?c^8R&WeVa+V&g0LG3}0V%xTDCnHym-W}V?$j(-; z*fuJ*?PO#-S+Q*l_cXR`r}vI++eXD!A2GW+_8Z%_ZH;Z?2}b+K$X16#JD+XaR!@V` zXT-_uvu%H$2ivw4Ns?`A3NtGUGb&qlf6aMRQ+MwcFfK8e|ua7bDq~@ z{$IpKZQHhO+je8Cw%b9>Nk&mK!Ptm~asUA!AjHVFZQHKNwmlh>&B?ZH+qP}nJx&&6 z+d0V7f}-Y+s^3bY7?Gbtt$-V-UJT7^{=p=KW)v4X~LLfD87%O36+KL7h)?yrA4MF}0*)*MOMji}1Z%FIt@qHPu?O@bX@Y7-Zq zU9b%T8w+2t}lmT?rCI-r~CLNXMJnV;^|ImZ~Tz| z4ou}R(yTW0FL|YjRm}F8l(!p$s@oD;%Rv8Hf8O8h)i>r_ZsYhaaePv9JyWWudr6}r z68@G%B7e;-5mMKd(9Q<(mk|=7izGsKNrX8q5qeu9%u9*TFA|}DBtrj7goY$S!xEu% z*WxSmC+A>xukKsLncv1A7;p3kH7ZilJ>0V>CEewgLjQ>byZ_QchUu@(YzY}95xQ0) z%yo&-cg1y%6u-$AxYgA#Oth)}e`&$PH=@|*hkK0qp7MUDXdp}n1O3aaZ(aoYE4Z zfzq4p>dL-!b=_?AjVZo`1w6q+@9;s)1SF{cxn6qm~@IMzn?eeBqi-Ar@M*bIoefwv2=?`i& zjz(TV+$~0nG}3kwVJ@<)g`9?JRAI}oysEokjiKvsLX{M$!d>rEHKKAn2+PWDNF&qM zVzjuAHcDSzB4nSgyHeT?PW@HKw`oy-t5CFnP&wCfVbyVMK9kb4tFF(@tB(c>(V8`A z=B<+m{io~SNiM1AVbY@h&h-vhj3JyMXM4SOIYm-|;;9_j*4-_7YA(IhKxjva&?mZ1 zZpW4q)o@aomlq9OBofY+tNOZXSgGqHH>2wtFo!O>(A{XX8-sF4gs#)|(!>{P#~U=E z{M2)_M3E|taMqm4dN;2Aat*|n@zT1!&)1*;{e>}p?(_Qu$t}tvf zl5qCS&SABvy>keEoV?Rqys-2z}hPdmfbBMnhS>cHQvPV23RvV?cW#R@=-H@MmG8rA7P#9BO1IGt|jDhyvUootSp z@F7W^8C@duhk-J^Q7soXd)hve>EzODNbWquacmcESKO?ZCJ1K$Y$;c1kZgTPzr`3_ zE4$XF^T;)j3#F0FaCcEtNn`N;1g4fox>eUp>lk|y=-20u7CU*tE`Ksxqi!B+Udszu zvj?HX4$XO~3M8{QHKPdjocyl*`hTe_qIinZ$l7NA-(hK8&{WcBL;sR@H;Av+W!*fq z=|18n*s~UGZBMcj6o8aqfGPkr0;&T<0h9?K7f8HHb8f1^t0-LmS=(@*IDO(PxPda4 ztSK$vQYK4Dquq008Hv!xcpC;n1f7CNl%-Ztpx=BV!I6ULOqHlr02F)xq5@h5bPkv? zz^DK$GX=;3hzY0(;2VIil*w5nSzR??nLu!)-)y2<1t9|s5@_}pj0V9URwCqy?Q=kR zmvIQSxEm!x>qvxoudk4<$gcW&`0x%dz8{{Vs%1J4x9BCEsz?=zb>UQx(p9)o7_lA5 zyaDV1jRE=q%m`ojU+;U;+plsZ@=S2 z8gp}{*`CmzzgNC^v0oaCV8|v~)_8;3oG<*a0|0sa7=S?lBLX~FGaO)WK*IoK)866Y zpY2tR@k<+<3)=PfX@9rJIpmI)x#FDR3WvOwYq`n^R=3n+zv3u23CNlKGT*GtGgg(- z=iQ4n4MKQCX(WGaCeq5%*=|XT^vpCQ&31&P3a*O{uLqQ)iGf$ENUc$&S}KOlR=4$9 z<4%rEO1nL-eSeRSZ^F%1gYiwpTCKo>1;}}?HUKjM@ZibtfbIa50ek`o-?cxs&tJ9` zl!9`4HxDfF!`MW6n9L4gG7$2|@JyQL=XY{@Gl|e2gxP@xXhQiRN4UyT19!><^aL0g z;K7-h17raFC*aN=WZoUpnEG33!epT2=aW0tN?NSvzm%2;eM^`_O@k$}GYwru$Q3OY zjMG6runb^(fLDD}Z$tnT08G+@JKr@lph-Xw0Gr$XZ5x2M4(%JTL%^y4u$EIuXlJ<_ z!Xzj^vs*@;XeEueArbnJ&|2wK?ma_RQABs~A`-8H>=dxkaW>l*VE2G+0|Ej90DJ)^ zPxBiB0X}M2fQKp>3D6fH9bnG?lOMoun7q>C?}1;ydu_nhHU#Jpus6Vd0Q&_TXQPeY zj^UfJ=``3z?AG2mkN`T~n4o-#vejpGmbn`P;lHUQLM{;IFteZ3-W@`SzOwNgr6$N- z_XiMN{UoOgkR8w(;1%lxJog?vzT){$eo4v)v;xQkn0|nVidn^8038650A5i}0zfK2 zw}2m*LBJ#lKhkwDcDws{At6Lc@4m2;FT%t-Z>qT?(n*_0Pou?lWNjjB4Kw*kx4fZ9 z9R?Yy3irdvRsj##h`^ZzOedfi;2h6!2tdDp{sBb*=lC>ZJfw$9$dOKnQI67Rz#;*% zq-njIJ_X+>c!TraW1ki7i~|}i2Fgs)i@Pe>(?H3bXXIIks`cLh00sqkxZs@hZ$P4| zJc=ImlCI)C;-lz-7Eo7repbFSD%cXzRo9DSd&IYL31zZHtI1w%W&keN%mXR~0Fq{N zf>0)|Spa*S0XZtjQu6aVq1iqMl#>YamC(~|Qu%K1G6v@1PXzhN2XeVO2KWacqxWAV z!JOv&5qiPA_itWm*5T%E+@iA}R9Y8&7AGHb@sKyh4FLAw`dz9e^8)~+X*a{45csq% zggTtp9W5r9w|8uk#bTh$9K!q&0~BKkLWZm(l&A0k00abd1{e z{zz8>pQ0#hGM~?PB*wScj-(Z#Qp6XRth*fqF`Wfl1?G$Z01l)+z;pvn!=*WmkEk2q z51{Cio`N8z+ubgXH#fY_6fcX)zoJ7QS`7-4RU-7caw!o)*E`)DA;_kw9xmhVFy#sL z*1qB=eV0q}UF#SZu(iq_Wrp1mv{Qof(#O|aa~yZ$oYw~mSIh&f=)hxn8YZ$b=oBc( zDEz%6D8viICR*Ui^4}c(MY*K#C7IIfydRHIC-p6n*F8{6_3= zq-Yx|arTTP>j0KjEf-KdAU@!Wo&89;=QkA`fcJl_7_e6~TFak!9_$SEH}+GEHHpO| zF*G)NLGHDHluN(o-FRtqetw5(hW`)E5OfN<=M5gLfB?`_ky<0^`m(E+Ikmy^pEt?* zbVApcbE0~6{BSlvNL>Ku_EA7c0KQMP%PLteXP7VONn`_*iJt$5fu6J7Q`7mJeO)s7 zdI!PtP6N&Utl$v@miS^x85(`sh3x!cQGsoEzha|8m57Wm*sWWYl@ zvt`6P>o$@>)yrz>h6l1?fR1Dt#)Z0{gV8fk@R z3^)&C1&@%-59<(sw+SNkv6U?X3jO@?cHUVx@~}lyZl*lL4rWDiUQbw(#-oo6lAdzq z)h6l8CQ#U9M?-Sdcb*TzzQCax)8@?JdI?06*@BEO&vd+17PvX z+0?mX>*#om8B6EKi|Px0khKW#aAsWqWQ~TOWVxP!KbKCAm({cTB>>k4u#6MV(5cXs zX*8{uu;bE{sXG$+)3EBQsttA&Igixyc{R!mlr-1%fb+1htnN@mBjjV7DQeUnNZ2YC zSb>*=q86{r)7AMrvocM_@5u@SoZA3^$s<*-s+pDcI8m7;gsQU1pzvOjVpW?jaY*sI z5*i`93W$1xC-Y9AXk-USo|fSMLL!Nf(-NV+Gw|f^8j(6e35}d214n~#qjDUjOTr$m ze5*-@;U1;RoknsW<{vt@X##`QRJ&-`| z`Fpd>JHak8!&Vp`$Gx-xk{T#;8TB&_l#rF{C}{K~?ztLE>qRiMe6dc-S1CuMONXa> zvOb+-PTf5gQ0Rd)3K#+4?1#bW#}qAsP7TM0G=(Mz9au!(gu5Rl5^&bzdkNjJ3Ab(ul2(pWNaJ&};TY6gl-nEdcb z`{!iw%L4L_d+y=vSq=bzn8kA)o}Nf~eCHFRuB1Sw@9IHnEO9h7WX~cI3M~TPgZg`p z&$+b1{}Uua&L}!uJ|3PBzpR7CmUwN6>b7kHiT&i}V>M;8q{DADY?}b`NzQLLfXvZU zN;>?aX}{4Y>jDr+=vV!=)z@fj3BcJP-X9(m{awop~k z7}Io7t1MG%+;PjZ74A~gY~&sQ;8k$lYI;<&S>)_SWL%unh^*TzaSqe3SoFGT=a;oZ z$`l4ntZ8pq!>*MINe*F3w|QxJ-v; z)W6rQ+oVylo=wvnEe*s!XV+3yN#je+ezOwD!t07fFdBHs3W&2<$!4f6V8DOxTKOPzt(B>_ORr4*l- zO*w112jcVL&4#il&%tdd5o8Kl=I+k1J?O~FCK37!V@FvcaB=_u^+rNmlJsnqd0ha& zKX2DqT2CXTldP7LsWnNpjKsJVGqgI(>S(0Is>@hQJ&?coTe&otGy=qqHM65Zlnj)z zra`aDoC5NDS^=@l+$F;5rR%N=)o@ZB`+La3=z0sp<7*_@3R$-2(TGXjab<}c%%E&_ ztZEpUahqYeNA^%CMaM(=SLvXdA$~cBTjg<(+ayb~Ib+v!UDxG?nOMGE-(*kEQU=QG z!3PWCS_K9!<2cGq5dWM_qbVPcn02{JLPBW<8ZB)#rTSiUQA7WpOxmW5MoVN%1puJM zvJ#J`ids`cHg$j^Q5>@nK7@d5rvTQ5!}|8tb6^>X&~JKiFT$Y;uY;_qyq3n0+=1B4@iu&B zfwOx}jUmmK)#MkTZQ}U{=7V)Z0&_<9C)T%4v7WA+cqf>6W8JRjc&ZX1MfmZ|YP?7|oZh;PoR#j1+Nl3an%C~)KIW#tY;G!{ShSx5-YC_&G| z?2)3_DuC2(y(fg&tl3f6ycS2$0*YS-*c1-yHx4$_a`@L3JS;XG=^muaDTs#B-5LoQfTv?4(BPri~Ar(0?NqRPoDe3A` zGb;6AH)B$t^mRFnE%FiI-L{HV3uTocVjnq1Sk9X{6in zf#O?K_(-`CnCC~rF`UVG#aOk|7?Xd~f?a`4E9SogpNtCt)D?;_ddsl74j>W47DHAM zgzOfvVdN;1pU`XaPq~jS5&9fc*6cxf51?Cz!7hpm!4=qYjCJaXM3x_5+(}gB6yi1| zTBxeTZA;O#UsTx)=6#lecI3T}lN;3dRdX zm^~?tb8@N*(XnS(zP1@j3tiuAg}S90LzYp5GR=7DWXoT%C&OkEb-Di!u^^#Q+}U3reeV=GRJO2B8e zQdMA6iQDbnWKBC6gfc^v(drUwVH=WEBd*^5-Yfr8V@o7x65eWy+B*XCwaceC3;vdb z9goJ6#2rT+fmlOHgNc#Hwvns=`vxtXt?^Mk^peBIF>}s-D~5NZ{$a7$>wK zr5Nfc-%)rIjXf2(a$X9{A7k~$#2P7sxJs-Bb(F}Rk?MDC-VGFSP)#)U#A!->i{d$% zK2G2sR>vAewu=B8+TXj>e<~mX^5R=;2_Z;{SsSo38SW(x#ZB*iLrU5aQR zPl45-jsj7 zO^Ed!IR{t!NKj^M+Lp}NhLH@$x5P8^2y*ggbA?uA6nAvI)FkiXE!mFBfTzY?3pK1f zQN6Jc;99B6v9;M~%D3H$A*crlt92=m%^}F*ScUMe3uCJfmZEG3*Z-hI$Rd18=G8?M z{T^oOaDP=auNElc11@FTDoti}G?oO{OmLBGHKKuCC02tuN<{C7yEUf4m4@O5j+T}e zD01N)9jz7A2t9!bJNwGA+liw0!xA~4PUz-*R^!n}fdng1 z#4qa**3jG4ub7ysk%X@d!3GmtH6fWS^KQdfT5a@PBx{3tiUyKKR7nQ57ON_?2NDjd z$rFPj!9rJWy zzy=hT-&VD#(3Uy=1RW3UCAQ&&FO07D#EMri2gU3l2~u=i(`4wdnr_u3O;d_QK3Rei zPaMwB*)K}F-mh4 zMqvztalA2Z)>4ljBWSOv!rXNqfpzLAuR%KM*r^sJt}(K$QmNOaHecd5lDh#dR+Vbn zwTQlI_umg6@PyJ7TFHuKe>%mCJl(#K6>UPC3U=h=9RIW%&SR4+TAsiPh_{(ot#xeA zS_Bb6He?^mtW5id8ybl|vy-(*cfyVnRO(6rkA1f-*Od}=T=iMAylKkJGByk#~0&p zL@lH*;g<*goFJzbUIg1Q$y5{9$_oEZWFh;5))N_k&HAsW7^EM^jHkWhO}B2V=m0ej z@U|LThOkxGO^dFlv89^z9|6SyKutKUTg%yVot-W*;~8Wai=q*QLl+zG1RbsUgA{8u zRRbZ@@wPH7ucGMluz;DEHkuUGuPfLq8k-E1L@=~ksVlI^RF#`4VF0_C5p4b=jiSpE z#?S*we6TLz^)9vVxY{|~LVm6F1>tj=0E3#5nKoT2D*XX5{dJ31K_;xaL zj_XP79&Sn2q^V=eHn|{z*|PvCHcF4i(t328)9b1o$BEylaJ!hv(p}JZ0EvITpCsvo zQWb7JMf*(0$E%*mO!n;*(-16&cioxT{^cq~cH&+29v z?rAxFu95Jw%IyaJxqe=N#a=O+nCf!4PP~U>9nv(V=p0lN!1!dG$}&1M5PZ{E^hv8# zrp`|nnHlo^6g!Xo5oKc+tbpXD+=*=k0r(`sImIqgkG*w%IN>Z$35sXf;@vs})|@pO`zE!V_I z@CF;d2Ot{E5Mfm^B&Ctw#yHRby(Du`@g&1=pA`f03BN|MlKD5ShG$*)jKsL~x#&PW z^ZtFZBvl5pBC41>eDDU@pHHVLTS>?Vty-IkM94FY`RiLDpw7jpGf(kdfnqDuZCP=1HhBnK}}o z?=bh=Pc{kJkX@YaF8gq4F?~t4p%k5Eb!L%s@ZipW>N?@0IHXHQDsu$5EY*-*EV91y z$;!X?kN_pWY1WOa8q^$+mKdpTs#ehjh!^0A^a zauRR~8s*E5jEw=-u`e}|M*0U6o#{4UJ_gx9!$i5Cj^L5)dP+r>nB3is-IO895elg^ zaDV$fZCfPm*)&oraOC7}O0qf)j|m1I#v?n@&ys*sFtnad{$G&r3qLb4H&Et3qw}4B zM+b-NLaTC!qp9&oB&pjH)2W$wsFWFzdZeq%5LLTv0|1by%%-AfjEn>*i>+OC^#k|T z5fH{WGE9|>Qvby`WP=W4a22=JKO*1h{w?GYR}a-vXx)nF7G&4Qv%>wVBo)K7=oPiK=A*;w1;W4SlAY0V3?*pVMl$wL(`eaZw%8p~g&OX+OY}j^` zX}E01qtz-CAeDMuND8gnc&M1Z7oCXYvqo#th{v+OH(BrT3}Gfe5}?E{^`;FO0BbCC zr=B#@HyB$=^b*gZUcV(#l}%RTwgt(wYX)kXF{u~PI7m>@Ouc@~&MQZryfP>Ms@Y{` zNt2;TQW)bsfY$|4tUKQCMYd;~qiR^HBxEcAA_hXw;j;PLQS^FThDU-b zhpf?zk04D`N?jf2wMc?Vy?AX6#ol2AZ7_+u08*A95qzrgkCb&k&HW18gV=%6ZuiJd_Lv=B@cY7y-q&5i z$+>m|WO5vOyzF2hJM0OS%$yTcrQQuBpPdNc<^})(CK)PA?4b{VtXMNrFUOTQv{t8k zE#w_Zu1HNYB=?}I(&OXM%W;JYxKJIg zP7>twxnF=o^5z+T+yJBkM~-;8^RQHOE!2_!CgKk`EQlbR$A!WU`)uQn#?`Y_AtIax z;G0fW6TDwaC#LmoqBx6OPsgxJ(?9YMNV&EG=?2mEi)xhdGS0gfx=ibl)DcR^-hka6 zQ_h-1n#C00j1JtsCo#eHM-FEq=g#dh?6x}oIeo|1JK7QSVj%{x(!q{XP z6M!gZ&zH@T$wO9ADEiw^$9dlEn|0bn;`Nd>8yRjYowxR~YO}N?ahnn$bt5Fa-ldbn z73{{K*ezXQ^IAM}lFDpyEkY@xd_7U1qaN3i2GTFC5o!&6)Uqi70C2DxDM@>_Asn@} zQRPnGb!@%oB9GgQgH&bxC3es^5)8 z=2qae0lPNaWU^>0DUFz>L!r!KudV?qqu+Ek^Q=fD5#k%#g*>VSFnWtf#v(*5gL_(#nA$}E zrC3E5p>0UV0w`8O*t#?#b2-()1m*J2(-F|FgVwg;f>l>tbvr-50KX&Wf&k!$cZhfS z#-v)BA2Xhw@}#uR=aIq-27{Q zmJTDoIP@GYA!wJM*~h*&83TR%i31$qOD9Ky>jv4sr$DlNP1DvNuXOq$Tb>_J@W}h~ ziLO3nSi)q#evte7Qzlc6?n{=c8FDpE&Q&tx*C z|KkNa%?O~8x&n{%uV1iMEMSxC%nPMJ)S_c4#x{x|ZE#oOq_NUX>PnZ@%Pz+Ke zxB>j2i4st6l=lHsTvC|snrd;2jsWuct>~*6~KIY)iR9gb^%h`4DDT~R}>Bk%nx`BMJ_?))6|DxOLMI1dw z>hkYwNe<~FPOR;fg457u6jf#TG64)pAJga^$G@x$T7wh`1SLhf2)VCEn3dF4T-*McIYNk&L7FD4kVBey53WAsIROc>+EATlgSjxRR91h1`;&ib$z+Q%3csH#|ij5CO>@AaKa}K zzSkHDnM|fbuRY~M{P8yN&fl=h@+FNyHADRKHo1R1g;XDj003m%KVoJtTLD4y*YSBx zw(+O!x?i9r88hr$vH>U~$F67jPE2&B%K-UUg7yg4WagL}O{xLXSX)l<53>XSgfw(g z{{F^RGwSGlt_qf&mLG{=tC*0M55LKh)EPJiyPt<?B7z@sYU<*zBng)%_!=&#p1?1{nY-u zd}P_arfOl}h`MbS$(8$emkt1M__5DF$TGZsPS$Mv2D1;{K|#}|^T_pKOBuzu%Ihhq z5Mj||ICQ!Jqybc|Y0%MU!H@kfGei}Q#;xzeTpUG@BkTNQ7L%OjdM@Q}mI8Q%GIf4{ zG!N@z zAOybnbd);(l*L3)-KrAv z>4u=isSXy~DDubgC1;0fGrt$;(h2dNi?K3*aNzQTO{~*9j4wh@gXHtqqAJ>k`APN> z1QOE$HaqbAr*~5*k?Bn_lAPa?RlaGXWTRTtmQs4ybzYdTWTLkK zGy^e7He+QI&}GWnNeb`9lkVR2-$8$O$RYZ8`L6r1s%~9L0Z#|-@+G)_7131)APn%w z*L@H*VQ}_EV5v>M{?iAbfgC3Z-M>uXJs_wVv>#&vMzwBvWR(6@wr{CA` z4DYeH7>rZByB`$z!=T>XPfYV-2&$V5KYP*f6Aol!s(U2?CfhR~ z`IK>w*Jf_GQUESR05#gy$$P=PDJJ60W$)<5?0-Mi1gw`3-wNOHNIQI==Ly<=TjX&v zzAl#G^>cTZ?)6LQUcHe1j~CLvdLhHB=Q6%3HaD4H7g?Am=} zGR1!39ksWLfBvfV-#&PPEiNuR6}6505ptMhrZLE>W-^bA_Yt52?^6FQ7+f~CBk zT0FebLW=VodjQ1CgLG_aih$sgzm)EMEN`+q@Gu_zc9;_YRpU>v+sfS?7XUW7{@}Z9 zq}s`PZ@(}GEZlc_uurGqa7JiCOIO4OJPk~}E1R&=YKUdDZ2qs)WQy$UuisI$; zH3@Fm$yc6WJ;4yCCPZ(?rD%`1o8{Tf9z@9-ASj?*Ky<(=)CQ;r5EsxDphrMIfSsLg zz|IZ~=naq^&^90;pl-mwsToi$pen%cbpZGQ&;;ar92@x|_rs|TAsFm#*vS{6Owb0h zGL+YnM}Vf{N(9^OnY+IL`~VUFss_f=+dp8IC9I6m${#K!1j{4Rn&{+oMZ#sZuz-Mo z4G0V<56~9iH}wseUck%)Sl)_gM8He~Hhxe*en37z7C_5@iUIb34OrF5E8i!nwL0Ae z5Zgcx$)M+GiOQg*6Uwecb&eaB3zY~PUKMtkTn<2&jh*GaK#=r%UsSsM2zyyvq< zW}HrAcz`Nzg*F0U2tW!z96*DBd1{`uY2N<@z10oDuV$(y`bwrC%<}CyC+N!6>~bmM z9R6JZHUWsmM`rA>L-x0%oIhTNz}Htgg<#3Y_P3vIZOPL#ugj_UUNN%(m^pycx=fzS z8N}h?F_@}}|5>}(;ExmLDW>{i`VN5F`~lV1G6BSc&L}*_#WM#E-@1v(Qg(^8Si)Kqys*((N)jPH9Xcq*~F~ z2!J*dZRVU{qHn8LkY>v|G)VdPww>3Ia3#jj0lMEtC& zE72J{9U}k=uBkAU@%0}8_;LWsgIU&kA~_)1fQesGTQFKkFvh73*56^edBCP#+Q;&9 zeF6Z`8|gL$;8~p>K-B<%IWG#gVYggZrFnSPClQP}KBKO*gnW1O{5b(2=@pgcXV?b30&{ExL$Ht!$ zO^1=x#`^OE8`Hy=)EY{t1A2cem!WG(L002G#|8ESoH=~eYa+wB1#;&dmF;K-C=9T=in(@1_LbU=zRF z=fr5|QM4NzdOIxj$mX>Vr*{K6Q25+RqE;EMU$YIB~bA_Zs z5kHHjpQ%#SSN(=x-xnh#sawYbAUX+DZHM}*KeAEDc8|3QjpPFmd-bVL^gucKy~@`G zK+g3x11JKZzu9yQfNy}zH)GTA>ZP9h)h3$yn1Vx}=`^c%kK~z+eo|cxbpTao(&ed2 zj-_r9UVTih)EtNE>A?>n2lZ3Q+xN54&#Eioj-{!bDP0dsdzBkVmvR6Ka~$gH zfynl*U-06XiC6MO_Jm`OkZKxRNSfX4?#7A@WO zm}1Qcg3A#kb(4LjPI{o*Cz&X9GshfXu9PNMP`z;g3E$F&lIn$GPm_W-muL=CfR!i^ zE#g!M6I}Us>ls>TD#&qT6l%Nes z3GB{>Zg;!zt`taSX~-#525UuSD=0PLX6q{-k8|xP?@1@Ou_k8+oq28=#k8@Y*XFjg z+f;u%o#6=zAW_kUbpX2-3wE#w<^D1bn1ws1BY@5E)yin z=r^B8@TO2YL(1(Lv6uHX>1L_f_^dLeLapXU&A92}FK<> z6tbY90HSs}fNcLcCq6;_l*u1zR{Lys-JFx#?!MT`={lv<+m9^&H76s@#)6@~mPvfn zu3>3YEC8VFXgD=jV%W(2b)$>Tayz`%7)+;3t4nr%Eax%S&SzX*@e$Xm(@GadIXjrh zL(P?VXTuQ>#7t>X>mpXKCJF#jy%X7p0t~dL)5uOs;-{wD=6m6HdfHs=*!6vp&sR*rnZT06f~{I7(AmocU>nrq7Otz4(_ z&^pW7?blp^jegQ@^eez1k{yl*S|v-+u19nB$IYYxHWg~VA{LcbJ*du|Z0%oiO|Mth zi;-TO7X4SXV!X2690!@%=_y*~WPVj05?`d7ldkJbHO7-h?R1ZF)yuiaUG&uoo^(5h z>_R0fqq)BL1W<^PrshiQrBIxeToWhgO$QpF;=9rBjb#?w+0VDw)o(8l+vD%gv6sJ} zVXyx@!9M?a0L;Dq{RBsTc`WwRZT`JeTqur@<38tSxvj&}l{1m^Ps!cr%(b+{kwLu@99nF zBA^fr@yQT?kVm3+U6WtIEhPYvT54!Q=d@k()|?D8wZ|yNO$u6&Z{InB*?W6AnnZC9 zJ%6G(8C@TH#)+X+EmpGV{#%P%R~B`XTs7a&MAqqSP6JcTdI42E#73!C!h#oel@QP!+6PyE_^#77GYjf2ocX%$uUZGKp zPu8TKu5~L1KqS=HyPP2lK-38{hSj;X8+~-;_>zyV8p{gmVx!@7x;+1ZKZdiz2#ve&(wiU~j{HSDNbQ96$+hcG@kj^QQklUv(w=aPx4?8Os`q z@zhw(XsVFB?&f5weh*F$U}3OPXhxjQW^>*&PV>+FK$})ewGYsd?bP)6CT#Tn64v&H zjlD;)Pi*2V8~^s;zr2O5y=P-@max%#n^3c3bYxpVn^yM&UBd0V7VwAK+qK$OO=O4h8uHdJ29J>3+E}oU>cZD=X6xw;of%Dx9zHb z8%JOpHSk1_JIY1B{GE9Vo0v|_*>fAiBSYoXSjgoT<=gikuRHt?#OYR@5X;s2aHu#q z;g~xYC=>;S;%qwGWz)HAYwldf#9$G}`f9nDPO;+rFj48Q={mvUDH&rqTVT>xjxSdn z88i$#vY4|mvzU%y!{A79xp>((8L;JI#*)W^6;ggrAOx#^i^t>!5O(E4wLsrw%GX-mzwMg)IvE|!EN$b{M8^?=@|r3$eQ+75~k9qu@G|sh~;vjP{?M(dAVH6 z6)PA=OQuA{pSFr(1f}N?L?KaXHJ$cbDwTxCV-nHhNmMEpe>z Date: Tue, 30 Jun 2026 07:26:33 +0200 Subject: [PATCH 12/31] Fix Monochrome --- .../res/drawable/ic_launcher_monochrome.xml | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/app/src/main/res/drawable/ic_launcher_monochrome.xml b/app/src/main/res/drawable/ic_launcher_monochrome.xml index 2979fd6..6b7c66f 100644 --- a/app/src/main/res/drawable/ic_launcher_monochrome.xml +++ b/app/src/main/res/drawable/ic_launcher_monochrome.xml @@ -9,17 +9,6 @@ android:translateX="13.153846" android:translateY="13.153846"> - - - - - @@ -27,5 +16,16 @@ + + + + + + + + + + + From e98dc4a86b8fdb2d77afa5bc74e154d981e7d5a4 Mon Sep 17 00:00:00 2001 From: soraefir Date: Tue, 30 Jun 2026 07:30:57 +0200 Subject: [PATCH 13/31] Cleanup imports and co --- .../android/account/AccountActivity.kt | 3 +- .../currencies/ManageCurrenciesScreen.kt | 30 +++++++++++++++---- .../android/helper/QrCodeScannerActivity.kt | 30 +++++++++++++------ .../android/main/BillsListComponents.kt | 21 +++++++++++-- .../android/main/BillsListViewActivity.kt | 5 ---- .../net/helcel/cowspent/util/SupportUtil.kt | 7 +++-- 6 files changed, 69 insertions(+), 27 deletions(-) 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 3250acf..98735b7 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 @@ -13,8 +13,8 @@ import android.webkit.WebView import android.webkit.WebViewClient import android.widget.Toast import androidx.activity.OnBackPressedCallback -import androidx.activity.enableEdgeToEdge import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.layout.Box @@ -33,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 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 962f2d8..5e3c8ee 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 @@ -4,11 +4,32 @@ import android.annotation.SuppressLint import androidx.compose.foundation.BorderStroke 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.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.foundation.shape.RoundedCornerShape -import androidx.compose.material.* +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 @@ -28,10 +49,9 @@ 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.TextIcon -import net.helcel.cowspent.android.helper.TextIconDisplay import net.helcel.cowspent.android.helper.formatAmount import net.helcel.cowspent.model.DBCurrency +import kotlin.time.Duration.Companion.milliseconds @Composable fun ManageCurrenciesScreen( @@ -96,7 +116,7 @@ fun ManageCurrenciesScreen( ) { LaunchedEffect(viewModel.mainCurrencyName) { if (viewModel.mainCurrencyName.isNotEmpty()) { - delay(500) + delay(500.milliseconds) onSaveMain() } } 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 f5ba6fd..2204e41 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,37 +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.enableEdgeToEdge 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 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 0d30465..5468ee8 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 @@ -16,7 +32,6 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import net.helcel.cowspent.R -import net.helcel.cowspent.android.helper.formatAmount import net.helcel.cowspent.android.helper.MemberAvatar import net.helcel.cowspent.model.DBBill import net.helcel.cowspent.model.DBMember 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 799a8f5..848be87 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 @@ -26,10 +24,8 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AddCircleOutline import androidx.compose.material.icons.filled.Sync import androidx.compose.ui.graphics.vector.ImageVector -import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.core.content.edit -import androidx.core.net.toUri import androidx.lifecycle.lifecycleScope import androidx.preference.PreferenceManager import com.nextcloud.android.sso.helper.SingleAccountHelper @@ -49,7 +45,6 @@ import net.helcel.cowspent.model.Category import net.helcel.cowspent.model.DBBill import net.helcel.cowspent.model.DBProject import net.helcel.cowspent.model.GroupedBill -import net.helcel.cowspent.model.ProjectType import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper import net.helcel.cowspent.persistence.CowspentServerSyncHelper import net.helcel.cowspent.theme.ThemeUtils 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..74f5f5d 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 From 5f7266013db53ed6e960fd6862799375fff2f70f Mon Sep 17 00:00:00 2001 From: soraefir Date: Tue, 30 Jun 2026 07:33:49 +0200 Subject: [PATCH 14/31] More cleanup --- .../net/helcel/cowspent/android/bill_edit/EditBillViewModel.kt | 3 ++- .../res/{mipmap-anydpi-v26 => mipmap-anydpi}/ic_launcher.xml | 0 .../{mipmap-anydpi-v26 => mipmap-anydpi}/ic_launcher_round.xml | 0 gradle/wrapper/gradle-wrapper.properties | 2 +- 4 files changed, 3 insertions(+), 2 deletions(-) rename app/src/main/res/{mipmap-anydpi-v26 => mipmap-anydpi}/ic_launcher.xml (100%) rename app/src/main/res/{mipmap-anydpi-v26 => mipmap-anydpi}/ic_launcher_round.xml (100%) 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 14545ec..8e46b44 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,6 +1,7 @@ package net.helcel.cowspent.android.bill_edit import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableDoubleStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateMapOf @@ -31,7 +32,7 @@ class EditBillViewModel : ViewModel() { var currencies by mutableStateOf>(emptyList()) var mainCurrencyName by mutableStateOf("") var selectedCurrencyName by mutableStateOf("") - var selectedCurrencyRate by mutableStateOf(1.0) + var selectedCurrencyRate by mutableDoubleStateOf(1.0) var members by mutableStateOf>(emptyList()) var owersSelection = mutableStateMapOf() 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 100% rename from app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml rename to app/src/main/res/mipmap-anydpi/ic_launcher.xml 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 100% 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 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 From 756a37de235e08065ff0f4576a3400399728c427 Mon Sep 17 00:00:00 2001 From: soraefir Date: Tue, 30 Jun 2026 07:37:52 +0200 Subject: [PATCH 15/31] String cleanup --- app/src/main/res/drawable/test.xml | 52 ----------------------------- app/src/main/res/values/strings.xml | 6 ---- 2 files changed, 58 deletions(-) delete mode 100644 app/src/main/res/drawable/test.xml 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/values/strings.xml b/app/src/main/res/values/strings.xml index 00ff0c4..b27a701 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -20,14 +20,9 @@ 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 @@ -75,7 +70,6 @@ Ok Yes No - Add Edit bill New bill Add project in Cowspent From a8ba00269b53e74ed3a7a558106e9623044e2450 Mon Sep 17 00:00:00 2001 From: soraefir Date: Tue, 30 Jun 2026 07:41:34 +0200 Subject: [PATCH 16/31] Hide inactive members in drawer --- .../main/java/net/helcel/cowspent/android/drawer/Drawer.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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 d2bc0c3..397a020 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 @@ -100,8 +100,11 @@ fun Drawer( // Members Section val membersState = rememberLazyListState() - val sortedMembers = remember(members, memberBalances) { - members.sortedWith(compareBy { + 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 From c3d380ddb467dfd580e06f8d8a59174b0e877cb1 Mon Sep 17 00:00:00 2001 From: soraefir Date: Tue, 30 Jun 2026 18:55:56 +0200 Subject: [PATCH 17/31] Migrate to encryptedPref --- app/build.gradle | 1 + .../android/account/AccountActivity.kt | 5 +- .../android/account/AccountViewModel.kt | 4 +- .../helcel/cowspent/model/DBAccountProject.kt | 2 +- .../net/helcel/cowspent/model/DBProject.kt | 2 +- .../persistence/CowspentSQLiteOpenHelper.kt | 28 +++++--- .../helcel/cowspent/util/NextcloudClient.kt | 12 ++-- .../net/helcel/cowspent/util/SecureStorage.kt | 47 ++++++++++++ .../util/VersatileProjectSyncClient.kt | 72 ++++--------------- 9 files changed, 93 insertions(+), 80 deletions(-) create mode 100644 app/src/main/java/net/helcel/cowspent/util/SecureStorage.kt diff --git a/app/build.gradle b/app/build.gradle index 9f49880..13796be 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -114,5 +114,6 @@ 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.security:security-crypto:1.1.0' implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.11.0' } 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 98735b7..3935a17 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 @@ -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 @@ -128,8 +129,6 @@ class AccountActivity : AppCompatActivity() { ) } } - - oldPassword = preferences.getString(SETTINGS_PASSWORD, DEFAULT_SETTINGS) ?: "" viewModel.validateUrl() } @@ -310,10 +309,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/AccountViewModel.kt b/app/src/main/java/net/helcel/cowspent/android/account/AccountViewModel.kt index 8966d0c..3e1e3b3 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) @@ -63,7 +64,7 @@ class AccountViewModel(application: Application) : AndroidViewModel(application) val password = if (useSso) { "" } else { - preferences.getString(AccountActivity.SETTINGS_PASSWORD, "") + SecureStorage.getPassword(getApplication(), AccountActivity.SETTINGS_PASSWORD) } if (!url.isNullOrEmpty() && !username.isNullOrEmpty()) { @@ -87,6 +88,7 @@ class AccountViewModel(application: Application) : AndroidViewModel(application) } fun logout() { + 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/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/DBProject.kt b/app/src/main/java/net/helcel/cowspent/model/DBProject.kt index 19d513c..55b7682 100644 --- a/app/src/main/java/net/helcel/cowspent/model/DBProject.kt +++ b/app/src/main/java/net/helcel/cowspent/model/DBProject.kt @@ -5,7 +5,7 @@ import java.io.Serializable class DBProject( var id: Long, var remoteId: String, - var password: String, + var password: String?, var name: String, var serverUrl: String?, var email: String?, 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 ca107fd..fc90853 100644 --- a/app/src/main/java/net/helcel/cowspent/persistence/CowspentSQLiteOpenHelper.kt +++ b/app/src/main/java/net/helcel/cowspent/persistence/CowspentSQLiteOpenHelper.kt @@ -13,6 +13,7 @@ import androidx.preference.PreferenceManager import net.helcel.cowspent.R import net.helcel.cowspent.android.main.BillsListViewActivity import net.helcel.cowspent.model.* +import net.helcel.cowspent.util.SecureStorage import net.helcel.cowspent.util.SupportUtil import java.text.SimpleDateFormat import java.util.* @@ -316,11 +317,12 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) : val db = writableDatabase val values = ContentValues() values.put(key_remoteId, accountProject.remoteId) - values.put(key_password, accountProject.password) values.put(key_ncUrl, accountProject.ncUrl) values.put(key_name, accountProject.name) values.put(key_archived, accountProject.archivedTs ?: 0L) - return db.insert(table_account_projects, null, values) + val id = db.insert(table_account_projects, null, values) + SecureStorage.savePassword(context, "AccountProjectPassword_$id", accountProject.password) + return id } val accountProjects: List @@ -344,11 +346,13 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) : @SuppressLint("Range") private fun getAccountProjectFromCursor(cursor: Cursor): DBAccountProject { + val id = cursor.getLong(cursor.getColumnIndex(key_id)) val archivedTs = cursor.getLong(cursor.getColumnIndex(key_archived)) + val password = SecureStorage.getPassword(context, "AccountProjectPassword_$id") return DBAccountProject( - cursor.getLong(cursor.getColumnIndex(key_id)), + id, cursor.getString(cursor.getColumnIndex(key_remoteId)), - cursor.getString(cursor.getColumnIndex(key_password)), + password, cursor.getString(cursor.getColumnIndex(key_name)), cursor.getString(cursor.getColumnIndex(key_ncUrl)), if (archivedTs > 0) archivedTs else null @@ -608,14 +612,15 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) : val db = writableDatabase val values = ContentValues() values.put(key_remoteId, project.remoteId) - values.put(key_password, project.password) values.put(key_bearer_token, project.bearerToken) values.put(key_email, project.email) values.put(key_name, project.name) values.put(key_ihmUrl, project.serverUrl) values.put(key_type, project.type.id) values.put(key_archived, project.archivedTs ?: 0L) - return db.insert(table_projects, null, values) + val id = db.insert(table_projects, null, values) + SecureStorage.savePassword(context, "ProjectPassword_$id", project.password) + return id } fun getProject(id: Long): DBProject? { @@ -644,11 +649,13 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) : @SuppressLint("Range") private fun getProjectFromCursor(cursor: Cursor): DBProject { + val id = cursor.getLong(cursor.getColumnIndex(key_id)) val archivedTs = cursor.getLong(cursor.getColumnIndex(key_archived)) + val password = SecureStorage.getPassword(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)), @@ -671,6 +678,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) : } db.delete(table_members, "$key_projectid = ?", arrayOf(id.toString())) db.delete(table_projects, "$key_id = ?", arrayOf(id.toString())) + SecureStorage.removePassword(context, "ProjectPassword_$id") } fun updateProject( @@ -685,7 +693,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) : val values = ContentValues() if (newName != null) values.put(key_name, newName) if (newEmail != null) values.put(key_email, newEmail) - if (newPassword != null) values.put(key_password, newPassword) + if (newPassword != null) SecureStorage.savePassword(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) @@ -725,7 +733,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) : val values = ContentValues() if (newName != null) values.put(key_name, newName) if (newEmail != null) values.put(key_email, newEmail) - if (newPassword != null) values.put(key_password, newPassword) + if (newPassword != null) SecureStorage.savePassword(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) 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..2e652d5 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 @@ -184,7 +184,7 @@ class NextcloudClient( ): VersatileProjectSyncClient.ResponseData { val result = StringBuilder() val targetURL = url + target.replace("^/".toRegex(), "") - Log.d(javaClass.simpleName, "method and target URL: $method $targetURL") +// Log.d(javaClass.simpleName, "method and target URL: $method $targetURL") val httpCon = SupportUtil.getHttpURLConnection(targetURL) httpCon.requestMethod = method if (needLogin) { @@ -206,7 +206,7 @@ class NextcloudClient( var paramData: ByteArray? = null if (params != null) { paramData = params.toString().toByteArray() - Log.d(javaClass.simpleName, "Params: $params") +// Log.d(javaClass.simpleName, "Params: $params") httpCon.setFixedLengthStreamingMode(paramData.size) httpCon.setRequestProperty("Content-Type", application_json) httpCon.doOutput = true @@ -263,11 +263,11 @@ class NextcloudClient( httpCon.setRequestProperty("OCS-APIRequest", "true") } httpCon.connectTimeout = 10 * 1000 // 10 seconds - Log.d(javaClass.simpleName, "$method $targetURL") +// Log.d(javaClass.simpleName, "$method $targetURL") var paramData: ByteArray? if (params != null) { paramData = params.toString().toByteArray() - Log.d(javaClass.simpleName, "Params: $params") +// Log.d(javaClass.simpleName, "Params: $params") httpCon.setFixedLengthStreamingMode(paramData.size) httpCon.setRequestProperty("Content-Type", application_json) httpCon.doOutput = true 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..dea4986 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/util/SecureStorage.kt @@ -0,0 +1,47 @@ +package net.helcel.cowspent.util + +import android.content.Context +import android.content.SharedPreferences +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import androidx.core.content.edit + +object SecureStorage { + private const val SECURE_PREFS_NAME = "secure_prefs" + private var encryptedPrefs: SharedPreferences? = null + + @Synchronized + fun getEncryptedPrefs(context: Context): SharedPreferences { + encryptedPrefs?.let { return it } + + val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + val prefs = EncryptedSharedPreferences.create( + context, + SECURE_PREFS_NAME, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + encryptedPrefs = prefs + return prefs + } + + fun savePassword(context: Context, key: String, password: String?) { + if (password == null) { + removePassword(context, key) + } else { + getEncryptedPrefs(context).edit { putString(key, password) } + } + } + + fun getPassword(context: Context, key: String): String? { + return getEncryptedPrefs(context).getString(key, null) + } + + fun removePassword(context: Context, key: String) { + getEncryptedPrefs(context).edit { remove(key) } + } +} 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..c77bb5d 100644 --- a/app/src/main/java/net/helcel/cowspent/util/VersatileProjectSyncClient.kt +++ b/app/src/main/java/net/helcel/cowspent/util/VersatileProjectSyncClient.kt @@ -81,13 +81,9 @@ class VersatileProjectSyncClient( else project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId useOcsApiRequest = cospendVersionGT161 - if (cospendVersionGT161) { - Log.i(TAG, "using new API (weblogin, $username:$password) for getProjectInfo") - } } else if (canAccessProjectWithSSO(project)) { return if (cospendVersionGT161) { target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId - Log.i(TAG, "using new API for getProjectInfo") ServerResponse.ProjectResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_GET, null, null, true), true) } else { target = "/index.php/apps/cospend/api-priv/projects/" + project.remoteId @@ -99,7 +95,6 @@ class VersatileProjectSyncClient( project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) else project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) - Log.i(TAG, "using public API, target is: ${target}for getProjectInfo") } } else { target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId @@ -172,13 +167,9 @@ class VersatileProjectSyncClient( else project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId useOcsApiRequest = cospendVersionGT161 - if (cospendVersionGT161) { - Log.i(TAG, "using new API (weblogin, $username:$password) for editRemoteProject") - } } else if (canAccessProjectWithSSO(project)) { return if (cospendVersionGT161) { target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId - Log.i(TAG, "using new API for editRemoteProject") ServerResponse.EditRemoteProjectResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_PUT, paramKeys, paramValues, true), true) } else { target = "/index.php/apps/cospend/api-priv/projects/" + project.remoteId @@ -190,7 +181,6 @@ class VersatileProjectSyncClient( project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) else project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) - Log.i(TAG, "using public API, target is: ${target}for editRemoteProject") } } else { target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId @@ -239,13 +229,9 @@ class VersatileProjectSyncClient( else project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/members/" + member.remoteId useOcsApiRequest = cospendVersionGT161 - if (cospendVersionGT161) { - Log.i(TAG, "using new API (weblogin, $username:$password) for editRemoteMember") - } } else if (canAccessProjectWithSSO(project)) { return if (cospendVersionGT161) { target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/members/" + member.remoteId - Log.i(TAG, "using new API for editRemoteMember") ServerResponse.EditRemoteMemberResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_PUT, paramKeys, paramValues, true), true) } else { target = "/index.php/apps/cospend/api-priv/projects/" + project.remoteId + "/members/" + member.remoteId @@ -257,7 +243,6 @@ class VersatileProjectSyncClient( project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/members/" + member.remoteId else project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/members/" + member.remoteId - Log.i(TAG, "using public API, target is: ${target}for editRemoteMember") } } else { target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/members/" + member.remoteId @@ -333,9 +318,6 @@ class VersatileProjectSyncClient( else project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/bills/" + bill.remoteId useOcsApiRequest = cospendVersionGT161 - if (cospendVersionGT161) { - Log.i(TAG, "using new API (weblogin, $username:$password) for editRemoteBill") - } } else if (canAccessProjectWithSSO(project)) { return if (cospendVersionGT161) { target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/bills/" + bill.remoteId @@ -351,7 +333,7 @@ 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") + Log.i(TAG, "using public API, target is: ${SupportUtil.maskUrl(target)} for editRemoteBill") } } else { target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/bills/" + bill.remoteId @@ -389,9 +371,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 +386,7 @@ 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") + Log.i(TAG, "using public API, target is: ${SupportUtil.maskUrl(target)} for deleteRemoteProject") } } else { target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId @@ -439,9 +418,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 +433,7 @@ 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") + Log.i(TAG, "using public API, target is: ${SupportUtil.maskUrl(target)} for deleteRemoteProject") } } else { target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/bills/" + billRemoteId @@ -529,8 +505,7 @@ class VersatileProjectSyncClient( project.getRequestBaseUrl(false) + "/api-priv/projects" useOcsApiRequest = cospendVersionGT161 if (cospendVersionGT161) { - Log.i(TAG, "using new API (weblogin, $username:$password) for createAuthenticatedRemoteProject") - } + } return ServerResponse.CreateRemoteProjectResponse( requestServer( target, METHOD_POST, paramKeys, paramValues, @@ -594,9 +569,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 +584,7 @@ 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") + Log.i(TAG, "using public API, target is: ${SupportUtil.maskUrl(target)} for createRemoteBill") } } else { target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/bills" @@ -664,9 +636,6 @@ class VersatileProjectSyncClient( else project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/members" useOcsApiRequest = cospendVersionGT161 - if (cospendVersionGT161) { - Log.i(TAG, "using new API (weblogin, $username:$password) for createRemoteMember") - } } else if (canAccessProjectWithSSO(project)) { return if (cospendVersionGT161) { target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/members" @@ -682,7 +651,7 @@ 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") + Log.i(TAG, "using public API, target is: ${SupportUtil.maskUrl(target)} for createRemoteBill") } } else { target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/members" @@ -718,8 +687,7 @@ class VersatileProjectSyncClient( project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/bills?lastchanged=" + tsLastSync useOcsApiRequest = cospendVersionGT161 if (cospendVersionGT161) { - Log.i(TAG, "using new API (weblogin, $username:$password) for getBills") - } + } return ServerResponse.BillsResponse( requestServer( target, METHOD_GET, null, null, @@ -750,7 +718,7 @@ 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") + Log.i(TAG, "using public API, target is: ${SupportUtil.maskUrl(target)} for getBills") return ServerResponse.BillsResponse( requestServer( target, METHOD_GET, null, null, @@ -790,9 +758,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 +773,7 @@ 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") + Log.i(TAG, "using public API, target is: ${SupportUtil.maskUrl(target)} for getMembers") } } else { target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/members" @@ -847,9 +812,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 +827,7 @@ 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") + Log.i(TAG, "using public API, target is: ${SupportUtil.maskUrl(target)} for createRemoteCurrency") } } else { target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/currency" @@ -904,9 +866,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 +881,7 @@ 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") + Log.i(TAG, "using public API, target is: ${SupportUtil.maskUrl(target)} for createRemoteCurrency") } } else { target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/currency/" + currency.remoteId @@ -954,9 +913,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 +928,7 @@ 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") + Log.i(TAG, "using public API, target is: ${SupportUtil.maskUrl(target)} for deleteRemoteCurrency") } } else { target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/currency/" + currencyRemoteId @@ -1071,7 +1027,7 @@ class VersatileProjectSyncClient( httpCon.setRequestProperty("Accept", "application/json") } httpCon.connectTimeout = 10 * 1000 // 10 seconds - Log.d(javaClass.simpleName, "$method $target") + Log.d(javaClass.simpleName, "$method ${SupportUtil.maskUrl(target)}") if (paramKeys != null && paramValues != null) { var dataString = "" for (i in paramKeys.indices) { @@ -1084,7 +1040,7 @@ class VersatileProjectSyncClient( dataString += URLEncoder.encode(value, "UTF-8") } val data = dataString.toByteArray() - Log.d(javaClass.simpleName, "Params: $dataString") + Log.d(javaClass.simpleName, "Params: ${SupportUtil.maskParams(dataString)}") httpCon.setFixedLengthStreamingMode(data.size) httpCon.setRequestProperty("Content-Type", "application/x-www-form-urlencoded") httpCon.setRequestProperty("Content-Length", data.size.toString()) From 119ae757b9b8989989cfd6ea45a6624d9fff7a92 Mon Sep 17 00:00:00 2001 From: soraefir Date: Tue, 30 Jun 2026 19:00:15 +0200 Subject: [PATCH 18/31] Migrate to SecureStorage --- app/build.gradle | 2 + .../android/account/AccountActivity.kt | 7 +- .../android/account/AccountViewModel.kt | 15 +-- .../project/create/NewProjectScreen.kt | 8 +- .../net/helcel/cowspent/model/DBProject.kt | 2 +- .../persistence/CowspentSQLiteOpenHelper.kt | 14 +-- .../net/helcel/cowspent/util/SecureStorage.kt | 97 +++++++++++++------ .../util/VersatileProjectSyncClient.kt | 17 +--- 8 files changed, 89 insertions(+), 73 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 13796be..e27c227 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -116,4 +116,6 @@ dependencies { implementation 'androidx.activity:activity-ktx:1.13.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/java/net/helcel/cowspent/android/account/AccountActivity.kt b/app/src/main/java/net/helcel/cowspent/android/account/AccountActivity.kt index 3935a17..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 @@ -69,7 +69,6 @@ class AccountActivity : AppCompatActivity() { } private lateinit var preferences: SharedPreferences - private var oldPassword = "" private var useWebLogin = true private var showLoginDialog by mutableStateOf(false) @@ -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) } 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 3e1e3b3..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 @@ -61,14 +61,15 @@ class AccountViewModel(application: Application) : AndroidViewModel(application) } else { preferences.getString(AccountActivity.SETTINGS_USERNAME, "") } - val password = if (useSso) { - "" - } else { - SecureStorage.getPassword(getApplication(), 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) { @@ -88,7 +89,9 @@ class AccountViewModel(application: Application) : AndroidViewModel(application) } fun logout() { - SecureStorage.removePassword(getApplication(), AccountActivity.SETTINGS_PASSWORD) + 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/project/create/NewProjectScreen.kt b/app/src/main/java/net/helcel/cowspent/android/project/create/NewProjectScreen.kt index ab0efce..f29dff2 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 @@ -121,13 +121,7 @@ fun NewProjectScreen( Spacer(modifier = Modifier.height(8.dp)) if (viewModel.whatTodoIsCreate) { - if (viewModel.projectType == ProjectType.COSPEND) { - Button( - onClick = onChooseFromNextcloud - ) { - Text(stringResource(R.string.new_project_from_nextcloud_tooltip)) - } - } else if (viewModel.projectType == ProjectType.LOCAL) { + if (viewModel.projectType == ProjectType.LOCAL) { Button( onClick = onImportFile ) { diff --git a/app/src/main/java/net/helcel/cowspent/model/DBProject.kt b/app/src/main/java/net/helcel/cowspent/model/DBProject.kt index 55b7682..19d513c 100644 --- a/app/src/main/java/net/helcel/cowspent/model/DBProject.kt +++ b/app/src/main/java/net/helcel/cowspent/model/DBProject.kt @@ -5,7 +5,7 @@ import java.io.Serializable class DBProject( var id: Long, var remoteId: String, - var password: String?, + var password: String, var name: String, var serverUrl: String?, var email: String?, 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 fc90853..90bdca0 100644 --- a/app/src/main/java/net/helcel/cowspent/persistence/CowspentSQLiteOpenHelper.kt +++ b/app/src/main/java/net/helcel/cowspent/persistence/CowspentSQLiteOpenHelper.kt @@ -321,7 +321,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) : values.put(key_name, accountProject.name) values.put(key_archived, accountProject.archivedTs ?: 0L) val id = db.insert(table_account_projects, null, values) - SecureStorage.savePassword(context, "AccountProjectPassword_$id", accountProject.password) + SecureStorage.savePasswordSync(context, "AccountProjectPassword_$id", accountProject.password) return id } @@ -348,7 +348,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) : 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.getPassword(context, "AccountProjectPassword_$id") + val password = SecureStorage.getPasswordSync(context, "AccountProjectPassword_$id") return DBAccountProject( id, cursor.getString(cursor.getColumnIndex(key_remoteId)), @@ -619,7 +619,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) : values.put(key_type, project.type.id) values.put(key_archived, project.archivedTs ?: 0L) val id = db.insert(table_projects, null, values) - SecureStorage.savePassword(context, "ProjectPassword_$id", project.password) + SecureStorage.savePasswordSync(context, "ProjectPassword_$id", project.password) return id } @@ -651,7 +651,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) : 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.getPassword(context, "ProjectPassword_$id") + val password = SecureStorage.getPasswordSync(context, "ProjectPassword_$id") ?: "" return DBProject( id, cursor.getString(cursor.getColumnIndex(key_remoteId)), @@ -678,7 +678,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) : } db.delete(table_members, "$key_projectid = ?", arrayOf(id.toString())) db.delete(table_projects, "$key_id = ?", arrayOf(id.toString())) - SecureStorage.removePassword(context, "ProjectPassword_$id") + SecureStorage.removePasswordSync(context, "ProjectPassword_$id") } fun updateProject( @@ -693,7 +693,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) : val values = ContentValues() if (newName != null) values.put(key_name, newName) if (newEmail != null) values.put(key_email, newEmail) - if (newPassword != null) SecureStorage.savePassword(context, "ProjectPassword_$projId", 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) @@ -733,7 +733,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) : val values = ContentValues() if (newName != null) values.put(key_name, newName) if (newEmail != null) values.put(key_email, newEmail) - if (newPassword != null) SecureStorage.savePassword(context, "ProjectPassword_$projId", 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) diff --git a/app/src/main/java/net/helcel/cowspent/util/SecureStorage.kt b/app/src/main/java/net/helcel/cowspent/util/SecureStorage.kt index dea4986..233e26d 100644 --- a/app/src/main/java/net/helcel/cowspent/util/SecureStorage.kt +++ b/app/src/main/java/net/helcel/cowspent/util/SecureStorage.kt @@ -1,47 +1,84 @@ package net.helcel.cowspent.util import android.content.Context -import android.content.SharedPreferences -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKey -import androidx.core.content.edit +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 const val SECURE_PREFS_NAME = "secure_prefs" - private var encryptedPrefs: SharedPreferences? = null + private var aead: Aead? = null @Synchronized - fun getEncryptedPrefs(context: Context): SharedPreferences { - encryptedPrefs?.let { return it } - - val masterKey = MasterKey.Builder(context) - .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + 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 prefs = EncryptedSharedPreferences.create( - context, - SECURE_PREFS_NAME, - masterKey, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM - ) - encryptedPrefs = prefs - return prefs + val newAead = keysetManager.keysetHandle.getPrimitive(RegistryConfiguration.get(), Aead::class.java) + aead = newAead + return newAead } - fun savePassword(context: Context, key: String, password: String?) { - if (password == null) { - removePassword(context, key) - } else { - getEncryptedPrefs(context).edit { putString(key, password) } + 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 } } - fun getPassword(context: Context, key: String): String? { - return getEncryptedPrefs(context).getString(key, 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 } + } } - fun removePassword(context: Context, key: String) { - getEncryptedPrefs(context).edit { remove(key) } + 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/VersatileProjectSyncClient.kt b/app/src/main/java/net/helcel/cowspent/util/VersatileProjectSyncClient.kt index c77bb5d..0d027bf 100644 --- a/app/src/main/java/net/helcel/cowspent/util/VersatileProjectSyncClient.kt +++ b/app/src/main/java/net/helcel/cowspent/util/VersatileProjectSyncClient.kt @@ -333,7 +333,6 @@ class VersatileProjectSyncClient( project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/bills/" + bill.remoteId else project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/bills/" + bill.remoteId - Log.i(TAG, "using public API, target is: ${SupportUtil.maskUrl(target)} for editRemoteBill") } } else { target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/bills/" + bill.remoteId @@ -386,7 +385,6 @@ class VersatileProjectSyncClient( project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) else project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) - Log.i(TAG, "using public API, target is: ${SupportUtil.maskUrl(target)} for deleteRemoteProject") } } else { target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId @@ -433,7 +431,6 @@ class VersatileProjectSyncClient( project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/bills/" + billRemoteId else project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/bills/" + billRemoteId - Log.i(TAG, "using public API, target is: ${SupportUtil.maskUrl(target)} for deleteRemoteProject") } } else { target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/bills/" + billRemoteId @@ -504,8 +501,7 @@ class VersatileProjectSyncClient( else project.getRequestBaseUrl(false) + "/api-priv/projects" useOcsApiRequest = cospendVersionGT161 - if (cospendVersionGT161) { - } + return ServerResponse.CreateRemoteProjectResponse( requestServer( target, METHOD_POST, paramKeys, paramValues, @@ -584,7 +580,6 @@ class VersatileProjectSyncClient( project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/bills" else project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/bills" - Log.i(TAG, "using public API, target is: ${SupportUtil.maskUrl(target)} for createRemoteBill") } } else { target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/bills" @@ -651,7 +646,6 @@ class VersatileProjectSyncClient( project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/members" else project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/members" - Log.i(TAG, "using public API, target is: ${SupportUtil.maskUrl(target)} for createRemoteBill") } } else { target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/members" @@ -686,8 +680,6 @@ class VersatileProjectSyncClient( else project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/bills?lastchanged=" + tsLastSync useOcsApiRequest = cospendVersionGT161 - if (cospendVersionGT161) { - } return ServerResponse.BillsResponse( requestServer( target, METHOD_GET, null, null, @@ -718,7 +710,6 @@ class VersatileProjectSyncClient( project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/bills?lastChanged=" + tsLastSync else project.getRequestBaseUrl(false) + "/apiv2/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/bills?lastchanged=" + tsLastSync - Log.i(TAG, "using public API, target is: ${SupportUtil.maskUrl(target)} for getBills") return ServerResponse.BillsResponse( requestServer( target, METHOD_GET, null, null, @@ -773,7 +764,6 @@ class VersatileProjectSyncClient( project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/members" else project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/members" - Log.i(TAG, "using public API, target is: ${SupportUtil.maskUrl(target)} for getMembers") } } else { target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/members" @@ -827,7 +817,6 @@ class VersatileProjectSyncClient( project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/currency" else project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/currency" - Log.i(TAG, "using public API, target is: ${SupportUtil.maskUrl(target)} for createRemoteCurrency") } } else { target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/currency" @@ -881,7 +870,6 @@ class VersatileProjectSyncClient( project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/currency/" + currency.remoteId else project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/currency/" + currency.remoteId - Log.i(TAG, "using public API, target is: ${SupportUtil.maskUrl(target)} for createRemoteCurrency") } } else { target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/currency/" + currency.remoteId @@ -928,7 +916,6 @@ class VersatileProjectSyncClient( project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/currency/" + currencyRemoteId else project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/currency/" + currencyRemoteId - Log.i(TAG, "using public API, target is: ${SupportUtil.maskUrl(target)} for deleteRemoteCurrency") } } else { target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/currency/" + currencyRemoteId @@ -1027,7 +1014,6 @@ class VersatileProjectSyncClient( httpCon.setRequestProperty("Accept", "application/json") } httpCon.connectTimeout = 10 * 1000 // 10 seconds - Log.d(javaClass.simpleName, "$method ${SupportUtil.maskUrl(target)}") if (paramKeys != null && paramValues != null) { var dataString = "" for (i in paramKeys.indices) { @@ -1040,7 +1026,6 @@ class VersatileProjectSyncClient( dataString += URLEncoder.encode(value, "UTF-8") } val data = dataString.toByteArray() - Log.d(javaClass.simpleName, "Params: ${SupportUtil.maskParams(dataString)}") httpCon.setFixedLengthStreamingMode(data.size) httpCon.setRequestProperty("Content-Type", "application/x-www-form-urlencoded") httpCon.setRequestProperty("Content-Length", data.size.toString()) From 88469fa96ca7d155374d6d6a7e49d24c6d66a8bb Mon Sep 17 00:00:00 2001 From: soraefir Date: Tue, 30 Jun 2026 19:31:39 +0200 Subject: [PATCH 19/31] Fix monochrome logo --- .../res/drawable/ic_launcher_background.xml | 41 ++++++++++++++++--- .../res/drawable/ic_launcher_foreground.xml | 25 ----------- 2 files changed, 35 insertions(+), 31 deletions(-) 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 e0c14c5..074841e 100644 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -9,20 +9,6 @@ android:scaleY="0.63461536" android:translateX="13.153846" android:translateY="13.153846"> - - - - - - - - @@ -43,17 +29,6 @@ - - - - - - From 678a90ead942a23e14202104122bd247f96ceb0a Mon Sep 17 00:00:00 2001 From: soraefir Date: Tue, 30 Jun 2026 20:40:32 +0200 Subject: [PATCH 20/31] Archiving and Label options --- .../cowspent/android/main/BillsListScreen.kt | 6 + .../android/main/BillsListViewActivity.kt | 43 +++++- .../android/project/ProjectOptionsDialog.kt | 124 ++++++++++++++---- app/src/main/res/values/strings.xml | 21 +-- 4 files changed, 159 insertions(+), 35 deletions(-) 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 f1a9e70..0c0f1a0 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 @@ -65,6 +65,7 @@ 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 @@ -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,6 +160,7 @@ 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 ) 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 848be87..148786e 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 @@ -45,6 +45,7 @@ import net.helcel.cowspent.model.Category import net.helcel.cowspent.model.DBBill import net.helcel.cowspent.model.DBProject import net.helcel.cowspent.model.GroupedBill +import net.helcel.cowspent.model.ProjectType import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper import net.helcel.cowspent.persistence.CowspentServerSyncHelper import net.helcel.cowspent.theme.ThemeUtils @@ -254,13 +255,21 @@ 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 -> { /* Manage Labels - Placeholder for now */ } } }, onAccountSwitcherClick = { @@ -423,6 +432,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 { 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..178b13f 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,6 +4,7 @@ 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 @@ -15,8 +16,8 @@ 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,12 +25,14 @@ 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 ) { @@ -49,41 +52,63 @@ fun ProjectOptionsDialogContent( 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() + + // Row 1: Edit, Share, Remove/Archive 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_project), 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_project), 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.fab_rm_project), 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.fab_manage_members), Icons.Default.Group, onManageMembers)) + row2.add(ProjectOption(stringResource(R.string.fab_manage_labels), Icons.AutoMirrored.Filled.Label, onManageLabels)) + row2.add(ProjectOption(stringResource(R.string.fab_manage_currencies), Icons.Default.MonetizationOn, onManageCurrencies)) + } + + // Row 3: Statistics, Settle, Export + row3.add(ProjectOption(stringResource(R.string.fab_statistics), Icons.Default.BarChart, onStatistics)) + if (!isArchived && isParticipant) { + row3.add(ProjectOption(stringResource(R.string.fab_settle), Icons.Default.Handshake, onSettle)) + } + row3.add(ProjectOption(stringResource(R.string.fab_export_project), 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,10 +170,59 @@ fun ProjectOptionsDialogPreview() { onManageCurrencies = {}, onStatistics = {}, onSettle = {}, + onManageLabels = {}, onShareProject = {}, onExportProject = {}, onDismiss = {}, isArchived = false, + projectType = ProjectType.COSPEND, + accessLevel = DBProject.ACCESS_LEVEL_ADMIN, + isShareable = 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 + ) + } +} + +@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 ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b27a701..3f61dc1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -8,8 +8,8 @@ Scan QRCode from Cospend/Cowspent Add project Save bill - Edit project - Share project + Edit + Share What? Who paid? All @@ -82,12 +82,15 @@ Share Cowspent link Delete project on server Save project - Add a member - Remove Project + Add member + Remove Project %1$s has been removed - Manage project - Manage members - Export project + Project + Members + Labels + Archive + Unarchive + Export Last sync: %1$02d:%2$02d Scan QR code Choose from Nextcloud account @@ -147,7 +150,7 @@ Paid Spent Balance - Statistics + Stats Settle Project %1$s was successfully added Project %1$s was successfully created @@ -240,7 +243,7 @@ QR code could not be scanned. - Manage currencies + Currencies Show archived projects From 810ca20201c938466d3002278f0ddc547f5d87f3 Mon Sep 17 00:00:00 2001 From: soraefir Date: Wed, 1 Jul 2026 22:10:08 +0200 Subject: [PATCH 21/31] Custom Labels, User archive, Versioning, String and UI cleanup --- .github/workflows/build.yml | 3 +- app/build.gradle | 8 +- app/src/main/AndroidManifest.xml | 26 +- .../cowspent/android/about/AboutScreen.kt | 2 +- .../cowspent/android/account/AccountScreen.kt | 99 ++-- .../cowspent/android/account/LoginDialog.kt | 7 +- .../android/bill_edit/EditBillActivity.kt | 16 +- .../android/bill_edit/EditBillScreen.kt | 51 +- .../android/bill_label/LabelBillsScreen.kt | 16 +- .../currencies/ManageCurrenciesActivity.kt | 2 +- .../currencies/ManageCurrenciesScreen.kt | 25 +- .../helcel/cowspent/android/drawer/Drawer.kt | 26 +- .../cowspent/android/helper/AlertDialog.kt | 7 +- .../cowspent/android/helper/ColorPicker.kt | 13 +- .../android/helper/QrCodeScannerActivity.kt | 2 +- .../android/label/LabelManagementActivity.kt | 46 ++ .../android/label/LabelManagementScreen.kt | 398 ++++++++++++++++ .../android/label/LabelManagementViewModel.kt | 109 +++++ .../android/main/BillsListComponents.kt | 91 +++- .../cowspent/android/main/BillsListScreen.kt | 139 ++---- .../cowspent/android/main/BillsListUtils.kt | 8 +- .../android/main/BillsListViewActivity.kt | 45 +- .../android/project/ProjectOptionsDialog.kt | 28 +- .../android/project/ProjectShareDialog.kt | 171 +++---- .../project/create/NewProjectActivity.kt | 2 +- .../project/create/NewProjectScreen.kt | 60 +-- .../project/edit/EditProjectActivity.kt | 2 +- .../android/project/edit/EditProjectScreen.kt | 79 ++-- .../project/edit/EditProjectViewModel.kt | 2 + .../android/project/member/MemberAddDialog.kt | 9 +- .../project/member/MemberEditDialog.kt | 39 +- .../member/MemberManagementActivity.kt | 122 +++++ .../project/member/MemberManagementDialog.kt | 127 ----- .../project/member/MemberManagementScreen.kt | 162 +++++++ .../member/MemberManagementViewModel.kt | 40 ++ .../project/settle/ProjectSettlementScreen.kt | 12 +- .../android/settings/SettingsScreen.kt | 28 +- .../statistics/ProjectSankeyDiagram.kt | 4 +- .../statistics/ProjectSpendingGraph.kt | 4 +- .../statistics/ProjectStatisticsScreen.kt | 4 +- .../statistics/ProjectStatisticsTable.kt | 26 +- .../helcel/cowspent/util/CospendClientUtil.kt | 2 +- app/src/main/res/values/strings.xml | 434 +++++++++--------- 43 files changed, 1646 insertions(+), 850 deletions(-) create mode 100644 app/src/main/java/net/helcel/cowspent/android/label/LabelManagementActivity.kt create mode 100644 app/src/main/java/net/helcel/cowspent/android/label/LabelManagementScreen.kt create mode 100644 app/src/main/java/net/helcel/cowspent/android/label/LabelManagementViewModel.kt create mode 100644 app/src/main/java/net/helcel/cowspent/android/project/member/MemberManagementActivity.kt delete mode 100644 app/src/main/java/net/helcel/cowspent/android/project/member/MemberManagementDialog.kt create mode 100644 app/src/main/java/net/helcel/cowspent/android/project/member/MemberManagementScreen.kt create mode 100644 app/src/main/java/net/helcel/cowspent/android/project/member/MemberManagementViewModel.kt 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/app/build.gradle b/app/build.gradle index e27c227..9b8ea02 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -14,7 +14,7 @@ android { applicationId "net.helcel.cowspent" minSdk = 26 targetSdk = 37 - versionName "1.0b" + versionName project.hasProperty('VERSION_NAME') ? project.property('VERSION_NAME') : "1" versionCode project.hasProperty('VERSION_CODE') ? project.property('VERSION_CODE').toInteger() : 1 } @@ -92,8 +92,8 @@ android { } dependencies { - implementation 'androidx.compose.foundation:foundation:1.11.3' - implementation 'androidx.compose.runtime:runtime:1.11.3' + 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' @@ -106,7 +106,7 @@ dependencies { implementation 'com.github.nextcloud:Android-SingleSignOn:1.1.0' implementation 'com.opencsv:opencsv:5.12.0' - implementation platform('androidx.compose:compose-bom:2026.06.00') + 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' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 778d94d..2e082dc 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -46,7 +46,7 @@ @@ -102,7 +102,7 @@ @@ -110,20 +110,32 @@ + + + + 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/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/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 fa6af65..7906472 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 @@ -255,22 +255,22 @@ class EditBillActivity : AppCompatActivity() { 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) { @@ -288,7 +288,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 = { 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 1aeeb30..89412fc 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 @@ -60,7 +61,7 @@ fun EditBillScreen( Scaffold( topBar = { TopAppBar( - title = { Text(stringResource(if (viewModel.isNewBill) 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) @@ -89,11 +90,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( @@ -107,7 +108,7 @@ fun EditBillScreen( }) { Icon( Icons.Default.Done, - contentDescription = stringResource(R.string.action_save_bill) + contentDescription = stringResource(R.string.action_save) ) } } @@ -164,6 +165,14 @@ 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) @@ -172,7 +181,7 @@ fun BillBasicInfoSection( 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) } ) @@ -262,13 +271,13 @@ 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) { MemberAvatar( member = selectedPayer, @@ -340,8 +349,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)) @@ -447,19 +458,27 @@ 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) + ) + var categoryExpanded by remember { mutableStateOf(false) } val selectedCategory = categories.find { it.remoteId.toInt() == viewModel.categoryRemoteId } 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 { @@ -497,13 +516,13 @@ fun BillAdditionalDetailsSection( 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 { @@ -548,7 +567,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 }, @@ -572,7 +591,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 = { 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..f1e06bb 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)) @@ -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)) @@ -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)) } } } 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 41dda19..c47fd0d 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 @@ -34,7 +34,7 @@ class ManageCurrenciesActivity : AppCompatActivity() { 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), + message=getString(R.string.action_currencies), positiveText = getString(android.R.string.ok)) } } 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 5e3c8ee..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 @@ -96,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) @@ -125,11 +125,11 @@ fun ManageCurrenciesScreen( // Section 1: Main Currency Text( text = stringResource(R.string.main_currency).uppercase(), - style = MaterialTheme.typography.overline, - color = MaterialTheme.colors.primary, - fontWeight = FontWeight.Bold + style = MaterialTheme.typography.subtitle1, + color = MaterialTheme.colors.onSurface, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 8.dp) ) - Spacer(modifier = Modifier.height(8.dp)) Card( shape = RoundedCornerShape(12.dp), elevation = 2.dp, @@ -153,11 +153,11 @@ fun ManageCurrenciesScreen( val isEditing = viewModel.editingCurrencyId != null Text( text = (if (isEditing) "Edit exchange rate" else "Add exchange rate").uppercase(), - style = MaterialTheme.typography.overline, - color = if (isEditing) MaterialTheme.colors.secondary else MaterialTheme.colors.primary, - fontWeight = FontWeight.Bold + style = MaterialTheme.typography.subtitle1, + color = MaterialTheme.colors.onSurface, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 8.dp) ) - Spacer(modifier = Modifier.height(8.dp)) // Integrated Add/Edit Ribbon Surface( @@ -236,9 +236,10 @@ fun ManageCurrenciesScreen( Text( text = "SAVED RATES", - style = MaterialTheme.typography.overline, - color = MaterialTheme.colors.onSurface.copy(alpha = 0.4f), - fontWeight = FontWeight.Bold + style = MaterialTheme.typography.subtitle1, + color = MaterialTheme.colors.onSurface, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 8.dp) ) } 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 397a020..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 @@ -160,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)) @@ -200,6 +207,7 @@ private fun DrawerHeader( } if (lastSyncText.isNotEmpty()) { Row(verticalAlignment = Alignment.CenterVertically) { + Spacer(Modifier.width(16.dp)) Icon( Icons.Default.Sync, contentDescription = null, @@ -291,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( 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 421a38c..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 @@ -230,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 + ) } } } 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/QrCodeScannerActivity.kt b/app/src/main/java/net/helcel/cowspent/android/helper/QrCodeScannerActivity.kt index 2204e41..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 @@ -134,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/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..5568011 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/label/LabelManagementScreen.kt @@ -0,0 +1,398 @@ +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.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: (Long, String, String, String) -> Unit, + onDeleteCategory: (Long) -> Unit, + onAddPaymentMode: (String, String, String) -> Unit, + onUpdatePaymentMode: (Long, 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, + 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.action_add_project) 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!!.id, name, icon, color) + } + showEditDialog = false + } + ) + } else { + EditLabelDialog( + title = if (editingPaymentMode == null) stringResource(R.string.action_add_project) 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!!.id, 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(1, 1, 1, "Groceries", "🛒", "#FF0000"), + DBCategory(2, 2, 1, "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(1, 1, 1, "Cash", "💵", "#0000FF"), + DBPaymentMode(2, 2, 1, "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..1421622 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/label/LabelManagementViewModel.kt @@ -0,0 +1,109 @@ +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.persistence.CowspentSQLiteOpenHelper +import net.helcel.cowspent.util.CategoryUtils + +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 cats = withContext(Dispatchers.IO) { db.getCategories(projId) } + val pms = withContext(Dispatchers.IO) { db.getPaymentModes(projId) } + + if (cats.isEmpty()) { + val defaults = CategoryUtils.getDefaultCategories(getApplication(), projId) + withContext(Dispatchers.IO) { + defaults.forEach { db.addCategory(it) } + } + categories = withContext(Dispatchers.IO) { db.getCategories(projId) } + } else { + categories = cats + } + + if (pms.isEmpty()) { + val defaults = CategoryUtils.getDefaultPaymentModes(getApplication(), projId) + withContext(Dispatchers.IO) { + defaults.forEach { db.addPaymentMode(it) } + } + paymentModes = withContext(Dispatchers.IO) { db.getPaymentModes(projId) } + } else { + paymentModes = pms + } + } + } + + fun addCategory(name: String, icon: String, color: String) { + viewModelScope.launch { + withContext(Dispatchers.IO) { + db.addCategory(DBCategory(0, 0, projectId, name, icon, color)) + } + loadLabels(projectId) + } + } + + fun updateCategory(id: Long, name: String, icon: String, color: String) { + viewModelScope.launch { + withContext(Dispatchers.IO) { + db.updateCategory(id, name, icon, color) + } + loadLabels(projectId) + } + } + + fun deleteCategory(id: Long) { + viewModelScope.launch { + withContext(Dispatchers.IO) { + db.deleteCategory(id) + } + loadLabels(projectId) + } + } + + fun addPaymentMode(name: String, icon: String, color: String) { + viewModelScope.launch { + withContext(Dispatchers.IO) { + db.addPaymentMode(DBPaymentMode(0, 0, projectId, name, icon, color)) + } + loadLabels(projectId) + } + } + + fun updatePaymentMode(id: Long, name: String, icon: String, color: String) { + viewModelScope.launch { + withContext(Dispatchers.IO) { + db.updatePaymentMode(id, name, icon, color) + } + loadLabels(projectId) + } + } + + fun deletePaymentMode(id: Long) { + viewModelScope.launch { + withContext(Dispatchers.IO) { + db.deletePaymentMode(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 5468ee8..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 @@ -29,6 +29,7 @@ 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 @@ -44,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)) } } @@ -65,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) + ) } } @@ -78,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) + ) } } @@ -122,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 ) @@ -151,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 + ) + } } } @@ -168,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 0c0f1a0..3057b91 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,9 +57,6 @@ 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 @@ -104,7 +101,6 @@ 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) @@ -214,114 +210,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 @@ -330,8 +229,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) @@ -364,7 +263,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), @@ -387,8 +292,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 + ) + } } } }, @@ -431,7 +348,7 @@ 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)) } } }, 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..7412547 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 @@ -65,11 +65,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 +80,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) } 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 148786e..2aeed56 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 @@ -119,12 +119,12 @@ class BillsListViewActivity : 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) + title = getString(R.string.msg_project_added) + message = getString(R.string.msg_project_added, 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) + title = getString(R.string.msg_project_added) + message = getString(R.string.msg_project_added, addedProj?.remoteId) } showDialog(message, title, Icons.Default.AddCircleOutline) } @@ -221,7 +221,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 { @@ -269,7 +269,9 @@ class BillsListViewActivity : 5 -> onSettleProjectClick(pid) 6 -> onShareProjectClick(pid) 7 -> onExportProjectClick(pid) - 8 -> { /* Manage Labels - Placeholder for now */ } + 8 -> { + startActivity(net.helcel.cowspent.android.label.LabelManagementActivity.createIntent(this@BillsListViewActivity, pid)) + } } }, onAccountSwitcherClick = { @@ -391,17 +393,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) { @@ -410,8 +405,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 { @@ -473,7 +468,7 @@ class BillsListViewActivity : return@launch } - viewModel.showMemberManagementDialogByProjectId = projectId + startActivity(net.helcel.cowspent.android.project.member.MemberManagementActivity.createIntent(this@BillsListViewActivity, projectId)) } } @@ -500,7 +495,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) } } @@ -601,7 +596,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 } } @@ -699,7 +694,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 { @@ -813,8 +808,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 ) @@ -834,7 +829,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/ProjectOptionsDialog.kt b/app/src/main/java/net/helcel/cowspent/android/project/ProjectOptionsDialog.kt index 178b13f..5f96043 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 @@ -11,6 +11,7 @@ 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 @@ -47,8 +48,10 @@ 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) ) @@ -59,34 +62,35 @@ fun ProjectOptionsDialogContent( val row2 = mutableListOf() val row3 = mutableListOf() - // Row 1: Edit, Share, Remove/Archive if (!isArchived && isMaintainer) { - row1.add(ProjectOption(stringResource(R.string.action_edit_project), Icons.Default.Edit, onEditProject)) + row1.add(ProjectOption(stringResource(R.string.action_edit), Icons.Default.Edit, onEditProject)) } if (isShareable && isParticipant) { - row1.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.fab_rm_project), Icons.Default.Delete, onRemoveProject)) + row1.add(ProjectOption(stringResource(R.string.action_delete), Icons.Default.Delete, onRemoveProject)) } // Row 2: Manage Member, Manage Labels, Manage Currencies if (!isArchived && isMaintainer) { - row2.add(ProjectOption(stringResource(R.string.fab_manage_members), Icons.Default.Group, onManageMembers)) - row2.add(ProjectOption(stringResource(R.string.fab_manage_labels), Icons.AutoMirrored.Filled.Label, onManageLabels)) - row2.add(ProjectOption(stringResource(R.string.fab_manage_currencies), Icons.Default.MonetizationOn, onManageCurrencies)) + row2.add(ProjectOption(stringResource(R.string.action_members), Icons.Default.Group, onManageMembers)) + if (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.fab_statistics), Icons.Default.BarChart, onStatistics)) + row3.add(ProjectOption(stringResource(R.string.action_stats), Icons.Default.BarChart, onStatistics)) if (!isArchived && isParticipant) { - row3.add(ProjectOption(stringResource(R.string.fab_settle), Icons.Default.Handshake, onSettle)) + row3.add(ProjectOption(stringResource(R.string.action_settle), Icons.Default.Handshake, onSettle)) } - row3.add(ProjectOption(stringResource(R.string.fab_export_project), Icons.Default.Download, onExportProject)) + row3.add(ProjectOption(stringResource(R.string.action_export), Icons.Default.Download, onExportProject)) val allRows = listOf(row1, row2, row3).filter { it.isNotEmpty() } 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 d348563..0fc0bb2 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 @@ -213,7 +213,7 @@ class NewProjectActivity : AppCompatActivity() { 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 } 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 f29dff2..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 @@ -42,7 +43,7 @@ fun NewProjectScreen( 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) @@ -55,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)) } } } @@ -70,11 +71,11 @@ fun NewProjectScreen( ) { // 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 @@ -84,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 } ) @@ -99,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 } ) @@ -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) } @@ -166,7 +162,7 @@ fun NewProjectScreen( OutlinedTextField( value = viewModel.projectId, onValueChange = { viewModel.projectId = it }, - label = { Text(stringResource(R.string.setting_project_id)) }, + placeholder = { Text(stringResource(R.string.label_project_id)) }, modifier = Modifier.fillMaxWidth(), leadingIcon = { Icon(Icons.AutoMirrored.Filled.LibraryBooks, contentDescription = null) } ) @@ -177,7 +173,7 @@ fun NewProjectScreen( 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) } ) @@ -188,7 +184,7 @@ fun NewProjectScreen( 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) } ) @@ -198,7 +194,7 @@ fun NewProjectScreen( OutlinedTextField( value = viewModel.projectEmail, onValueChange = { viewModel.projectEmail = it }, - label = { Text(stringResource(R.string.setting_new_project_email)) }, + placeholder = { Text(stringResource(R.string.label_email)) }, modifier = Modifier.fillMaxWidth(), leadingIcon = { Icon(Icons.Default.Email, contentDescription = null) } ) @@ -210,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 @@ -265,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 = {} @@ -280,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 }) { @@ -296,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() } } 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 4e39ac1..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 @@ -137,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 2b9ad31..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 @@ -16,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 @@ -61,24 +62,26 @@ 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 -> @@ -91,43 +94,59 @@ fun EditProjectScreen( .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 aa476f4..0000000 --- a/app/src/main/java/net/helcel/cowspent/android/project/member/MemberManagementDialog.kt +++ /dev/null @@ -1,127 +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.MemberAvatar -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 - ) { - MemberAvatar( - member = member, - 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/SettingsScreen.kt b/app/src/main/java/net/helcel/cowspent/android/settings/SettingsScreen.kt index b84290b..71f0e33 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 @@ -135,10 +135,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 +160,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 +178,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 +203,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 +218,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 +234,16 @@ 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)) SettingsPreference( - title = stringResource(R.string.settings_about), + title = stringResource(R.string.title_about), icon = Icons.Default.Info, onClick = onAboutClick ) @@ -255,9 +255,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 351234f..976fccd 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 @@ -92,7 +92,7 @@ fun ProjectSankeyDiagram( 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) } @@ -105,7 +105,7 @@ fun ProjectSankeyDiagram( 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 } 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/ProjectStatisticsScreen.kt b/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectStatisticsScreen.kt index 7e19785..4e90aed 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) 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..ebb086f 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 @@ -57,8 +57,8 @@ 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>() @@ -164,12 +164,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 +198,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 +269,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/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/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3f61dc1..81e9cf1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2,228 +2,148 @@ Cowspent - All bills - New bill - App settings - Scan QRCode from Cospend/Cowspent + + + New bill Add project - Save bill - Edit - Share - What? - Who paid? - All - None - - Bill edition - Bill values have been changed. What do you want to do? - Save - Discard - Currency manager - Comment - Convert current amount from another currency into %s - Main currency - 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 + 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 - 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 member - Remove - Project %1$s has been removed - Project - Members - Labels - Archive - Unarchive - Export - 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 + 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 - Stats - 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 @@ -233,36 +153,28 @@ colorMode offlineMode showArchived - Light - Dark - Follow system 1 2 -1 - Use Nextcloud app account - - - QR code could not be scanned. - 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 @@ -276,4 +188,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 + 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 + From 7581985abda6fb6db95ef0d06ca473a184f0be24 Mon Sep 17 00:00:00 2001 From: soraefir Date: Wed, 1 Jul 2026 22:12:42 +0200 Subject: [PATCH 22/31] Stripping fix --- app/build.gradle | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/build.gradle b/app/build.gradle index 9b8ea02..f5adace 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -83,6 +83,10 @@ android { generateLocaleConfig = true } + packaging { + jniLibs.keepDebugSymbols.add("**/*.so") + } + dependenciesInfo { // Disables dependency metadata when building APKs (for IzzyOnDroid/F-Droid) includeInApk = false From 5b3c47045c8b0320521c835a45b6ea50b2800a1a Mon Sep 17 00:00:00 2001 From: sora Date: Thu, 2 Jul 2026 02:01:36 +0200 Subject: [PATCH 23/31] Disable custom Label until fully implemented --- .../net/helcel/cowspent/android/project/ProjectOptionsDialog.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 5f96043..4bba2d9 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 @@ -79,7 +79,7 @@ fun ProjectOptionsDialogContent( // Row 2: Manage Member, Manage Labels, Manage Currencies if (!isArchived && isMaintainer) { row2.add(ProjectOption(stringResource(R.string.action_members), Icons.Default.Group, onManageMembers)) - if (projectType == ProjectType.LOCAL || projectType == ProjectType.COSPEND) { + if (false && (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)) From 937c3db1f5b0d63cb125d385b73f1fe774fc7591 Mon Sep 17 00:00:00 2001 From: sora Date: Thu, 2 Jul 2026 02:06:08 +0200 Subject: [PATCH 24/31] Update app/src/main/java/net/helcel/cowspent/persistence/CowspentSQLiteOpenHelper.kt --- .../persistence/CowspentSQLiteOpenHelper.kt | 166 ++++-------------- 1 file changed, 33 insertions(+), 133 deletions(-) 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 90bdca0..e80da6a 100644 --- a/app/src/main/java/net/helcel/cowspent/persistence/CowspentSQLiteOpenHelper.kt +++ b/app/src/main/java/net/helcel/cowspent/persistence/CowspentSQLiteOpenHelper.kt @@ -125,7 +125,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)" ) } @@ -137,7 +138,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)" ) } @@ -155,120 +157,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") + if (oldVersion < 1) { } } @@ -372,6 +261,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) : 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) } @@ -412,16 +302,18 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) : 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.getString(cursor.getColumnIndex(key_color)), + cursor.getInt(cursor.getColumnIndex(key_state)) ) } - fun updatePaymentMode(id: Long, name: String?, icon: String?, color: String?) { + fun updatePaymentMode(id: Long, name: String?, icon: String?, color: String?, state: Int? = 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 (values.size() > 0) { db.update(table_payment_modes, values, "$key_id = ?", arrayOf(id.toString())) } @@ -440,6 +332,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) : 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) } @@ -452,6 +345,11 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) : 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): List { return getCategoriesCustom("$key_projectid = ?", arrayOf(projectId.toString()), null) } @@ -480,16 +378,18 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) : 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.getString(cursor.getColumnIndex(key_color)), + cursor.getInt(cursor.getColumnIndex(key_state)) ) } - fun updateCategory(id: Long, name: String?, icon: String?, color: String?) { + fun updateCategory(id: Long, name: String?, icon: String?, color: String?, state: Int? = 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 (values.size() > 0) { db.update(table_categories, values, "$key_id = ?", arrayOf(id.toString())) } @@ -893,8 +793,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) { @@ -915,8 +815,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() @@ -928,7 +828,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) { @@ -940,14 +840,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() @@ -1107,9 +1007,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)) ) } @@ -1165,7 +1065,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) : @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" @@ -1237,10 +1137,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 From 3aeabf4dd30f8cf6ef46221dc0a02b5141bae294 Mon Sep 17 00:00:00 2001 From: sora Date: Thu, 2 Jul 2026 02:06:41 +0200 Subject: [PATCH 25/31] Update app/src/main/java/net/helcel/cowspent/model/DBCategory.kt --- app/src/main/java/net/helcel/cowspent/model/DBCategory.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 { From c2c1017633077a297b8819c22466ca25f30d9cd1 Mon Sep 17 00:00:00 2001 From: sora Date: Thu, 2 Jul 2026 02:06:55 +0200 Subject: [PATCH 26/31] Update app/src/main/java/net/helcel/cowspent/model/DBPaymentMode.kt --- app/src/main/java/net/helcel/cowspent/model/DBPaymentMode.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 { From 4fa29da79def6ca0494b3a860f80dc1088dc4855 Mon Sep 17 00:00:00 2001 From: soraefir Date: Thu, 2 Jul 2026 02:37:40 +0200 Subject: [PATCH 27/31] Better category/paymentmode storage and handling --- .../android/bill_edit/EditBillActivity.kt | 30 ++++++------ .../android/bill_edit/EditBillScreen.kt | 12 ++--- .../android/bill_edit/EditBillViewModel.kt | 8 ++-- .../android/bill_label/LabelBillsActivity.kt | 8 ++-- .../android/bill_label/LabelBillsScreen.kt | 8 ++-- .../android/bill_label/LabelBillsViewModel.kt | 14 +++--- .../android/main/BillsListViewActivity.kt | 6 +-- .../android/project/ProjectImportHelper.kt | 20 ++++++-- .../statistics/ProjectSankeyDiagram.kt | 14 +++--- .../statistics/ProjectStatisticsScreen.kt | 2 +- .../java/net/helcel/cowspent/model/DBBill.kt | 47 +++++++++---------- .../net/helcel/cowspent/model/GroupedBill.kt | 4 +- .../persistence/CowspentServerSyncHelper.kt | 24 +++++++--- .../net/helcel/cowspent/util/BillFormatter.kt | 8 ++-- .../net/helcel/cowspent/util/CategoryUtils.kt | 35 +++++++------- .../net/helcel/cowspent/util/ExportUtil.kt | 2 +- .../helcel/cowspent/util/ServerResponse.kt | 14 +++--- .../net/helcel/cowspent/util/SupportUtil.kt | 6 +-- .../util/VersatileProjectSyncClient.kt | 28 ++++++++--- 19 files changed, 164 insertions(+), 126 deletions(-) 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 7906472..ef21ca1 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 @@ -57,7 +57,7 @@ class EditBillActivity : AppCompatActivity() { val hardcoded = if (projectType == ProjectType.LOCAL) { defaultCategories } else { - listOfNotNull(defaultCategories.find { it.remoteId.toInt() == DBBill.CATEGORY_REIMBURSEMENT }) + listOfNotNull(defaultCategories.find { it.remoteId == DBBill.CATEGORY_REIMBURSEMENT.toLong() }) } syncedCategories + hardcoded } @@ -144,7 +144,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() @@ -168,15 +168,15 @@ class EditBillActivity : AppCompatActivity() { bill = DBBill( 0, 0, projectId, 0, 0.0, timeNowSeconds, "", DBBill.STATE_ADDED, DBBill.NON_REPEATED, - DBBill.PAYMODE_NONE, DBBill.CATEGORY_NONE, "", DBBill.PAYMODE_ID_NONE + DBBill.PAYMODE_NONE, DBBill.CATEGORY_NONE.toLong(), "", DBBill.PAYMODE_ID_NONE.toLong() ) } else { val btd = db.getBill(billIdToDuplicate)!! 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 { @@ -239,8 +239,8 @@ class EditBillActivity : AppCompatActivity() { bill = DBBill( 0, 0, bill.projectId, viewModel.payerId, viewModel.amountAsDouble, System.currentTimeMillis() / 1000, viewModel.what, DBBill.STATE_ADDED, - viewModel.repeat, bill.paymentMode, viewModel.categoryRemoteId, - viewModel.getFinalComment(), viewModel.paymentModeRemoteId + viewModel.repeat, bill.paymentMode, viewModel.categoryId, + viewModel.getFinalComment(), viewModel.paymentModeId ) calendar.timeInMillis = System.currentTimeMillis() viewModel.timestamp = calendar.timeInMillis / 1000 @@ -326,8 +326,8 @@ class EditBillActivity : AppCompatActivity() { bill.payerId == viewModel.payerId && 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) } @@ -386,8 +386,8 @@ class EditBillActivity : AppCompatActivity() { listOf(memberId), viewModel.repeat, existingBill.paymentMode, - viewModel.paymentModeRemoteId, - viewModel.categoryRemoteId, + viewModel.paymentModeId, + viewModel.categoryId, finalComment ) if (firstSavedId == 0L) firstSavedId = billToUseId @@ -396,7 +396,7 @@ class EditBillActivity : AppCompatActivity() { val newBill = DBBill( 0, 0, bill.projectId, viewModel.payerId, amount, viewModel.timestamp, viewModel.what, DBBill.STATE_ADDED, viewModel.repeat, - bill.paymentMode, viewModel.categoryRemoteId, finalComment, viewModel.paymentModeRemoteId + bill.paymentMode, viewModel.categoryId, finalComment, viewModel.paymentModeId ) newBill.billOwers = listOf(DBBillOwer(0, 0, memberId)) val newId = db.addBill(newBill) @@ -441,8 +441,8 @@ class EditBillActivity : AppCompatActivity() { newOwersIds, viewModel.repeat, bill.paymentMode, - viewModel.paymentModeRemoteId, - viewModel.categoryRemoteId, + viewModel.paymentModeId, + viewModel.categoryId, finalComment ) if (groupedBillIds != null) { @@ -460,7 +460,7 @@ class EditBillActivity : AppCompatActivity() { val newBill = DBBill( 0, 0, bill.projectId, viewModel.payerId, newAmount, viewModel.timestamp, viewModel.what, DBBill.STATE_ADDED, viewModel.repeat, - bill.paymentMode, viewModel.categoryRemoteId, finalComment, 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 89412fc..9f179d8 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 @@ -468,7 +468,7 @@ fun BillAdditionalDetailsSection( var categoryExpanded by remember { mutableStateOf(false) } val selectedCategory = - categories.find { it.remoteId.toInt() == viewModel.categoryRemoteId } + categories.find { (if (it.id > 0) it.id else it.remoteId) == viewModel.categoryId } EditableExposedDropdownMenu( value = selectedCategory?.name ?: "", @@ -488,7 +488,7 @@ fun BillAdditionalDetailsSection( }, content = { DropdownMenuItem(onClick = { - viewModel.categoryRemoteId = 0 + viewModel.categoryId = 0 categoryExpanded = false }) { Icon(Icons.Default.Close, tint = Color.Red, contentDescription = null) @@ -497,7 +497,7 @@ fun BillAdditionalDetailsSection( } categories.forEach { category -> DropdownMenuItem(onClick = { - viewModel.categoryRemoteId = category.remoteId.toInt() + viewModel.categoryId = if (category.id > 0) category.id else category.remoteId categoryExpanded = false }) { Text(text = category.icon, fontSize = 20.sp) @@ -512,7 +512,7 @@ fun BillAdditionalDetailsSection( var pmExpanded by remember { mutableStateOf(false) } val selectedPm = - paymentModes.find { it.remoteId.toInt() == viewModel.paymentModeRemoteId } + paymentModes.find { (if (it.id > 0) it.id else it.remoteId) == viewModel.paymentModeId } EditableExposedDropdownMenu( value = selectedPm?.name ?: "", @@ -532,7 +532,7 @@ fun BillAdditionalDetailsSection( }, content = { DropdownMenuItem(onClick = { - viewModel.paymentModeRemoteId = 0 + viewModel.paymentModeId = 0 pmExpanded = false }) { Icon(Icons.Default.Close, tint = Color.Red, contentDescription = null) @@ -541,7 +541,7 @@ fun BillAdditionalDetailsSection( } paymentModes.forEach { pm -> DropdownMenuItem(onClick = { - viewModel.paymentModeRemoteId = pm.remoteId.toInt() + viewModel.paymentModeId = if (pm.id > 0) pm.id else pm.remoteId pmExpanded = false }) { Text(text = pm.icon, fontSize = 20.sp) 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 8e46b44..cd9b3ba 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 @@ -25,8 +25,8 @@ 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()) @@ -166,8 +166,8 @@ class EditBillViewModel : ViewModel() { 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 ?: "" 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 07545c1..a0ad915 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 @@ -39,15 +39,15 @@ 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 }) + listOfNotNull(defaultCategories.find { it.remoteId == DBBill.CATEGORY_REIMBURSEMENT.toLong() }) } val categories = syncedCategories + hardcoded @@ -56,7 +56,7 @@ class LabelBillsActivity : AppCompatActivity() { 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 f1e06bb..51dc338 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 @@ -84,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) } ) } } @@ -126,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) } ) } } @@ -245,7 +245,7 @@ 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", "🛒", ""), @@ -254,7 +254,7 @@ fun LabelBillsScreenPreview() { DBCategory(4, 4, 1, "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/main/BillsListViewActivity.kt b/app/src/main/java/net/helcel/cowspent/android/main/BillsListViewActivity.kt index 2aeed56..12b78d4 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 @@ -636,13 +636,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, 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..a6b0a4d 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 @@ -150,8 +150,16 @@ 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 { pm -> + val localId = db.addPaymentMode(DBPaymentMode(0, pm.remoteId, pid, pm.name, pm.icon, pm.color)) + pmRemoteToLocal[pm.remoteId] = localId + } + val catRemoteToLocal = mutableMapOf() + categories.forEach { cat -> + val localId = db.addCategory(DBCategory(0, cat.remoteId, pid, cat.name, cat.icon, cat.color)) + catRemoteToLocal[cat.remoteId] = localId + } 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] ?: b.categoryId + val localPmId = pmRemoteToLocal[b.paymentModeId] ?: b.paymentModeId + 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/statistics/ProjectSankeyDiagram.kt b/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectSankeyDiagram.kt index 976fccd..951703d 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 @@ -97,11 +97,11 @@ fun ProjectSankeyDiagram( var expanded by remember { mutableStateOf(false) } val activeBills = remember(allBills) { - allBills.filter { it.state != DBBill.STATE_DELETED && it.categoryRemoteId != DBBill.CATEGORY_REIMBURSEMENT } + allBills.filter { it.state != DBBill.STATE_DELETED && it.categoryId != DBBill.CATEGORY_REIMBURSEMENT.toLong() } } 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) { @@ -112,13 +112,13 @@ fun ProjectSankeyDiagram( val spendings = remember(activeBills, selectedMemberId, membersMap) { val spentMap = mutableMapOf().apply { membersMap.keys.forEach { put(it, 0.0) } } - val catMap = mutableMapOf() + val catMap = mutableMapOf() activeBills.forEach { bill -> 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 spentMap[bo.memberId] = (spentMap[bo.memberId] ?: 0.0) + (bill.amount / totalWeight) * weight @@ -126,7 +126,7 @@ fun ProjectSankeyDiagram( } else { bill.billOwers.find { it.memberId == selectedMemberId }?.let { bo -> val weight = membersMap[bo.memberId]?.weight ?: 1.0 - catMap[bill.categoryRemoteId] = (catMap[bill.categoryRemoteId] ?: 0.0) + (bill.amount / totalWeight) * weight + catMap[bill.categoryId] = (catMap[bill.categoryId] ?: 0.0) + (bill.amount / totalWeight) * weight spentMap[selectedMemberId] = (spentMap[selectedMemberId] ?: 0.0) + (bill.amount / totalWeight) * weight } } @@ -215,9 +215,9 @@ private fun SankeyContent( selectedMemberId: Long, totalAmount: Double, displayMemberSpendings: List>, - displayCategorySpendings: List>, + displayCategorySpendings: List>, membersMap: Map, - categoriesMap: Map + categoriesMap: Map ) { val density = LocalDensity.current val scope = rememberCoroutineScope() 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 4e90aed..d4fd63a 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 @@ -99,7 +99,7 @@ fun ProjectStatisticsScreen( val hardcoded = if (proj.type == ProjectType.LOCAL) { defaultCategories } else { - listOfNotNull(defaultCategories.find { it.remoteId.toInt() == DBBill.CATEGORY_REIMBURSEMENT }) + listOfNotNull(defaultCategories.find { it.remoteId == DBBill.CATEGORY_REIMBURSEMENT }) } (data.categories + hardcoded).distinctBy { it.remoteId } } 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..91fd0e5 100644 --- a/app/src/main/java/net/helcel/cowspent/model/DBBill.kt +++ b/app/src/main/java/net/helcel/cowspent/model/DBBill.kt @@ -16,9 +16,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 +39,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 +57,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 +72,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 +91,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/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/CowspentServerSyncHelper.kt b/app/src/main/java/net/helcel/cowspent/persistence/CowspentServerSyncHelper.kt index 457b6a6..e4497b6 100644 --- a/app/src/main/java/net/helcel/cowspent/persistence/CowspentServerSyncHelper.kt +++ b/app/src/main/java/net/helcel/cowspent/persistence/CowspentServerSyncHelper.kt @@ -264,6 +264,10 @@ 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 categories = dbHelper.getCategories(project.id) + val categoryIdToRemoteId = categories.associate { it.id to it.remoteId } + val paymentModes = dbHelper.getPaymentModes(project.id) + val paymentModeIdToRemoteId = paymentModes.associate { it.id to it.remoteId } val toDelete = dbHelper.getBillsOfProjectWithState(project.id, DBBill.STATE_DELETED) for (bToDel in toDelete) { @@ -293,7 +297,7 @@ 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) + val editRemoteBillResponse = client!!.editRemoteBill(project, bToEdit, memberIdToRemoteId, categoryIdToRemoteId, paymentModeIdToRemoteId) if (editRemoteBillResponse.stringContent == bToEdit.remoteId.toString()) { dbHelper.setBillState(bToEdit.id, DBBill.STATE_OK) Log.d(TAG, "SUCCESSFUL remote bill edition (${editRemoteBillResponse.stringContent})") @@ -307,7 +311,7 @@ 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 createRemoteBillResponse = client!!.createRemoteBill(project, bToAdd, memberIdToRemoteId, categoryIdToRemoteId, paymentModeIdToRemoteId) val newRemoteId = createRemoteBillResponse.stringContent.toLong() if (newRemoteId > 0) { dbHelper.updateBill( @@ -613,7 +617,15 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen val localBills = dbHelper.getBillsOfProject(project.id) val localBillsByRemoteId = localBills.associateBy { it.remoteId } + val syncedCategories = dbHelper.getCategories(project.id) + val catRemoteToLocal = syncedCategories.associate { it.remoteId to it.id } + val syncedPaymentModes = dbHelper.getPaymentModes(project.id) + val pmRemoteToLocal = syncedPaymentModes.associate { it.remoteId to it.id } + for (remoteBill in remoteBills) { + if (remoteBill.categoryId > 0) remoteBill.categoryId = catRemoteToLocal[remoteBill.categoryId] ?: 0 + if (remoteBill.paymentModeId > 0) remoteBill.paymentModeId = pmRemoteToLocal[remoteBill.paymentModeId] ?: 0 + if (!localBillsByRemoteId.containsKey(remoteBill.remoteId)) { dbHelper.addBill(remoteBill) nbPulledNewBills++ @@ -626,8 +638,8 @@ 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" @@ -1084,8 +1096,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/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..e20cb7b 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,29 @@ 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(0, + DBBill.CATEGORY_GROCERIES, projectId, context.getString(R.string.category_groceries), "\uD83D\uDED2", "#ffaa00"), + DBCategory(0, DBBill.CATEGORY_LEISURE, projectId, context.getString(R.string.category_leisure), "\uD83C\uDF89", "#aa55ff"), + DBCategory(0, DBBill.CATEGORY_RENT, projectId, context.getString(R.string.category_rent), "\uD83C\uDFE0", "#da8733"), + DBCategory(0, DBBill.CATEGORY_BILLS, projectId, context.getString(R.string.category_bills), "\uD83C\uDF29", "#4aa6b0"), + DBCategory(0, DBBill.CATEGORY_CULTURE, projectId, context.getString(R.string.category_excursion), "\uD83D\uDEB8", "#0055ff"), + DBCategory(0, DBBill.CATEGORY_HEALTH, projectId, context.getString(R.string.category_health), "\uD83D\uDC9A", "#bf090c"), + DBCategory(0, DBBill.CATEGORY_SHOPPING, projectId, context.getString(R.string.category_shopping), "\uD83D\uDECD", "#e167d1"), + DBCategory(0, DBBill.CATEGORY_REIMBURSEMENT, projectId, context.getString(R.string.category_reimbursement), "\uD83D\uDCB0", "#00ced1"), + DBCategory(0, DBBill.CATEGORY_RESTAURANT, projectId, context.getString(R.string.category_restaurant), "\uD83C\uDF74", "#d0d5e1"), + DBCategory(0, DBBill.CATEGORY_ACCOMMODATION, projectId, context.getString(R.string.category_accomodation), "\uD83D\uDECC", "#5de1a3"), + DBCategory(0, DBBill.CATEGORY_TRANSPORT, projectId, context.getString(R.string.category_transport), "\uD83D\uDE8C", "#6f2ee1"), + DBCategory(0, 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(0, DBBill.PAYMODE_ID_CARD, projectId, context.getString(R.string.payment_mode_credit_card), "\uD83D\uDCB3", "#ff7f50"), + DBPaymentMode(0, DBBill.PAYMODE_ID_CASH, projectId, context.getString(R.string.payment_mode_cash), "\uD83D\uDCB5", "#556b2f"), + DBPaymentMode(0, DBBill.PAYMODE_ID_CHECK, projectId, context.getString(R.string.payment_mode_check), "\uD83C\uDFAB", "#a9a9a9"), + DBPaymentMode(0, DBBill.PAYMODE_ID_TRANSFER, projectId, context.getString(R.string.payment_mode_transfer), "⇄", "#00ced1"), + DBPaymentMode(0, DBBill.PAYMODE_ID_ONLINE_SERVICE, projectId, context.getString(R.string.payment_mode_online), "\uD83C\uDF0E", "#9932cc") ) } } 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/ServerResponse.kt b/app/src/main/java/net/helcel/cowspent/util/ServerResponse.kt index c8416ee..3aeb6f5 100644 --- a/app/src/main/java/net/helcel/cowspent/util/ServerResponse.kt +++ b/app/src/main/java/net/helcel/cowspent/util/ServerResponse.kt @@ -599,8 +599,8 @@ open class ServerResponse( var comment = "" var repeat = DBBill.NON_REPEATED var paymentMode = DBBill.PAYMODE_NONE - var paymentModeRemoteId = DBBill.PAYMODE_ID_NONE - var categoryId = DBBill.CATEGORY_NONE + var paymentModeId = DBBill.PAYMODE_ID_NONE.toLong() + var categoryId = DBBill.CATEGORY_NONE.toLong() if (!json.isNull("id")) { remoteId = json.getLong("id") } @@ -641,21 +641,21 @@ open class ServerResponse( paymentMode = json.getString("paymentmode") } if (json.has("categoryid") && !json.isNull("categoryid")) { - categoryId = json.getInt("categoryid") + categoryId = json.getLong("categoryid") Log.d("PLOP", "LOADED CATTTTTTTTTTTT $categoryId") } if (json.has("paymentmodeid") && !json.isNull("paymentmodeid")) { - paymentModeRemoteId = json.getInt("paymentmodeid") + paymentModeId = 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) { + if (DBBill.PAYMODE_NONE != paymentMode && "" != paymentMode && paymentModeId == DBBill.PAYMODE_ID_NONE.toLong()) { Log.d("PaymentMode", "old: $paymentMode and new: 0") - paymentModeRemoteId = DBBill.oldPmIdToNew[paymentMode] ?: DBBill.PAYMODE_ID_NONE + paymentModeId = (DBBill.oldPmIdToNew[paymentMode] ?: DBBill.PAYMODE_ID_NONE).toLong() } 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 74f5f5d..01e8dbf 100644 --- a/app/src/main/java/net/helcel/cowspent/util/SupportUtil.kt +++ b/app/src/main/java/net/helcel/cowspent/util/SupportUtil.kt @@ -124,9 +124,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 == -1000 || catId == -100 || b.categoryId == catId.toLong()) && + (catId != -100 || b.categoryId != DBBill.CATEGORY_REIMBURSEMENT.toLong()) && + (paymentModeId == -1000 || b.paymentModeId == paymentModeId.toLong())) && (dateMin == null || b.date >= dateMin) && (dateMax == null || b.date <= dateMax) ) { 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 0d027bf..27c93c2 100644 --- a/app/src/main/java/net/helcel/cowspent/util/VersatileProjectSyncClient.kt +++ b/app/src/main/java/net/helcel/cowspent/util/VersatileProjectSyncClient.kt @@ -260,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") @@ -307,8 +313,10 @@ class VersatileProjectSyncClient( payedFor = payedFor.replace(",$".toRegex(), "") paramValues.add(payedFor) paramValues.add(bill.paymentMode ?: "") - paramValues.add(bill.categoryRemoteId.toString()) - paramValues.add(bill.paymentModeRemoteId.toString()) + val remoteCatId = if (bill.categoryId <= 0) bill.categoryId else categoryIdToRemoteId[bill.categoryId] ?: 0 + val remotePmId = if (bill.paymentModeId <= 0) bill.paymentModeId else paymentModeIdToRemoteId[bill.paymentModeId] ?: 0 + paramValues.add(remoteCatId.toString()) + paramValues.add(remotePmId.toString()) if (canAccessProjectWithNCLogin(project)) { username = this.username @@ -512,7 +520,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") @@ -554,8 +568,10 @@ class VersatileProjectSyncClient( payedFor = payedFor.replace(",$".toRegex(), "") paramValues.add(payedFor) paramValues.add(bill.paymentMode ?: "") - paramValues.add(bill.categoryRemoteId.toString()) - paramValues.add(bill.paymentModeRemoteId.toString()) + val remoteCatId = if (bill.categoryId <= 0) bill.categoryId else categoryIdToRemoteId[bill.categoryId] ?: 0 + val remotePmId = if (bill.paymentModeId <= 0) bill.paymentModeId else paymentModeIdToRemoteId[bill.paymentModeId] ?: 0 + paramValues.add(remoteCatId.toString()) + paramValues.add(remotePmId.toString()) if (canAccessProjectWithNCLogin(project)) { username = this.username From 56036d5840ca2d89b4285d1e2e081149d0d30a8f Mon Sep 17 00:00:00 2001 From: soraefir Date: Thu, 2 Jul 2026 02:43:33 +0200 Subject: [PATCH 28/31] Fix toast & minor category/paymentmode fix --- .../android/bill_label/LabelBillsScreen.kt | 8 ++-- .../android/main/BillsListViewActivity.kt | 16 ++----- .../net/helcel/cowspent/util/CategoryUtils.kt | 45 ++++++++++++------- 3 files changed, 35 insertions(+), 34 deletions(-) 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 51dc338..e2f841d 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 @@ -248,10 +248,10 @@ fun LabelBillsScreenPreview() { 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(-1, -1, 1, "Groceries", "🛒", ""), + DBCategory(-2, -2, 1, "Leisure", "🥳", ""), + DBCategory(-3, -3, 1, "Rent", "🏠", ""), + DBCategory(-4, -4, 1, "Bills", "💸", "") ) categories = cats categoriesMap = cats.associateBy { it.id } 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 12b78d4..18df8c1 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 @@ -115,18 +115,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.msg_project_added) - message = getString(R.string.msg_project_added, addedProj?.remoteId) - } else { - Log.e(TAG, "ADDED !!!") - title = getString(R.string.msg_project_added) - message = getString(R.string.msg_project_added, 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) } } } @@ -142,7 +132,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) } } } 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 e20cb7b..84a73b6 100644 --- a/app/src/main/java/net/helcel/cowspent/util/CategoryUtils.kt +++ b/app/src/main/java/net/helcel/cowspent/util/CategoryUtils.kt @@ -10,29 +10,40 @@ object CategoryUtils { fun getDefaultCategories(context: Context, projectId: Long): List { return listOf( - DBCategory(0, + DBCategory(DBBill.CATEGORY_GROCERIES, DBBill.CATEGORY_GROCERIES, projectId, context.getString(R.string.category_groceries), "\uD83D\uDED2", "#ffaa00"), - DBCategory(0, DBBill.CATEGORY_LEISURE, projectId, context.getString(R.string.category_leisure), "\uD83C\uDF89", "#aa55ff"), - DBCategory(0, DBBill.CATEGORY_RENT, projectId, context.getString(R.string.category_rent), "\uD83C\uDFE0", "#da8733"), - DBCategory(0, DBBill.CATEGORY_BILLS, projectId, context.getString(R.string.category_bills), "\uD83C\uDF29", "#4aa6b0"), - DBCategory(0, DBBill.CATEGORY_CULTURE, projectId, context.getString(R.string.category_excursion), "\uD83D\uDEB8", "#0055ff"), - DBCategory(0, DBBill.CATEGORY_HEALTH, projectId, context.getString(R.string.category_health), "\uD83D\uDC9A", "#bf090c"), - DBCategory(0, DBBill.CATEGORY_SHOPPING, projectId, context.getString(R.string.category_shopping), "\uD83D\uDECD", "#e167d1"), - DBCategory(0, DBBill.CATEGORY_REIMBURSEMENT, projectId, context.getString(R.string.category_reimbursement), "\uD83D\uDCB0", "#00ced1"), - DBCategory(0, DBBill.CATEGORY_RESTAURANT, projectId, context.getString(R.string.category_restaurant), "\uD83C\uDF74", "#d0d5e1"), - DBCategory(0, DBBill.CATEGORY_ACCOMMODATION, projectId, context.getString(R.string.category_accomodation), "\uD83D\uDECC", "#5de1a3"), - DBCategory(0, DBBill.CATEGORY_TRANSPORT, projectId, context.getString(R.string.category_transport), "\uD83D\uDE8C", "#6f2ee1"), - DBCategory(0, DBBill.CATEGORY_SPORT, projectId, context.getString(R.string.category_sport), "\uD83C\uDFBE", "#69e177") + 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, projectId, context.getString(R.string.payment_mode_credit_card), "\uD83D\uDCB3", "#ff7f50"), - DBPaymentMode(0, DBBill.PAYMODE_ID_CASH, projectId, context.getString(R.string.payment_mode_cash), "\uD83D\uDCB5", "#556b2f"), - DBPaymentMode(0, DBBill.PAYMODE_ID_CHECK, projectId, context.getString(R.string.payment_mode_check), "\uD83C\uDFAB", "#a9a9a9"), - DBPaymentMode(0, DBBill.PAYMODE_ID_TRANSFER, projectId, context.getString(R.string.payment_mode_transfer), "⇄", "#00ced1"), - DBPaymentMode(0, DBBill.PAYMODE_ID_ONLINE_SERVICE, 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") ) } } From 56968dbe3b4763804130828624a042f1618674f0 Mon Sep 17 00:00:00 2001 From: sora Date: Thu, 2 Jul 2026 03:05:16 +0200 Subject: [PATCH 29/31] Update metadata/en-US/full_description.txt --- metadata/en-US/full_description.txt | 2 -- 1 file changed, 2 deletions(-) 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 From 47d0471b48909aa7ec1057431353ce9b96f29bb0 Mon Sep 17 00:00:00 2001 From: soraefir Date: Sun, 5 Jul 2026 15:01:38 +0200 Subject: [PATCH 30/31] Label sync and workflows --- .../android/bill_edit/EditBillActivity.kt | 27 +- .../android/bill_edit/EditBillScreen.kt | 22 +- .../android/bill_label/LabelBillsActivity.kt | 13 +- .../android/bill_label/LabelBillsScreen.kt | 8 +- .../currencies/ManageCurrenciesActivity.kt | 8 +- .../android/label/LabelManagementScreen.kt | 23 +- .../android/label/LabelManagementViewModel.kt | 42 +- .../cowspent/android/main/BillsListUtils.kt | 7 +- .../android/main/BillsListViewActivity.kt | 16 +- .../android/project/ProjectImportHelper.kt | 18 +- .../project/create/NewProjectActivity.kt | 1 - .../statistics/ProjectSankeyDiagram.kt | 12 +- .../statistics/ProjectStatisticsScreen.kt | 32 +- .../statistics/ProjectStatisticsTable.kt | 26 +- .../persistence/CowspentSQLiteOpenHelper.kt | 811 ++++++++++-------- .../persistence/CowspentServerSyncHelper.kt | 162 +++- .../net/helcel/cowspent/util/CategoryUtils.kt | 17 + .../helcel/cowspent/util/NextcloudClient.kt | 25 +- .../helcel/cowspent/util/ServerResponse.kt | 272 +++++- .../net/helcel/cowspent/util/SupportUtil.kt | 28 +- .../util/VersatileProjectSyncClient.kt | 432 +++++++++- app/src/main/res/values/strings.xml | 4 +- 22 files changed, 1443 insertions(+), 563 deletions(-) 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 ef21ca1..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 @@ -26,7 +26,6 @@ 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 @@ -52,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 == DBBill.CATEGORY_REIMBURSEMENT.toLong() }) - } - 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( @@ -168,7 +154,7 @@ class EditBillActivity : AppCompatActivity() { bill = DBBill( 0, 0, projectId, 0, 0.0, timeNowSeconds, "", DBBill.STATE_ADDED, DBBill.NON_REPEATED, - DBBill.PAYMODE_NONE, DBBill.CATEGORY_NONE.toLong(), "", DBBill.PAYMODE_ID_NONE.toLong() + DBBill.PAYMODE_NONE, DBBill.CATEGORY_NONE, "", DBBill.PAYMODE_ID_NONE ) } else { val btd = db.getBill(billIdToDuplicate)!! @@ -195,6 +181,11 @@ class EditBillActivity : AppCompatActivity() { 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 ?: "" @@ -399,6 +390,8 @@ class EditBillActivity : AppCompatActivity() { 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 } 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 9f179d8..901469b 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 @@ -31,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 @@ -466,9 +467,10 @@ fun BillAdditionalDetailsSection( modifier = Modifier.padding(bottom = 8.dp, top = 16.dp) ) + val context = androidx.compose.ui.platform.LocalContext.current var categoryExpanded by remember { mutableStateOf(false) } val selectedCategory = - categories.find { (if (it.id > 0) it.id else it.remoteId) == viewModel.categoryId } + categories.find { it.id == viewModel.categoryId } ?: CategoryUtils.getCategoryById(context, viewModel.categoryId) EditableExposedDropdownMenu( value = selectedCategory?.name ?: "", @@ -495,9 +497,19 @@ fun BillAdditionalDetailsSection( 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.categoryId = if (category.id > 0) category.id else category.remoteId + viewModel.categoryId = category.id categoryExpanded = false }) { Text(text = category.icon, fontSize = 20.sp) @@ -512,7 +524,7 @@ fun BillAdditionalDetailsSection( var pmExpanded by remember { mutableStateOf(false) } val selectedPm = - paymentModes.find { (if (it.id > 0) it.id else it.remoteId) == viewModel.paymentModeId } + paymentModes.find { it.id == viewModel.paymentModeId } ?: CategoryUtils.getPaymentModeById(context, viewModel.paymentModeId) EditableExposedDropdownMenu( value = selectedPm?.name ?: "", @@ -541,7 +553,7 @@ fun BillAdditionalDetailsSection( } paymentModes.forEach { pm -> DropdownMenuItem(onClick = { - viewModel.paymentModeId = if (pm.id > 0) pm.id else pm.remoteId + viewModel.paymentModeId = pm.id pmExpanded = false }) { Text(text = pm.icon, fontSize = 20.sp) 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 a0ad915..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 @@ -13,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 @@ -42,14 +41,10 @@ class LabelBillsActivity : AppCompatActivity() { 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 == DBBill.CATEGORY_REIMBURSEMENT.toLong() }) - } - 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) } 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 e2f841d..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 @@ -248,10 +248,10 @@ fun LabelBillsScreenPreview() { 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.id } 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 c47fd0d..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 @@ -33,9 +33,11 @@ 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.action_currencies), - 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() {} 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 index 5568011..dcfedf8 100644 --- a/app/src/main/java/net/helcel/cowspent/android/label/LabelManagementScreen.kt +++ b/app/src/main/java/net/helcel/cowspent/android/label/LabelManagementScreen.kt @@ -28,6 +28,7 @@ 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 @@ -59,10 +60,10 @@ fun LabelManagementScreenContent( dialogState: net.helcel.cowspent.android.helper.DialogState?, onBack: () -> Unit, onAddCategory: (String, String, String) -> Unit, - onUpdateCategory: (Long, String, String, String) -> Unit, + onUpdateCategory: (DBCategory, String, String, String) -> Unit, onDeleteCategory: (Long) -> Unit, onAddPaymentMode: (String, String, String) -> Unit, - onUpdatePaymentMode: (Long, String, String, String) -> Unit, + onUpdatePaymentMode: (DBPaymentMode, String, String, String) -> Unit, onDeletePaymentMode: (Long) -> Unit, onDismissDialog: () -> Unit, onShowDialog: (net.helcel.cowspent.android.helper.DialogState) -> Unit, @@ -123,7 +124,7 @@ fun LabelManagementScreenContent( if (selectedTab == 0) { CategoryList( - categories = categories, + categories = categories.filter { it.remoteId != DBBill.CATEGORY_REIMBURSEMENT }, onEdit = { editingCategory = it showEditDialog = true @@ -162,7 +163,7 @@ fun LabelManagementScreenContent( if (showEditDialog) { if (selectedTab == 0) { EditLabelDialog( - title = if (editingCategory == null) stringResource(R.string.action_add_project) else stringResource(R.string.action_edit), + 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", @@ -171,14 +172,14 @@ fun LabelManagementScreenContent( if (editingCategory == null) { onAddCategory(name, icon, color) } else { - onUpdateCategory(editingCategory!!.id, name, icon, color) + onUpdateCategory(editingCategory!!, name, icon, color) } showEditDialog = false } ) } else { EditLabelDialog( - title = if (editingPaymentMode == null) stringResource(R.string.action_add_project) else stringResource(R.string.action_edit), + 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", @@ -187,7 +188,7 @@ fun LabelManagementScreenContent( if (editingPaymentMode == null) { onAddPaymentMode(name, icon, color) } else { - onUpdatePaymentMode(editingPaymentMode!!.id, name, icon, color) + onUpdatePaymentMode(editingPaymentMode!!, name, icon, color) } showEditDialog = false } @@ -353,8 +354,8 @@ fun LabelManagementCategoriesPreview() { MaterialTheme { LabelManagementScreenContent( categories = listOf( - DBCategory(1, 1, 1, "Groceries", "🛒", "#FF0000"), - DBCategory(2, 2, 1, "Rent", "🏠", "#00FF00") + DBCategory(1L, 1L, 1L, "Groceries", "🛒", "#FF0000"), + DBCategory(2L, 2L, 1L, "Rent", "🏠", "#00FF00") ), paymentModes = emptyList(), dialogState = null, @@ -379,8 +380,8 @@ fun LabelManagementPaymentModesPreview() { LabelManagementScreenContent( categories = emptyList(), paymentModes = listOf( - DBPaymentMode(1, 1, 1, "Cash", "💵", "#0000FF"), - DBPaymentMode(2, 2, 1, "Credit Card", "💳", "#FFFF00") + DBPaymentMode(1L, 1L, 1L, "Cash", "💵", "#0000FF"), + DBPaymentMode(2L, 2L, 1L, "Credit Card", "💳", "#FFFF00") ), dialogState = null, onBack = {}, 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 index 1421622..733eeed 100644 --- a/app/src/main/java/net/helcel/cowspent/android/label/LabelManagementViewModel.kt +++ b/app/src/main/java/net/helcel/cowspent/android/label/LabelManagementViewModel.kt @@ -13,8 +13,8 @@ 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 -import net.helcel.cowspent.util.CategoryUtils class LabelManagementViewModel(application: Application) : AndroidViewModel(application) { private val db = CowspentSQLiteOpenHelper.getInstance(application) @@ -28,44 +28,30 @@ class LabelManagementViewModel(application: Application) : AndroidViewModel(appl fun loadLabels(projId: Long) { projectId = projId viewModelScope.launch { - val cats = withContext(Dispatchers.IO) { db.getCategories(projId) } - val pms = withContext(Dispatchers.IO) { db.getPaymentModes(projId) } - - if (cats.isEmpty()) { - val defaults = CategoryUtils.getDefaultCategories(getApplication(), projId) + val project = withContext(Dispatchers.IO) { db.getProject(projId) } + if (project != null) { withContext(Dispatchers.IO) { - defaults.forEach { db.addCategory(it) } + db.ensureDefaultLabels(projId, project.type) } - categories = withContext(Dispatchers.IO) { db.getCategories(projId) } - } else { - categories = cats - } - - if (pms.isEmpty()) { - val defaults = CategoryUtils.getDefaultPaymentModes(getApplication(), projId) - withContext(Dispatchers.IO) { - defaults.forEach { db.addPaymentMode(it) } - } - paymentModes = withContext(Dispatchers.IO) { db.getPaymentModes(projId) } - } else { - paymentModes = pms } + 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.addCategory(DBCategory(0, 0, projectId, name, icon, color)) + db.addCategoryAndSync(DBCategory(0, 0, projectId, name, icon, color, DBBill.STATE_ADDED)) } loadLabels(projectId) } } - fun updateCategory(id: Long, name: String, icon: String, color: String) { + fun updateCategory(cat: DBCategory, name: String, icon: String, color: String) { viewModelScope.launch { withContext(Dispatchers.IO) { - db.updateCategory(id, name, icon, color) + db.updateCategoryAndSync(cat, name, icon, color) } loadLabels(projectId) } @@ -74,7 +60,7 @@ class LabelManagementViewModel(application: Application) : AndroidViewModel(appl fun deleteCategory(id: Long) { viewModelScope.launch { withContext(Dispatchers.IO) { - db.deleteCategory(id) + db.deleteCategoryAndSync(id) } loadLabels(projectId) } @@ -83,16 +69,16 @@ class LabelManagementViewModel(application: Application) : AndroidViewModel(appl fun addPaymentMode(name: String, icon: String, color: String) { viewModelScope.launch { withContext(Dispatchers.IO) { - db.addPaymentMode(DBPaymentMode(0, 0, projectId, name, icon, color)) + db.addPaymentModeAndSync(DBPaymentMode(0, 0, projectId, name, icon, color, DBBill.STATE_ADDED)) } loadLabels(projectId) } } - fun updatePaymentMode(id: Long, name: String, icon: String, color: String) { + fun updatePaymentMode(pm: DBPaymentMode, name: String, icon: String, color: String) { viewModelScope.launch { withContext(Dispatchers.IO) { - db.updatePaymentMode(id, name, icon, color) + db.updatePaymentModeAndSync(pm, name, icon, color) } loadLabels(projectId) } @@ -101,7 +87,7 @@ class LabelManagementViewModel(application: Application) : AndroidViewModel(appl fun deletePaymentMode(id: Long) { viewModelScope.launch { withContext(Dispatchers.IO) { - db.deletePaymentMode(id) + db.deletePaymentModeAndSync(id) } loadLabels(projectId) } 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 7412547..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.* @@ -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 18df8c1..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 @@ -21,9 +21,7 @@ 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.content.ContextCompat import androidx.core.content.edit import androidx.lifecycle.lifecycleScope @@ -50,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 @@ -555,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) } @@ -698,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) 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 a6b0a4d..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 @@ -149,16 +149,16 @@ 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)) - + val pmRemoteToLocal = mutableMapOf() - paymentModes.forEach { pm -> - val localId = db.addPaymentMode(DBPaymentMode(0, pm.remoteId, pid, pm.name, pm.icon, pm.color)) - pmRemoteToLocal[pm.remoteId] = localId + 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 { cat -> - val localId = db.addCategory(DBCategory(0, cat.remoteId, pid, cat.name, cat.icon, cat.color)) - catRemoteToLocal[cat.remoteId] = localId + 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)) } @@ -168,8 +168,8 @@ object ProjectImportHelper { bills.forEach { b -> val payerId = memberNameToId[billRemoteIdToPayerName[b.remoteId]] ?: 0L - val localCatId = catRemoteToLocal[b.categoryId] ?: b.categoryId - val localPmId = pmRemoteToLocal[b.paymentModeId] ?: b.paymentModeId + 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/create/NewProjectActivity.kt b/app/src/main/java/net/helcel/cowspent/android/project/create/NewProjectActivity.kt index 0fc0bb2..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 @@ -320,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/statistics/ProjectSankeyDiagram.kt b/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectSankeyDiagram.kt index 951703d..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 @@ -71,6 +71,7 @@ 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 net.helcel.cowspent.util.CategoryUtils import kotlin.math.abs import kotlin.math.roundToInt @@ -87,6 +88,8 @@ private object SankeyDimens { @Composable fun ProjectSankeyDiagram( projectName: String, + projectId: Long, + projectRemoteId: String?, allMembers: List, allBills: List, customCategories: List, @@ -96,8 +99,11 @@ fun ProjectSankeyDiagram( var selectedMemberId by remember { mutableLongStateOf(-1L) } var expanded by remember { mutableStateOf(false) } - val activeBills = remember(allBills) { - allBills.filter { it.state != DBBill.STATE_DELETED && it.categoryId != DBBill.CATEGORY_REIMBURSEMENT.toLong() } + 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 } } @@ -399,4 +405,4 @@ private fun DrawScope.drawSankeyFlow(startX: Float, startY: Float, startWidth: F @Preview(showBackground = true, widthDp = 360, heightDp = 640) @Composable -fun ProjectSankeyDiagramPreview() = MaterialTheme { ProjectSankeyDiagram("Test Project", StatisticsMockData.members, StatisticsMockData.bills, emptyList()) {} } +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/ProjectStatisticsScreen.kt b/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectStatisticsScreen.kt index d4fd63a..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 @@ -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 == 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 ebb086f..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) } @@ -61,10 +61,10 @@ fun ProjectStatisticsTable( 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" 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 e80da6a..ee1cb76 100644 --- a/app/src/main/java/net/helcel/cowspent/persistence/CowspentSQLiteOpenHelper.kt +++ b/app/src/main/java/net/helcel/cowspent/persistence/CowspentSQLiteOpenHelper.kt @@ -13,9 +13,9 @@ import androidx.preference.PreferenceManager import net.helcel.cowspent.R import net.helcel.cowspent.android.main.BillsListViewActivity import net.helcel.cowspent.model.* +import net.helcel.cowspent.util.CategoryUtils import net.helcel.cowspent.util.SecureStorage import net.helcel.cowspent.util.SupportUtil -import java.text.SimpleDateFormat import java.util.* /** @@ -156,10 +156,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) : } @SuppressLint("Range") - override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { - if (oldVersion < 1) { - } - } + override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {} override fun onDowngrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { recreateDatabase(db) @@ -202,311 +199,7 @@ 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_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) - } - - 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 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)), - cursor.getInt(cursor.getColumnIndex(key_state)) - ) - } - - fun updatePaymentMode(id: Long, name: String?, icon: String?, color: String?, state: Int? = 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 (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) - values.put(key_state, category.state) - 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 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): 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)), - cursor.getInt(cursor.getColumnIndex(key_state)) - ) - } - - fun updateCategory(id: Long, name: String?, icon: String?, color: String?, state: Int? = 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 (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 = ? AND $key_state != ?", - arrayOf(projectId.toString(), DBBill.STATE_DELETED.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 @@ -571,16 +264,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())) - SecureStorage.removePasswordSync(context, "ProjectPassword_$id") - } - fun updateProject( projId: Long, newName: String?, newEmail: String?, newPassword: String?, newLastPayerId: Long?, @@ -647,8 +330,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) @@ -705,7 +399,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) } @@ -780,8 +473,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) @@ -866,7 +560,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) } @@ -887,29 +580,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() @@ -1019,8 +694,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) @@ -1062,6 +738,443 @@ 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 @@ -1100,16 +1213,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" 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 e4497b6..a750d01 100644 --- a/app/src/main/java/net/helcel/cowspent/persistence/CowspentServerSyncHelper.kt +++ b/app/src/main/java/net/helcel/cowspent/persistence/CowspentServerSyncHelper.kt @@ -29,9 +29,11 @@ import net.helcel.cowspent.android.account.AccountActivity import net.helcel.cowspent.android.main.BillsListViewActivity import net.helcel.cowspent.android.main.MainConstants import net.helcel.cowspent.model.DBBill +import net.helcel.cowspent.model.DBCategory import net.helcel.cowspent.model.DBProject import net.helcel.cowspent.model.ProjectType import net.helcel.cowspent.util.CospendClientUtil.LoginStatus +import net.helcel.cowspent.util.CategoryUtils import net.helcel.cowspent.util.ICallback import net.helcel.cowspent.util.IProjectCreationCallback import net.helcel.cowspent.util.NextcloudClient @@ -264,10 +266,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 } + 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 } + 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) { @@ -298,11 +411,15 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen for (bToEdit in toEdit) { try { val editRemoteBillResponse = client!!.editRemoteBill(project, bToEdit, memberIdToRemoteId, categoryIdToRemoteId, paymentModeIdToRemoteId) - if (editRemoteBillResponse.stringContent == bToEdit.remoteId.toString()) { + 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") @@ -312,7 +429,7 @@ 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, categoryIdToRemoteId, paymentModeIdToRemoteId) - val newRemoteId = createRemoteBillResponse.stringContent.toLong() + val newRemoteId = createRemoteBillResponse.remoteBillId if (newRemoteId > 0) { dbHelper.updateBill( bToAdd.id, newRemoteId, null, @@ -379,7 +496,7 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen val currencyToAdd = dbHelper.getCurrenciesOfProjectWithState(project.id, DBBill.STATE_ADDED) for (cToAdd in currencyToAdd) { val createRemoteCurrencyResponse = client!!.createRemoteCurrency(project, cToAdd) - val newRemoteId = createRemoteCurrencyResponse.stringContent.toLong() + val newRemoteId = createRemoteCurrencyResponse.remoteCurrencyId if (newRemoteId > 0) { dbHelper.setCurrencyState(cToAdd.id, DBBill.STATE_OK) } @@ -475,7 +592,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") } @@ -485,6 +602,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") @@ -504,7 +622,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") } @@ -532,7 +650,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") } @@ -599,13 +717,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 } @@ -617,20 +745,11 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen val localBills = dbHelper.getBillsOfProject(project.id) val localBillsByRemoteId = localBills.associateBy { it.remoteId } - val syncedCategories = dbHelper.getCategories(project.id) - val catRemoteToLocal = syncedCategories.associate { it.remoteId to it.id } - val syncedPaymentModes = dbHelper.getPaymentModes(project.id) - val pmRemoteToLocal = syncedPaymentModes.associate { it.remoteId to it.id } - for (remoteBill in remoteBills) { - if (remoteBill.categoryId > 0) remoteBill.categoryId = catRemoteToLocal[remoteBill.categoryId] ?: 0 - if (remoteBill.paymentModeId > 0) remoteBill.paymentModeId = pmRemoteToLocal[remoteBill.paymentModeId] ?: 0 - if (!localBillsByRemoteId.containsKey(remoteBill.remoteId)) { 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)) { @@ -643,7 +762,6 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen ) nbPulledUpdatedBills++ updatedBillsDialogText += "✏ ${remoteBill.what}\n" - Log.d(TAG, "Update local bill : $remoteBill") } else { Log.d(TAG, "Nothing to do for bill : $localBill") } 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 84a73b6..618d700 100644 --- a/app/src/main/java/net/helcel/cowspent/util/CategoryUtils.kt +++ b/app/src/main/java/net/helcel/cowspent/util/CategoryUtils.kt @@ -46,4 +46,21 @@ object CategoryUtils { 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/NextcloudClient.kt b/app/src/main/java/net/helcel/cowspent/util/NextcloudClient.kt index 2e652d5..b8a1ded 100644 --- a/app/src/main/java/net/helcel/cowspent/util/NextcloudClient.kt +++ b/app/src/main/java/net/helcel/cowspent/util/NextcloudClient.kt @@ -93,23 +93,30 @@ 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) { val acceptHeader: MutableList = ArrayList() acceptHeader.add("application/json") headers["Accept"] = acceptHeader + val ocsHeader: MutableList = ArrayList() + ocsHeader.add("true") + headers["OCS-APIRequest"] = ocsHeader } 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,8 +189,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" + } val result = StringBuilder() - val targetURL = url + target.replace("^/".toRegex(), "") + val targetURL = url + finalTarget.replace("^/".toRegex(), "") // Log.d(javaClass.simpleName, "method and target URL: $method $targetURL") val httpCon = SupportUtil.getHttpURLConnection(targetURL) httpCon.requestMethod = method @@ -194,7 +205,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) } @@ -244,8 +255,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) { 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 3aeb6f5..4ae3e17 100644 --- a/app/src/main/java/net/helcel/cowspent/util/ServerResponse.kt +++ b/app/src/main/java/net/helcel/cowspent/util/ServerResponse.kt @@ -68,7 +68,7 @@ open class ServerResponse( } val rawData = JSONObject(content) val data = rawData.getJSONObject("ocs") - return data.getString("data") + return data.get("data").toString() } class ProjectResponse(response: VersatileProjectSyncClient.ResponseData, isOcsResponse: Boolean) : @@ -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 @@ -599,8 +823,8 @@ open class ServerResponse( var comment = "" var repeat = DBBill.NON_REPEATED var paymentMode = DBBill.PAYMODE_NONE - var paymentModeId = DBBill.PAYMODE_ID_NONE.toLong() - var categoryId = DBBill.CATEGORY_NONE.toLong() + var paymentModeRemoteId = DBBill.PAYMODE_ID_NONE + var categoryRemoteId = DBBill.CATEGORY_NONE if (!json.isNull("id")) { remoteId = json.getLong("id") } @@ -639,20 +863,26 @@ 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.getLong("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")) { - paymentModeId = json.getLong("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 && paymentModeId == DBBill.PAYMODE_ID_NONE.toLong()) { - Log.d("PaymentMode", "old: $paymentMode and new: 0") - paymentModeId = (DBBill.oldPmIdToNew[paymentMode] ?: DBBill.PAYMODE_ID_NONE).toLong() + if (DBBill.PAYMODE_NONE != paymentMode && "" != paymentMode && paymentModeRemoteId == DBBill.PAYMODE_ID_NONE) { + 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, paymentModeId 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 01e8dbf..c2b0cf9 100644 --- a/app/src/main/java/net/helcel/cowspent/util/SupportUtil.kt +++ b/app/src/main/java/net/helcel/cowspent/util/SupportUtil.kt @@ -87,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 ) } @@ -106,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 @@ -124,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.categoryId == catId.toLong()) && - (catId != -100 || b.categoryId != DBBill.CATEGORY_REIMBURSEMENT.toLong()) && - (paymentModeId == -1000 || b.paymentModeId == paymentModeId.toLong())) && + ((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) ) { @@ -285,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 27c93c2..2312552 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 @@ -82,13 +84,11 @@ class VersatileProjectSyncClient( project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId useOcsApiRequest = cospendVersionGT161 } else if (canAccessProjectWithSSO(project)) { - return if (cospendVersionGT161) { - target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId - 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, true), true) } else { useOcsApiRequest = cospendVersionGT161 target = if (cospendVersionGT161) @@ -313,10 +313,8 @@ class VersatileProjectSyncClient( payedFor = payedFor.replace(",$".toRegex(), "") paramValues.add(payedFor) paramValues.add(bill.paymentMode ?: "") - val remoteCatId = if (bill.categoryId <= 0) bill.categoryId else categoryIdToRemoteId[bill.categoryId] ?: 0 - val remotePmId = if (bill.paymentModeId <= 0) bill.paymentModeId else paymentModeIdToRemoteId[bill.paymentModeId] ?: 0 - paramValues.add(remoteCatId.toString()) - paramValues.add(remotePmId.toString()) + paramValues.add((categoryIdToRemoteId[bill.categoryId] ?: 0L).toString()) + paramValues.add((paymentModeIdToRemoteId[bill.paymentModeId] ?: 0L).toString()) if (canAccessProjectWithNCLogin(project)) { username = this.username @@ -568,10 +566,8 @@ class VersatileProjectSyncClient( payedFor = payedFor.replace(",$".toRegex(), "") paramValues.add(payedFor) paramValues.add(bill.paymentMode ?: "") - val remoteCatId = if (bill.categoryId <= 0) bill.categoryId else categoryIdToRemoteId[bill.categoryId] ?: 0 - val remotePmId = if (bill.paymentModeId <= 0) bill.paymentModeId else paymentModeIdToRemoteId[bill.paymentModeId] ?: 0 - paramValues.add(remoteCatId.toString()) - paramValues.add(remotePmId.toString()) + paramValues.add((categoryIdToRemoteId[bill.categoryId] ?: 0L).toString()) + paramValues.add((paymentModeIdToRemoteId[bill.paymentModeId] ?: 0L).toString()) if (canAccessProjectWithNCLogin(project)) { username = this.username @@ -650,7 +646,6 @@ class VersatileProjectSyncClient( } 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" @@ -714,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" @@ -749,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 @@ -795,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() @@ -952,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) { @@ -965,17 +1348,20 @@ class VersatileProjectSyncClient( val acceptHeader: MutableList = ArrayList() acceptHeader.add("application/json") headers["Accept"] = acceptHeader + val ocsHeader: MutableList = ArrayList() + ocsHeader.add("true") + headers["OCS-APIRequest"] = ocsHeader } 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() @@ -1009,8 +1395,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") @@ -1021,7 +1411,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) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 81e9cf1..fbdadd5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -35,6 +35,8 @@ Settle Project Share Project Add Project + Add Category + Add Payment Mode Nextcloud Account Web link Cowspent link @@ -229,7 +231,7 @@ Settlement - Choose Currency + Choose Currency (%s) None All Currency settings saved. From 106c4e27fc82c956a9b6273d4f4cc3e39000f5bb Mon Sep 17 00:00:00 2001 From: soraefir Date: Thu, 9 Jul 2026 19:22:04 +0200 Subject: [PATCH 31/31] Fix sync and add beta features --- app/build.gradle | 2 +- .../android/bill_edit/EditBillScreen.kt | 2 +- .../android/bill_edit/EditBillViewModel.kt | 10 ++++------ .../cowspent/android/main/BillsListScreen.kt | 9 +++++++-- .../android/project/ProjectOptionsDialog.kt | 14 +++++++++----- .../cowspent/android/settings/SettingsScreen.kt | 17 +++++++++++++++++ .../java/net/helcel/cowspent/model/DBBill.kt | 1 - .../persistence/CowspentSQLiteOpenHelper.kt | 15 ++++++++++----- .../persistence/CowspentServerSyncHelper.kt | 2 -- .../net/helcel/cowspent/util/NextcloudClient.kt | 3 --- .../net/helcel/cowspent/util/ServerResponse.kt | 2 +- .../cowspent/util/VersatileProjectSyncClient.kt | 5 +---- app/src/main/res/values/strings.xml | 3 +++ 13 files changed, 54 insertions(+), 31 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index f5adace..d195e9e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -14,7 +14,7 @@ android { applicationId "net.helcel.cowspent" minSdk = 26 targetSdk = 37 - versionName project.hasProperty('VERSION_NAME') ? project.property('VERSION_NAME') : "1" + versionName project.hasProperty('VERSION_NAME') ? project.property('VERSION_NAME') : "1.4" versionCode project.hasProperty('VERSION_CODE') ? project.property('VERSION_CODE').toInteger() : 1 } 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 901469b..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 @@ -467,7 +467,7 @@ fun BillAdditionalDetailsSection( modifier = Modifier.padding(bottom = 8.dp, top = 16.dp) ) - val context = androidx.compose.ui.platform.LocalContext.current + val context = LocalContext.current var categoryExpanded by remember { mutableStateOf(false) } val selectedCategory = categories.find { it.id == viewModel.categoryId } ?: CategoryUtils.getCategoryById(context, viewModel.categoryId) 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 cd9b3ba..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 @@ -2,22 +2,20 @@ package net.helcel.cowspent.android.bill_edit import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableDoubleStateOf -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateMapOf 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("") 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 3057b91..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 @@ -103,6 +103,10 @@ fun BillsListScreen( val context = LocalContext.current 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, @@ -158,7 +162,8 @@ fun BillsListScreen( 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 ) } } @@ -327,7 +332,7 @@ fun BillsListScreen( }, actions = { if (!isSearchExpanded) { - if (viewModel.hasUnlabeledBills) { + if (showBetaFeatures && viewModel.hasUnlabeledBills) { IconButton(onClick = onLabelBillsClick) { Icon( Icons.Default.Category, 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 4bba2d9..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 @@ -35,7 +35,8 @@ fun ProjectOptionsDialogContent( 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, @@ -79,7 +80,7 @@ fun ProjectOptionsDialogContent( // Row 2: Manage Member, Manage Labels, Manage Currencies if (!isArchived && isMaintainer) { row2.add(ProjectOption(stringResource(R.string.action_members), Icons.Default.Group, onManageMembers)) - if (false && (projectType == ProjectType.LOCAL || projectType == ProjectType.COSPEND)) { + 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)) @@ -181,7 +182,8 @@ fun ProjectOptionsDialogPreview() { isArchived = false, projectType = ProjectType.COSPEND, accessLevel = DBProject.ACCESS_LEVEL_ADMIN, - isShareable = true + isShareable = true, + showBetaFeatures = true ) } } @@ -205,7 +207,8 @@ fun ProjectOptionsDialogPreview2() { isArchived = true, projectType = ProjectType.COSPEND, accessLevel = DBProject.ACCESS_LEVEL_ADMIN, - isShareable = true + isShareable = true, + showBetaFeatures = true ) } } @@ -228,7 +231,8 @@ fun ProjectOptionsDialogPreview3() { isArchived = false, projectType = ProjectType.LOCAL, accessLevel = DBProject.ACCESS_LEVEL_ADMIN, - isShareable = true + isShareable = true, + showBetaFeatures = 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 71f0e33..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 = { @@ -242,6 +246,19 @@ fun SettingsScreen( // Other 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.title_about), icon = Icons.Default.Info, 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 91fd0e5..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 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 ee1cb76..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,16 +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.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. @@ -1177,7 +1183,6 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) : @Suppress("ConstPropertyName") companion object { - private val TAG = CowspentSQLiteOpenHelper::class.java.simpleName private const val database_version = 1 private const val database_name = "COWSPENT" private const val table_members = "MEMBERS" 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 a750d01..d6272ca 100644 --- a/app/src/main/java/net/helcel/cowspent/persistence/CowspentServerSyncHelper.kt +++ b/app/src/main/java/net/helcel/cowspent/persistence/CowspentServerSyncHelper.kt @@ -29,11 +29,9 @@ import net.helcel.cowspent.android.account.AccountActivity import net.helcel.cowspent.android.main.BillsListViewActivity import net.helcel.cowspent.android.main.MainConstants import net.helcel.cowspent.model.DBBill -import net.helcel.cowspent.model.DBCategory import net.helcel.cowspent.model.DBProject import net.helcel.cowspent.model.ProjectType import net.helcel.cowspent.util.CospendClientUtil.LoginStatus -import net.helcel.cowspent.util.CategoryUtils import net.helcel.cowspent.util.ICallback import net.helcel.cowspent.util.IProjectCreationCallback import net.helcel.cowspent.util.NextcloudClient 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 b8a1ded..5d0e244 100644 --- a/app/src/main/java/net/helcel/cowspent/util/NextcloudClient.kt +++ b/app/src/main/java/net/helcel/cowspent/util/NextcloudClient.kt @@ -103,9 +103,6 @@ class NextcloudClient( val acceptHeader: MutableList = ArrayList() acceptHeader.add("application/json") headers["Accept"] = acceptHeader - val ocsHeader: MutableList = ArrayList() - ocsHeader.add("true") - headers["OCS-APIRequest"] = ocsHeader } val nextcloudRequest: NextcloudRequest = if (params == null) { NextcloudRequest.Builder() 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 4ae3e17..d1feac3 100644 --- a/app/src/main/java/net/helcel/cowspent/util/ServerResponse.kt +++ b/app/src/main/java/net/helcel/cowspent/util/ServerResponse.kt @@ -68,7 +68,7 @@ open class ServerResponse( } val rawData = JSONObject(content) val data = rawData.getJSONObject("ocs") - return data.get("data").toString() + return data.getString("data") } class ProjectResponse(response: VersatileProjectSyncClient.ResponseData, isOcsResponse: Boolean) : 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 2312552..1918ecb 100644 --- a/app/src/main/java/net/helcel/cowspent/util/VersatileProjectSyncClient.kt +++ b/app/src/main/java/net/helcel/cowspent/util/VersatileProjectSyncClient.kt @@ -88,7 +88,7 @@ class VersatileProjectSyncClient( "/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, true), true) + return ServerResponse.ProjectResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_GET, null, null, cospendVersionGT161), cospendVersionGT161) } else { useOcsApiRequest = cospendVersionGT161 target = if (cospendVersionGT161) @@ -1348,9 +1348,6 @@ class VersatileProjectSyncClient( val acceptHeader: MutableList = ArrayList() acceptHeader.add("application/json") headers["Accept"] = acceptHeader - val ocsHeader: MutableList = ArrayList() - ocsHeader.add("true") - headers["OCS-APIRequest"] = ocsHeader } val nextcloudRequest: NextcloudRequest = if (params == null) { NextcloudRequest.Builder() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fbdadd5..c11e725 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -135,6 +135,8 @@ 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 @@ -155,6 +157,7 @@ colorMode offlineMode showArchived + betaFeatures 1 2 -1