diff --git a/.github/images/apk.png b/.github/images/apk.png new file mode 100644 index 0000000..d7850af Binary files /dev/null and b/.github/images/apk.png differ diff --git a/.github/images/izzy.png b/.github/images/izzy.png new file mode 100644 index 0000000..af5bf5b Binary files /dev/null and b/.github/images/izzy.png differ diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 82c5eaa..50903f1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -52,7 +52,8 @@ jobs: - name: Build APK run: | VERSION_CODE=$(git rev-list --count HEAD) - ./gradlew assembleSignedRelease -PVERSION_CODE=$VERSION_CODE + VERSION_NAME=$(git describe --tags --always) + ./gradlew assembleSignedRelease -PVERSION_CODE=$VERSION_CODE -PVERSION_NAME=$VERSION_NAME - name: Release uses: softprops/action-gh-release@v3 diff --git a/README.md b/README.md index e2a45dc..8017486 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,10 @@

Cowspent

- Logo + 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). diff --git a/app/build.gradle b/app/build.gradle index 9a1a5aa..d195e9e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,7 +1,7 @@ plugins { id 'com.android.application' version '9.2.1' - id 'org.jetbrains.kotlin.plugin.serialization' version '2.3.21' - id 'org.jetbrains.kotlin.plugin.compose' version '2.3.21' + id 'org.jetbrains.kotlin.plugin.serialization' version '2.4.0' + id 'org.jetbrains.kotlin.plugin.compose' version '2.4.0' } android { @@ -14,7 +14,7 @@ android { applicationId "net.helcel.cowspent" minSdk = 26 targetSdk = 37 - versionName "1.0" + versionName project.hasProperty('VERSION_NAME') ? project.property('VERSION_NAME') : "1.4" versionCode project.hasProperty('VERSION_CODE') ? project.property('VERSION_CODE').toInteger() : 1 } @@ -82,11 +82,22 @@ android { androidResources { generateLocaleConfig = true } + + packaging { + jniLibs.keepDebugSymbols.add("**/*.so") + } + + dependenciesInfo { + // Disables dependency metadata when building APKs (for IzzyOnDroid/F-Droid) + includeInApk = false + // Disables dependency metadata when building Android App Bundles (for Google Play) + includeInBundle = false + } } dependencies { - implementation 'androidx.compose.foundation:foundation:1.11.2' - implementation 'androidx.compose.runtime:runtime:1.11.2' + implementation 'androidx.compose.foundation:foundation:1.11.4' + implementation 'androidx.compose.runtime:runtime:1.11.4' coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.5' implementation 'androidx.preference:preference-ktx:1.2.1' @@ -99,7 +110,7 @@ dependencies { implementation 'com.github.nextcloud:Android-SingleSignOn:1.1.0' implementation 'com.opencsv:opencsv:5.12.0' - implementation platform('androidx.compose:compose-bom:2026.05.01') + implementation platform('androidx.compose:compose-bom:2026.06.01') implementation 'androidx.compose.ui:ui' implementation 'androidx.compose.material:material' implementation 'androidx.compose.material:material-icons-extended' @@ -107,5 +118,8 @@ dependencies { debugImplementation 'androidx.compose.ui:ui-tooling' implementation 'androidx.activity:activity-compose:1.13.0' implementation 'androidx.activity:activity-ktx:1.13.0' - implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0' + implementation 'androidx.security:security-crypto:1.1.0' + implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.11.0' + implementation "androidx.datastore:datastore-preferences:1.2.1" + implementation "com.google.crypto.tink:tink-android:1.22.0" } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 67a0b67..2e082dc 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -20,11 +20,14 @@ android:supportsRtl="true" android:theme="@style/AppTheme"> - + @@ -43,9 +46,9 @@ @@ -64,9 +67,9 @@ @@ -91,36 +94,48 @@ + + + + diff --git a/app/src/main/java/net/helcel/cowspent/android/about/AboutActivity.kt b/app/src/main/java/net/helcel/cowspent/android/about/AboutActivity.kt index a0606b1..a8c09d6 100644 --- a/app/src/main/java/net/helcel/cowspent/android/about/AboutActivity.kt +++ b/app/src/main/java/net/helcel/cowspent/android/about/AboutActivity.kt @@ -1,6 +1,7 @@ package net.helcel.cowspent.android.about import android.os.Bundle +import androidx.activity.enableEdgeToEdge import androidx.activity.compose.setContent import androidx.appcompat.app.AppCompatActivity import net.helcel.cowspent.theme.ThemeUtils @@ -8,6 +9,7 @@ import net.helcel.cowspent.theme.ThemeUtils class AboutActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() super.onCreate(savedInstanceState) setContent { diff --git a/app/src/main/java/net/helcel/cowspent/android/about/AboutScreen.kt b/app/src/main/java/net/helcel/cowspent/android/about/AboutScreen.kt index e9ae939..aa34814 100644 --- a/app/src/main/java/net/helcel/cowspent/android/about/AboutScreen.kt +++ b/app/src/main/java/net/helcel/cowspent/android/about/AboutScreen.kt @@ -45,7 +45,7 @@ fun AboutScreen( Scaffold( topBar = { TopAppBar( - title = { Text(stringResource(R.string.simple_about)) }, + title = { Text(stringResource(R.string.title_about)) }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) diff --git a/app/src/main/java/net/helcel/cowspent/android/account/AccountActivity.kt b/app/src/main/java/net/helcel/cowspent/android/account/AccountActivity.kt index efe64de..e02bcbc 100644 --- a/app/src/main/java/net/helcel/cowspent/android/account/AccountActivity.kt +++ b/app/src/main/java/net/helcel/cowspent/android/account/AccountActivity.kt @@ -14,6 +14,7 @@ import android.webkit.WebViewClient import android.widget.Toast import androidx.activity.OnBackPressedCallback import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.layout.Box @@ -32,7 +33,6 @@ import androidx.lifecycle.lifecycleScope import androidx.preference.PreferenceManager import com.nextcloud.android.sso.AccountImporter import com.nextcloud.android.sso.helper.SingleAccountHelper -import com.nextcloud.android.sso.model.SingleSignOnAccount import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -41,6 +41,7 @@ import net.helcel.cowspent.android.main.MainConstants import net.helcel.cowspent.theme.ThemeUtils import net.helcel.cowspent.util.CospendClientUtil import net.helcel.cowspent.util.CospendClientUtil.LoginStatus +import net.helcel.cowspent.util.SecureStorage import java.net.URLDecoder import java.util.Locale @@ -68,11 +69,11 @@ class AccountActivity : AppCompatActivity() { } private lateinit var preferences: SharedPreferences - private var oldPassword = "" private var useWebLogin = true private var showLoginDialog by mutableStateOf(false) override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() super.onCreate(savedInstanceState) preferences = PreferenceManager.getDefaultSharedPreferences(applicationContext) @@ -127,8 +128,6 @@ class AccountActivity : AppCompatActivity() { ) } } - - oldPassword = preferences.getString(SETTINGS_PASSWORD, DEFAULT_SETTINGS) ?: "" viewModel.validateUrl() } @@ -222,11 +221,7 @@ class AccountActivity : AppCompatActivity() { private fun legacyLogin() { val url = CospendClientUtil.formatURL(viewModel.serverUrl.trim()) val username = viewModel.username - var password = viewModel.password - - if (password.isEmpty()) { - password = oldPassword - } + val password = viewModel.password performLogin(url, username, password) } @@ -309,10 +304,10 @@ class AccountActivity : AppCompatActivity() { } if (status == LoginStatus.OK) { + SecureStorage.savePassword(applicationContext, SETTINGS_PASSWORD, password) preferences.edit { putString(SETTINGS_URL, url) putString(SETTINGS_USERNAME, username) - putString(SETTINGS_PASSWORD, password) remove(SETTINGS_KEY_ETAG) remove(SETTINGS_KEY_LAST_MODIFIED) } diff --git a/app/src/main/java/net/helcel/cowspent/android/account/AccountScreen.kt b/app/src/main/java/net/helcel/cowspent/android/account/AccountScreen.kt index 9c7c213..c6e25d0 100644 --- a/app/src/main/java/net/helcel/cowspent/android/account/AccountScreen.kt +++ b/app/src/main/java/net/helcel/cowspent/android/account/AccountScreen.kt @@ -4,39 +4,18 @@ package net.helcel.cowspent.android.account -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.Button -import androidx.compose.material.CircularProgressIndicator -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.MaterialTheme -import androidx.compose.material.OutlinedTextField -import androidx.compose.material.Scaffold -import androidx.compose.material.Switch -import androidx.compose.material.SwitchDefaults -import androidx.compose.material.Text -import androidx.compose.material.TopAppBar +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Link -import androidx.compose.material.icons.filled.Lock -import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.* import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp @@ -99,7 +78,7 @@ fun AccountScreenContent( Scaffold( topBar = { TopAppBar( - title = { Text(stringResource(R.string.settings_server_settings)) }, + title = { Text(stringResource(R.string.title_account)) }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) @@ -123,30 +102,70 @@ fun AccountScreenContent( } } else if (isLoggedIn) { Text( - text = stringResource(R.string.account_logged_in_as, username), - style = MaterialTheme.typography.h6 - ) - Text( - text = serverUrl, - style = MaterialTheme.typography.body2, - color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f) + text = "CURRENT ACCOUNT", + style = MaterialTheme.typography.subtitle1, + color = MaterialTheme.colors.onSurface, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 8.dp) ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Person, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colors.onSurface.copy(alpha = 0.6f) + ) + Spacer(modifier = Modifier.width(32.dp)) + Column { + Text( + text = stringResource(R.string.msg_logged_in_as, username), + style = MaterialTheme.typography.subtitle1 + ) + Text( + text = serverUrl, + style = MaterialTheme.typography.caption, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f) + ) + } + } + Spacer(modifier = Modifier.height(16.dp)) Button( onClick = onLogout, modifier = Modifier.fillMaxWidth() ) { - Text(stringResource(R.string.account_logout)) + Text(stringResource(R.string.action_logout)) } Spacer(modifier = Modifier.height(24.dp)) } if (!isValidatingLogin) { + Text( + text = "CONNECTION SETTINGS", + style = MaterialTheme.typography.subtitle1, + color = MaterialTheme.colors.onSurface, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 8.dp, top = if (isLoggedIn) 16.dp else 0.dp) + ) + Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 8.dp) ) { - Text(stringResource(R.string.use_sso_toggle), modifier = Modifier.weight(1f)) + Icon( + Icons.Default.Sync, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colors.onSurface.copy(alpha = 0.6f) + ) + Spacer(modifier = Modifier.width(32.dp)) + Text(stringResource(R.string.label_use_sso), modifier = Modifier.weight(1f), style = MaterialTheme.typography.subtitle1) Switch( checked = useSso, onCheckedChange = { onSsoClick(it) }, @@ -163,7 +182,7 @@ fun AccountScreenContent( OutlinedTextField( value = serverUrl, onValueChange = onServerUrlChange, - label = { Text(stringResource(R.string.settings_url)) }, + placeholder = { Text(stringResource(R.string.label_url)) }, modifier = Modifier.fillMaxWidth(), leadingIcon = { Icon(Icons.Default.Link, contentDescription = null) }, trailingIcon = { @@ -192,7 +211,7 @@ fun AccountScreenContent( OutlinedTextField( value = username, onValueChange = onUsernameChange, - label = { Text(stringResource(R.string.settings_username)) }, + placeholder = { Text(stringResource(R.string.label_username)) }, modifier = Modifier.fillMaxWidth(), leadingIcon = { Icon(Icons.Default.Person, contentDescription = null) }, singleLine = true @@ -204,7 +223,7 @@ fun AccountScreenContent( OutlinedTextField( value = password, onValueChange = onPasswordChange, - label = { Text(stringResource(R.string.settings_password)) }, + placeholder = { Text(stringResource(R.string.label_password)) }, modifier = Modifier.fillMaxWidth(), leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) }, visualTransformation = PasswordVisualTransformation(), @@ -221,7 +240,7 @@ fun AccountScreenContent( if (isSubmitting) { CustomCircularProgressIndicator(size = 24.dp, color = Color.White) } else { - Text(stringResource(R.string.settings_submit)) + Text(stringResource(R.string.action_connect)) } } } diff --git a/app/src/main/java/net/helcel/cowspent/android/account/AccountViewModel.kt b/app/src/main/java/net/helcel/cowspent/android/account/AccountViewModel.kt index 8966d0c..c08b63f 100644 --- a/app/src/main/java/net/helcel/cowspent/android/account/AccountViewModel.kt +++ b/app/src/main/java/net/helcel/cowspent/android/account/AccountViewModel.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import net.helcel.cowspent.util.CospendClientUtil +import net.helcel.cowspent.util.SecureStorage class AccountViewModel(application: Application) : AndroidViewModel(application) { private val preferences: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(application) @@ -60,14 +61,15 @@ class AccountViewModel(application: Application) : AndroidViewModel(application) } else { preferences.getString(AccountActivity.SETTINGS_USERNAME, "") } - val password = if (useSso) { - "" - } else { - preferences.getString(AccountActivity.SETTINGS_PASSWORD, "") - } if (!url.isNullOrEmpty() && !username.isNullOrEmpty()) { viewModelScope.launch { + val password = if (useSso) { + "" + } else { + SecureStorage.getPassword(getApplication(), AccountActivity.SETTINGS_PASSWORD) + } + isValidatingLogin = true isLoggedIn = withContext(Dispatchers.IO) { if (useSso) { @@ -87,6 +89,9 @@ class AccountViewModel(application: Application) : AndroidViewModel(application) } fun logout() { + viewModelScope.launch { + SecureStorage.removePassword(getApplication(), AccountActivity.SETTINGS_PASSWORD) + } preferences.edit { remove(AccountActivity.SETTINGS_USE_SSO) remove(AccountActivity.SETTINGS_SSO_URL) diff --git a/app/src/main/java/net/helcel/cowspent/android/account/LoginDialog.kt b/app/src/main/java/net/helcel/cowspent/android/account/LoginDialog.kt index c630148..1b45f1c 100644 --- a/app/src/main/java/net/helcel/cowspent/android/account/LoginDialog.kt +++ b/app/src/main/java/net/helcel/cowspent/android/account/LoginDialog.kt @@ -15,6 +15,7 @@ import androidx.compose.material.TextButton import androidx.compose.material.contentColorFor import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -65,8 +66,10 @@ fun LoginDialogContent( ) { Column(modifier = Modifier.padding(24.dp)) { Text( - text = "Login", - style = MaterialTheme.typography.h6, + text = "LOGIN", + style = MaterialTheme.typography.subtitle1, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.onSurface, modifier = Modifier.padding(bottom = 16.dp) ) diff --git a/app/src/main/java/net/helcel/cowspent/android/bill_edit/EditBillActivity.kt b/app/src/main/java/net/helcel/cowspent/android/bill_edit/EditBillActivity.kt index 4681bfc..e29c411 100644 --- a/app/src/main/java/net/helcel/cowspent/android/bill_edit/EditBillActivity.kt +++ b/app/src/main/java/net/helcel/cowspent/android/bill_edit/EditBillActivity.kt @@ -5,6 +5,7 @@ import android.app.TimePickerDialog import android.content.Intent import android.os.Bundle import androidx.activity.OnBackPressedCallback +import androidx.activity.enableEdgeToEdge import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels @@ -25,7 +26,7 @@ import net.helcel.cowspent.model.ProjectType import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper import net.helcel.cowspent.theme.ThemeUtils import net.helcel.cowspent.util.BillParser -import net.helcel.cowspent.util.CategoryUtils +import net.helcel.cowspent.util.SupportUtil import java.text.ParseException import java.time.ZoneId import java.util.Calendar @@ -39,6 +40,7 @@ class EditBillActivity : AppCompatActivity() { private val calendar = Calendar.getInstance() override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() super.onCreate(savedInstanceState) db = CowspentSQLiteOpenHelper.getInstance(this) @@ -49,23 +51,10 @@ class EditBillActivity : AppCompatActivity() { ThemeUtils.CowspentTheme { val categories = remember { - val syncedCategories = db.getCategories(bill.projectId) - val defaultCategories = CategoryUtils.getDefaultCategories(this@EditBillActivity, bill.projectId) - val hardcoded = if (projectType == ProjectType.LOCAL) { - defaultCategories - } else { - listOfNotNull(defaultCategories.find { it.remoteId.toInt() == DBBill.CATEGORY_REIMBURSEMENT }) - } - syncedCategories + hardcoded + db.getCategories(bill.projectId) } val paymentModes = remember { - val syncedPaymentModes = db.getPaymentModes(bill.projectId) - val defaultPaymentModes = CategoryUtils.getDefaultPaymentModes(this@EditBillActivity, bill.projectId) - if (projectType == ProjectType.LOCAL) { - syncedPaymentModes + defaultPaymentModes - } else { - syncedPaymentModes.ifEmpty { defaultPaymentModes } - } + db.getPaymentModes(bill.projectId) } EditBillScreen( @@ -105,7 +94,8 @@ class EditBillActivity : AppCompatActivity() { val createIntent = Intent(this@EditBillActivity, QrCodeScannerActivity::class.java) scanQRCodeLauncher.launch(createIntent) }, - onDelete = if (bill.id > 0) { { deleteBillAsked() } } else null, + onDuplicate = if (!viewModel.isNewBill) { { duplicateCurrentBill() } } else null, + onDelete = if (!viewModel.isNewBill) { { deleteBillAsked() } } else null, accessLevel = db.getProject(bill.projectId)?.myAccessLevel ?: DBProject.ACCESS_LEVEL_ADMIN ) } @@ -140,7 +130,7 @@ class EditBillActivity : AppCompatActivity() { bill = DBBill( first.id, 0, first.projectId, first.payerId, totalAmount, first.timestamp, first.what, first.state, first.repeat, - first.paymentMode, first.categoryRemoteId, first.comment, first.paymentModeRemoteId + first.paymentMode, first.categoryId, first.comment, first.paymentModeId ) val splits = mutableMapOf() @@ -171,8 +161,8 @@ class EditBillActivity : AppCompatActivity() { bill = DBBill( 0, 0, projectId, btd.payerId, btd.amount, timeNowSeconds, btd.what, DBBill.STATE_ADDED, - btd.repeat, btd.paymentMode, btd.categoryRemoteId, - btd.comment, btd.paymentModeRemoteId + btd.repeat, btd.paymentMode, btd.categoryId, + btd.comment, btd.paymentModeId ) val btdOwers = btd.billOwers val newBillOwers = btdOwers.filter { @@ -183,12 +173,23 @@ class EditBillActivity : AppCompatActivity() { } } calendar.timeInMillis = bill.timestamp * 1000 - val members = db.getMembersOfProject(bill.projectId, null) + val allMembers = db.getMembersOfProject(bill.projectId, null) + val billOwerIds = bill.billOwersIds + val members = allMembers.filter { m -> + m.isActivated || m.id == bill.payerId || billOwerIds.contains(m.id) + } + val project = db.getProject(bill.projectId) val currencies = db.getCurrencies(bill.projectId) + + if (project != null) { + db.ensureDefaultLabels(bill.projectId, project.type) + } + withContext(Dispatchers.Main) { viewModel.currencies = currencies viewModel.mainCurrencyName = project?.currencyName ?: "" + viewModel.isNewBill = (bill.id == 0L) viewModel.initFromBill(bill, members, customSplits) } } @@ -204,6 +205,7 @@ class EditBillActivity : AppCompatActivity() { calendar.timeInMillis = austrianBill.date.time viewModel.timestamp = calendar.timeInMillis / 1000 viewModel.amount = austrianBill.amount.toString() + viewModel.updateSplits() return@registerForActivityResult } catch (_: ParseException) { } @@ -215,6 +217,7 @@ class EditBillActivity : AppCompatActivity() { viewModel.timestamp = calendar.timeInMillis / 1000 } viewModel.amount = croatianBill.amount.toString() + viewModel.updateSplits() return@registerForActivityResult } catch (_: ParseException) { } @@ -223,28 +226,42 @@ class EditBillActivity : AppCompatActivity() { } } + private fun duplicateCurrentBill() { + bill = DBBill( + 0, 0, bill.projectId, viewModel.payerId, viewModel.amountAsDouble, + System.currentTimeMillis() / 1000, viewModel.what, DBBill.STATE_ADDED, + viewModel.repeat, bill.paymentMode, viewModel.categoryId, + viewModel.getFinalComment(), viewModel.paymentModeId + ) + calendar.timeInMillis = System.currentTimeMillis() + viewModel.timestamp = calendar.timeInMillis / 1000 + viewModel.isNewBill = true + + showToast(this, "Duplicating bill...") + } + private fun onBack() { if (!valuesHaveChanged()) { finish() return } viewModel.showDialog( - title = getString(R.string.save_or_discard_bill_dialog_title), - message = getString(R.string.save_or_discard_bill_dialog_message), - positiveText = getString(R.string.save_or_discard_bill_dialog_save), + title = getString(R.string.dialog_unsaved_changes_title), + message = getString(R.string.dialog_unsaved_changes_msg), + positiveText = getString(R.string.action_save), onConfirm = { saveBillAsked() }, - negativeText = getString(R.string.save_or_discard_bill_dialog_discard), + negativeText = getString(R.string.action_discard), onCancel = { finish() } ) } private fun saveBillAsked() { val validationError = viewModel.getValidationError( - getString(R.string.error_invalid_bill_what), + getString(R.string.error_invalid_bill_name), getString(R.string.error_invalid_bill_date), - getString(R.string.error_invalid_bill_payerid), + getString(R.string.error_invalid_bill_payer), getString(R.string.error_invalid_bill_owers), - getString(R.string.simple_error) + getString(R.string.error_generic) ) if (validationError != null) { @@ -262,7 +279,7 @@ class EditBillActivity : AppCompatActivity() { private fun deleteBillAsked() { viewModel.showDialog( - title = getString(R.string.confirm_remove_project_dialog_title), + title = getString(R.string.title_confirm), message = bill.what, positiveText = getString(R.string.action_delete), onConfirm = { @@ -296,12 +313,12 @@ class EditBillActivity : AppCompatActivity() { return !(bill.what == viewModel.what && bill.timestamp == viewModel.timestamp && - bill.amount == viewModel.amountAsDouble && + bill.amount == viewModel.getFinalAmount() && bill.payerId == viewModel.payerId && - bill.comment == viewModel.comment && + bill.comment == viewModel.getFinalComment() && bill.repeat == viewModel.repeat && - bill.categoryRemoteId == viewModel.categoryRemoteId && - bill.paymentModeRemoteId == viewModel.paymentModeRemoteId && + bill.categoryId == viewModel.categoryId && + bill.paymentModeId == viewModel.paymentModeId && !owersChanged) } @@ -310,13 +327,17 @@ class EditBillActivity : AppCompatActivity() { val isCustomSplit = viewModel.isCustomSplit if (isCustomSplit) { - val splits = viewModel.owersCustomSplit.filter { (id, amount) -> - viewModel.owersSelection[id] == true && (amount.replace(',', '.').toDoubleOrNull() + val splits: Map = viewModel.owersCustomSplit.filter { (id, amountStr) -> + viewModel.owersSelection[id] == true && (amountStr.replace(',', '.').toDoubleOrNull() ?: 0.0) > 0 - }.mapValues { it.value.replace(',', '.').toDoubleOrNull() ?: 0.0 } + }.mapValues { + val uiAmount = it.value.replace(',', '.').toDoubleOrNull() ?: 0.0 + SupportUtil.round2(uiAmount / viewModel.selectedCurrencyRate) + } if (splits.isEmpty()) return@withContext 0L - + + val finalComment = viewModel.getFinalComment() val splitEntries = splits.entries.toList() // Pool of existing bills in this group that we can potentially reuse @@ -356,9 +377,9 @@ class EditBillActivity : AppCompatActivity() { listOf(memberId), viewModel.repeat, existingBill.paymentMode, - viewModel.paymentModeRemoteId, - viewModel.categoryRemoteId, - viewModel.comment + viewModel.paymentModeId, + viewModel.categoryId, + finalComment ) if (firstSavedId == 0L) firstSavedId = billToUseId } else { @@ -366,9 +387,11 @@ class EditBillActivity : AppCompatActivity() { val newBill = DBBill( 0, 0, bill.projectId, viewModel.payerId, amount, viewModel.timestamp, viewModel.what, DBBill.STATE_ADDED, viewModel.repeat, - bill.paymentMode, viewModel.categoryRemoteId, viewModel.comment, viewModel.paymentModeRemoteId + bill.paymentMode, viewModel.categoryId, finalComment, viewModel.paymentModeId ) newBill.billOwers = listOf(DBBillOwer(0, 0, memberId)) + newBill.categoryId = viewModel.categoryId + newBill.paymentModeId = viewModel.paymentModeId val newId = db.addBill(newBill) if (firstSavedId == 0L) firstSavedId = newId } @@ -396,7 +419,8 @@ class EditBillActivity : AppCompatActivity() { return@withContext firstSavedId } else { - val newAmount = viewModel.amountAsDouble + val newAmount = viewModel.getFinalAmount() + val finalComment = viewModel.getFinalComment() val newOwersIds = viewModel.getOwersIds() if (bill.id != 0L) { @@ -410,9 +434,9 @@ class EditBillActivity : AppCompatActivity() { newOwersIds, viewModel.repeat, bill.paymentMode, - viewModel.paymentModeRemoteId, - viewModel.categoryRemoteId, - viewModel.comment + viewModel.paymentModeId, + viewModel.categoryId, + finalComment ) if (groupedBillIds != null) { for (id in groupedBillIds) { @@ -429,7 +453,7 @@ class EditBillActivity : AppCompatActivity() { val newBill = DBBill( 0, 0, bill.projectId, viewModel.payerId, newAmount, viewModel.timestamp, viewModel.what, DBBill.STATE_ADDED, viewModel.repeat, - bill.paymentMode, viewModel.categoryRemoteId, viewModel.comment, viewModel.paymentModeRemoteId + bill.paymentMode, viewModel.categoryId, finalComment, viewModel.paymentModeId ) newOwersIds.forEach { newBill.billOwers += DBBillOwer(0, 0, it) } val newBillId = db.addBill(newBill) diff --git a/app/src/main/java/net/helcel/cowspent/android/bill_edit/EditBillScreen.kt b/app/src/main/java/net/helcel/cowspent/android/bill_edit/EditBillScreen.kt index 242356c..42fddcc 100644 --- a/app/src/main/java/net/helcel/cowspent/android/bill_edit/EditBillScreen.kt +++ b/app/src/main/java/net/helcel/cowspent/android/bill_edit/EditBillScreen.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign @@ -30,6 +31,7 @@ import androidx.compose.ui.unit.sp import net.helcel.cowspent.R import net.helcel.cowspent.android.helper.* import net.helcel.cowspent.model.* +import net.helcel.cowspent.util.CategoryUtils import net.helcel.cowspent.util.SupportUtil import java.util.Date import kotlin.math.abs @@ -45,6 +47,7 @@ fun EditBillScreen( onDateClick: () -> Unit, onTimeClick: () -> Unit, onScan: () -> Unit, + onDuplicate: (() -> Unit)? = null, onDelete: (() -> Unit)? = null, accessLevel: Int = DBProject.ACCESS_LEVEL_ADMIN ) { @@ -59,7 +62,7 @@ fun EditBillScreen( Scaffold( topBar = { TopAppBar( - title = { Text(stringResource(if (viewModel.what.isEmpty()) R.string.simple_new_bill else R.string.simple_edit_bill)) }, + title = { Text(stringResource(if (viewModel.isNewBill) R.string.action_new_bill else R.string.action_edit)) }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) @@ -70,6 +73,11 @@ fun EditBillScreen( IconButton(onClick = onScan) { Icon(Icons.Default.QrCodeScanner, contentDescription = null) } + if (onDuplicate != null) { + IconButton(onClick = onDuplicate) { + Icon(Icons.Default.ContentCopy, contentDescription = "Duplicate") + } + } if (onDelete != null) { IconButton(onClick = onDelete) { Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.action_delete)) @@ -83,11 +91,11 @@ fun EditBillScreen( }, floatingActionButton = { if (canEdit) { - val errorWhat = stringResource(R.string.error_invalid_bill_what) + val errorWhat = stringResource(R.string.error_invalid_bill_name) val errorDate = stringResource(R.string.error_invalid_bill_date) - val errorPayer = stringResource(R.string.error_invalid_bill_payerid) + val errorPayer = stringResource(R.string.error_invalid_bill_payer) val errorOwers = stringResource(R.string.error_invalid_bill_owers) - val errorInvalidForm = stringResource(R.string.simple_error) + val errorInvalidForm = stringResource(R.string.error_generic) FloatingActionButton(onClick = { val validationError = viewModel.getValidationError( @@ -101,7 +109,7 @@ fun EditBillScreen( }) { Icon( Icons.Default.Done, - contentDescription = stringResource(R.string.action_save_bill) + contentDescription = stringResource(R.string.action_save) ) } } @@ -111,6 +119,7 @@ fun EditBillScreen( Column( modifier = Modifier .padding(padding) + .imePadding() .padding(16.dp) .fillMaxSize() .verticalScroll(scrollState) @@ -157,16 +166,23 @@ fun BillBasicInfoSection( onDateClick: () -> Unit, onTimeClick: () -> Unit ) { + Text( + text = "GENERAL", + style = MaterialTheme.typography.subtitle1, + color = MaterialTheme.colors.onSurface, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 8.dp) + ) + val context = LocalContext.current val currencyDialogTitle = stringResource(R.string.currency_dialog_title, viewModel.mainCurrencyName) - val noCurrencyError = stringResource(R.string.no_currency_error) OutlinedTextField( value = viewModel.what, onValueChange = { viewModel.what = it }, enabled = canEdit, - placeholder = { Text(stringResource(R.string.setting_what)) }, + placeholder = { Text(stringResource(R.string.label_what)) }, modifier = Modifier.fillMaxWidth(), leadingIcon = { Icon(Icons.Default.Title, contentDescription = null) } ) @@ -182,25 +198,37 @@ fun BillBasicInfoSection( enabled = canEdit, placeholder = { Text("0") }, modifier = Modifier.fillMaxWidth(), - leadingIcon = { Icon(Icons.Default.AttachMoney, contentDescription = null) }, + leadingIcon = { + val currencyToShow = viewModel.selectedCurrencyName.ifEmpty { + viewModel.mainCurrencyName.ifEmpty { "$" } + } + TextIconDisplay( + textIcon = TextIcon.Symbol(currencyToShow), + tint = MaterialTheme.colors.onSurface.copy(alpha = 0.6f) + ) + }, trailingIcon = { IconButton( enabled = canEdit, onClick = { - if (viewModel.currencies.isNotEmpty()) { - viewModel.showDialog( - title = currencyDialogTitle, - items = viewModel.currencies.map { "${it.name} (${it.exchangeRate})" }, - onItemSelected = { index -> - viewModel.convertCurrency(viewModel.currencies[index]) - } - ) - } else { - showToast(context, noCurrencyError) + val mainLabel = viewModel.mainCurrencyName.ifEmpty { "$" } + val options = listOf("$mainLabel | Base") + viewModel.currencies.map { + "${it.name} | 1 $mainLabel = ${it.exchangeRate} ${it.name}" } + viewModel.showDialog( + title = currencyDialogTitle, + items = options, + onItemSelected = { index -> + if (index == 0) { + viewModel.resetCurrency() + } else { + viewModel.convertCurrency(viewModel.currencies[index - 1]) + } + } + ) } ) { - Icon(Icons.Default.CurrencyExchange, contentDescription = null) + Icon(Icons.Default.SwapHoriz, contentDescription = "Change Currency") } }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) @@ -221,7 +249,7 @@ fun BillBasicInfoSection( onClick = onDateClick, modifier = Modifier.weight(1f), enabled = canEdit, - leadingIcon = { Icon(Icons.Default.Event, contentDescription = null) } + leadingIcon = { Icon(Icons.Default.Event, contentDescription = "Select Date") } ) Spacer(modifier = Modifier.width(8.dp)) ClickableOutlinedTextField( @@ -229,7 +257,7 @@ fun BillBasicInfoSection( onClick = onTimeClick, modifier = Modifier.weight(1f), enabled = canEdit, - leadingIcon = { Icon(Icons.Default.AccessTime, contentDescription = null) } + leadingIcon = { Icon(Icons.Default.AccessTime, contentDescription = "Select Time") } ) } } @@ -244,20 +272,16 @@ fun PayerSection( EditableExposedDropdownMenu( value = selectedPayer?.name ?: "", - placeholder = stringResource(R.string.setting_payer), + placeholder = stringResource(R.string.label_payer), expanded = payerExpanded, onExpandedChange = { payerExpanded = it }, onDismissRequest = { payerExpanded = false }, enabled = canEdit, leadingIcon = { - Box(modifier = Modifier.padding(start = 12.dp)) { + Box(modifier = Modifier) { if (selectedPayer != null) { - UserAvatar( - name = selectedPayer.name, - r = selectedPayer.r, - g = selectedPayer.g, - b = selectedPayer.b, - disabled = !selectedPayer.isActivated, + MemberAvatar( + member = selectedPayer, size = 24.dp ) } else { @@ -271,12 +295,8 @@ fun PayerSection( viewModel.payerId = member.id payerExpanded = false }) { - UserAvatar( - name = member.name, - r = member.r, - g = member.g, - b = member.b, - disabled = !member.isActivated, + MemberAvatar( + member = member, size = 24.dp ) Spacer(modifier = Modifier.width(8.dp)) @@ -330,8 +350,10 @@ fun OwerSelectionSection( } Spacer(modifier = Modifier.width(8.dp)) Text( - stringResource(R.string.setting_owers), fontSize = 12.sp, - color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f) + text = stringResource(R.string.label_owers).uppercase(), + style = MaterialTheme.typography.subtitle1, + color = MaterialTheme.colors.onSurface, + fontWeight = FontWeight.Bold ) Spacer(Modifier.weight(1f)) @@ -381,12 +403,8 @@ fun OwerSelectionSection( enabled = canEdit, onCheckedChange = { viewModel.toggleMember(member.id, it) } ) - UserAvatar( - name = member.name, - r = member.r, - g = member.g, - b = member.b, - disabled = !member.isActivated, + MemberAvatar( + member = member, size = 32.dp ) Spacer(modifier = Modifier.width(8.dp)) @@ -441,19 +459,28 @@ fun BillAdditionalDetailsSection( paymentModes: List, canEdit: Boolean ) { + Text( + text = "DETAILS", + style = MaterialTheme.typography.subtitle1, + color = MaterialTheme.colors.onSurface, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 8.dp, top = 16.dp) + ) + + val context = LocalContext.current var categoryExpanded by remember { mutableStateOf(false) } val selectedCategory = - categories.find { it.remoteId.toInt() == viewModel.categoryRemoteId } + categories.find { it.id == viewModel.categoryId } ?: CategoryUtils.getCategoryById(context, viewModel.categoryId) EditableExposedDropdownMenu( value = selectedCategory?.name ?: "", - placeholder = stringResource(R.string.setting_category), + placeholder = stringResource(R.string.label_category), expanded = categoryExpanded, onExpandedChange = { categoryExpanded = it }, onDismissRequest = { categoryExpanded = false }, enabled = canEdit, leadingIcon = { - Box(modifier = Modifier.padding(start = 12.dp)) { + Box(modifier = Modifier) { if (selectedCategory != null) { Text(text = selectedCategory.icon, fontSize = 20.sp) } else { @@ -463,16 +490,26 @@ fun BillAdditionalDetailsSection( }, content = { DropdownMenuItem(onClick = { - viewModel.categoryRemoteId = 0 + viewModel.categoryId = 0 categoryExpanded = false }) { Icon(Icons.Default.Close, tint = Color.Red, contentDescription = null) Spacer(modifier = Modifier.width(12.dp)) Text(stringResource(R.string.category_none)) } - categories.forEach { category -> + + DropdownMenuItem(onClick = { + viewModel.categoryId = DBBill.CATEGORY_REIMBURSEMENT + categoryExpanded = false + }) { + Text(text = "\uD83D\uDCB0", fontSize = 20.sp) + Spacer(modifier = Modifier.width(12.dp)) + Text(stringResource(R.string.category_reimbursement)) + } + + categories.filter { it.remoteId != DBBill.CATEGORY_REIMBURSEMENT }.forEach { category -> DropdownMenuItem(onClick = { - viewModel.categoryRemoteId = category.remoteId.toInt() + viewModel.categoryId = category.id categoryExpanded = false }) { Text(text = category.icon, fontSize = 20.sp) @@ -487,17 +524,17 @@ fun BillAdditionalDetailsSection( var pmExpanded by remember { mutableStateOf(false) } val selectedPm = - paymentModes.find { it.remoteId.toInt() == viewModel.paymentModeRemoteId } + paymentModes.find { it.id == viewModel.paymentModeId } ?: CategoryUtils.getPaymentModeById(context, viewModel.paymentModeId) EditableExposedDropdownMenu( value = selectedPm?.name ?: "", - placeholder = stringResource(R.string.setting_payment_mode), + placeholder = stringResource(R.string.label_mode), expanded = pmExpanded, onExpandedChange = { pmExpanded = it }, onDismissRequest = { pmExpanded = false }, enabled = canEdit, leadingIcon = { - Box(modifier = Modifier.padding(start = 12.dp)) { + Box(modifier = Modifier) { if (selectedPm != null) { Text(text = selectedPm.icon, fontSize = 20.sp) } else { @@ -507,7 +544,7 @@ fun BillAdditionalDetailsSection( }, content = { DropdownMenuItem(onClick = { - viewModel.paymentModeRemoteId = 0 + viewModel.paymentModeId = 0 pmExpanded = false }) { Icon(Icons.Default.Close, tint = Color.Red, contentDescription = null) @@ -516,7 +553,7 @@ fun BillAdditionalDetailsSection( } paymentModes.forEach { pm -> DropdownMenuItem(onClick = { - viewModel.paymentModeRemoteId = pm.remoteId.toInt() + viewModel.paymentModeId = pm.id pmExpanded = false }) { Text(text = pm.icon, fontSize = 20.sp) @@ -542,7 +579,7 @@ fun BillAdditionalDetailsSection( EditableExposedDropdownMenu( value = selectedRepeat?.second ?: "", - placeholder = stringResource(R.string.setting_project_repetition), + placeholder = stringResource(R.string.label_repeat), expanded = repeatExpanded, onExpandedChange = { repeatExpanded = it }, onDismissRequest = { repeatExpanded = false }, @@ -566,7 +603,7 @@ fun BillAdditionalDetailsSection( value = viewModel.comment, onValueChange = { viewModel.comment = it }, enabled = canEdit, - placeholder = { Text(stringResource(R.string.setting_comment)) }, + placeholder = { Text(stringResource(R.string.label_comment)) }, modifier = Modifier.fillMaxWidth(), singleLine = false, leadingIcon = { @@ -587,6 +624,7 @@ fun EditBillScreenPreview() { viewModel = EditBillViewModel().apply { what = "Pizza" amount = "12.50" + mainCurrencyName = "EUR" members = listOf( DBMember(1, 0, 0, "Alice", true, 1.0, 0, null, null, null, null, null), DBMember(2, 0, 0, "Bob", true, 1.0, 0, null, null, null, null, null) diff --git a/app/src/main/java/net/helcel/cowspent/android/bill_edit/EditBillViewModel.kt b/app/src/main/java/net/helcel/cowspent/android/bill_edit/EditBillViewModel.kt index 34f0592..16918d8 100644 --- a/app/src/main/java/net/helcel/cowspent/android/bill_edit/EditBillViewModel.kt +++ b/app/src/main/java/net/helcel/cowspent/android/bill_edit/EditBillViewModel.kt @@ -1,21 +1,21 @@ package net.helcel.cowspent.android.bill_edit import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableDoubleStateOf import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.vector.ImageVector import androidx.lifecycle.ViewModel -import net.helcel.cowspent.model.DBBill -import net.helcel.cowspent.model.DBMember import net.helcel.cowspent.android.helper.DialogState +import net.helcel.cowspent.android.helper.parseAmount +import net.helcel.cowspent.model.DBBill +import net.helcel.cowspent.model.DBCurrency +import net.helcel.cowspent.model.DBMember import net.helcel.cowspent.util.SupportUtil import net.helcel.cowspent.util.evalMath -import net.helcel.cowspent.model.DBCurrency -import androidx.compose.ui.graphics.vector.ImageVector - class EditBillViewModel : ViewModel() { var what by mutableStateOf("") var amount by mutableStateOf("") @@ -23,11 +23,14 @@ class EditBillViewModel : ViewModel() { var timestamp by mutableLongStateOf(0L) var payerId by mutableLongStateOf(0L) var repeat by mutableStateOf(DBBill.NON_REPEATED) - var paymentModeRemoteId by mutableIntStateOf(0) - var categoryRemoteId by mutableIntStateOf(0) + var paymentModeId by mutableLongStateOf(0L) + var categoryId by mutableLongStateOf(0L) + var isNewBill by mutableStateOf(false) var currencies by mutableStateOf>(emptyList()) var mainCurrencyName by mutableStateOf("") + var selectedCurrencyName by mutableStateOf("") + var selectedCurrencyRate by mutableDoubleStateOf(1.0) var members by mutableStateOf>(emptyList()) var owersSelection = mutableStateMapOf() @@ -38,13 +41,8 @@ class EditBillViewModel : ViewModel() { val amountAsDouble: Double get() { - val amountStr = amount.replace(',', '.') - return try { - if (amountStr.matches("[0-9.]+".toRegex())) { - amountStr.toDouble() - } else { - evalMath(amountStr) - } + return parseAmount(amount) ?: try { + evalMath(amount.replace(',', '.')) } catch (_: Exception) { 0.0 } @@ -128,46 +126,81 @@ class EditBillViewModel : ViewModel() { } fun convertCurrency(currency: DBCurrency) { - val originalAmountStr = amount - val originalAmount = amountAsDouble - if (originalAmount == 0.0) return - - val newAmount = originalAmount / currency.exchangeRate - amount = SupportUtil.round2(newAmount).toString() - - val currencyLabel = currency.name ?: "" - val conversionNote = "($originalAmountStr $currencyLabel)" - if (!comment.contains(conversionNote)) { - if (comment.isNotEmpty() && !comment.endsWith(" ")) { - comment += " " - } - comment += conversionNote - } - - if (isCustomSplit) { - owersCustomSplit.keys.toList().forEach { id -> - val value = owersCustomSplit[id] ?: "" - val partAmount = value.replace(',', '.').toDoubleOrNull() ?: 0.0 - if (partAmount != 0.0) { - val newPartAmount = partAmount / currency.exchangeRate - owersCustomSplit[id] = SupportUtil.round2(newPartAmount).toString() - } - } - } - + selectedCurrencyName = currency.name ?: "" + selectedCurrencyRate = currency.exchangeRate + + // We don't change 'amount' here anymore as per request. + // It stays as the original amount. + updateSplits() } + fun resetCurrency() { + selectedCurrencyName = "" + selectedCurrencyRate = 1.0 + updateSplits() + } + + fun getFinalAmount(): Double { + return SupportUtil.round2(amountAsDouble / selectedCurrencyRate) + } + + fun getFinalComment(): String { + var baseComment = comment + val regex = "\\s*#[^:]+:[\\d.,]+@[\\d.,]+".toRegex() + baseComment = baseComment.replace(regex, "").trim() + + return if (selectedCurrencyName.isNotEmpty() && selectedCurrencyRate != 1.0) { + val metadata = "#$selectedCurrencyName:$amount@$selectedCurrencyRate" + if (baseComment.isEmpty()) metadata else "$baseComment $metadata" + } else { + baseComment + } + } + fun initFromBill(bill: DBBill, members: List, customSplits: Map? = null) { this.members = members what = bill.what - amount = if (bill.amount == 0.0) "" else bill.amount.toString() - comment = bill.comment ?: "" timestamp = bill.timestamp payerId = bill.payerId repeat = bill.repeat ?: DBBill.NON_REPEATED - paymentModeRemoteId = bill.paymentModeRemoteId - categoryRemoteId = bill.categoryRemoteId + paymentModeId = bill.paymentModeId + categoryId = bill.categoryId + + val rawComment = bill.comment ?: "" + + // Try to parse existing conversion metadata #CURR:ORIG@RATE + // Using [^:]+ for currency name to support symbols and names with spaces + val regex = "#([^:]+):([\\d.,]+)@([\\d.,]+)".toRegex() + val match = regex.find(rawComment) + + if (match != null) { + val (currName, origAmount, rate) = match.destructured + selectedCurrencyName = currName + selectedCurrencyRate = rate.replace(',', '.').toDoubleOrNull() ?: 1.0 + amount = origAmount + comment = rawComment.replace(match.value, "").trim() + + // Check if latest rate is different + val latestCurrency = currencies.find { it.name == selectedCurrencyName } + if (latestCurrency != null && latestCurrency.exchangeRate != selectedCurrencyRate) { + showDialog( + title = "Update Exchange Rate?", + message = "The exchange rate for $selectedCurrencyName has changed from $selectedCurrencyRate to ${latestCurrency.exchangeRate}. Do you want to update the conversion for the saved total?", + positiveText = "Update Rate", + negativeText = "Keep Old", + onConfirm = { + selectedCurrencyRate = latestCurrency.exchangeRate + updateSplits() + } + ) + } + } else { + selectedCurrencyName = "" + selectedCurrencyRate = 1.0 + amount = if (bill.amount == 0.0) "" else bill.amount.toString() + comment = rawComment + } owersSelection.clear() owersCustomSplit.clear() @@ -178,13 +211,23 @@ class EditBillViewModel : ViewModel() { val selected = customSplits.containsKey(member.id) owersSelection[member.id] = selected if (selected) { - owersCustomSplit[member.id] = SupportUtil.round2(customSplits[member.id]!!).toString() + // If we have metadata, the custom splits from DB are also converted. + // We should show them as "Original" if possible? + // Actually, if we use metadata, we should probably store original splits too, + // but for now let's just reverse the rate for display. + val dbPart = customSplits[member.id]!! + val uiPart = if (selectedCurrencyRate != 1.0) dbPart * selectedCurrencyRate else dbPart + owersCustomSplit[member.id] = SupportUtil.round2(uiPart).toString() } } } else { + // Even split logic val billOwerIds = bill.billOwersIds val selectedCount = billOwerIds.size - val evenSplit = if (selectedCount > 0) bill.amount / selectedCount else 0.0 + + // Use UI amount for even split calculation + val uiAmount = amountAsDouble + val evenSplit = if (selectedCount > 0) uiAmount / selectedCount else 0.0 val evenSplitStr = if (evenSplit == 0.0) "" else SupportUtil.round2(evenSplit).toString() for (member in members) { diff --git a/app/src/main/java/net/helcel/cowspent/android/bill_label/LabelBillsActivity.kt b/app/src/main/java/net/helcel/cowspent/android/bill_label/LabelBillsActivity.kt index 6345092..681fd8e 100644 --- a/app/src/main/java/net/helcel/cowspent/android/bill_label/LabelBillsActivity.kt +++ b/app/src/main/java/net/helcel/cowspent/android/bill_label/LabelBillsActivity.kt @@ -3,6 +3,7 @@ package net.helcel.cowspent.android.bill_label import android.content.Context import android.content.Intent import android.os.Bundle +import androidx.activity.enableEdgeToEdge import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity @@ -12,7 +13,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper import net.helcel.cowspent.theme.ThemeUtils -import net.helcel.cowspent.util.CategoryUtils import net.helcel.cowspent.model.DBBill import net.helcel.cowspent.model.ProjectType @@ -20,6 +20,7 @@ class LabelBillsActivity : AppCompatActivity() { private val viewModel: LabelBillsViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() super.onCreate(savedInstanceState) val projectId = intent.getLongExtra(EXTRA_PROJECT_ID, -1L) @@ -37,24 +38,20 @@ class LabelBillsActivity : AppCompatActivity() { val members = db.getMembersOfProject(projectId, null) val allBills = db.getBillsOfProject(projectId) - val billsToLabel = allBills.filter { it.categoryRemoteId == 0 && it.state != DBBill.STATE_DELETED } - val allCategorized = allBills.filter { it.categoryRemoteId != 0 && it.state != DBBill.STATE_DELETED } + val billsToLabel = allBills.filter { it.categoryId == 0L && it.state != DBBill.STATE_DELETED } + val allCategorized = allBills.filter { it.categoryId != 0L && it.state != DBBill.STATE_DELETED } - val syncedCategories = db.getCategories(projectId) - val defaultCategories = CategoryUtils.getDefaultCategories(this@LabelBillsActivity, projectId) - val hardcoded = if (projectType == ProjectType.LOCAL) { - defaultCategories - } else { - listOfNotNull(defaultCategories.find { it.remoteId.toInt() == DBBill.CATEGORY_REIMBURSEMENT }) - } - val categories = syncedCategories + hardcoded + db.ensureDefaultLabels(projectId, projectType) + + // Reload from DB to get real IDs + val categories = db.getCategories(projectId) Quadruple(members, billsToLabel, categories, allCategorized) } viewModel.billsToLabel = billsToLabel viewModel.categories = categories - viewModel.categoriesMap = categories.associateBy { it.remoteId } + viewModel.categoriesMap = categories.associateBy { it.id } viewModel.allCategorizedBills = allCategorized viewModel.updateSuggestions() diff --git a/app/src/main/java/net/helcel/cowspent/android/bill_label/LabelBillsScreen.kt b/app/src/main/java/net/helcel/cowspent/android/bill_label/LabelBillsScreen.kt index cf1263a..4e1889b 100644 --- a/app/src/main/java/net/helcel/cowspent/android/bill_label/LabelBillsScreen.kt +++ b/app/src/main/java/net/helcel/cowspent/android/bill_label/LabelBillsScreen.kt @@ -42,7 +42,7 @@ fun LabelBillsScreen( Scaffold( topBar = { TopAppBar( - title = { Text(stringResource(R.string.label_bills_title)) }, + title = { Text(stringResource(R.string.title_label_bills)) }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) @@ -64,10 +64,11 @@ fun LabelBillsScreen( Spacer(modifier = Modifier.height(8.dp)) Text( - text = stringResource(R.string.label_bills_suggested), + text = stringResource(R.string.label_bills_suggested).uppercase(), style = MaterialTheme.typography.subtitle1, + color = MaterialTheme.colors.onSurface, fontWeight = FontWeight.Bold, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth().padding(top = 8.dp) ) Spacer(modifier = Modifier.height(8.dp)) @@ -83,7 +84,7 @@ fun LabelBillsScreen( CategoryButton( icon = category.icon, name = category.name ?: "", - onClick = { viewModel.labelCurrentBill(db, category.remoteId.toInt()) } + onClick = { viewModel.labelCurrentBill(db, category.id) } ) } } @@ -93,7 +94,7 @@ fun LabelBillsScreen( } } else { Text( - text = stringResource(R.string.label_bills_no_suggestions), + text = stringResource(R.string.msg_no_suggestions), style = MaterialTheme.typography.caption, color = MaterialTheme.colors.onSurface.copy(alpha = 0.4f), modifier = Modifier.fillMaxWidth(), @@ -105,10 +106,11 @@ fun LabelBillsScreen( Spacer(modifier = Modifier.height(8.dp)) Text( - text = stringResource(R.string.setting_category), + text = stringResource(R.string.label_category).uppercase(), style = MaterialTheme.typography.subtitle1, + color = MaterialTheme.colors.onSurface, fontWeight = FontWeight.Bold, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth().padding(top = 8.dp) ) Spacer(modifier = Modifier.height(8.dp)) @@ -124,7 +126,7 @@ fun LabelBillsScreen( CategoryButton( icon = category.icon, name = category.name ?: "", - onClick = { viewModel.labelCurrentBill(db, category.remoteId.toInt()) } + onClick = { viewModel.labelCurrentBill(db, category.id) } ) } } @@ -141,7 +143,7 @@ fun LabelBillsScreen( Spacer(modifier = Modifier.height(8.dp)) } else { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text(stringResource(R.string.label_bills_no_more)) + Text(stringResource(R.string.msg_bill_labeled_done)) } } } @@ -243,16 +245,16 @@ fun CategoryButton(icon: String, name: String, onClick: () -> Unit) { fun LabelBillsScreenPreview() { val viewModel = LabelBillsViewModel().apply { billsToLabel = listOf( - DBBill(1L, 0, 1L, 1L, 120.5, System.currentTimeMillis() / 1000, "Groceries at Aldi", 0, null, null, 0, null, -1) + DBBill(1L, 0, 1L, 1L, 120.5, System.currentTimeMillis() / 1000, "Groceries at Aldi", 0, null, null, 0L, null, -1L) ) val cats = listOf( - DBCategory(1, 1, 1, "Groceries", "🛒", ""), - DBCategory(2, 2, 1, "Leisure", "🥳", ""), - DBCategory(3, 3, 1, "Rent", "🏠", ""), - DBCategory(4, 4, 1, "Bills", "💸", "") + DBCategory(1L, 1L, 1L, "Groceries", "🛒", ""), + DBCategory(2L, 2L, 1L, "Leisure", "🥳", ""), + DBCategory(3L, 3L, 1L, "Rent", "🏠", ""), + DBCategory(4L, 4L, 1L, "Bills", "💸", "") ) categories = cats - categoriesMap = cats.associateBy { it.remoteId } + categoriesMap = cats.associateBy { it.id } } val members = listOf( DBMember(1L, 0, 1L, "Alice", true, 1.0, 0, 255, 100, 100, null, null), diff --git a/app/src/main/java/net/helcel/cowspent/android/bill_label/LabelBillsViewModel.kt b/app/src/main/java/net/helcel/cowspent/android/bill_label/LabelBillsViewModel.kt index af27cc4..194f575 100644 --- a/app/src/main/java/net/helcel/cowspent/android/bill_label/LabelBillsViewModel.kt +++ b/app/src/main/java/net/helcel/cowspent/android/bill_label/LabelBillsViewModel.kt @@ -43,18 +43,18 @@ class LabelBillsViewModel : ViewModel() { otherName == name || (name.length > 3 && otherName.contains(name)) || (otherName.length > 3 && name.contains(otherName)) } - val counts = matches.groupBy { it.categoryRemoteId } + val counts = matches.groupBy { it.categoryId } .mapValues { it.value.size } .toList() .sortedByDescending { it.second } .take(2) suggestedCategories = counts.mapNotNull { (catId, _) -> - categoriesMap[catId.toLong()] + categoriesMap[catId] } } - fun labelCurrentBill(db: CowspentSQLiteOpenHelper, categoryId: Int) { + fun labelCurrentBill(db: CowspentSQLiteOpenHelper, categoryId: Long) { currentBill?.let { bill -> db.updateBillAndSync( bill = bill, @@ -65,11 +65,11 @@ class LabelBillsViewModel : ViewModel() { newOwersIds = bill.billOwersIds, newRepeat = bill.repeat, newPaymentMode = bill.paymentMode, - newPaymentModeRemoteId = bill.paymentModeRemoteId, + newPaymentModeId = bill.paymentModeId, newCategoryId = categoryId, newComment = bill.comment ) - bill.categoryRemoteId = categoryId + bill.categoryId = categoryId onBillProcessed?.invoke(bill.id) moveToNext() } @@ -87,11 +87,11 @@ class LabelBillsViewModel : ViewModel() { val start = currentBillIndex var next = (start + 1) % billsToLabel.size - while (next != start && billsToLabel[next].categoryRemoteId != 0) { + while (next != start && billsToLabel[next].categoryId != 0L) { next = (next + 1) % billsToLabel.size } - currentBillIndex = if (billsToLabel[next].categoryRemoteId == 0) { + currentBillIndex = if (billsToLabel[next].categoryId == 0L) { next } else { billsToLabel.size diff --git a/app/src/main/java/net/helcel/cowspent/android/currencies/ManageCurrenciesActivity.kt b/app/src/main/java/net/helcel/cowspent/android/currencies/ManageCurrenciesActivity.kt index a0c711b..a49f441 100644 --- a/app/src/main/java/net/helcel/cowspent/android/currencies/ManageCurrenciesActivity.kt +++ b/app/src/main/java/net/helcel/cowspent/android/currencies/ManageCurrenciesActivity.kt @@ -3,6 +3,7 @@ package net.helcel.cowspent.android.currencies import android.os.Bundle import android.util.Log import android.widget.Toast +import androidx.activity.enableEdgeToEdge import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity @@ -14,6 +15,7 @@ import net.helcel.cowspent.R import net.helcel.cowspent.android.helper.showToast import net.helcel.cowspent.model.DBBill import net.helcel.cowspent.model.DBCurrency +import net.helcel.cowspent.model.ProjectType import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper import net.helcel.cowspent.theme.ThemeUtils import net.helcel.cowspent.util.ICallback @@ -31,15 +33,18 @@ class ManageCurrenciesActivity : AppCompatActivity() { if (message.isEmpty()) { showToast(this@ManageCurrenciesActivity,getString(R.string.currency_saved_success), Toast.LENGTH_LONG) } else { - viewModel.showDialog(title=getString(R.string.error_edit_remote_project_helper, message), - message=getString(R.string.currency_manager), - positiveText = getString(android.R.string.ok)) + viewModel.showDialog( + title = getString(R.string.dialog_sync_error_title), + message = getString(R.string.error_edit_remote_project_helper, message), + positiveText = getString(android.R.string.ok) + ) } } override fun onScheduled() {} } override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() super.onCreate(savedInstanceState) intent.extras?.let { @@ -64,8 +69,10 @@ class ManageCurrenciesActivity : AppCompatActivity() { viewModel = viewModel, onBack = { finish() }, onSaveMain = { saveMainCurrency() }, - onAdd = { addCurrency() }, - onDelete = { deleteCurrency(it) } + onAdd = { addOrUpdateCurrency() }, + onDelete = { deleteCurrency(it) }, + onEdit = { startEditing(it) }, + onCancelEdit = { cancelEditing() } ) } } @@ -84,11 +91,17 @@ class ManageCurrenciesActivity : AppCompatActivity() { val project = db!!.getProject(selectedProjectID) if (project != null) { db!!.syncIfRemote(project) - withContext(Dispatchers.Main) { - if (!db!!.cowspentServerSyncHelper - .editRemoteProject(selectedProjectID, project.name, null, null, newMainCurrencyName, editMainCurrencyCallBack) - ) { - showToast(this@ManageCurrenciesActivity, getString(R.string.remote_project_operation_no_network), Toast.LENGTH_LONG) + if (project.type == ProjectType.COSPEND) { + withContext(Dispatchers.Main) { + if (!db!!.cowspentServerSyncHelper + .editRemoteProject(selectedProjectID, project.name, null, null, newMainCurrencyName, editMainCurrencyCallBack) + ) { + showToast(this@ManageCurrenciesActivity, getString(R.string.remote_project_operation_no_network), Toast.LENGTH_LONG) + } + } + } else { + withContext(Dispatchers.Main) { + showToast(this@ManageCurrenciesActivity, getString(R.string.currency_saved_success), Toast.LENGTH_LONG) } } } @@ -96,22 +109,44 @@ class ManageCurrenciesActivity : AppCompatActivity() { } } - private fun addCurrency() { + private fun addOrUpdateCurrency() { val exchangeRate = try { viewModel.newCurrencyRate.toDouble() } catch (_: Exception) { 0.0 } - val newCurrency = DBCurrency( - 0, 0, selectedProjectID, - viewModel.newCurrencyName, exchangeRate, DBBill.STATE_ADDED - ) + val currencyName = viewModel.newCurrencyName + val editingId = viewModel.editingCurrencyId + lifecycleScope.launch { withContext(Dispatchers.IO) { - db!!.addCurrencyAndSync(newCurrency) + if (editingId != null) { + db!!.updateCurrency(editingId, currencyName, exchangeRate) + val currency = db!!.getCurrency(editingId) + if (currency != null) { + db!!.setCurrencyStateSync(editingId, DBBill.STATE_EDITED) + } + } else { + val newCurrency = DBCurrency( + 0, 0, selectedProjectID, + currencyName, exchangeRate, DBBill.STATE_ADDED + ) + db!!.addCurrencyAndSync(newCurrency) + } } - viewModel.newCurrencyName = "" - viewModel.newCurrencyRate = "" + cancelEditing() updateCurrenciesList() } } + private fun startEditing(currency: DBCurrency) { + viewModel.editingCurrencyId = currency.id + viewModel.newCurrencyName = currency.name ?: "" + viewModel.newCurrencyRate = currency.exchangeRate.toString() + } + + private fun cancelEditing() { + viewModel.editingCurrencyId = null + viewModel.newCurrencyName = "" + viewModel.newCurrencyRate = "" + } + private fun deleteCurrency(currency: DBCurrency) { lifecycleScope.launch { withContext(Dispatchers.IO) { diff --git a/app/src/main/java/net/helcel/cowspent/android/currencies/ManageCurrenciesScreen.kt b/app/src/main/java/net/helcel/cowspent/android/currencies/ManageCurrenciesScreen.kt index 346fe2a..030477a 100644 --- a/app/src/main/java/net/helcel/cowspent/android/currencies/ManageCurrenciesScreen.kt +++ b/app/src/main/java/net/helcel/cowspent/android/currencies/ManageCurrenciesScreen.kt @@ -1,22 +1,57 @@ package net.helcel.cowspent.android.currencies import android.annotation.SuppressLint -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Card +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.LocalTextStyle +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.TextFieldDefaults +import androidx.compose.material.TopAppBar import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Done import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.delay import net.helcel.cowspent.R import net.helcel.cowspent.android.helper.AlertDialog +import net.helcel.cowspent.android.helper.formatAmount import net.helcel.cowspent.model.DBCurrency +import kotlin.time.Duration.Companion.milliseconds @Composable fun ManageCurrenciesScreen( @@ -24,7 +59,9 @@ fun ManageCurrenciesScreen( onBack: () -> Unit, onSaveMain: () -> Unit, onAdd: () -> Unit, - onDelete: (DBCurrency) -> Unit + onDelete: (DBCurrency) -> Unit, + onEdit: (DBCurrency) -> Unit, + onCancelEdit: () -> Unit ) { val dialogState = viewModel.dialogState if (dialogState != null) { @@ -59,7 +96,7 @@ fun ManageCurrenciesScreen( Scaffold( topBar = { TopAppBar( - title = { Text(stringResource(R.string.currency_manager)) }, + title = { Text(stringResource(R.string.action_currencies)) }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) @@ -73,55 +110,152 @@ fun ManageCurrenciesScreen( Column( modifier = Modifier .padding(padding) - .padding(16.dp) + .imePadding() .fillMaxSize() + .background(MaterialTheme.colors.onSurface.copy(alpha = 0.02f)) ) { - Text(stringResource(R.string.main_currency), style = MaterialTheme.typography.h6) - Row(verticalAlignment = Alignment.CenterVertically) { - OutlinedTextField( - value = viewModel.mainCurrencyName, - onValueChange = { viewModel.mainCurrencyName = it }, - label = { Text(stringResource(R.string.currency_edit_name)) }, - modifier = Modifier.weight(1f) - ) - Spacer(modifier = Modifier.width(8.dp)) - Button(onClick = onSaveMain, enabled = viewModel.mainCurrencyName.isNotEmpty()) { - Text(stringResource(R.string.save_or_discard_bill_dialog_save)) + LaunchedEffect(viewModel.mainCurrencyName) { + if (viewModel.mainCurrencyName.isNotEmpty()) { + delay(500.milliseconds) + onSaveMain() } } - Spacer(modifier = Modifier.height(24.dp)) - - Text(stringResource(R.string.add_currency_title), style = MaterialTheme.typography.h6) - Row(verticalAlignment = Alignment.CenterVertically) { - OutlinedTextField( - value = viewModel.newCurrencyName, - onValueChange = { viewModel.newCurrencyName = it }, - label = { Text(stringResource(R.string.currency_edit_name)) }, - modifier = Modifier.weight(1f) + Column(modifier = Modifier.padding(16.dp)) { + // Section 1: Main Currency + Text( + text = stringResource(R.string.main_currency).uppercase(), + style = MaterialTheme.typography.subtitle1, + color = MaterialTheme.colors.onSurface, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 8.dp) ) - Spacer(modifier = Modifier.width(8.dp)) - OutlinedTextField( - value = viewModel.newCurrencyRate, - onValueChange = { viewModel.newCurrencyRate = it }, - label = { Text(stringResource(R.string.currency_rate)) }, - modifier = Modifier.weight(1f) + Card( + shape = RoundedCornerShape(12.dp), + elevation = 2.dp, + modifier = Modifier.fillMaxWidth() + ) { + OutlinedTextField( + value = viewModel.mainCurrencyName, + onValueChange = { viewModel.mainCurrencyName = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("e.g. EUR", fontSize = 14.sp) }, + colors = TextFieldDefaults.outlinedTextFieldColors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent + ) + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Section 2: Exchange Rates + val isEditing = viewModel.editingCurrencyId != null + Text( + text = (if (isEditing) "Edit exchange rate" else "Add exchange rate").uppercase(), + style = MaterialTheme.typography.subtitle1, + color = MaterialTheme.colors.onSurface, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 8.dp) + ) + + // Integrated Add/Edit Ribbon + Surface( + color = MaterialTheme.colors.surface, + shape = RoundedCornerShape(12.dp), + modifier = Modifier.fillMaxWidth(), + elevation = 2.dp + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text("1 ", style = MaterialTheme.typography.body2, color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)) + Text(viewModel.mainCurrencyName.ifEmpty { "$" }, fontWeight = FontWeight.Bold) + Text( + text = " = ", + style = MaterialTheme.typography.h6, + color = if (isEditing) MaterialTheme.colors.secondary else MaterialTheme.colors.primary, + modifier = Modifier.padding(horizontal = 4.dp) + ) + + OutlinedTextField( + value = viewModel.newCurrencyRate, + onValueChange = { viewModel.newCurrencyRate = it }, + modifier = Modifier.weight(1.2f).height(52.dp), + placeholder = { Text("1", fontSize = 12.sp) }, + singleLine = true, + textStyle = LocalTextStyle.current.copy(fontWeight = FontWeight.ExtraBold, fontSize = 14.sp), + shape = RoundedCornerShape(8.dp), + colors = TextFieldDefaults.outlinedTextFieldColors( + focusedBorderColor = if (isEditing) MaterialTheme.colors.secondary else MaterialTheme.colors.primary, + unfocusedBorderColor = MaterialTheme.colors.onSurface.copy(alpha = 0.12f), + backgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.02f) + ) + ) + Spacer(modifier = Modifier.width(8.dp)) + OutlinedTextField( + value = viewModel.newCurrencyName, + onValueChange = { viewModel.newCurrencyName = it }, + modifier = Modifier.weight(1f).height(52.dp), + placeholder = { Text(viewModel.mainCurrencyName.ifEmpty { "$" }, fontSize = 12.sp) }, + singleLine = true, + textStyle = LocalTextStyle.current.copy(fontWeight = FontWeight.Bold, fontSize = 14.sp), + shape = RoundedCornerShape(8.dp), + colors = TextFieldDefaults.outlinedTextFieldColors( + focusedBorderColor = if (isEditing) MaterialTheme.colors.secondary else MaterialTheme.colors.primary, + unfocusedBorderColor = MaterialTheme.colors.onSurface.copy(alpha = 0.12f), + backgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.02f) + ) + ) + + Row(modifier = Modifier.padding(start = 4.dp)) { + IconButton( + onClick = onAdd, + enabled = viewModel.isAddEnabled() + ) { + Icon( + imageVector = if (isEditing) Icons.Default.Done else Icons.Default.Add, + contentDescription = null, + tint = if (viewModel.isAddEnabled()) { + if (isEditing) MaterialTheme.colors.secondary else MaterialTheme.colors.primary + } else MaterialTheme.colors.onSurface.copy(alpha = 0.2f) + ) + } + + if (isEditing) { + IconButton(onClick = onCancelEdit) { + Icon(Icons.Default.Close, contentDescription = null, tint = MaterialTheme.colors.error) + } + } + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "SAVED RATES", + style = MaterialTheme.typography.subtitle1, + color = MaterialTheme.colors.onSurface, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 8.dp) ) } - Spacer(modifier = Modifier.height(8.dp)) - Button( - onClick = onAdd, - modifier = Modifier.fillMaxWidth(), - enabled = viewModel.isAddEnabled() + + LazyColumn( + modifier = Modifier.weight(1f).fillMaxWidth(), + contentPadding = PaddingValues(16.dp, 0.dp, 16.dp, 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) ) { - Text(stringResource(R.string.simple_add)) - } - - Spacer(modifier = Modifier.height(24.dp)) - - LazyColumn(modifier = Modifier.weight(1f)) { items(viewModel.currencies) { currency -> - CurrencyRow(currency, onDelete = { onDelete(currency) }) + CurrencyRow( + currency = currency, + mainCurrencyName = viewModel.mainCurrencyName, + isEditing = viewModel.editingCurrencyId == currency.id, + onEdit = { onEdit(currency) }, + onDelete = { onDelete(currency) } + ) } } } @@ -129,20 +263,70 @@ fun ManageCurrenciesScreen( } @Composable -fun CurrencyRow(currency: DBCurrency, onDelete: () -> Unit) { - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically +fun CurrencyRow( + currency: DBCurrency, + mainCurrencyName: String, + isEditing: Boolean, + onEdit: () -> Unit, + onDelete: () -> Unit +) { + Card( + shape = RoundedCornerShape(12.dp), + elevation = if (isEditing) 4.dp else 1.dp, + border = if (isEditing) BorderStroke(1.dp, MaterialTheme.colors.secondary.copy(alpha = 0.5f)) else null, + modifier = Modifier.fillMaxWidth() ) { - Column(modifier = Modifier.weight(1f)) { - Text(currency.name ?: "", style = MaterialTheme.typography.subtitle1) - Text("Rate: ${currency.exchangeRate}", style = MaterialTheme.typography.caption) - } - IconButton(onClick = onDelete) { - Icon(Icons.Default.Delete, contentDescription = null, tint = MaterialTheme.colors.error) + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onEdit) + .padding(16.dp, 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "1 ", + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f) + ) + Text( + text = mainCurrencyName.ifEmpty { "$" }, + style = MaterialTheme.typography.body1, + fontWeight = FontWeight.Bold + ) + Text( + text = " = ", + style = MaterialTheme.typography.h6, + color = if (isEditing) MaterialTheme.colors.secondary else MaterialTheme.colors.primary, + modifier = Modifier.padding(horizontal = 8.dp) + ) + Text( + text = formatAmount(currency.exchangeRate), + style = MaterialTheme.typography.body1, + fontWeight = FontWeight.ExtraBold, + color = if (isEditing) MaterialTheme.colors.secondary else MaterialTheme.colors.onSurface + ) + Text( + text = " ${currency.name ?: ""}", + style = MaterialTheme.typography.body1, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f) + ) + } + + IconButton(onClick = onDelete) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null, + tint = MaterialTheme.colors.error.copy(alpha = 0.7f), + modifier = Modifier.size(20.dp) + ) + } } } - Divider() } @Preview(showBackground = true) @@ -151,6 +335,9 @@ fun CurrencyRowPreview() { MaterialTheme { CurrencyRow( currency = DBCurrency(1, 0, 0, "USD", 1.0, 0), + mainCurrencyName = "EUR", + isEditing = false, + onEdit = {}, onDelete = {} ) } @@ -172,7 +359,9 @@ fun ManageCurrenciesScreenPreview() { onBack = {}, onSaveMain = {}, onAdd = {}, - onDelete = {} + onDelete = {}, + onEdit = {}, + onCancelEdit = {} ) } } diff --git a/app/src/main/java/net/helcel/cowspent/android/currencies/ManageCurrenciesViewModel.kt b/app/src/main/java/net/helcel/cowspent/android/currencies/ManageCurrenciesViewModel.kt index f4e0002..9d80694 100644 --- a/app/src/main/java/net/helcel/cowspent/android/currencies/ManageCurrenciesViewModel.kt +++ b/app/src/main/java/net/helcel/cowspent/android/currencies/ManageCurrenciesViewModel.kt @@ -13,6 +13,8 @@ class ManageCurrenciesViewModel : ViewModel() { var newCurrencyName by mutableStateOf("") var newCurrencyRate by mutableStateOf("") + var editingCurrencyId by mutableStateOf(null) + var currencies by mutableStateOf>(emptyList()) var dialogState by mutableStateOf(null) diff --git a/app/src/main/java/net/helcel/cowspent/android/drawer/Drawer.kt b/app/src/main/java/net/helcel/cowspent/android/drawer/Drawer.kt index a50188d..24e5bb0 100644 --- a/app/src/main/java/net/helcel/cowspent/android/drawer/Drawer.kt +++ b/app/src/main/java/net/helcel/cowspent/android/drawer/Drawer.kt @@ -11,6 +11,7 @@ import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.scale @@ -23,9 +24,11 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import net.helcel.cowspent.R -import net.helcel.cowspent.android.helper.UserAvatar +import net.helcel.cowspent.android.helper.MemberAvatar import net.helcel.cowspent.android.helper.formatBalance import net.helcel.cowspent.android.helper.lazyVerticalScrollbar +import net.helcel.cowspent.android.helper.TextIcon +import net.helcel.cowspent.android.helper.TextIconDisplay import net.helcel.cowspent.model.DBMember import net.helcel.cowspent.model.DBProject import net.helcel.cowspent.model.ProjectType @@ -38,6 +41,7 @@ fun Drawer( selectedProjectId: Long, selectedMemberId: Long?, lastSyncText: String, + mainCurrency: String? = null, showArchived: Boolean = false, onProjectClick: (Long) -> Unit, onProjectOptionsClick: (Long) -> Unit, @@ -96,22 +100,40 @@ fun Drawer( // Members Section val membersState = rememberLazyListState() + val sortedMembers = remember(members, memberBalances, selectedMemberId) { + members.filter { + val balance = memberBalances[it.id] ?: 0.0 + it.isActivated || balance > 0.01 || balance < -0.01 || it.id == selectedMemberId + }.sortedWith(compareBy { + val balance = memberBalances[it.id] ?: 0.0 + when { + balance > 0.01 -> 0 + balance < -0.01 -> 1 + else -> 2 + } + }.thenBy { + val balance = memberBalances[it.id] ?: 0.0 + if (balance > 0.01) -balance else balance + }.thenBy { it.name }) + } + LazyColumn( state = membersState, modifier = Modifier .heightIn(max = maxBottomHeight) .lazyVerticalScrollbar(membersState) ) { - if (members.isNotEmpty()) { + if (sortedMembers.isNotEmpty()) { item { DrawerItem( icon = Icons.Default.Receipt, text = stringResource(R.string.label_all_bills), + secondaryText = mainCurrency, selected = selectedMemberId == null, onClick = { onMemberClick(null) } ) } - items(members) { member -> + items(sortedMembers) { member -> val balance = memberBalances[member.id] ?: 0.0 MemberDrawerItem( member = member, @@ -138,20 +160,27 @@ private fun DrawerHeader( modifier = Modifier .fillMaxWidth() .background(MaterialTheme.colors.primary) - .padding(16.dp, 4.dp) + .padding(4.dp, 4.dp) ) { Column { Row(verticalAlignment = Alignment.CenterVertically) { - Image( - painter = painterResource(id = R.drawable.ic_launcher_foreground), - contentDescription = null, - modifier = Modifier.size(48.dp) - ) + Box(modifier = Modifier.size(48.dp)) { + Image( + painter = painterResource(id = R.drawable.ic_launcher_background), + contentDescription = null, + modifier = Modifier.fillMaxSize() + ) + Image( + painter = painterResource(id = R.drawable.ic_launcher_foreground), + contentDescription = null, + modifier = Modifier.fillMaxSize() + ) + } Spacer(modifier = Modifier.width(8.dp)) Text( - stringResource(id = R.string.app_name), + text = stringResource(id = R.string.app_name), color = MaterialTheme.colors.onPrimary, - fontSize = 18.sp, + style = MaterialTheme.typography.subtitle1, fontWeight = FontWeight.Bold ) Spacer(modifier = Modifier.weight(1f)) @@ -178,6 +207,7 @@ private fun DrawerHeader( } if (lastSyncText.isNotEmpty()) { Row(verticalAlignment = Alignment.CenterVertically) { + Spacer(Modifier.width(16.dp)) Icon( Icons.Default.Sync, contentDescription = null, @@ -241,6 +271,7 @@ fun DrawerItem( member: DBMember? = null, text: String? = null, balanceText: String? = null, + secondaryText: String? = null, balanceColor: Color = Color.Unspecified, selected: Boolean = false, alpha: Float = 1f, @@ -259,13 +290,8 @@ fun DrawerItem( verticalAlignment = Alignment.CenterVertically ) { if (member != null) { - UserAvatar( - name = member.name, - r = member.r, - g = member.g, - b = member.b, - avatar = member.avatar, - disabled = !member.isActivated, + MemberAvatar( + member = member, size = 24.dp, alpha = alpha ) @@ -273,7 +299,7 @@ fun DrawerItem( Icon(icon, contentDescription = null, tint = contentColor.copy(alpha = 0.6f * alpha)) } - Spacer(modifier = Modifier.width(8.dp)) + Spacer(modifier = Modifier.width(32.dp)) val itemText = text ?: member?.name ?: "" Text( @@ -294,6 +320,17 @@ fun DrawerItem( modifier = Modifier.padding(end = 8.dp) ) } + + if (secondaryText != null) { + Box(modifier = Modifier + .padding(end = 8.dp) + ) { + TextIconDisplay( + textIcon = TextIcon.Symbol(secondaryText), + tint = contentColor.copy(alpha = 0.6f) + ) + } + } if (onSecondaryClick != null) { IconButton(onClick = onSecondaryClick) { @@ -340,6 +377,7 @@ fun DrawerPreview() { selectedProjectId = 1, selectedMemberId = null, lastSyncText = "Last sync: 5 mins ago", + mainCurrency = "EUR", onProjectClick = {}, onProjectOptionsClick = {}, onMemberClick = {}, diff --git a/app/src/main/java/net/helcel/cowspent/android/helper/AlertDialog.kt b/app/src/main/java/net/helcel/cowspent/android/helper/AlertDialog.kt index 55ae77f..99fb016 100644 --- a/app/src/main/java/net/helcel/cowspent/android/helper/AlertDialog.kt +++ b/app/src/main/java/net/helcel/cowspent/android/helper/AlertDialog.kt @@ -11,6 +11,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -110,28 +111,55 @@ fun AlertDialog( if (message != null) Spacer(Modifier.height(8.dp)) LazyColumn { itemsIndexed(items) { index, item -> - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { - onItemSelected?.invoke(index) + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + onItemSelected?.invoke(index) + } + .padding(vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (itemIcons != null && index < itemIcons.size) { + Icon( + itemIcons[index], + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colors.primary.copy(alpha = 0.7f) + ) + Spacer(Modifier.width(16.dp)) + } + + val text = item.toString() + if (text.contains("|")) { + val parts = text.split("|") + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = parts[0].trim(), + style = MaterialTheme.typography.body1, + fontWeight = FontWeight.Bold + ) + Text( + text = parts[1].trim(), + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f) + ) + } + } else { + Text( + text = text, + style = MaterialTheme.typography.body1 + ) } - .padding(vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - if (itemIcons != null && index < itemIcons.size) { - Icon( - itemIcons[index], - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colors.primary.copy(alpha = 0.7f) - ) - Spacer(Modifier.width(16.dp)) } - Text( - text = item.toString(), - style = MaterialTheme.typography.body1 - ) + if (index < items.size - 1) { + Divider(color = MaterialTheme.colors.onSurface.copy(alpha = 0.08f)) + } } } } @@ -202,7 +230,12 @@ fun AlertDialogContent( Spacer(Modifier.width(8.dp)) } if (title != null) { - Text(text = title, style = MaterialTheme.typography.h6) + Text( + text = title.uppercase(), + style = MaterialTheme.typography.subtitle1, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.onSurface + ) } } } @@ -216,28 +249,55 @@ fun AlertDialogContent( if (message != null) Spacer(Modifier.height(8.dp)) LazyColumn { itemsIndexed(items) { index, item -> - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { - onItemSelected?.invoke(index) + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + onItemSelected?.invoke(index) + } + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (itemIcons != null && index < itemIcons.size) { + Icon( + itemIcons[index], + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colors.primary.copy(alpha = 0.7f) + ) + Spacer(Modifier.width(16.dp)) + } + + val text = item.toString() + if (text.contains("|")) { + val parts = text.split("|") + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = parts[0].trim(), + style = MaterialTheme.typography.body1, + fontWeight = FontWeight.Bold + ) + Text( + text = parts[1].trim(), + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f) + ) + } + } else { + Text( + text = text, + style = MaterialTheme.typography.body1 + ) } - .padding(vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - if (itemIcons != null && index < itemIcons.size) { - Icon( - itemIcons[index], - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colors.primary.copy(alpha = 0.7f) - ) - Spacer(Modifier.width(16.dp)) } - Text( - text = item.toString(), - style = MaterialTheme.typography.body1 - ) + if (index < items.size - 1) { + Divider(color = MaterialTheme.colors.onSurface.copy(alpha = 0.08f)) + } } } } diff --git a/app/src/main/java/net/helcel/cowspent/android/helper/ColorPicker.kt b/app/src/main/java/net/helcel/cowspent/android/helper/ColorPicker.kt index 12855ad..0c9ec90 100644 --- a/app/src/main/java/net/helcel/cowspent/android/helper/ColorPicker.kt +++ b/app/src/main/java/net/helcel/cowspent/android/helper/ColorPicker.kt @@ -188,12 +188,13 @@ fun ColorPicker( Spacer(modifier = Modifier.height(16.dp)) - val hueBrush = remember { - Brush.horizontalGradient( - listOf( - Color.Red, Color.Yellow, Color.Green, Color.Cyan, Color.Blue, Color.Magenta, Color.Red - ) - ) + val hueBrush = remember(lightness, chroma) { + val hueSteps = 36 // More steps for smoother gradient + val colors = (0..hueSteps).map { step -> + val h = (step.toFloat() / hueSteps) * 360f + Color(mLCHtoRBG(lightness, chroma, h)) + } + Brush.horizontalGradient(colors) } LchSlider( label = "H", diff --git a/app/src/main/java/net/helcel/cowspent/android/helper/FormatUtils.kt b/app/src/main/java/net/helcel/cowspent/android/helper/FormatUtils.kt index ae57271..9d46c80 100644 --- a/app/src/main/java/net/helcel/cowspent/android/helper/FormatUtils.kt +++ b/app/src/main/java/net/helcel/cowspent/android/helper/FormatUtils.kt @@ -1,20 +1,80 @@ package net.helcel.cowspent.android.helper -import net.helcel.cowspent.util.SupportUtil +import java.text.NumberFormat import java.util.Locale import kotlin.math.abs import kotlin.math.round +/** + * Formats amount using k/M notation (e.g., 1.5k for 1500). + */ fun formatShortValue(value: Double): String { + val absValue = abs(value) + val sign = if (value < 0) "-" else "" return when { - value >= 1_000_000 -> String.format(Locale.ROOT, "%.1fM", value / 1_000_000).replace(".0", "") - value >= 1_000 -> String.format(Locale.ROOT, "%.1fk", value / 1_000).replace(".0", "") - else -> String.format(Locale.ROOT, "%.0f", value) + absValue >= 1_000_000 -> sign + formatAmount(absValue / 1_000_000) + "M" + absValue >= 1_000 -> sign + formatAmount(absValue / 1_000) + "k" + else -> sign + formatAmount(absValue) } } +/** + * Formats balance with sign and locale-aware number formatting. + */ fun formatBalance(balance: Double): String { val rbalance = round(abs(balance) * 100.0) / 100.0 val balanceSign = if (balance > 0.01) "+" else if (balance < -0.01) "-" else "" - return if (rbalance == 0.0) "" else "$balanceSign${SupportUtil.normalNumberFormat.format(rbalance)}" + return if (rbalance == 0.0) "" else "$balanceSign${formatAmount(rbalance)}" +} + +/** + * Formats amount using system locale or specified locale. + */ +fun formatAmount(value: Double, locale: Locale = Locale.getDefault()): String { + val formatter = NumberFormat.getNumberInstance(locale) + formatter.maximumFractionDigits = 2 + formatter.minimumFractionDigits = 0 + return formatter.format(value) +} + +/** + * Parses amount from string robustly. + * Tries common separators and cleans up non-numeric characters (except separators). + */ +fun parseAmount(input: String?): Double? { + if (input.isNullOrBlank()) return null + + // Remove non-numeric characters except for delimiters and minus sign + val cleaned = input.replace(Regex("[^0-9,.-]"), "").trim() + + if (cleaned.isEmpty()) return null + + // If there's both a comma and a dot, we assume the last one is the decimal separator + val lastDot = cleaned.lastIndexOf('.') + val lastComma = cleaned.lastIndexOf(',') + + return if (lastDot != -1 && lastComma != -1) { + if (lastDot > lastComma) { + // Dot is decimal, comma is thousands + cleaned.replace(",", "").toDoubleOrNull() + } else { + // Comma is decimal, dot is thousands + cleaned.replace(".", "").replace(",", ".").toDoubleOrNull() + } + } else if (lastComma != -1) { + // Only comma. Could be decimal or thousands. + if (cleaned.count { it == ',' } == 1) { + cleaned.replace(',', '.').toDoubleOrNull() + } else { + // Multiple commas -> thousands + cleaned.replace(",", "").toDoubleOrNull() + } + } else { + // Only dots or no separators + if (cleaned.count { it == '.' } > 1) { + cleaned.replace(".", "").toDoubleOrNull() + } else { + cleaned.toDoubleOrNull() + } + } } diff --git a/app/src/main/java/net/helcel/cowspent/android/helper/IconUtils.kt b/app/src/main/java/net/helcel/cowspent/android/helper/IconUtils.kt new file mode 100644 index 0000000..e0a2908 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/helper/IconUtils.kt @@ -0,0 +1,33 @@ +package net.helcel.cowspent.android.helper + +import androidx.compose.material.LocalContentColor +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight + +sealed class TextIcon { + data class Symbol(val text: String) : TextIcon() +} + +/** + * A helper Composable to display a [TextIcon]. + */ +@Composable +fun TextIconDisplay( + textIcon: TextIcon, + modifier: Modifier = Modifier, + tint: Color = LocalContentColor.current +) { + when (textIcon) { + is TextIcon.Symbol -> Text( + text = textIcon.text, + modifier = modifier, + color = tint, + style = MaterialTheme.typography.body1, + fontWeight = FontWeight.Bold + ) + } +} diff --git a/app/src/main/java/net/helcel/cowspent/android/helper/QrCodeScannerActivity.kt b/app/src/main/java/net/helcel/cowspent/android/helper/QrCodeScannerActivity.kt index 2c87df7..3837685 100644 --- a/app/src/main/java/net/helcel/cowspent/android/helper/QrCodeScannerActivity.kt +++ b/app/src/main/java/net/helcel/cowspent/android/helper/QrCodeScannerActivity.kt @@ -4,36 +4,49 @@ import android.Manifest import android.content.Intent import android.content.pm.PackageManager import android.os.Bundle -import android.os.VibrationEffect -import android.os.Vibrator import android.util.Log import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge import androidx.annotation.OptIn import androidx.appcompat.app.AppCompatActivity -import net.helcel.cowspent.theme.ThemeUtils -import androidx.camera.core.* +import androidx.camera.core.CameraSelector +import androidx.camera.core.ExperimentalGetImage +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageProxy import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.view.PreviewView import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.material.* +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.TopAppBar import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat -import com.google.zxing.* +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.google.zxing.BarcodeFormat +import com.google.zxing.BinaryBitmap +import com.google.zxing.DecodeHintType +import com.google.zxing.MultiFormatReader +import com.google.zxing.PlanarYUVLuminanceSource +import com.google.zxing.Result import com.google.zxing.common.HybridBinarizer import net.helcel.cowspent.R import net.helcel.cowspent.android.main.MainConstants +import net.helcel.cowspent.theme.ThemeUtils import java.util.concurrent.ExecutorService import java.util.concurrent.Executors @@ -42,6 +55,7 @@ class QrCodeScannerActivity : AppCompatActivity() { private var isScanning = true override fun onCreate(state: Bundle?) { + enableEdgeToEdge() super.onCreate(state) cameraExecutor = Executors.newSingleThreadExecutor() @@ -120,7 +134,7 @@ fun QrCodeScannerScreen( Scaffold( topBar = { TopAppBar( - title = { Text(stringResource(R.string.scan_qrcode)) }, + title = { Text(stringResource(R.string.action_scan_qrcode)) }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) diff --git a/app/src/main/java/net/helcel/cowspent/android/helper/UserAvatar.kt b/app/src/main/java/net/helcel/cowspent/android/helper/UserAvatar.kt index 30bd1c8..3a61358 100644 --- a/app/src/main/java/net/helcel/cowspent/android/helper/UserAvatar.kt +++ b/app/src/main/java/net/helcel/cowspent/android/helper/UserAvatar.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import android.graphics.BitmapFactory import android.util.Base64 +import net.helcel.cowspent.model.DBMember @Composable fun UserAvatar( @@ -91,6 +92,26 @@ fun UserAvatar( } } +@Composable +fun MemberAvatar( + member: DBMember, + modifier: Modifier = Modifier, + size: Dp = 40.dp, + alpha: Float = 1f +) { + UserAvatar( + name = member.name, + modifier = modifier, + size = size, + r = member.r, + g = member.g, + b = member.b, + avatar = member.avatar, + disabled = !member.isActivated, + alpha = alpha + ) +} + @Preview @Composable fun UserAvatarPreview() { diff --git a/app/src/main/java/net/helcel/cowspent/android/label/LabelManagementActivity.kt b/app/src/main/java/net/helcel/cowspent/android/label/LabelManagementActivity.kt new file mode 100644 index 0000000..d6bfad0 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/label/LabelManagementActivity.kt @@ -0,0 +1,46 @@ +package net.helcel.cowspent.android.label + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import net.helcel.cowspent.theme.ThemeUtils + +class LabelManagementActivity : AppCompatActivity() { + private val viewModel: LabelManagementViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + + val projectId = intent.getLongExtra(EXTRA_PROJECT_ID, 0L) + if (projectId == 0L) { + finish() + return + } + + viewModel.loadLabels(projectId) + + setContent { + ThemeUtils.CowspentTheme { + LabelManagementScreen( + viewModel = viewModel, + onBack = { finish() } + ) + } + } + } + + companion object { + const val EXTRA_PROJECT_ID = "EXTRA_PROJECT_ID" + + fun createIntent(context: Context, projectId: Long): Intent { + return Intent(context, LabelManagementActivity::class.java).apply { + putExtra(EXTRA_PROJECT_ID, projectId) + } + } + } +} diff --git a/app/src/main/java/net/helcel/cowspent/android/label/LabelManagementScreen.kt b/app/src/main/java/net/helcel/cowspent/android/label/LabelManagementScreen.kt new file mode 100644 index 0000000..dcfedf8 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/label/LabelManagementScreen.kt @@ -0,0 +1,399 @@ +package net.helcel.cowspent.android.label + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.graphics.toColorInt +import net.helcel.cowspent.R +import net.helcel.cowspent.android.helper.ColorPicker +import net.helcel.cowspent.android.helper.StatefulAlertDialog +import net.helcel.cowspent.model.DBBill +import net.helcel.cowspent.model.DBCategory +import net.helcel.cowspent.model.DBPaymentMode + +@Composable +fun LabelManagementScreen( + viewModel: LabelManagementViewModel, + onBack: () -> Unit +) { + LabelManagementScreenContent( + categories = viewModel.categories, + paymentModes = viewModel.paymentModes, + dialogState = viewModel.dialogState, + onBack = onBack, + onAddCategory = viewModel::addCategory, + onUpdateCategory = viewModel::updateCategory, + onDeleteCategory = viewModel::deleteCategory, + onAddPaymentMode = viewModel::addPaymentMode, + onUpdatePaymentMode = viewModel::updatePaymentMode, + onDeletePaymentMode = viewModel::deletePaymentMode, + onDismissDialog = { viewModel.dialogState = null }, + onShowDialog = { viewModel.dialogState = it } + ) +} + +@Composable +fun LabelManagementScreenContent( + categories: List, + paymentModes: List, + dialogState: net.helcel.cowspent.android.helper.DialogState?, + onBack: () -> Unit, + onAddCategory: (String, String, String) -> Unit, + onUpdateCategory: (DBCategory, String, String, String) -> Unit, + onDeleteCategory: (Long) -> Unit, + onAddPaymentMode: (String, String, String) -> Unit, + onUpdatePaymentMode: (DBPaymentMode, String, String, String) -> Unit, + onDeletePaymentMode: (Long) -> Unit, + onDismissDialog: () -> Unit, + onShowDialog: (net.helcel.cowspent.android.helper.DialogState) -> Unit, + initialTab: Int = 0 +) { + var selectedTab by remember { mutableIntStateOf(initialTab) } + val tabs = listOf( + stringResource(R.string.label_categories), + stringResource(R.string.label_payment_modes) + ) + + val deleteLabelTitle = stringResource(R.string.delete_label_confirmation_title) + val deleteLabelMessage = stringResource(R.string.delete_label_confirmation_message) + val yesText = stringResource(R.string.simple_yes) + val noText = stringResource(R.string.simple_no) + + var showEditDialog by remember { mutableStateOf(false) } + var editingCategory by remember { mutableStateOf(null) } + var editingPaymentMode by remember { mutableStateOf(null) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.title_labels)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + backgroundColor = MaterialTheme.colors.primary, + elevation = 0.dp + ) + }, + floatingActionButton = { + FloatingActionButton(onClick = { + if (selectedTab == 0) { + editingCategory = null + showEditDialog = true + } else { + editingPaymentMode = null + showEditDialog = true + } + }) { + Icon(Icons.Default.Add, contentDescription = null) + } + } + ) { padding -> + Column(modifier = Modifier.padding(padding)) { + TabRow(selectedTabIndex = selectedTab) { + tabs.forEachIndexed { index, title -> + Tab( + selected = selectedTab == index, + onClick = { selectedTab = index }, + text = { Text(title) } + ) + } + } + + if (selectedTab == 0) { + CategoryList( + categories = categories.filter { it.remoteId != DBBill.CATEGORY_REIMBURSEMENT }, + onEdit = { + editingCategory = it + showEditDialog = true + }, + onDelete = { category -> + onShowDialog(net.helcel.cowspent.android.helper.DialogState( + title = deleteLabelTitle, + message = deleteLabelMessage, + positiveText = yesText, + negativeText = noText, + onConfirm = { onDeleteCategory(category.id) } + )) + } + ) + } else { + PaymentModeList( + paymentModes = paymentModes, + onEdit = { + editingPaymentMode = it + showEditDialog = true + }, + onDelete = { pm -> + onShowDialog(net.helcel.cowspent.android.helper.DialogState( + title = deleteLabelTitle, + message = deleteLabelMessage, + positiveText = yesText, + negativeText = noText, + onConfirm = { onDeletePaymentMode(pm.id) } + )) + } + ) + } + } + } + + if (showEditDialog) { + if (selectedTab == 0) { + EditLabelDialog( + title = if (editingCategory == null) stringResource(R.string.title_add_category) else stringResource(R.string.action_edit), + initialName = editingCategory?.name ?: "", + initialIcon = editingCategory?.icon ?: "", + initialColor = editingCategory?.color ?: "#FF0000", + onDismiss = { showEditDialog = false }, + onSave = { name, icon, color -> + if (editingCategory == null) { + onAddCategory(name, icon, color) + } else { + onUpdateCategory(editingCategory!!, name, icon, color) + } + showEditDialog = false + } + ) + } else { + EditLabelDialog( + title = if (editingPaymentMode == null) stringResource(R.string.title_add_payment_mode) else stringResource(R.string.action_edit), + initialName = editingPaymentMode?.name ?: "", + initialIcon = editingPaymentMode?.icon ?: "", + initialColor = editingPaymentMode?.color ?: "#00FF00", + onDismiss = { showEditDialog = false }, + onSave = { name, icon, color -> + if (editingPaymentMode == null) { + onAddPaymentMode(name, icon, color) + } else { + onUpdatePaymentMode(editingPaymentMode!!, name, icon, color) + } + showEditDialog = false + } + ) + } + } + + StatefulAlertDialog( + state = dialogState, + onDismissRequest = onDismissDialog + ) +} + +@Composable +fun CategoryList( + categories: List, + onEdit: (DBCategory) -> Unit, + onDelete: (DBCategory) -> Unit +) { + LazyColumn { + items(categories) { category -> + LabelItem( + name = category.name ?: "", + icon = category.icon, + color = category.color, + onEdit = { onEdit(category) }, + onDelete = { onDelete(category) } + ) + } + } +} + +@Composable +fun PaymentModeList( + paymentModes: List, + onEdit: (DBPaymentMode) -> Unit, + onDelete: (DBPaymentMode) -> Unit +) { + LazyColumn { + items(paymentModes) { pm -> + LabelItem( + name = pm.name ?: "", + icon = pm.icon, + color = pm.color, + onEdit = { onEdit(pm) }, + onDelete = { onDelete(pm) } + ) + } + } +} + +@Composable +fun LabelItem( + name: String, + icon: String, + color: String, + onEdit: () -> Unit, + onDelete: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onEdit() } + .padding(16.dp, 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + .background(parseColor(color)), + contentAlignment = Alignment.Center + ) { + Text(text = icon, fontSize = 12.sp) + } + Spacer(modifier = Modifier.width(32.dp)) + Text(text = name, modifier = Modifier.weight(1f), style = MaterialTheme.typography.subtitle1) + IconButton(onClick = onEdit) { + Icon(Icons.Default.Edit, contentDescription = null, tint = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)) + } + IconButton(onClick = onDelete) { + Icon(Icons.Default.Delete, contentDescription = null, tint = MaterialTheme.colors.error.copy(alpha = 0.6f)) + } + } +} + +@Composable +fun EditLabelDialog( + title: String, + initialName: String, + initialIcon: String, + initialColor: String, + onDismiss: () -> Unit, + onSave: (String, String, String) -> Unit +) { + var name by remember { mutableStateOf(initialName) } + var icon by remember { mutableStateOf(initialIcon) } + var color by remember { mutableStateOf(initialColor) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + placeholder = { Text(stringResource(R.string.label_name)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = icon, + onValueChange = { if (it.length <= 2) icon = it }, + placeholder = { Text(stringResource(R.string.label_icon)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + Spacer(modifier = Modifier.height(16.dp)) + Text(text = stringResource(R.string.label_color), style = MaterialTheme.typography.caption) + ColorPicker( + initialColor = parseColorInt(color), + onColorChanged = { color = String.format("#%06X", 0xFFFFFF and it) } + ) + } + }, + confirmButton = { + Button( + onClick = { onSave(name, icon, color) }, + enabled = name.isNotBlank() + ) { + Text(stringResource(R.string.action_save)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.simple_cancel)) + } + } + ) +} + +fun parseColor(colorString: String): Color { + return try { + Color(colorString.toColorInt()) + } catch (_: Exception) { + Color.Gray + } +} + +fun parseColorInt(colorString: String): Int { + return try { + colorString.toColorInt() + } catch (_: Exception) { + Color.Gray.toArgb() + } +} + +@Preview(showBackground = true) +@Composable +fun LabelManagementCategoriesPreview() { + MaterialTheme { + LabelManagementScreenContent( + categories = listOf( + DBCategory(1L, 1L, 1L, "Groceries", "🛒", "#FF0000"), + DBCategory(2L, 2L, 1L, "Rent", "🏠", "#00FF00") + ), + paymentModes = emptyList(), + dialogState = null, + onBack = {}, + onAddCategory = { _, _, _ -> }, + onUpdateCategory = { _, _, _, _ -> }, + onDeleteCategory = { _ -> }, + onAddPaymentMode = { _, _, _ -> }, + onUpdatePaymentMode = { _, _, _, _ -> }, + onDeletePaymentMode = { _ -> }, + onDismissDialog = {}, + onShowDialog = {}, + initialTab = 0 + ) + } +} + +@Preview(showBackground = true) +@Composable +fun LabelManagementPaymentModesPreview() { + MaterialTheme { + LabelManagementScreenContent( + categories = emptyList(), + paymentModes = listOf( + DBPaymentMode(1L, 1L, 1L, "Cash", "💵", "#0000FF"), + DBPaymentMode(2L, 2L, 1L, "Credit Card", "💳", "#FFFF00") + ), + dialogState = null, + onBack = {}, + onAddCategory = { _, _, _ -> }, + onUpdateCategory = { _, _, _, _ -> }, + onDeleteCategory = { _ -> }, + onAddPaymentMode = { _, _, _ -> }, + onUpdatePaymentMode = { _, _, _, _ -> }, + onDeletePaymentMode = { _ -> }, + onDismissDialog = {}, + onShowDialog = {}, + initialTab = 1 + ) + } +} diff --git a/app/src/main/java/net/helcel/cowspent/android/label/LabelManagementViewModel.kt b/app/src/main/java/net/helcel/cowspent/android/label/LabelManagementViewModel.kt new file mode 100644 index 0000000..733eeed --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/label/LabelManagementViewModel.kt @@ -0,0 +1,95 @@ +package net.helcel.cowspent.android.label + +import android.app.Application +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import net.helcel.cowspent.android.helper.DialogState +import net.helcel.cowspent.model.DBCategory +import net.helcel.cowspent.model.DBPaymentMode +import net.helcel.cowspent.model.DBBill +import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper + +class LabelManagementViewModel(application: Application) : AndroidViewModel(application) { + private val db = CowspentSQLiteOpenHelper.getInstance(application) + + var projectId by mutableLongStateOf(0L) + var categories by mutableStateOf>(emptyList()) + var paymentModes by mutableStateOf>(emptyList()) + + var dialogState by mutableStateOf(null) + + fun loadLabels(projId: Long) { + projectId = projId + viewModelScope.launch { + val project = withContext(Dispatchers.IO) { db.getProject(projId) } + if (project != null) { + withContext(Dispatchers.IO) { + db.ensureDefaultLabels(projId, project.type) + } + } + categories = withContext(Dispatchers.IO) { db.getCategories(projId) } + paymentModes = withContext(Dispatchers.IO) { db.getPaymentModes(projId) } + } + } + + fun addCategory(name: String, icon: String, color: String) { + viewModelScope.launch { + withContext(Dispatchers.IO) { + db.addCategoryAndSync(DBCategory(0, 0, projectId, name, icon, color, DBBill.STATE_ADDED)) + } + loadLabels(projectId) + } + } + + fun updateCategory(cat: DBCategory, name: String, icon: String, color: String) { + viewModelScope.launch { + withContext(Dispatchers.IO) { + db.updateCategoryAndSync(cat, name, icon, color) + } + loadLabels(projectId) + } + } + + fun deleteCategory(id: Long) { + viewModelScope.launch { + withContext(Dispatchers.IO) { + db.deleteCategoryAndSync(id) + } + loadLabels(projectId) + } + } + + fun addPaymentMode(name: String, icon: String, color: String) { + viewModelScope.launch { + withContext(Dispatchers.IO) { + db.addPaymentModeAndSync(DBPaymentMode(0, 0, projectId, name, icon, color, DBBill.STATE_ADDED)) + } + loadLabels(projectId) + } + } + + fun updatePaymentMode(pm: DBPaymentMode, name: String, icon: String, color: String) { + viewModelScope.launch { + withContext(Dispatchers.IO) { + db.updatePaymentModeAndSync(pm, name, icon, color) + } + loadLabels(projectId) + } + } + + fun deletePaymentMode(id: Long) { + viewModelScope.launch { + withContext(Dispatchers.IO) { + db.deletePaymentModeAndSync(id) + } + loadLabels(projectId) + } + } +} diff --git a/app/src/main/java/net/helcel/cowspent/android/main/BillsListComponents.kt b/app/src/main/java/net/helcel/cowspent/android/main/BillsListComponents.kt index cb855b2..5935ecc 100644 --- a/app/src/main/java/net/helcel/cowspent/android/main/BillsListComponents.kt +++ b/app/src/main/java/net/helcel/cowspent/android/main/BillsListComponents.kt @@ -2,9 +2,25 @@ package net.helcel.cowspent.android.main import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.* +import androidx.compose.material.Button +import androidx.compose.material.Divider +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Receipt import androidx.compose.material.icons.filled.Repeat @@ -13,10 +29,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import net.helcel.cowspent.R -import net.helcel.cowspent.android.helper.UserAvatar +import net.helcel.cowspent.android.helper.MemberAvatar import net.helcel.cowspent.model.DBBill import net.helcel.cowspent.model.DBMember import net.helcel.cowspent.util.SupportUtil @@ -28,15 +45,25 @@ fun EmptyProjectsState(onConfigureNextcloud: () -> Unit, onAddManually: () -> Un verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { - Text(stringResource(R.string.no_projects_title), style = MaterialTheme.typography.h6) + Text( + text = stringResource(R.string.error_no_projects).uppercase(), + style = MaterialTheme.typography.subtitle1, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.onSurface + ) Spacer(modifier = Modifier.height(8.dp)) - Text(stringResource(R.string.no_projects_text)) - Spacer(modifier = Modifier.height(16.dp)) - Button(onClick = onConfigureNextcloud) { + Text( + text = stringResource(R.string.no_projects_text), + style = MaterialTheme.typography.body2, + textAlign = TextAlign.Center, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f) + ) + Spacer(modifier = Modifier.height(24.dp)) + Button(onClick = onConfigureNextcloud, modifier = Modifier.fillMaxWidth()) { Text(stringResource(R.string.configure_account_choice)) } Spacer(modifier = Modifier.height(8.dp)) - Button(onClick = onAddManually) { + Button(onClick = onAddManually, modifier = Modifier.fillMaxWidth()) { Text(stringResource(R.string.add_project_choice)) } } @@ -49,9 +76,19 @@ fun EmptyMembersState() { verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { - Text(stringResource(R.string.no_members_title), style = MaterialTheme.typography.h6) + Text( + text = stringResource(R.string.error_no_members).uppercase(), + style = MaterialTheme.typography.subtitle1, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.onSurface + ) Spacer(modifier = Modifier.height(8.dp)) - Text(stringResource(R.string.no_members_text)) + Text( + text = stringResource(R.string.no_members_text), + style = MaterialTheme.typography.body2, + textAlign = TextAlign.Center, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f) + ) } } @@ -62,9 +99,19 @@ fun EmptyBillsState() { verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { - Text(stringResource(R.string.no_bills_title), style = MaterialTheme.typography.h6) + Text( + text = stringResource(R.string.error_no_bills).uppercase(), + style = MaterialTheme.typography.subtitle1, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.onSurface + ) Spacer(modifier = Modifier.height(8.dp)) - Text(stringResource(R.string.no_bills_text)) + Text( + text = stringResource(R.string.no_bills_text), + style = MaterialTheme.typography.body2, + textAlign = TextAlign.Center, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f) + ) } } @@ -83,13 +130,8 @@ fun BillItemRow(bill: DBBill, payer: DBMember?, onClick: () -> Unit) { ) { if (payer != null) { Box { - UserAvatar( - name = payer.name, - r = payer.r, - g = payer.g, - b = payer.b, - avatar = payer.avatar, - disabled = !payer.isActivated, + MemberAvatar( + member = payer, size = 40.dp ) if (bill.repeat != null && bill.repeat != DBBill.NON_REPEATED) { @@ -111,15 +153,21 @@ fun BillItemRow(bill: DBBill, payer: DBMember?, onClick: () -> Unit) { Icons.Default.Receipt, contentDescription = null, modifier = Modifier.size(40.dp), - tint = MaterialTheme.colors.primary + tint = MaterialTheme.colors.onSurface.copy(alpha = 0.6f) ) } Spacer(modifier = Modifier.width(16.dp)) Column(modifier = Modifier.weight(1f)) { - Text(bill.formattedWhat.ifEmpty { bill.what }, fontWeight = FontWeight.Bold) + Text( + text = bill.formattedWhat.ifEmpty { bill.what }, + style = MaterialTheme.typography.subtitle1, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.onSurface + ) Text( text = bill.formattedSubtitle.ifEmpty { bill.comment ?: "" }, style = MaterialTheme.typography.caption, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f), maxLines = 1, overflow = TextOverflow.Ellipsis ) @@ -140,13 +188,16 @@ fun SectionHeader(title: String) { color = MaterialTheme.colors.background, modifier = Modifier.fillMaxWidth() ) { - Divider(thickness = 2.dp) - Text( - text = title, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - style = MaterialTheme.typography.caption.copy(fontWeight = FontWeight.Bold), - color = MaterialTheme.colors.primary - ) + Column { + Divider(thickness = 1.dp, color = MaterialTheme.colors.onSurface.copy(alpha = 0.08f)) + Text( + text = title.uppercase(), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + style = MaterialTheme.typography.subtitle1, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.onSurface + ) + } } } @@ -157,7 +208,18 @@ fun EmptyState() { verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { - Text(stringResource(R.string.no_bills_title), style = MaterialTheme.typography.h6) - Text(stringResource(R.string.no_bills_text), modifier = Modifier.padding(16.dp)) + Text( + text = stringResource(R.string.error_no_bills).uppercase(), + style = MaterialTheme.typography.subtitle1, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.onSurface + ) + Text( + text = stringResource(R.string.no_bills_text), + modifier = Modifier.padding(16.dp), + style = MaterialTheme.typography.body2, + textAlign = TextAlign.Center, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f) + ) } } diff --git a/app/src/main/java/net/helcel/cowspent/android/main/BillsListScreen.kt b/app/src/main/java/net/helcel/cowspent/android/main/BillsListScreen.kt index 283f037..bf4d881 100644 --- a/app/src/main/java/net/helcel/cowspent/android/main/BillsListScreen.kt +++ b/app/src/main/java/net/helcel/cowspent/android/main/BillsListScreen.kt @@ -2,7 +2,6 @@ package net.helcel.cowspent.android.main import android.annotation.SuppressLint import android.content.Intent -import android.widget.Toast import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -46,6 +45,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog @@ -57,14 +57,12 @@ import net.helcel.cowspent.android.drawer.Drawer import net.helcel.cowspent.android.helper.StatefulAlertDialog import net.helcel.cowspent.android.project.ProjectOptionsDialogContent import net.helcel.cowspent.android.project.ProjectShareDialogContent -import net.helcel.cowspent.android.project.member.MemberAddDialogContent -import net.helcel.cowspent.android.project.member.MemberEditDialogContent -import net.helcel.cowspent.android.project.member.MemberManagementDialogContent import net.helcel.cowspent.android.project.settle.ProjectSettlementDialogContent import net.helcel.cowspent.android.statistics.ProjectStatisticsActivity import net.helcel.cowspent.model.DBBill import net.helcel.cowspent.model.DBMember import net.helcel.cowspent.model.DBProject +import net.helcel.cowspent.model.ProjectType import net.helcel.cowspent.model.SectionItem import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper import net.helcel.cowspent.util.IRefreshBillsListCallback @@ -103,9 +101,12 @@ fun BillsListScreen( val pullRefreshState = rememberPullRefreshState(viewModel.isRefreshing, onRefresh) val context = LocalContext.current - val memberAlreadyExistsError = stringResource(R.string.member_already_exists) val sharedPreferences = remember { PreferenceManager.getDefaultSharedPreferences(context) } val showArchived = sharedPreferences.getBoolean(stringResource(R.string.pref_key_show_archived), false) + val keyBetaFeatures = stringResource(R.string.pref_key_beta_features) + var showBetaFeatures by remember(keyBetaFeatures) { + mutableStateOf(sharedPreferences.getBoolean(keyBetaFeatures, false)) + } StatefulAlertDialog( state = viewModel.dialogState, @@ -137,6 +138,10 @@ fun BillsListScreen( onProjectAction(projectOptionsProjectId, 3) viewModel.showProjectOptionsDialogByProjectId = null }, + onManageLabels = { + onProjectAction(projectOptionsProjectId, 8) + viewModel.showProjectOptionsDialogByProjectId = null + }, onStatistics = { onProjectAction(projectOptionsProjectId, 4) viewModel.showProjectOptionsDialogByProjectId = null @@ -155,8 +160,10 @@ fun BillsListScreen( }, onDismiss = { viewModel.showProjectOptionsDialogByProjectId = null }, isArchived = proj?.isArchived == true, + projectType = proj?.type ?: ProjectType.LOCAL, accessLevel = proj?.myAccessLevel ?: DBProject.ACCESS_LEVEL_ADMIN, - isShareable = proj?.isShareable() ?: true + isShareable = proj?.isShareable() ?: true, + showBetaFeatures = showBetaFeatures ) } } @@ -208,114 +215,17 @@ fun BillsListScreen( val manageMembersProjectId = viewModel.showMemberManagementDialogByProjectId if (manageMembersProjectId != null) { - val members = viewModel.members // Use members from ViewModel as they are already loaded - Dialog( - onDismissRequest = { viewModel.showMemberManagementDialogByProjectId = null }, - properties = DialogProperties(usePlatformDefaultWidth = false) - ) { - MemberManagementDialogContent( - members = members, - onAddMember = { - viewModel.showAddMemberDialogByProjectId = manageMembersProjectId - viewModel.showMemberManagementDialogByProjectId = null - }, - onEditMember = { member -> - viewModel.showEditMemberDialogByProjectId = member.id - viewModel.showMemberManagementDialogByProjectId = null - }, - onDismiss = { viewModel.showMemberManagementDialogByProjectId = null } - ) - } + viewModel.showMemberManagementDialogByProjectId = null } val addMemberProjectId = viewModel.showAddMemberDialogByProjectId if (addMemberProjectId != null) { - Dialog( - onDismissRequest = { viewModel.showAddMemberDialogByProjectId = null }, - properties = DialogProperties(usePlatformDefaultWidth = false) - ) { - MemberAddDialogContent( - onAdd = { memberName -> - val memberNames = - db.getMembersOfProject(addMemberProjectId, null).map { it.name } - if (memberNames.contains(memberName)) { - Toast.makeText( - context, - memberAlreadyExistsError, - Toast.LENGTH_SHORT - ).show() - } else { - val color = net.helcel.cowspent.android.helper.TextDrawable.getColorFromName(memberName) - db.addMemberAndSync( - DBMember( - 0, - 0, - addMemberProjectId, - memberName, - true, - 1.0, - DBBill.STATE_ADDED, - android.graphics.Color.red(color), - android.graphics.Color.green(color), - android.graphics.Color.blue(color), - null, - null - ) - ) - refreshCallback.refreshLists(false) - viewModel.showAddMemberDialogByProjectId = null - viewModel.showMemberManagementDialogByProjectId = addMemberProjectId - } - }, - onDismiss = { - viewModel.showAddMemberDialogByProjectId = null - viewModel.showMemberManagementDialogByProjectId = addMemberProjectId - } - ) - } + viewModel.showAddMemberDialogByProjectId = null } val editMemberId = viewModel.showEditMemberDialogByProjectId if (editMemberId != null) { - val memberToEdit = remember(editMemberId, viewModel.members) { - viewModel.members.find { it.id == editMemberId } - } - if (memberToEdit != null) { - Dialog( - onDismissRequest = { viewModel.showEditMemberDialogByProjectId = null }, - properties = DialogProperties(usePlatformDefaultWidth = false) - ) { - MemberEditDialogContent( - member = memberToEdit, - onSave = { name, weight, isActivated, r, g, b -> - db.updateMemberAndSync( - memberToEdit, - name, - weight, - isActivated, - r, - g, - b, - "", - "" - ) - refreshCallback.refreshLists(false) - viewModel.showEditMemberDialogByProjectId = null - viewModel.showMemberManagementDialogByProjectId = memberToEdit.projectId - }, - onDelete = { - db.deleteMember(editMemberId) - refreshCallback.refreshLists(false) - viewModel.showEditMemberDialogByProjectId = null - viewModel.showMemberManagementDialogByProjectId = memberToEdit.projectId - }, - onDismiss = { - viewModel.showEditMemberDialogByProjectId = null - viewModel.showMemberManagementDialogByProjectId = memberToEdit.projectId - } - ) - } - } + viewModel.showEditMemberDialogByProjectId = null } val shareProjectId = viewModel.showShareDialogByProjectId @@ -324,8 +234,8 @@ fun BillsListScreen( viewModel.projects.find { it.id == shareProjectId } } if (proj != null) { - val shareIntentTitle = stringResource(R.string.share_share_intent_title, proj.name) - val shareChooserTitle = stringResource(R.string.share_share_chooser_title, proj.name) + val shareIntentTitle = stringResource(R.string.share_intent_title, proj.name) + val shareChooserTitle = stringResource(R.string.share_chooser_title, proj.name) Dialog( onDismissRequest = { viewModel.showShareDialogByProjectId = null }, properties = DialogProperties(usePlatformDefaultWidth = false) @@ -358,7 +268,13 @@ fun BillsListScreen( TextField( value = viewModel.searchQuery, onValueChange = { viewModel.searchQuery = it }, - placeholder = { Text(stringResource(R.string.action_search), color = MaterialTheme.colors.onPrimary.copy(alpha = 0.7f)) }, + placeholder = { + Text( + text = stringResource(R.string.action_search), + color = MaterialTheme.colors.onPrimary.copy(alpha = 0.7f), + style = MaterialTheme.typography.subtitle1 + ) + }, modifier = Modifier .fillMaxWidth() .focusRequester(focusRequester), @@ -381,8 +297,20 @@ fun BillsListScreen( ) } else { Column { - if (viewModel.title.isNotEmpty()) Text(viewModel.title) - else Text(stringResource(R.string.app_name)) + if (viewModel.title.isNotEmpty()) { + Text( + text = viewModel.title, + style = MaterialTheme.typography.subtitle1, + fontWeight = FontWeight.Bold + ) + } + else { + Text( + text = stringResource(R.string.app_name), + style = MaterialTheme.typography.subtitle1, + fontWeight = FontWeight.Bold + ) + } } } }, @@ -404,7 +332,7 @@ fun BillsListScreen( }, actions = { if (!isSearchExpanded) { - if (viewModel.hasUnlabeledBills) { + if (showBetaFeatures && viewModel.hasUnlabeledBills) { IconButton(onClick = onLabelBillsClick) { Icon( Icons.Default.Category, @@ -425,11 +353,12 @@ fun BillsListScreen( val selectedProject = viewModel.projects.find { it.id == viewModel.selectedProjectId } if (selectedProject != null && !selectedProject.isArchived) { FloatingActionButton(onClick = onAddBillClick) { - Icon(Icons.Default.Add, contentDescription = stringResource(R.string.action_create_bill)) + Icon(Icons.Default.Add, contentDescription = stringResource(R.string.action_new_bill)) } } }, drawerContent = { + val selectedProject = viewModel.projects.find { it.id == viewModel.selectedProjectId } Drawer( projects = viewModel.projects, members = viewModel.members, @@ -437,6 +366,7 @@ fun BillsListScreen( selectedProjectId = viewModel.selectedProjectId, selectedMemberId = viewModel.selectedMemberId, lastSyncText = viewModel.lastSyncText, + mainCurrency = selectedProject?.currencyName, showArchived = showArchived, onProjectClick = { viewModel.selectedMemberId = null diff --git a/app/src/main/java/net/helcel/cowspent/android/main/BillsListUtils.kt b/app/src/main/java/net/helcel/cowspent/android/main/BillsListUtils.kt index b0eee62..05f6177 100644 --- a/app/src/main/java/net/helcel/cowspent/android/main/BillsListUtils.kt +++ b/app/src/main/java/net/helcel/cowspent/android/main/BillsListUtils.kt @@ -5,6 +5,7 @@ import android.content.Intent import net.helcel.cowspent.R import net.helcel.cowspent.model.* import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper +import net.helcel.cowspent.util.CategoryUtils import net.helcel.cowspent.util.IRefreshBillsListCallback import java.text.SimpleDateFormat import java.util.* @@ -65,11 +66,11 @@ object BillsListUtils { memberIdToName: Map ) { val projectName = proj.name.ifEmpty { proj.remoteId } - var text = context.getString(R.string.share_settle_intro, projectName) + "\n" + var text = context.getString(R.string.msg_settle_intro, projectName) + "\n" for (t in transactions) { val amount = round(t.amount * 100.0) / 100.0 text += "\n" + context.getString( - R.string.share_settle_sentence, + R.string.msg_settle_sentence, memberIdToName[t.owerMemberId], memberIdToName[t.receiverMemberId], amount @@ -80,12 +81,12 @@ object BillsListUtils { shareIntent.type = "text/plain" shareIntent.putExtra( Intent.EXTRA_SUBJECT, - context.getString(R.string.share_settle_title, projectName) + context.getString(R.string.title_settle) ) shareIntent.putExtra(Intent.EXTRA_TEXT, text) val chooserIntent = Intent.createChooser( shareIntent, - context.getString(R.string.share_settle_title, projectName) + context.getString(R.string.title_settle) ) context.startActivity(chooserIntent) } @@ -98,6 +99,10 @@ object BillsListUtils { context: Context ) { val timestamp = System.currentTimeMillis() / 1000 + val proj = db.getProject(projectId) + val categories = db.getCategories(projectId) + val reimbursementCategoryId = CategoryUtils.getReimbursementCategoryId(categories, projectId, proj?.remoteId) + for (t in transactions) { val owerId = t.owerMemberId val receiverId = t.receiverMemberId @@ -106,7 +111,7 @@ object BillsListUtils { 0, 0, projectId, owerId, amount, timestamp, context.getString(R.string.settle_bill_what), DBBill.STATE_ADDED, DBBill.NON_REPEATED, - DBBill.PAYMODE_NONE, DBBill.CATEGORY_NONE, + DBBill.PAYMODE_NONE, reimbursementCategoryId, "", DBBill.PAYMODE_ID_NONE ) bill.billOwers += DBBillOwer(0, 0, receiverId) diff --git a/app/src/main/java/net/helcel/cowspent/android/main/BillsListViewActivity.kt b/app/src/main/java/net/helcel/cowspent/android/main/BillsListViewActivity.kt index 06df8f1..c089426 100644 --- a/app/src/main/java/net/helcel/cowspent/android/main/BillsListViewActivity.kt +++ b/app/src/main/java/net/helcel/cowspent/android/main/BillsListViewActivity.kt @@ -1,12 +1,10 @@ package net.helcel.cowspent.android.main -import android.Manifest import android.app.SearchManager import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter -import android.content.pm.PackageManager import android.graphics.BitmapFactory import android.net.Uri import android.os.Build @@ -17,18 +15,15 @@ import android.util.Log import android.widget.Toast import androidx.activity.OnBackPressedCallback import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.view.ActionMode import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AddCircleOutline import androidx.compose.material.icons.filled.Sync -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.core.content.edit -import androidx.core.net.toUri import androidx.lifecycle.lifecycleScope import androidx.preference.PreferenceManager import com.nextcloud.android.sso.helper.SingleAccountHelper @@ -53,6 +48,7 @@ import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper import net.helcel.cowspent.persistence.CowspentServerSyncHelper import net.helcel.cowspent.theme.ThemeUtils import net.helcel.cowspent.util.BillFormatter +import net.helcel.cowspent.util.CategoryUtils import net.helcel.cowspent.util.CospendClientUtil import net.helcel.cowspent.util.ExportUtil import net.helcel.cowspent.util.ICallback @@ -118,18 +114,8 @@ class BillsListViewActivity : Log.d(TAG, "CREATED project id: $pid") lifecycleScope.launch { val addedProj = withContext(Dispatchers.IO) { db.getProject(pid) } - val message: String - val title: String - if (created) { - Log.e(TAG, "CREATED !!!") - title = getString(R.string.project_create_success_title) - message = getString(R.string.project_create_success_message, addedProj?.remoteId) - } else { - Log.e(TAG, "ADDED !!!") - title = getString(R.string.project_add_success_title) - message = getString(R.string.project_add_success_message, addedProj?.remoteId) - } - showDialog(message, title, Icons.Default.AddCircleOutline) + val message = getString(R.string.msg_project_added, addedProj?.name?.ifEmpty { addedProj.remoteId } ?: pid.toString()) + showToast(this@BillsListViewActivity, message) } } } @@ -145,7 +131,7 @@ class BillsListViewActivity : } if (!db.cowspentServerSyncHelper.isSyncPossible) { if (CowspentServerSyncHelper.isNextcloudAccountConfigured(applicationContext)) { - Toast.makeText(applicationContext, getString(R.string.error_sync, getString(CospendClientUtil.LoginStatus.NO_NETWORK.str)), Toast.LENGTH_LONG).show() + showToast(this@BillsListViewActivity, getString(R.string.error_sync, getString(CospendClientUtil.LoginStatus.NO_NETWORK.str)), Toast.LENGTH_LONG) } } } @@ -193,6 +179,7 @@ class BillsListViewActivity : } override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() super.onCreate(savedInstanceState) isActivityVisible = true if (savedInstanceState != null) { @@ -223,7 +210,7 @@ class BillsListViewActivity : lifecycleScope.launch { val members = withContext(Dispatchers.IO) { db.getActivatedMembersOfProject(selectedProjectId) } if (members.isEmpty()) { - showToast(this@BillsListViewActivity, getString(R.string.add_bill_impossible_no_member)) + showToast(this@BillsListViewActivity, getString(R.string.error_no_members)) } else { val proj = withContext(Dispatchers.IO) { db.getProject(selectedProjectId) } val createIntent = Intent(applicationContext, EditBillActivity::class.java).apply { @@ -257,13 +244,23 @@ class BillsListViewActivity : onProjectAction = { pid, actionIndex -> when (actionIndex) { 0 -> onEditProjectClick(pid) - 1 -> onRemoveProjectClick(pid) + 1 -> { + val proj = db.getProject(pid) + if (proj?.type == ProjectType.COSPEND) { + onArchiveProjectClick(pid) + } else { + onRemoveProjectClick(pid) + } + } 2 -> onManageMembersClick(pid) 3 -> onManageCurrenciesClick(pid) 4 -> onProjectStatisticsClick(pid) 5 -> onSettleProjectClick(pid) 6 -> onShareProjectClick(pid) 7 -> onExportProjectClick(pid) + 8 -> { + startActivity(net.helcel.cowspent.android.label.LabelManagementActivity.createIntent(this@BillsListViewActivity, pid)) + } } }, onAccountSwitcherClick = { @@ -385,17 +382,10 @@ class BillsListViewActivity : private fun onEditProjectClick(projectId: Long) { if (projectId == 0L) return - lifecycleScope.launch { - val proj = withContext(Dispatchers.IO) { db.getProject(projectId) } - if (proj?.isLocal == false) { - val intent = Intent(applicationContext, EditProjectActivity::class.java).apply { - putExtra(EditProjectActivity.PARAM_PROJECT_ID, projectId) - } - editProjectLauncher.launch(intent) - } else { - showToast(this@BillsListViewActivity, getString(R.string.edit_project_local_impossible)) - } + val intent = Intent(applicationContext, EditProjectActivity::class.java).apply { + putExtra(EditProjectActivity.PARAM_PROJECT_ID, projectId) } + editProjectLauncher.launch(intent) } private fun onRemoveProjectClick(projectId: Long) { @@ -404,8 +394,8 @@ class BillsListViewActivity : val proj = withContext(Dispatchers.IO) { db.getProject(projectId) } ?: return@launch viewModel.showDialog( - title = getString(R.string.confirm_remove_project_dialog_title), - message = if (!proj.isLocal) getString(R.string.confirm_remove_project_dialog_message) else null, + title = getString(R.string.title_confirm), + message = if (!proj.isLocal) getString(R.string.dialog_confirm_remove_project_msg) else null, positiveText = getString(R.string.simple_yes), onConfirm = { lifecycleScope.launch { @@ -426,6 +416,38 @@ class BillsListViewActivity : } } + private fun onArchiveProjectClick(projectId: Long) { + if (projectId == 0L) return + lifecycleScope.launch { + val proj = withContext(Dispatchers.IO) { db.getProject(projectId) } ?: return@launch + val isArchiving = !proj.isArchived + + val newArchivedTs = if (isArchiving) System.currentTimeMillis() / 1000 else 0L + + withContext(Dispatchers.IO) { + db.updateProject( + projId = projectId, + newName = null, + newEmail = null, + newPassword = null, + newLastPayerId = null, + newLastSyncedTimestamp = null, + newCurrencyName = null, + newDeletionDisabled = null, + newMyAccessLevel = null, + newBearerToken = null, + newArchivedTs = newArchivedTs + ) + } + + setupDrawerProjects() + refreshLists() + + val msgRes = if (isArchiving) R.string.action_archive else R.string.action_unarchive + showToast(this@BillsListViewActivity, getString(msgRes)) + } + } + fun onManageMembersClick(projectId: Long) { if (projectId == 0L) return lifecycleScope.launch { @@ -435,18 +457,18 @@ class BillsListViewActivity : return@launch } - viewModel.showMemberManagementDialogByProjectId = projectId + startActivity(net.helcel.cowspent.android.project.member.MemberManagementActivity.createIntent(this@BillsListViewActivity, projectId)) } } fun onManageCurrenciesClick(projectId: Long) { lifecycleScope.launch { val proj = withContext(Dispatchers.IO) { db.getProject(projectId) } - if (proj != null && proj.type == ProjectType.COSPEND) { + if (proj != null) { startActivity(Intent(applicationContext, ManageCurrenciesActivity::class.java).apply { putExtra(ManageCurrenciesActivity.EXTRA_PROJECT_ID, projectId) }) - } else showToast(this@BillsListViewActivity, getString(R.string.currency_management_unavailable)) + } } } @@ -462,7 +484,7 @@ class BillsListViewActivity : lifecycleScope.launch { val proj = withContext(Dispatchers.IO) { db.getProject(projectId) } if (projectId != 0L && proj?.isShareable() == true) viewModel.showShareDialogByProjectId = projectId - else showToast(this@BillsListViewActivity, getString(R.string.share_impossible), Toast.LENGTH_LONG) + else showToast(this@BillsListViewActivity, getString(R.string.error_share_impossible), Toast.LENGTH_LONG) } } @@ -532,11 +554,13 @@ class BillsListViewActivity : val members = db.getMembersOfProject(proj.id, null) val bills = db.getBillsOfProject(proj.id) + val categories = db.getCategories(proj.id) + val reimbursementCategoryId = CategoryUtils.getReimbursementCategoryId(categories, proj.id, proj.remoteId) val balances = HashMap() SupportUtil.getStats( members, bills, mutableMapOf(), balances, mutableMapOf(), mutableMapOf(), - -1000, -1000, null, null + -1000L, -1000L, reimbursementCategoryId, null, null ) Triple(proj, members, balances) } @@ -563,7 +587,7 @@ class BillsListViewActivity : } else { val lastSyncTimestamp = proj.lastSyncedTimestamp ?: 0 val cal = Calendar.getInstance().apply { timeInMillis = lastSyncTimestamp * 1000 } - val text = getString(R.string.drawer_last_sync_text, cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE)) + val text = getString(R.string.drawer_last_sync, cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE)) viewModel.lastSyncText = text } } @@ -603,13 +627,13 @@ class BillsListViewActivity : mid == null || mid == it.payerId || it.billOwersIds.contains(mid) } - viewModel.hasUnlabeledBills = bills.any { it.categoryRemoteId == 0 && it.state != DBBill.STATE_DELETED } + viewModel.hasUnlabeledBills = bills.any { it.categoryId == 0L && it.state != DBBill.STATE_DELETED } val projectMembers = db.getMembersOfProject(projId, null) val memberMap = projectMembers.associateBy { it.id } - val projectPaymentModes = db.getPaymentModes(projId).associateBy { it.remoteId } - val projectCategories = db.getCategories(projId).associateBy { it.remoteId } + val projectPaymentModes = db.getPaymentModes(projId).associateBy { it.id } + val projectCategories = db.getCategories(projId).associateBy { it.id } BillFormatter.formatBills( bills, @@ -661,7 +685,7 @@ class BillsListViewActivity : lifecycleScope.launch { val members = withContext(Dispatchers.IO) { db.getActivatedMembersOfProject(selectedProjectId) } if (members.isEmpty()) { - showToast(this@BillsListViewActivity, getString(R.string.add_bill_impossible_no_member)) + showToast(this@BillsListViewActivity, getString(R.string.error_no_member)) } else { val projType = withContext(Dispatchers.IO) { db.getProject(selectedProjectId)?.type?.id } val intent = Intent(applicationContext, EditBillActivity::class.java).apply { @@ -675,15 +699,6 @@ class BillsListViewActivity : } } - private fun showDialog(msg: String, title: String, icon: ImageVector) { - viewModel.showDialog( - title = title, - message = msg, - positiveText = getString(android.R.string.ok), - icon = icon - ) - } - private fun updateUsernameInDrawer() { if (!CowspentServerSyncHelper.isNextcloudAccountConfigured(this)) { val text = getString(R.string.drawer_no_account) @@ -775,8 +790,8 @@ class BillsListViewActivity : lifecycleScope.launch { val project = withContext(Dispatchers.IO) { db.getProject(projectId) } ?: return@launch viewModel.showDialog( - title = getString(R.string.sync_error_dialog_title), - message = getString(R.string.sync_error_dialog_full_content, project.name, errorMessage), + title = getString(R.string.dialog_sync_error_title), + message = getString(R.string.dialog_sync_error_msg, project.name, errorMessage), positiveText = getString(R.string.simple_close), icon = Icons.Default.Sync ) @@ -796,7 +811,7 @@ class BillsListViewActivity : } MainConstants.BROADCAST_SSO_TOKEN_MISMATCH -> { viewModel.showDialog( - title = getString(R.string.sync_error_dialog_title), + title = getString(R.string.dialog_sync_error_title), message = getString(R.string.error_token_mismatch), positiveText = getString(R.string.simple_close), icon = Icons.Default.Sync diff --git a/app/src/main/java/net/helcel/cowspent/android/project/ProjectImportHelper.kt b/app/src/main/java/net/helcel/cowspent/android/project/ProjectImportHelper.kt index 9ef41cc..e63f12e 100644 --- a/app/src/main/java/net/helcel/cowspent/android/project/ProjectImportHelper.kt +++ b/app/src/main/java/net/helcel/cowspent/android/project/ProjectImportHelper.kt @@ -118,8 +118,8 @@ object ProjectImportHelper { val payerWeight = if (columns.containsKey("payer_weight")) line[columns["payer_weight"]!!].toDouble() else 1.0 val owersStr = if (columns.containsKey("owers")) line[columns["owers"]!!] else "" val payerActive = columns.containsKey("payer_active") && line[columns["payer_active"]!!] == "1" - val catId = if (columns.containsKey("categoryid") && line[columns["categoryid"]!!].isNotEmpty()) line[columns["categoryid"]!!].toInt() else 0 - val pmId = if (columns.containsKey("paymentmodeid") && line[columns["paymentmodeid"]!!].isNotEmpty()) line[columns["paymentmodeid"]!!].toInt() else 0 + val catId = if (columns.containsKey("categoryid") && line[columns["categoryid"]!!].isNotEmpty()) line[columns["categoryid"]!!].toLong() else 0L + val pmId = if (columns.containsKey("paymentmodeid") && line[columns["paymentmodeid"]!!].isNotEmpty()) line[columns["paymentmodeid"]!!].toLong() else 0L val pm = if (columns.containsKey("paymentmode")) line[columns["paymentmode"]!!] else null membersActive[payerName] = payerActive @@ -149,9 +149,17 @@ object ProjectImportHelper { val memberNameToId = mutableMapOf() val pid = db.addProject(DBProject(0, projectRemoteId, "", projectRemoteId, null, null, null, ProjectType.LOCAL, 0L, mainCurrencyName, false, DBProject.ACCESS_LEVEL_UNKNOWN, null)) - - paymentModes.forEach { db.addPaymentMode(DBPaymentMode(0, it.remoteId, pid, it.name, it.icon, it.color)) } - categories.forEach { db.addCategory(DBCategory(0, it.remoteId, pid, it.name, it.icon, it.color)) } + + val pmRemoteToLocal = mutableMapOf() + paymentModes.forEach { + val newId = db.addPaymentMode(DBPaymentMode(0, it.remoteId, pid, it.name, it.icon, it.color)) + pmRemoteToLocal[it.remoteId] = newId + } + val catRemoteToLocal = mutableMapOf() + categories.forEach { + val newId = db.addCategory(DBCategory(0, it.remoteId, pid, it.name, it.icon, it.color)) + catRemoteToLocal[it.remoteId] = newId + } currencies.forEach { db.addCurrency(DBCurrency(0, 0, pid, it.name, it.exchangeRate, DBBill.STATE_OK)) } membersWeight.keys.forEach { mName -> @@ -160,7 +168,9 @@ object ProjectImportHelper { bills.forEach { b -> val payerId = memberNameToId[billRemoteIdToPayerName[b.remoteId]] ?: 0L - val billId = db.addBill(DBBill(0, 0, pid, payerId, b.amount, b.timestamp, b.what, DBBill.STATE_OK, b.repeat, b.paymentMode, b.categoryRemoteId, b.comment, b.paymentModeRemoteId)) + val localCatId = catRemoteToLocal[b.categoryId] ?: 0L + val localPmId = pmRemoteToLocal[b.paymentModeId] ?: 0L + val billId = db.addBill(DBBill(0, 0, pid, payerId, b.amount, b.timestamp, b.what, DBBill.STATE_OK, b.repeat, b.paymentMode, localCatId, b.comment, localPmId)) billRemoteIdToOwerStr[b.remoteId]?.split(", ")?.filter { it.isNotEmpty() }?.forEach { ower -> memberNameToId[ower.trim()]?.let { owerId -> db.addBillower(billId, owerId) } } diff --git a/app/src/main/java/net/helcel/cowspent/android/project/ProjectOptionsDialog.kt b/app/src/main/java/net/helcel/cowspent/android/project/ProjectOptionsDialog.kt index 9d39a8b..13e2cfb 100644 --- a/app/src/main/java/net/helcel/cowspent/android/project/ProjectOptionsDialog.kt +++ b/app/src/main/java/net/helcel/cowspent/android/project/ProjectOptionsDialog.kt @@ -4,19 +4,21 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Label import androidx.compose.material.icons.filled.* import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import net.helcel.cowspent.R - import net.helcel.cowspent.model.DBProject +import net.helcel.cowspent.model.ProjectType @Composable fun ProjectOptionsDialogContent( @@ -24,14 +26,17 @@ fun ProjectOptionsDialogContent( onRemoveProject: () -> Unit, onManageMembers: () -> Unit, onManageCurrencies: () -> Unit, + onManageLabels: () -> Unit, onStatistics: () -> Unit, onSettle: () -> Unit, onShareProject: () -> Unit, onExportProject: () -> Unit, onDismiss: () -> Unit, isArchived: Boolean = false, + projectType: ProjectType = ProjectType.LOCAL, accessLevel: Int = DBProject.ACCESS_LEVEL_ADMIN, - isShareable: Boolean = true + isShareable: Boolean = true, + showBetaFeatures: Boolean = false ) { Surface( shape = MaterialTheme.shapes.large, @@ -44,46 +49,71 @@ fun ProjectOptionsDialogContent( .fillMaxWidth() ) { Text( - text = stringResource(R.string.choose_project_management_action), - style = MaterialTheme.typography.h6, + text = stringResource(R.string.choose_project_management_action).uppercase(), + style = MaterialTheme.typography.subtitle1, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.onSurface, modifier = Modifier.padding(bottom = 8.dp) ) - val options = mutableListOf() val isMaintainer = accessLevel >= DBProject.ACCESS_LEVEL_MAINTAINER || accessLevel == DBProject.ACCESS_LEVEL_UNKNOWN val isParticipant = accessLevel >= DBProject.ACCESS_LEVEL_PARTICIPANT || accessLevel == DBProject.ACCESS_LEVEL_UNKNOWN + val row1 = mutableListOf() + val row2 = mutableListOf() + val row3 = mutableListOf() + if (!isArchived && isMaintainer) { - options.add(ProjectOption(stringResource(R.string.action_edit_project), Icons.Default.Edit, onEditProject)) - } - options.add(ProjectOption(stringResource(R.string.fab_rm_project), Icons.Default.Delete, onRemoveProject)) - if (!isArchived && isMaintainer) { - options.add(ProjectOption(stringResource(R.string.fab_manage_members), Icons.Default.Group, onManageMembers)) - options.add(ProjectOption(stringResource(R.string.fab_manage_currencies), Icons.Default.MonetizationOn, onManageCurrencies)) - } - options.add(ProjectOption(stringResource(R.string.fab_statistics), Icons.Default.BarChart, onStatistics)) - if (!isArchived && isParticipant) { - options.add(ProjectOption(stringResource(R.string.fab_settle), Icons.Default.Handshake, onSettle)) + row1.add(ProjectOption(stringResource(R.string.action_edit), Icons.Default.Edit, onEditProject)) } if (isShareable && isParticipant) { - options.add(ProjectOption(stringResource(R.string.action_share_project), Icons.Default.Share, onShareProject)) + row1.add(ProjectOption(stringResource(R.string.action_share), Icons.Default.Share, onShareProject)) + } + if (projectType == ProjectType.COSPEND) { + val archiveLabel = if (isArchived) stringResource(R.string.action_unarchive) else stringResource(R.string.action_archive) + val archiveIcon = if (isArchived) Icons.Default.Unarchive else Icons.Default.Archive + row1.add(ProjectOption(archiveLabel, archiveIcon, onRemoveProject)) + } else { + row1.add(ProjectOption(stringResource(R.string.action_delete), Icons.Default.Delete, onRemoveProject)) } - options.add(ProjectOption(stringResource(R.string.fab_export_project), Icons.Default.Download, onExportProject)) - // Simple 2-column grid using Rows - for (i in options.indices step 2) { - Row(modifier = Modifier.fillMaxWidth()) { - ProjectOptionItem( - option = options[i], - modifier = Modifier.weight(1f) - ) - if (i + 1 < options.size) { + // Row 2: Manage Member, Manage Labels, Manage Currencies + if (!isArchived && isMaintainer) { + row2.add(ProjectOption(stringResource(R.string.action_members), Icons.Default.Group, onManageMembers)) + if (showBetaFeatures && (projectType == ProjectType.LOCAL || projectType == ProjectType.COSPEND)) { + row2.add(ProjectOption(stringResource(R.string.action_labels), Icons.AutoMirrored.Filled.Label, onManageLabels)) + } + row2.add(ProjectOption(stringResource(R.string.action_currencies), Icons.Default.MonetizationOn, onManageCurrencies)) + } + + // Row 3: Statistics, Settle, Export + row3.add(ProjectOption(stringResource(R.string.action_stats), Icons.Default.BarChart, onStatistics)) + if (!isArchived && isParticipant) { + row3.add(ProjectOption(stringResource(R.string.action_settle), Icons.Default.Handshake, onSettle)) + } + row3.add(ProjectOption(stringResource(R.string.action_export), Icons.Default.Download, onExportProject)) + + val allRows = listOf(row1, row2, row3).filter { it.isNotEmpty() } + + allRows.forEach { rowOptions -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + val emptySpace = 3 - rowOptions.size + if (emptySpace > 0) { + Spacer(modifier = Modifier.weight(emptySpace / 2f)) + } + + rowOptions.forEach { option -> ProjectOptionItem( - option = options[i + 1], + option = option, modifier = Modifier.weight(1f) ) - } else { - Spacer(modifier = Modifier.weight(1f)) + } + + if (emptySpace > 0) { + Spacer(modifier = Modifier.weight(emptySpace / 2f)) } } } @@ -145,12 +175,64 @@ fun ProjectOptionsDialogPreview() { onManageCurrencies = {}, onStatistics = {}, onSettle = {}, + onManageLabels = {}, onShareProject = {}, onExportProject = {}, onDismiss = {}, isArchived = false, + projectType = ProjectType.COSPEND, accessLevel = DBProject.ACCESS_LEVEL_ADMIN, - isShareable = true + isShareable = true, + showBetaFeatures = true + ) + } +} + + +@Preview(showBackground = true) +@Composable +fun ProjectOptionsDialogPreview2() { + MaterialTheme { + ProjectOptionsDialogContent( + onEditProject = {}, + onRemoveProject = {}, + onManageMembers = {}, + onManageCurrencies = {}, + onStatistics = {}, + onSettle = {}, + onManageLabels = {}, + onShareProject = {}, + onExportProject = {}, + onDismiss = {}, + isArchived = true, + projectType = ProjectType.COSPEND, + accessLevel = DBProject.ACCESS_LEVEL_ADMIN, + isShareable = true, + showBetaFeatures = true + ) + } +} + +@Preview(showBackground = true) +@Composable +fun ProjectOptionsDialogPreview3() { + MaterialTheme { + ProjectOptionsDialogContent( + onEditProject = {}, + onRemoveProject = {}, + onManageMembers = {}, + onManageCurrencies = {}, + onStatistics = {}, + onSettle = {}, + onManageLabels = {}, + onShareProject = {}, + onExportProject = {}, + onDismiss = {}, + isArchived = false, + projectType = ProjectType.LOCAL, + accessLevel = DBProject.ACCESS_LEVEL_ADMIN, + isShareable = true, + showBetaFeatures = true ) } } diff --git a/app/src/main/java/net/helcel/cowspent/android/project/ProjectShareDialog.kt b/app/src/main/java/net/helcel/cowspent/android/project/ProjectShareDialog.kt index ade833d..299bff8 100644 --- a/app/src/main/java/net/helcel/cowspent/android/project/ProjectShareDialog.kt +++ b/app/src/main/java/net/helcel/cowspent/android/project/ProjectShareDialog.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn @@ -31,8 +32,6 @@ import androidx.compose.material.Text import androidx.compose.material.contentColorFor import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ContentCopy -import androidx.compose.material.icons.filled.Link -import androidx.compose.material.icons.filled.QrCode import androidx.compose.material.icons.filled.Share import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -42,10 +41,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight @@ -69,7 +66,7 @@ fun ProjectShareDialogContent( ) { val context = LocalContext.current val clipboardManager = LocalClipboardManager.current - val qrCodeLinkWarn = stringResource(R.string.qrcode_link_open_attempt_warning) + val qrCodeLinkWarn = stringResource(R.string.msg_share_qr_warn) val shareUrl = remember { proj.getShareUrl() } val publicWebUrl = remember { proj.getPublicWebUrl() } @@ -93,35 +90,24 @@ fun ProjectShareDialogContent( .heightIn(max = 650.dp) .padding(16.dp) ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(bottom = 8.dp) - ) { - Icon( - Icons.Default.Share, - contentDescription = null, - tint = MaterialTheme.colors.primary, - modifier = Modifier.size(24.dp) - ) - Spacer(modifier = Modifier.width(12.dp)) - Text( - text = stringResource(R.string.share_dialog_title), - style = MaterialTheme.typography.h6, - fontWeight = FontWeight.Bold - ) - } + Text( + text = stringResource(R.string.title_share).uppercase(), + style = MaterialTheme.typography.subtitle1, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.onSurface, + modifier = Modifier.padding(bottom = 16.dp) + ) Column( modifier = Modifier .weight(1f, fill = false) .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(16.dp) + verticalArrangement = Arrangement.spacedBy(24.dp) ) { ShareCard( - title = stringResource(R.string.share_project_public_url_title), + title = stringResource(R.string.title_share_web), url = publicWebUrl, - description = stringResource(R.string.share_project_public_url_dialog_message), - icon = Icons.Default.Link, + description = stringResource(R.string.msg_share_web), onUrlClick = { val i = Intent(Intent.ACTION_VIEW).apply { data = publicWebUrl.toUri() @@ -130,22 +116,21 @@ fun ProjectShareDialogContent( }, onCopyClick = { clipboardManager.setText(AnnotatedString(publicWebUrl)) - Toast.makeText(context, "Link copied to clipboard", Toast.LENGTH_SHORT).show() + Toast.makeText(context, R.string.msg_link_copied, Toast.LENGTH_SHORT).show() } ) ShareCard( - title = stringResource(R.string.share_project_public_qrcode_title), + title = stringResource(R.string.title_share_qr), url = shareUrl, - description = stringResource(R.string.share_project_dialog_message), - icon = Icons.Default.QrCode, + description = stringResource(R.string.msg_share_qr), qrBitmap = qrBitmap, onUrlClick = { Toast.makeText(context, qrCodeLinkWarn, Toast.LENGTH_SHORT).show() }, onCopyClick = { clipboardManager.setText(AnnotatedString(shareUrl)) - Toast.makeText(context, "Link copied to clipboard", Toast.LENGTH_SHORT).show() + Toast.makeText(context, R.string.msg_link_copied, Toast.LENGTH_SHORT).show() } ) } @@ -165,43 +150,31 @@ private fun ShareCard( title: String, url: String, description: String, - icon: ImageVector, onUrlClick: () -> Unit, onCopyClick: () -> Unit, qrBitmap: Bitmap? = null ) { - Surface( - shape = MaterialTheme.shapes.medium, - color = colorResource(R.color.fg_default_low).copy(alpha = 0.04f), + Column( modifier = Modifier.fillMaxWidth() ) { - Column( - modifier = Modifier.padding(8.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colors.primary.copy(alpha = 0.7f) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = title, - style = MaterialTheme.typography.subtitle2, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colors.onSurface.copy(alpha = 0.8f) - ) - } + Text( + text = title.uppercase(), + style = MaterialTheme.typography.subtitle1, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.onSurface, + modifier = Modifier.padding(bottom = 8.dp) + ) - if (qrBitmap != null) { - Spacer(modifier = Modifier.height(8.dp)) + if (qrBitmap != null) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 12.dp), + contentAlignment = Alignment.Center + ) { Box( - modifier = Modifier.size(128.dp) + modifier = Modifier + .size(128.dp) .clip(MaterialTheme.shapes.small) .background(Color.White), contentAlignment = Alignment.Center @@ -209,55 +182,53 @@ private fun ShareCard( Image( bitmap = qrBitmap.asImageBitmap(), contentDescription = null, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxSize() ) } } + } - Spacer(modifier = Modifier.height(8.dp)) - - Surface( - shape = MaterialTheme.shapes.small, - color = MaterialTheme.colors.surface, - modifier = Modifier.fillMaxWidth() + Surface( + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.05f), + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .clickable { onUrlClick() } + .padding(start = 12.dp, end = 4.dp), + verticalAlignment = Alignment.CenterVertically ) { - Row( - modifier = Modifier - .clickable { onUrlClick() } - .padding(start = 12.dp, end = 4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = url, - modifier = Modifier.weight(1f), - color = MaterialTheme.colors.primary, - style = MaterialTheme.typography.body2, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - fontWeight = FontWeight.Medium + Text( + text = url, + modifier = Modifier.weight(1f), + color = MaterialTheme.colors.primary, + style = MaterialTheme.typography.body2, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontWeight = FontWeight.Medium + ) + IconButton(onClick = onCopyClick) { + Icon( + Icons.Default.ContentCopy, + contentDescription = "Copy", + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colors.onSurface.copy(alpha = 0.4f) ) - IconButton(onClick = onCopyClick) { - Icon( - Icons.Default.ContentCopy, - contentDescription = "Copy", - modifier = Modifier.size(18.dp), - tint = colorResource(R.color.fg_default_low) - ) - } } } + } - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(4.dp)) - CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { - Text( - text = description, - style = MaterialTheme.typography.caption, - textAlign = TextAlign.Center, - lineHeight = 16.sp, - modifier = Modifier.padding(horizontal = 8.dp) - ) - } + CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { + Text( + text = description, + style = MaterialTheme.typography.caption, + textAlign = TextAlign.Start, + lineHeight = 16.sp, + modifier = Modifier.padding(horizontal = 4.dp) + ) } } } @@ -291,7 +262,7 @@ private fun DialogActions( Icon(Icons.Default.Share, contentDescription = null, modifier = Modifier.size(16.dp)) Spacer(modifier = Modifier.width(8.dp)) Text( - text = stringResource(R.string.simple_share_share).uppercase(), + text = stringResource(R.string.action_share).uppercase(), style = MaterialTheme.typography.button, fontWeight = FontWeight.Bold ) diff --git a/app/src/main/java/net/helcel/cowspent/android/project/create/NewProjectActivity.kt b/app/src/main/java/net/helcel/cowspent/android/project/create/NewProjectActivity.kt index 92f58a6..b5be7bc 100644 --- a/app/src/main/java/net/helcel/cowspent/android/project/create/NewProjectActivity.kt +++ b/app/src/main/java/net/helcel/cowspent/android/project/create/NewProjectActivity.kt @@ -5,6 +5,7 @@ import android.net.Uri import android.os.Bundle import android.util.Patterns import android.widget.Toast +import androidx.activity.enableEdgeToEdge import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels @@ -20,6 +21,7 @@ import net.helcel.cowspent.model.* import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper import net.helcel.cowspent.theme.ThemeUtils import net.helcel.cowspent.util.* +import java.util.Locale import androidx.core.net.toUri import androidx.core.content.edit import net.helcel.cowspent.model.ProjectType @@ -33,6 +35,7 @@ class NewProjectActivity : AppCompatActivity() { private lateinit var db: CowspentSQLiteOpenHelper override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() super.onCreate(savedInstanceState) db = CowspentSQLiteOpenHelper.getInstance(this) @@ -58,7 +61,8 @@ class NewProjectActivity : AppCompatActivity() { chooseFromNextcloud() }, onOkPressed = { onPressOk() }, - onBack = { finish() } + onBack = { finish() }, + onFieldsChanged = { updateAuthStatus() } ) } } @@ -83,6 +87,8 @@ class NewProjectActivity : AppCompatActivity() { viewModel.projectUrl = defaultNcUrl } } + + updateAuthStatus() val defaultProjectId = intent.getStringExtra(PARAM_DEFAULT_PROJECT_ID) if (defaultProjectId != null) { @@ -137,6 +143,17 @@ class NewProjectActivity : AppCompatActivity() { } } + private fun updateAuthStatus() { + val url = getFormattedUrl() + val fakeProj = DBProject( + 0, "", "", "", url, + "", 0L, viewModel.projectType, 0L, + null, false, DBProject.ACCESS_LEVEL_UNKNOWN, + "" + ) + viewModel.isAuthenticatedAccount = db.cowspentServerSyncHelper.canCreateAuthenticatedProject(fakeProj) + } + private fun onPressOk() { val type = viewModel.projectType val todoCreate = viewModel.whatTodoIsCreate @@ -184,15 +201,24 @@ class NewProjectActivity : AppCompatActivity() { } private fun createProject() { - val isCospendScheme = isCospendSchemeLink(getFormattedUrl()) + val url = getFormattedUrl() + val isCospendScheme = isCospendSchemeLink(url) + + if (viewModel.whatTodoIsCreate && viewModel.projectId.isEmpty() && viewModel.projectName.isNotEmpty()) { + viewModel.projectId = viewModel.projectName.lowercase(Locale.ROOT) + .replace("[^a-z0-9]".toRegex(), "-") + .replace("-+".toRegex(), "-") + .trim('-') + } + val rid = viewModel.projectId if (!isCospendScheme && (rid == "" || rid.contains(",") || rid.contains("/"))) { - showToast(getString(R.string.error_invalid_project_remote_id), Toast.LENGTH_LONG) + showToast(getString(R.string.error_invalid_project_id), Toast.LENGTH_LONG) return } if (viewModel.projectType != ProjectType.LOCAL && !isCospendScheme) { - if (!isValidUrl(getFormattedUrl())) { + if (!isValidUrl(url)) { showToast("Invalid URL", Toast.LENGTH_SHORT) return } @@ -215,7 +241,16 @@ class NewProjectActivity : AppCompatActivity() { showToast("Invalid project name", Toast.LENGTH_SHORT) return } - if (!SupportUtil.isValidEmail(viewModel.projectEmail)) { + + val fakeProj = DBProject( + 0, rid, "", viewModel.projectName, url, + viewModel.projectEmail, 0L, viewModel.projectType, 0L, + null, false, DBProject.ACCESS_LEVEL_UNKNOWN, + "" + ) + val isAuthenticated = db.cowspentServerSyncHelper.canCreateAuthenticatedProject(fakeProj) + + if (!isAuthenticated && !SupportUtil.isValidEmail(viewModel.projectEmail)) { showToast("Invalid email", Toast.LENGTH_SHORT) return } @@ -224,7 +259,7 @@ class NewProjectActivity : AppCompatActivity() { if (!db.cowspentServerSyncHelper.createRemoteProject( viewModel.projectId, viewModel.projectName, - viewModel.projectEmail, viewModel.projectPassword, getFormattedUrl(), viewModel.projectType, createRemoteCallBack + viewModel.projectEmail, viewModel.projectPassword, url, viewModel.projectType, createRemoteCallBack ) ) { viewModel.isCreatingRemoteProject = false @@ -285,7 +320,6 @@ class NewProjectActivity : AppCompatActivity() { pid ) } - showToast(getString(R.string.project_added_success), Toast.LENGTH_LONG) return pid } diff --git a/app/src/main/java/net/helcel/cowspent/android/project/create/NewProjectScreen.kt b/app/src/main/java/net/helcel/cowspent/android/project/create/NewProjectScreen.kt index bceb66b..cfe97d7 100644 --- a/app/src/main/java/net/helcel/cowspent/android/project/create/NewProjectScreen.kt +++ b/app/src/main/java/net/helcel/cowspent/android/project/create/NewProjectScreen.kt @@ -19,6 +19,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -32,12 +33,17 @@ fun NewProjectScreen( onImportFile: () -> Unit, onChooseFromNextcloud: () -> Unit, onOkPressed: () -> Unit, - onBack: () -> Unit + onBack: () -> Unit, + onFieldsChanged: () -> Unit ) { + LaunchedEffect(viewModel.projectUrl, viewModel.projectType) { + onFieldsChanged() + } + Scaffold( topBar = { TopAppBar( - title = { Text(stringResource(R.string.action_add_project)) }, + title = { Text(stringResource(R.string.title_add_project)) }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) @@ -50,7 +56,7 @@ fun NewProjectScreen( floatingActionButton = { if (viewModel.isFormValid()) { FloatingActionButton(onClick = onOkPressed) { - Icon(Icons.Default.Done, contentDescription = stringResource(R.string.action_save_bill)) + Icon(Icons.Default.Done, contentDescription = stringResource(R.string.action_save)) } } } @@ -58,17 +64,18 @@ fun NewProjectScreen( Column( modifier = Modifier .padding(padding) + .imePadding() .padding(16.dp) .verticalScroll(rememberScrollState()) .fillMaxSize() ) { // What to do SectionRow( - label = stringResource(R.string.new_project_what_todo) + label = stringResource(R.string.new_project_action) ) { Row { ToggleButton( - text = stringResource(R.string.todo_join_label), + text = stringResource(R.string.todo_join), selected = !viewModel.whatTodoIsCreate, onClick = { viewModel.whatTodoIsCreate = false @@ -78,7 +85,7 @@ fun NewProjectScreen( ) Spacer(modifier = Modifier.width(8.dp)) ToggleButton( - text = stringResource(R.string.todo_create_label), + text = stringResource(R.string.todo_create), selected = viewModel.whatTodoIsCreate, onClick = { viewModel.whatTodoIsCreate = true } ) @@ -93,20 +100,20 @@ fun NewProjectScreen( Row { if (viewModel.whatTodoIsCreate) { ToggleButton( - text = stringResource(R.string.where_local_short), + text = stringResource(R.string.where_local), selected = viewModel.projectType == ProjectType.LOCAL, onClick = { viewModel.projectType = ProjectType.LOCAL } ) Spacer(modifier = Modifier.width(8.dp)) } ToggleButton( - text = stringResource(R.string.where_cospend_short), + text = stringResource(R.string.where_cospend), selected = viewModel.projectType == ProjectType.COSPEND, onClick = { viewModel.projectType = ProjectType.COSPEND } ) Spacer(modifier = Modifier.width(8.dp)) ToggleButton( - text = stringResource(R.string.where_ihatemoney_short), + text = stringResource(R.string.where_ihatemoney), selected = viewModel.projectType == ProjectType.IHATEMONEY, onClick = { viewModel.projectType = ProjectType.IHATEMONEY } ) @@ -115,13 +122,7 @@ fun NewProjectScreen( Spacer(modifier = Modifier.height(8.dp)) if (viewModel.whatTodoIsCreate) { - if (viewModel.projectType == ProjectType.COSPEND) { - Button( - onClick = onChooseFromNextcloud - ) { - Text(stringResource(R.string.new_project_from_nextcloud_tooltip)) - } - } else if (viewModel.projectType == ProjectType.LOCAL) { + if (viewModel.projectType == ProjectType.LOCAL) { Button( onClick = onImportFile ) { @@ -131,7 +132,7 @@ fun NewProjectScreen( } else { Spacer(modifier = Modifier.width(16.dp)) Button(onClick = onScanQrCode) { - Text(text = stringResource(R.string.scan_qrcode)) + Text(text = stringResource(R.string.action_scan_qrcode)) Spacer(modifier = Modifier.width(4.dp)) Icon( Icons.Default.QrCode2, @@ -148,13 +149,8 @@ fun NewProjectScreen( OutlinedTextField( value = viewModel.projectUrl, onValueChange = { viewModel.projectUrl = it }, - label = { - Text( - stringResource( - if (viewModel.projectType == ProjectType.COSPEND) R.string.setting_cospend_project_url - else R.string.setting_ihatemoney_project_url - ) - ) + placeholder = { + Text(stringResource(R.string.label_url)) }, modifier = Modifier.fillMaxWidth(), leadingIcon = { Icon(Icons.Default.Link, contentDescription = null) } @@ -162,42 +158,47 @@ fun NewProjectScreen( Spacer(modifier = Modifier.height(8.dp)) } - OutlinedTextField( - value = viewModel.projectId, - onValueChange = { viewModel.projectId = it }, - label = { Text(stringResource(R.string.setting_project_id)) }, - modifier = Modifier.fillMaxWidth(), - leadingIcon = { Icon(Icons.AutoMirrored.Filled.LibraryBooks, contentDescription = null) } - ) + if (!viewModel.whatTodoIsCreate || !viewModel.isAuthenticatedAccount || viewModel.projectType != ProjectType.COSPEND) { + OutlinedTextField( + value = viewModel.projectId, + onValueChange = { viewModel.projectId = it }, + placeholder = { Text(stringResource(R.string.label_project_id)) }, + modifier = Modifier.fillMaxWidth(), + leadingIcon = { Icon(Icons.AutoMirrored.Filled.LibraryBooks, contentDescription = null) } + ) + Spacer(modifier = Modifier.height(8.dp)) + } if (viewModel.projectType != ProjectType.LOCAL && (!viewModel.whatTodoIsCreate || viewModel.projectType == ProjectType.IHATEMONEY)) { - Spacer(modifier = Modifier.height(8.dp)) OutlinedTextField( value = viewModel.projectPassword, onValueChange = { viewModel.projectPassword = it }, - label = { Text(stringResource(R.string.setting_new_project_password)) }, + placeholder = { Text(stringResource(R.string.label_password)) }, modifier = Modifier.fillMaxWidth(), leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) } ) + Spacer(modifier = Modifier.height(8.dp)) } if (viewModel.whatTodoIsCreate && viewModel.projectType != ProjectType.LOCAL) { - Spacer(modifier = Modifier.height(8.dp)) OutlinedTextField( value = viewModel.projectName, onValueChange = { viewModel.projectName = it }, - label = { Text(stringResource(R.string.setting_new_project_name)) }, + placeholder = { Text(stringResource(R.string.label_project_title)) }, modifier = Modifier.fillMaxWidth(), leadingIcon = { Icon(Icons.Default.Title, contentDescription = null) } ) - Spacer(modifier = Modifier.height(8.dp)) - OutlinedTextField( - value = viewModel.projectEmail, - onValueChange = { viewModel.projectEmail = it }, - label = { Text(stringResource(R.string.setting_new_project_email)) }, - modifier = Modifier.fillMaxWidth(), - leadingIcon = { Icon(Icons.Default.Email, contentDescription = null) } - ) + + if (!viewModel.isAuthenticatedAccount || viewModel.projectType == ProjectType.IHATEMONEY) { + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = viewModel.projectEmail, + onValueChange = { viewModel.projectEmail = it }, + placeholder = { Text(stringResource(R.string.label_email)) }, + modifier = Modifier.fillMaxWidth(), + leadingIcon = { Icon(Icons.Default.Email, contentDescription = null) } + ) + } } } } @@ -205,8 +206,8 @@ fun NewProjectScreen( if (viewModel.showAuthWarningDialog) { AlertDialog( onDismissRequest = { viewModel.showAuthWarningDialog = false }, - title = { Text(stringResource(R.string.auth_project_creation_title)) }, - text = { Text(stringResource(R.string.warning_auth_project_creation)) }, + title = { Text(stringResource(R.string.app_name)) }, + text = { Text(stringResource(R.string.msg_auth_warning)) }, confirmButton = { TextButton(onClick = { viewModel.showAuthWarningDialog = false @@ -260,12 +261,12 @@ fun NewProjectScreen( if (viewModel.isCreatingRemoteProject) { AlertDialog( onDismissRequest = { }, - title = { Text(stringResource(R.string.simple_loading)) }, + title = { Text(stringResource(R.string.error_loading)) }, text = { Row(verticalAlignment = Alignment.CenterVertically) { CircularProgressIndicator() Spacer(modifier = Modifier.width(16.dp)) - Text(stringResource(R.string.creating_remote_project)) + Text(stringResource(R.string.action_add_project)) } }, confirmButton = {} @@ -275,7 +276,7 @@ fun NewProjectScreen( if (viewModel.errorDialogMessage != null) { AlertDialog( onDismissRequest = { viewModel.errorDialogMessage = null }, - title = { Text(stringResource(R.string.simple_error)) }, + title = { Text(stringResource(R.string.error_generic)) }, text = { Text(viewModel.errorDialogMessage!!) }, confirmButton = { TextButton(onClick = { viewModel.errorDialogMessage = null }) { @@ -291,11 +292,15 @@ fun SectionRow( label: String, content: @Composable () -> Unit ) { - Row(verticalAlignment = Alignment.Top) { - Column { - Text(label, fontSize = 12.sp, color = MaterialTheme.colors.onSurface) - content() - } + Column(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) { + Text( + text = label.uppercase(), + style = MaterialTheme.typography.subtitle1, + color = MaterialTheme.colors.onSurface, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 8.dp) + ) + content() } } @@ -334,6 +339,7 @@ fun NewProjectScreenPreview() { onImportFile = {}, onChooseFromNextcloud = {}, onOkPressed = {}, - onBack = {} + onBack = {}, + onFieldsChanged = {} ) } diff --git a/app/src/main/java/net/helcel/cowspent/android/project/create/NewProjectViewModel.kt b/app/src/main/java/net/helcel/cowspent/android/project/create/NewProjectViewModel.kt index b0060c0..16a8b82 100644 --- a/app/src/main/java/net/helcel/cowspent/android/project/create/NewProjectViewModel.kt +++ b/app/src/main/java/net/helcel/cowspent/android/project/create/NewProjectViewModel.kt @@ -30,6 +30,8 @@ class NewProjectViewModel : ViewModel() { var projectName by mutableStateOf("") var projectEmail by mutableStateOf("") + var isAuthenticatedAccount by mutableStateOf(false) + var showAuthWarningDialog by mutableStateOf(false) var showNextcloudProjectDialog by mutableStateOf(false) var nextcloudProjects by mutableStateOf>(emptyList()) @@ -39,12 +41,23 @@ class NewProjectViewModel : ViewModel() { fun isFormValid(): Boolean { if (whatTodoIsCreate) { - if (projectId.isEmpty()) return false - if (projectType != ProjectType.LOCAL) { + if (projectType == ProjectType.LOCAL) { + return projectId.isNotEmpty() + } else { if (projectUrl.isEmpty()) return false if (projectName.isEmpty()) return false - if (projectEmail.isEmpty()) return false - if (projectType == ProjectType.IHATEMONEY && projectPassword.isEmpty()) return false + + if (projectType == ProjectType.COSPEND && isAuthenticatedAccount) { + return true + } + + if (projectId.isEmpty()) return false + if (projectType == ProjectType.IHATEMONEY) { + if (projectEmail.isEmpty()) return false + if (projectPassword.isEmpty()) return false + } else { + if (projectEmail.isEmpty()) return false + } } } else { // Join diff --git a/app/src/main/java/net/helcel/cowspent/android/project/edit/EditProjectActivity.kt b/app/src/main/java/net/helcel/cowspent/android/project/edit/EditProjectActivity.kt index a105eb8..4afcf0c 100644 --- a/app/src/main/java/net/helcel/cowspent/android/project/edit/EditProjectActivity.kt +++ b/app/src/main/java/net/helcel/cowspent/android/project/edit/EditProjectActivity.kt @@ -3,6 +3,7 @@ package net.helcel.cowspent.android.project.edit import android.content.Intent import android.os.Bundle import android.widget.Toast +import androidx.activity.enableEdgeToEdge import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity @@ -27,6 +28,7 @@ class EditProjectActivity : AppCompatActivity() { private lateinit var project: DBProject override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() super.onCreate(savedInstanceState) db = CowspentSQLiteOpenHelper.getInstance(this) @@ -135,7 +137,7 @@ class EditProjectActivity : AppCompatActivity() { private fun onDeleteRemote() { viewModel.showDialog( - message = getString(R.string.confirm_delete_project_dialog_title), + message = getString(R.string.title_confirm), positiveText = getString(R.string.simple_yes), onConfirm = { if (!db.cowspentServerSyncHelper.deleteRemoteProject(project.id, deleteCallBack)) { diff --git a/app/src/main/java/net/helcel/cowspent/android/project/edit/EditProjectScreen.kt b/app/src/main/java/net/helcel/cowspent/android/project/edit/EditProjectScreen.kt index 350568b..f9e97f6 100644 --- a/app/src/main/java/net/helcel/cowspent/android/project/edit/EditProjectScreen.kt +++ b/app/src/main/java/net/helcel/cowspent/android/project/edit/EditProjectScreen.kt @@ -2,6 +2,8 @@ package net.helcel.cowspent.android.project.edit import android.annotation.SuppressLint import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack @@ -14,6 +16,7 @@ import androidx.compose.material.icons.filled.Title import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import net.helcel.cowspent.R @@ -59,70 +62,91 @@ fun EditProjectScreen( Scaffold( topBar = { TopAppBar( - title = { Text(stringResource(R.string.simple_edit_project)) }, + title = { Text(stringResource(R.string.title_edit_project)) }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) } }, actions = { - IconButton(onClick = onDeleteRemote) { - Icon(Icons.Default.DeleteForever, contentDescription = stringResource(R.string.menu_delete_project_remote)) + if (!viewModel.isLocal) { + IconButton(onClick = onDeleteRemote) { + Icon(Icons.Default.DeleteForever, contentDescription = stringResource(R.string.action_delete)) + } } }, - backgroundColor = MaterialTheme.colors.surface, + backgroundColor = MaterialTheme.colors.primary, elevation = 0.dp ) }, floatingActionButton = { FloatingActionButton(onClick = onSave) { - Icon(Icons.Default.Done, contentDescription = stringResource(R.string.menu_save_project)) + Icon(Icons.Default.Done, contentDescription = stringResource(R.string.action_save)) } } ) { padding -> + val scrollState = rememberScrollState() Column( modifier = Modifier .padding(padding) + .imePadding() .padding(16.dp) .fillMaxSize() + .verticalScroll(scrollState) ) { + Text( + text = "GENERAL", + style = MaterialTheme.typography.subtitle1, + color = MaterialTheme.colors.onSurface, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 8.dp) + ) + OutlinedTextField( value = viewModel.name, onValueChange = { viewModel.name = it }, - label = { Text(stringResource(R.string.setting_new_project_name)) }, + placeholder = { Text(stringResource(R.string.label_project_title)) }, modifier = Modifier.fillMaxWidth(), leadingIcon = { Icon(Icons.Default.Title, contentDescription = null) } ) - Spacer(modifier = Modifier.height(8.dp)) + if (!viewModel.isLocal) { + Spacer(modifier = Modifier.height(8.dp)) - OutlinedTextField( - value = viewModel.password, - onValueChange = { viewModel.password = it }, - label = { Text(stringResource(R.string.setting_password)) }, - modifier = Modifier.fillMaxWidth(), - leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) } - ) + OutlinedTextField( + value = viewModel.email, + onValueChange = { viewModel.email = it }, + placeholder = { Text(stringResource(R.string.label_email)) }, + modifier = Modifier.fillMaxWidth(), + leadingIcon = { Icon(Icons.Default.Email, contentDescription = null) } + ) - Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "SECURITY", + style = MaterialTheme.typography.subtitle1, + color = MaterialTheme.colors.onSurface, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 8.dp, top = 24.dp) + ) - OutlinedTextField( - value = viewModel.newPassword, - onValueChange = { viewModel.newPassword = it }, - label = { Text(stringResource(R.string.setting_new_project_password)) }, - modifier = Modifier.fillMaxWidth(), - leadingIcon = { Icon(Icons.Default.LockOpen, contentDescription = null) } - ) + OutlinedTextField( + value = viewModel.password, + onValueChange = { viewModel.password = it }, + placeholder = { Text("Old " + stringResource(R.string.label_password)) }, + modifier = Modifier.fillMaxWidth(), + leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) } + ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(8.dp)) - OutlinedTextField( - value = viewModel.email, - onValueChange = { viewModel.email = it }, - label = { Text(stringResource(R.string.setting_new_project_email)) }, - modifier = Modifier.fillMaxWidth(), - leadingIcon = { Icon(Icons.Default.Email, contentDescription = null) } - ) + OutlinedTextField( + value = viewModel.newPassword, + onValueChange = { viewModel.newPassword = it }, + placeholder = { Text("New " + stringResource(R.string.label_password)) }, + modifier = Modifier.fillMaxWidth(), + leadingIcon = { Icon(Icons.Default.LockOpen, contentDescription = null) } + ) + } } } } diff --git a/app/src/main/java/net/helcel/cowspent/android/project/edit/EditProjectViewModel.kt b/app/src/main/java/net/helcel/cowspent/android/project/edit/EditProjectViewModel.kt index fac868d..8b7d26e 100644 --- a/app/src/main/java/net/helcel/cowspent/android/project/edit/EditProjectViewModel.kt +++ b/app/src/main/java/net/helcel/cowspent/android/project/edit/EditProjectViewModel.kt @@ -13,6 +13,7 @@ class EditProjectViewModel : ViewModel() { var password by mutableStateOf("") var newPassword by mutableStateOf("") var email by mutableStateOf("") + var isLocal by mutableStateOf(false) var dialogState by mutableStateOf(null) @@ -53,5 +54,6 @@ class EditProjectViewModel : ViewModel() { password = project.password newPassword = project.password email = project.email?.let { if (it == "null") "" else it } ?: "" + isLocal = project.isLocal } } diff --git a/app/src/main/java/net/helcel/cowspent/android/project/member/MemberAddDialog.kt b/app/src/main/java/net/helcel/cowspent/android/project/member/MemberAddDialog.kt index 0cfd11d..b1b4a0b 100644 --- a/app/src/main/java/net/helcel/cowspent/android/project/member/MemberAddDialog.kt +++ b/app/src/main/java/net/helcel/cowspent/android/project/member/MemberAddDialog.kt @@ -6,6 +6,7 @@ import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.unit.dp import androidx.compose.ui.tooling.preview.Preview @@ -29,15 +30,17 @@ fun MemberAddDialogContent( .padding(16.dp) ) { Text( - text = stringResource(R.string.add_member_dialog_title), - style = MaterialTheme.typography.h6, + text = stringResource(R.string.add_member_dialog_title).uppercase(), + style = MaterialTheme.typography.subtitle1, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.onSurface, modifier = Modifier.padding(bottom = 16.dp) ) OutlinedTextField( value = name, onValueChange = { name = it }, - label = { Text(stringResource(R.string.member_edit_name)) }, + placeholder = { Text(stringResource(R.string.label_name)) }, modifier = Modifier.fillMaxWidth(), keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences), singleLine = true diff --git a/app/src/main/java/net/helcel/cowspent/android/project/member/MemberEditDialog.kt b/app/src/main/java/net/helcel/cowspent/android/project/member/MemberEditDialog.kt index 656bf80..642a841 100644 --- a/app/src/main/java/net/helcel/cowspent/android/project/member/MemberEditDialog.kt +++ b/app/src/main/java/net/helcel/cowspent/android/project/member/MemberEditDialog.kt @@ -13,6 +13,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp @@ -57,8 +58,10 @@ fun MemberEditDialogContent( .verticalScroll(rememberScrollState()) ) { Text( - text = stringResource(R.string.edit_member_dialog_title), - style = MaterialTheme.typography.h6, + text = stringResource(R.string.edit_member_dialog_title).uppercase(), + style = MaterialTheme.typography.subtitle1, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.onSurface, modifier = Modifier.padding(bottom = 16.dp) ) @@ -66,7 +69,7 @@ fun MemberEditDialogContent( OutlinedTextField( value = name, onValueChange = { name = it }, - label = { Text(stringResource(R.string.member_edit_name)) }, + placeholder = { Text(stringResource(R.string.label_name)) }, modifier = Modifier.fillMaxWidth(), leadingIcon = { Icon(Icons.Default.Person, contentDescription = null) @@ -80,7 +83,7 @@ fun MemberEditDialogContent( OutlinedTextField( value = weight, onValueChange = { weight = it }, - label = { Text(stringResource(R.string.member_edit_weight)) }, + placeholder = { Text(stringResource(R.string.label_weight)) }, modifier = Modifier.fillMaxWidth(), leadingIcon = { Icon(Icons.Default.LineWeight, contentDescription = null) @@ -98,9 +101,17 @@ fun MemberEditDialogContent( .clickable { isActivated = !isActivated } .padding(vertical = 8.dp) ) { - Icon(Icons.Default.Block, contentDescription = null) - Spacer(modifier = Modifier.width(16.dp)) - Text(stringResource(R.string.member_edit_toggle), modifier = Modifier.weight(1f)) + Icon( + imageVector = Icons.Default.Block, + contentDescription = null, + tint = MaterialTheme.colors.onSurface.copy(alpha = 0.6f) + ) + Spacer(modifier = Modifier.width(32.dp)) + Text( + text = stringResource(R.string.label_activated), + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.subtitle1 + ) Checkbox(checked = isActivated, onCheckedChange = { isActivated = it }) } @@ -113,9 +124,17 @@ fun MemberEditDialogContent( .fillMaxWidth() .padding(vertical = 8.dp) ) { - Icon(Icons.Default.Palette, contentDescription = null) - Spacer(modifier = Modifier.width(16.dp)) - Text(stringResource(R.string.member_edit_color), modifier = Modifier.weight(1f)) + Icon( + imageVector = Icons.Default.Palette, + contentDescription = null, + tint = MaterialTheme.colors.onSurface.copy(alpha = 0.6f) + ) + Spacer(modifier = Modifier.width(32.dp)) + Text( + text = stringResource(R.string.label_color), + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.subtitle1 + ) Box( modifier = Modifier .size(40.dp) diff --git a/app/src/main/java/net/helcel/cowspent/android/project/member/MemberManagementActivity.kt b/app/src/main/java/net/helcel/cowspent/android/project/member/MemberManagementActivity.kt new file mode 100644 index 0000000..5dba687 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/project/member/MemberManagementActivity.kt @@ -0,0 +1,122 @@ +package net.helcel.cowspent.android.project.member + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.widget.Toast +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import net.helcel.cowspent.R +import net.helcel.cowspent.model.DBBill +import net.helcel.cowspent.model.DBMember +import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper +import net.helcel.cowspent.theme.ThemeUtils + +class MemberManagementActivity : AppCompatActivity() { + + private val viewModel: MemberManagementViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + + val projectId = intent.getLongExtra(EXTRA_PROJECT_ID, 0) + if (projectId == 0L) { + finish() + return + } + + val db = CowspentSQLiteOpenHelper.getInstance(this) + viewModel.loadMembers(projectId) + + setContent { + ThemeUtils.CowspentTheme { + var showAddMemberDialog by remember { mutableStateOf(false) } + var editingMember by remember { mutableStateOf(null) } + + MemberManagementScreen( + members = viewModel.members, + onAddMember = { showAddMemberDialog = true }, + onEditMember = { editingMember = it }, + onToggleMember = { member, isActivated -> + db.updateMemberAndSync(member, member.name, member.weight, isActivated, member.r, member.g, member.b, "", "") + viewModel.loadMembers(projectId) + }, + onBack = { finish() } + ) + + if (showAddMemberDialog) { + Dialog( + onDismissRequest = { showAddMemberDialog = false }, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + MemberAddDialogContent( + onAdd = { memberName -> + val memberNames = db.getMembersOfProject(projectId, null).map { it.name } + if (memberNames.contains(memberName)) { + Toast.makeText(this, R.string.member_already_exists, Toast.LENGTH_SHORT).show() + } else { + val color = net.helcel.cowspent.android.helper.TextDrawable.getColorFromName(memberName) + db.addMemberAndSync( + DBMember( + 0, 0, projectId, memberName, true, 1.0, + DBBill.STATE_ADDED, + android.graphics.Color.red(color), + android.graphics.Color.green(color), + android.graphics.Color.blue(color), + null, null + ) + ) + viewModel.loadMembers(projectId) + showAddMemberDialog = false + } + }, + onDismiss = { showAddMemberDialog = false } + ) + } + } + + if (editingMember != null) { + val member = editingMember!! + Dialog( + onDismissRequest = { editingMember = null }, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + MemberEditDialogContent( + member = member, + onSave = { name, weight, isActivated, r, g, b -> + db.updateMemberAndSync(member, name, weight, isActivated, r, g, b, "", "") + viewModel.loadMembers(projectId) + editingMember = null + }, + onDelete = { + db.deleteMember(member.id) + viewModel.loadMembers(projectId) + editingMember = null + }, + onDismiss = { editingMember = null } + ) + } + } + } + } + } + + companion object { + const val EXTRA_PROJECT_ID = "project_id" + + fun createIntent(context: Context, projectId: Long): Intent { + return Intent(context, MemberManagementActivity::class.java).apply { + putExtra(EXTRA_PROJECT_ID, projectId) + } + } + } +} diff --git a/app/src/main/java/net/helcel/cowspent/android/project/member/MemberManagementDialog.kt b/app/src/main/java/net/helcel/cowspent/android/project/member/MemberManagementDialog.kt deleted file mode 100644 index d2121f3..0000000 --- a/app/src/main/java/net/helcel/cowspent/android/project/member/MemberManagementDialog.kt +++ /dev/null @@ -1,132 +0,0 @@ -package net.helcel.cowspent.android.project.member - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.material.TextButton -import androidx.compose.material.contentColorFor -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import net.helcel.cowspent.R -import net.helcel.cowspent.android.helper.UserAvatar -import net.helcel.cowspent.android.helper.lazyVerticalScrollbar -import net.helcel.cowspent.model.DBMember - -@Composable -fun MemberManagementDialogContent( - members: List, - onAddMember: () -> Unit, - onEditMember: (DBMember) -> Unit, - onDismiss: () -> Unit -) { - val listState = rememberLazyListState() - Surface( - shape = MaterialTheme.shapes.large, - color = MaterialTheme.colors.surface, - contentColor = contentColorFor(MaterialTheme.colors.surface) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .heightIn(max = 500.dp) - .padding(16.dp) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(R.string.fab_manage_members), - style = MaterialTheme.typography.h6 - ) - IconButton(onClick = onAddMember) { - Icon(Icons.Default.Add,modifier=Modifier.size(32.dp), - contentDescription = stringResource(R.string.fab_add_member)) - } - } - - Spacer(modifier = Modifier.height(8.dp)) - - LazyColumn( - state = listState, - modifier = Modifier - .weight(1f, fill = false) - .lazyVerticalScrollbar(listState) - ) { - items(members) { member -> - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { onEditMember(member) } - .padding(vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - UserAvatar( - name = member.name, - r = member.r, - g = member.g, - b = member.b, - avatar = member.avatar, - disabled = !member.isActivated, - size = 40.dp - ) - Spacer(modifier = Modifier.width(16.dp)) - Text( - text = member.name, - style = MaterialTheme.typography.body1 - ) - } - } - } - - Spacer(modifier = Modifier.height(8.dp)) - - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.simple_close).uppercase()) - } - } - } - } -} - -@Preview(showBackground = true) -@Composable -fun MemberManagementDialogContentPreview() { - MaterialTheme { - MemberManagementDialogContent( - members = listOf( - DBMember(1, 0, 1, "Alice", true, 1.0, 0, 255, 100, 100, null, null), - DBMember(2, 0, 1, "Bob", true, 1.0, 0, 100, 255, 100, null, null), - DBMember(3, 0, 1, "Charlie", false, 1.0, 0, 100, 100, 255, null, null) - ), - onAddMember = {}, - onEditMember = {}, - onDismiss = {} - ) - } -} - diff --git a/app/src/main/java/net/helcel/cowspent/android/project/member/MemberManagementScreen.kt b/app/src/main/java/net/helcel/cowspent/android/project/member/MemberManagementScreen.kt new file mode 100644 index 0000000..51aaf1c --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/project/member/MemberManagementScreen.kt @@ -0,0 +1,162 @@ +package net.helcel.cowspent.android.project.member + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import net.helcel.cowspent.R +import net.helcel.cowspent.android.helper.MemberAvatar +import net.helcel.cowspent.android.helper.lazyVerticalScrollbar +import net.helcel.cowspent.model.DBMember + +@Composable +fun MemberManagementScreen( + members: List, + onAddMember: () -> Unit, + onEditMember: (DBMember) -> Unit, + onToggleMember: (DBMember, Boolean) -> Unit, + onBack: () -> Unit +) { + val listState = rememberLazyListState() + val activatedMembers = members.filter { it.isActivated } + val deactivatedMembers = members.filter { !it.isActivated } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.action_members)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + backgroundColor = MaterialTheme.colors.primary, + elevation = 0.dp + ) + }, + floatingActionButton = { + FloatingActionButton(onClick = onAddMember) { + Icon(Icons.Default.Add, contentDescription = stringResource(R.string.action_save)) + } + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize() + .lazyVerticalScrollbar(listState) + ) { + item { + Text( + text = "ACTIVATED MEMBERS", + style = MaterialTheme.typography.subtitle1, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.onSurface, + modifier = Modifier.padding(16.dp) + ) + } + + items(activatedMembers) { member -> + MemberRow( + member = member, + onEditMember = onEditMember, + onToggleMember = { onToggleMember(member, it) } + ) + } + + if (deactivatedMembers.isNotEmpty()) { + item { + Text( + text = "DEACTIVATED MEMBERS", + style = MaterialTheme.typography.subtitle1, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f), + modifier = Modifier.padding(start = 16.dp, top = 24.dp, end = 16.dp, bottom = 16.dp) + ) + } + + items(deactivatedMembers) { member -> + MemberRow( + member = member, + onEditMember = onEditMember, + onToggleMember = { onToggleMember(member, it) }, + alpha = 0.6f + ) + } + } + } + } + } +} + +@Composable +fun MemberRow( + member: DBMember, + onEditMember: (DBMember) -> Unit, + onToggleMember: (Boolean) -> Unit, + alpha: Float = 1f +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onEditMember(member) } + .padding(horizontal = 16.dp, vertical = 0.dp), + verticalAlignment = Alignment.CenterVertically + ) { + MemberAvatar( + member = member, + size = 24.dp, + alpha = alpha + ) + Spacer(modifier = Modifier.width(32.dp)) + Text( + text = member.name, + style = MaterialTheme.typography.subtitle1, + modifier = Modifier.weight(1f), + color = MaterialTheme.colors.onSurface.copy(alpha = alpha) + ) + IconButton(onClick = { onToggleMember(!member.isActivated) }) { + Icon( + imageVector = if (member.isActivated) Icons.Default.Delete else Icons.Default.Add, + contentDescription = if (member.isActivated) "Deactivate" else "Activate", + tint = if (member.isActivated) MaterialTheme.colors.error.copy(alpha = 0.6f) else MaterialTheme.colors.primary.copy(alpha = 0.6f) + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun MemberManagementScreenPreview() { + MaterialTheme { + MemberManagementScreen( + members = listOf( + DBMember(1, 0, 1, "Alice", true, 1.0, 0, 255, 100, 100, null, null), + DBMember(2, 0, 1, "Bob", true, 1.0, 0, 100, 255, 100, null, null), + DBMember(3, 0, 1, "Charlie", false, 1.0, 0, 100, 100, 255, null, null) + ), + onAddMember = {}, + onEditMember = {}, + onToggleMember = { _, _ -> }, + onBack = {} + ) + } +} diff --git a/app/src/main/java/net/helcel/cowspent/android/project/member/MemberManagementViewModel.kt b/app/src/main/java/net/helcel/cowspent/android/project/member/MemberManagementViewModel.kt new file mode 100644 index 0000000..e061930 --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/android/project/member/MemberManagementViewModel.kt @@ -0,0 +1,40 @@ +package net.helcel.cowspent.android.project.member + +import android.app.Application +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import net.helcel.cowspent.android.helper.DialogState +import net.helcel.cowspent.model.DBMember +import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper + +class MemberManagementViewModel(application: Application) : AndroidViewModel(application) { + private val db = CowspentSQLiteOpenHelper.getInstance(application) + + var projectId by mutableLongStateOf(0L) + var members by mutableStateOf>(emptyList()) + + var dialogState by mutableStateOf(null) + + fun loadMembers(projId: Long) { + projectId = projId + viewModelScope.launch { + members = withContext(Dispatchers.IO) { db.getMembersOfProject(projId, null) } + } + } + + fun deleteMember(id: Long) { + viewModelScope.launch { + withContext(Dispatchers.IO) { + db.deleteMember(id) + } + loadMembers(projectId) + } + } +} diff --git a/app/src/main/java/net/helcel/cowspent/android/project/settle/ProjectSettlementScreen.kt b/app/src/main/java/net/helcel/cowspent/android/project/settle/ProjectSettlementScreen.kt index bcb6f04..364df84 100644 --- a/app/src/main/java/net/helcel/cowspent/android/project/settle/ProjectSettlementScreen.kt +++ b/app/src/main/java/net/helcel/cowspent/android/project/settle/ProjectSettlementScreen.kt @@ -129,8 +129,10 @@ fun ProjectSettlementUI( .padding(16.dp) ) { Text( - text = stringResource(R.string.settle_dialog_title), - style = MaterialTheme.typography.h6, + text = stringResource(R.string.title_settle).uppercase(), + style = MaterialTheme.typography.subtitle1, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.onSurface, modifier = Modifier.padding(bottom = 16.dp) ) @@ -174,7 +176,7 @@ private fun BalancedStateMessage() { contentAlignment = Alignment.Center ) { Text( - text = stringResource(R.string.settle_dialog_balanced), + text = stringResource(R.string.dialog_balanced_msg), style = MaterialTheme.typography.body1 ) } @@ -279,9 +281,9 @@ private fun TableHeaderText( Text( text = text.uppercase(), modifier = modifier, - fontWeight = FontWeight.SemiBold, + fontWeight = FontWeight.Bold, style = MaterialTheme.typography.overline, - color = colorResource(R.color.fg_default_low), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f), textAlign = textAlign, letterSpacing = 0.8.sp ) diff --git a/app/src/main/java/net/helcel/cowspent/android/settings/PreferencesActivity.kt b/app/src/main/java/net/helcel/cowspent/android/settings/PreferencesActivity.kt index 854f23f..623c2fd 100644 --- a/app/src/main/java/net/helcel/cowspent/android/settings/PreferencesActivity.kt +++ b/app/src/main/java/net/helcel/cowspent/android/settings/PreferencesActivity.kt @@ -3,6 +3,7 @@ package net.helcel.cowspent.android.settings import android.content.Intent import android.os.Bundle import androidx.activity.OnBackPressedCallback +import androidx.activity.enableEdgeToEdge import androidx.activity.compose.setContent import androidx.appcompat.app.AppCompatActivity import androidx.compose.runtime.getValue @@ -25,6 +26,7 @@ import net.helcel.cowspent.util.ColorUtils class PreferencesActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() super.onCreate(savedInstanceState) onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { diff --git a/app/src/main/java/net/helcel/cowspent/android/settings/SettingsScreen.kt b/app/src/main/java/net/helcel/cowspent/android/settings/SettingsScreen.kt index b84290b..dc3ffa1 100644 --- a/app/src/main/java/net/helcel/cowspent/android/settings/SettingsScreen.kt +++ b/app/src/main/java/net/helcel/cowspent/android/settings/SettingsScreen.kt @@ -79,6 +79,7 @@ fun SettingsScreen( val keyColor = stringResource(R.string.pref_key_color) val keyOfflineMode = stringResource(R.string.pref_key_offline_mode) val keyShowArchived = stringResource(R.string.pref_key_show_archived) + val keyBetaFeatures = stringResource(R.string.pref_key_beta_features) // States for preferences var nightMode by remember(keyNightMode) { @@ -113,6 +114,9 @@ fun SettingsScreen( var showArchived by remember(keyShowArchived) { mutableStateOf(sharedPreferences.getBoolean(keyShowArchived, false)) } + var betaFeatures by remember(keyBetaFeatures) { + mutableStateOf(sharedPreferences.getBoolean(keyBetaFeatures, false)) + } Scaffold( topBar = { @@ -135,10 +139,10 @@ fun SettingsScreen( .verticalScroll(rememberScrollState()) ) { // Appearance - SettingsCategory(stringResource(R.string.settings_appearance_category)) + SettingsCategory(stringResource(R.string.settings_appearance)) SettingsSwitchPreference( - title = stringResource(R.string.setting_show_archived), + title = stringResource(R.string.settings_show_archived), icon = Icons.Default.Archive, checked = showArchived, onCheckedChange = { @@ -160,7 +164,7 @@ fun SettingsScreen( ) SettingsListPreference( - title = stringResource(R.string.settings_night_mode_title), + title = stringResource(R.string.settings_night_mode), icon = Icons.Default.Brightness2, value = nightMode, entries = mapOf( @@ -178,7 +182,7 @@ fun SettingsScreen( ) SettingsListPreference( - title = stringResource(R.string.settings_color_mode_title), + title = stringResource(R.string.settings_color_mode), icon = Icons.Default.Palette, value = colorMode, entries = mapOf( @@ -203,8 +207,8 @@ fun SettingsScreen( if (colorMode == "manual") { SettingsColorPreference( - title = stringResource(R.string.settings_color_title), - summary = stringResource(R.string.settings_color_summary), + title = stringResource(R.string.settings_color_custom), + summary = stringResource(R.string.settings_color_custom), icon = Icons.Default.Palette, initialColor = appColor, onColorSelected = { @@ -218,10 +222,10 @@ fun SettingsScreen( } // Network - SettingsCategory(stringResource(R.string.settings_network_category)) + SettingsCategory(stringResource(R.string.settings_network)) SettingsSwitchPreference( - title = stringResource(R.string.settings_offline_mode_title), + title = stringResource(R.string.settings_offline_mode), summary = stringResource(R.string.settings_offline_mode_summary), icon = Icons.Default.Sync, checked = offlineMode, @@ -234,16 +238,29 @@ fun SettingsScreen( ) SettingsPreference( - title = stringResource(R.string.settings_server_settings), + title = stringResource(R.string.title_account), icon = Icons.Default.AccountCircle, onClick = onAccountSettingsClick ) // Other - SettingsCategory(stringResource(R.string.settings_other_category)) + SettingsCategory(stringResource(R.string.settings_other)) + + SettingsSwitchPreference( + title = stringResource(R.string.settings_beta_features), + summary = stringResource(R.string.settings_beta_features_summary), + icon = Icons.Default.Info, + checked = betaFeatures, + onCheckedChange = { + betaFeatures = it + sharedPreferences.edit { + putBoolean(keyBetaFeatures, it) + } + } + ) SettingsPreference( - title = stringResource(R.string.settings_about), + title = stringResource(R.string.title_about), icon = Icons.Default.Info, onClick = onAboutClick ) @@ -255,9 +272,9 @@ fun SettingsScreen( fun SettingsCategory(title: String) { Text( text = title.uppercase(), - modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 8.dp), - color = MaterialTheme.colors.primary, - style = MaterialTheme.typography.overline, + modifier = Modifier.padding(start = 16.dp, top = 24.dp, bottom = 8.dp), + color = MaterialTheme.colors.onSurface, + style = MaterialTheme.typography.subtitle1, fontWeight = FontWeight.Bold ) } diff --git a/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectSankeyDiagram.kt b/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectSankeyDiagram.kt index 19410a1..58f4d9e 100644 --- a/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectSankeyDiagram.kt +++ b/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectSankeyDiagram.kt @@ -1,469 +1,408 @@ package net.helcel.cowspent.android.statistics import android.annotation.SuppressLint +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring import androidx.compose.foundation.Canvas -import androidx.compose.foundation.layout.* -import androidx.compose.material.* +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.LocalContentColor +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Group -import androidx.compose.runtime.* +import androidx.compose.material.icons.filled.ViewColumn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.CornerRadius -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.res.stringResource +import androidx.core.graphics.toColorInt +import kotlinx.coroutines.launch import net.helcel.cowspent.R -import net.helcel.cowspent.android.helper.* +import net.helcel.cowspent.android.helper.EditableExposedDropdownMenu +import net.helcel.cowspent.android.helper.MemberAvatar +import net.helcel.cowspent.android.helper.formatShortValue import net.helcel.cowspent.model.DBBill import net.helcel.cowspent.model.DBCategory import net.helcel.cowspent.model.DBMember -import androidx.core.graphics.toColorInt +import net.helcel.cowspent.util.CategoryUtils +import kotlin.math.abs +import kotlin.math.roundToInt + +private object SankeyDimens { + val NodeHeight = 76.dp + val TotalNodeHeight = 42.dp + val NormalGap = 8.dp + val FocusGap = 16.dp + val ActiveMinWidth = 160.dp +} @OptIn(ExperimentalMaterialApi::class) @SuppressLint("UseKtx") @Composable fun ProjectSankeyDiagram( projectName: String, + projectId: Long, + projectRemoteId: String?, allMembers: List, allBills: List, customCategories: List, onShareReady: (String) -> Unit ) { - val shareStatsIntro = stringResource(R.string.share_stats_intro, projectName) - + val shareStatsIntro = stringResource(R.string.msg_stats_intro, projectName) var selectedMemberId by remember { mutableLongStateOf(-1L) } var expanded by remember { mutableStateOf(false) } - val activeBills = remember(allBills) { - allBills.filter { - it.state != DBBill.STATE_DELETED && - it.categoryRemoteId != DBBill.CATEGORY_REIMBURSEMENT - } + val reimbursementCategoryId = remember(customCategories, projectId, projectRemoteId) { + CategoryUtils.getReimbursementCategoryId(customCategories, projectId, projectRemoteId) + } + val activeBills = remember(allBills, reimbursementCategoryId) { + allBills.filter { it.state != DBBill.STATE_DELETED && it.categoryId != reimbursementCategoryId } } val membersMap = remember(allMembers) { allMembers.associateBy { it.id } } - val categoriesMap = remember(customCategories) { customCategories.associateBy { it.remoteId.toInt() } } + val categoriesMap = remember(customCategories) { customCategories.associateBy { it.id } } if (activeBills.isEmpty()) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text("No data to display", style = MaterialTheme.typography.h6) + Text("No data to display", style = MaterialTheme.typography.subtitle1, fontWeight = FontWeight.Bold, color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)) } return } - // Consolidated spending calculation val spendings = remember(activeBills, selectedMemberId, membersMap) { - val spentMap = mutableMapOf() - val catMap = mutableMapOf() - membersMap.keys.forEach { spentMap[it] = 0.0 } + val spentMap = mutableMapOf().apply { membersMap.keys.forEach { put(it, 0.0) } } + val catMap = mutableMapOf() activeBills.forEach { bill -> - val totalWeight = bill.billOwers.sumOf { bo -> - membersMap[bo.memberId]?.weight ?: 1.0 - } + val totalWeight = bill.billOwers.sumOf { membersMap[it.memberId]?.weight ?: 1.0 } if (totalWeight > 0) { if (selectedMemberId == -1L) { - catMap[bill.categoryRemoteId] = (catMap[bill.categoryRemoteId] ?: 0.0) + bill.amount + catMap[bill.categoryId] = (catMap[bill.categoryId] ?: 0.0) + bill.amount bill.billOwers.forEach { bo -> val weight = membersMap[bo.memberId]?.weight ?: 1.0 - val share = (bill.amount / totalWeight) * weight - spentMap[bo.memberId] = (spentMap[bo.memberId] ?: 0.0) + share + spentMap[bo.memberId] = (spentMap[bo.memberId] ?: 0.0) + (bill.amount / totalWeight) * weight } } else { bill.billOwers.find { it.memberId == selectedMemberId }?.let { bo -> val weight = membersMap[bo.memberId]?.weight ?: 1.0 - val share = (bill.amount / totalWeight) * weight - catMap[bill.categoryRemoteId] = (catMap[bill.categoryRemoteId] ?: 0.0) + share - spentMap[selectedMemberId] = (spentMap[selectedMemberId] ?: 0.0) + share + catMap[bill.categoryId] = (catMap[bill.categoryId] ?: 0.0) + (bill.amount / totalWeight) * weight + spentMap[selectedMemberId] = (spentMap[selectedMemberId] ?: 0.0) + (bill.amount / totalWeight) * weight } } } } - - val memberList = spentMap.toList().filter { it.second > 0 }.sortedByDescending { it.second } - val categoryList = catMap.toList().sortedByDescending { it.second } - memberList to categoryList + spentMap.toList().filter { it.second > 0 }.sortedByDescending { it.second } to catMap.toList().sortedByDescending { it.second } } val displayMemberSpendings = spendings.first val displayCategorySpendings = spendings.second val totalAmount = remember(displayMemberSpendings) { displayMemberSpendings.sumOf { it.second } } + var isSpecialMode by remember { mutableStateOf(false) } LaunchedEffect(displayMemberSpendings, displayCategorySpendings, totalAmount, projectName) { - val statsText = StringBuilder() - statsText.append("// ").append(shareStatsIntro.replace("\n", "\n// ")).append("\n\n") - - val middleNode = if (selectedMemberId == -1L) "Total" else "Spent" - - displayMemberSpendings.forEach { (memberId, amount) -> - val name = membersMap[memberId]?.name ?: "???" - statsText.append("$name [${formatShortValue(amount)}] $middleNode\n") - } - - statsText.append("\n") - - displayCategorySpendings.forEach { (catRemoteId, amount) -> - val name = categoriesMap[catRemoteId]?.name ?: "Other" - statsText.append("$middleNode [${formatShortValue(amount)}] $name\n") - } - - statsText.append("\n") - - displayMemberSpendings.forEach { (memberId, _) -> - val member = membersMap[memberId] - if (member != null) { - val hexColor = String.format("#%02x%02x%02x", member.r ?: 128, member.g ?: 128, member.b ?: 128) - statsText.append(":${member.name} $hexColor\n") + val statsText = StringBuilder().apply { + append("// ").append(shareStatsIntro.replace("\n", "\n// ")).append("\n\n") + val middleNode = if (selectedMemberId == -1L) "Total" else "Spent" + displayMemberSpendings.forEach { (id, amount) -> append("${membersMap[id]?.name ?: "???"} [${formatShortValue(amount)}] $middleNode\n") } + append("\n") + displayCategorySpendings.forEach { (id, amount) -> append("$middleNode [${formatShortValue(amount)}] ${categoriesMap[id]?.name ?: "Other"}\n") } + append("\n") + displayMemberSpendings.forEach { (id, _) -> + membersMap[id]?.let { append(":${it.name} ${String.format("#%02x%02x%02x", it.r ?: 128, it.g ?: 128, it.b ?: 128)}\n") } } + displayCategorySpendings.forEach { (id, _) -> categoriesMap[id]?.let { append(":${it.name ?: "Other"} ${it.color}\n") } } } - - displayCategorySpendings.forEach { (catRemoteId, _) -> - val category = categoriesMap[catRemoteId] - if (category != null) { - statsText.append(":${category.name ?: "Other"} ${category.color}\n") - } - } - onShareReady(statsText.toString()) } - Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { - val selectedMember = membersMap[selectedMemberId] - EditableExposedDropdownMenu( - value = selectedMember?.name ?: "All Members", - placeholder = "Filter by member", - expanded = expanded, - onExpandedChange = { expanded = it }, - onDismissRequest = { expanded = false }, - modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), - leadingIcon = { - Box(modifier = Modifier.padding(start = 12.dp)) { - if (selectedMember != null) { - UserAvatar( - name = selectedMember.name, - r = selectedMember.r, - g = selectedMember.g, - b = selectedMember.b, - disabled = !selectedMember.isActivated, - size = 24.dp - ) - } else { - Icon(Icons.Default.Group, contentDescription = null) - } - } - }, - content = { - DropdownMenuItem(onClick = { - selectedMemberId = -1L - expanded = false - }) { - Icon(Icons.Default.Group, contentDescription = null) - Spacer(modifier = Modifier.width(12.dp)) - Text("All Members") - } - allMembers.forEach { member -> - DropdownMenuItem(onClick = { - selectedMemberId = member.id - expanded = false - }) { - UserAvatar( - name = member.name, - r = member.r, - g = member.g, - b = member.b, - disabled = !member.isActivated, - size = 24.dp - ) - Spacer(modifier = Modifier.width(12.dp)) - Text(member.name) + Column(modifier = Modifier.fillMaxSize()) { + Box(modifier = Modifier.padding(16.dp)) { + val selectedMember = membersMap[selectedMemberId] + Row(verticalAlignment = Alignment.CenterVertically) { + EditableExposedDropdownMenu( + value = selectedMember?.name ?: "All Members", + placeholder = "Filter by member", + expanded = expanded, + onExpandedChange = { expanded = it }, + onDismissRequest = { expanded = false }, + modifier = Modifier.weight(1f).padding(bottom = 16.dp), + leadingIcon = { + Box(modifier = Modifier.padding(start = 12.dp)) { + if (selectedMember != null) { + MemberAvatar(member = selectedMember, size = 24.dp) + } else Icon(Icons.Default.Group, contentDescription = null) + } + }, + content = { + DropdownMenuItem(onClick = { selectedMemberId = -1L; expanded = false }) { + Icon(Icons.Default.Group, contentDescription = null) + Spacer(modifier = Modifier.width(12.dp)) + Text("All Members") + } + allMembers.forEach { member -> + DropdownMenuItem(onClick = { selectedMemberId = member.id; expanded = false }) { + MemberAvatar(member = member, size = 24.dp) + Spacer(modifier = Modifier.width(12.dp)) + Text(member.name) + } + } } + ) + IconButton(onClick = { isSpecialMode = !isSpecialMode }, modifier = Modifier.padding(bottom = 16.dp)) { + Icon(Icons.Default.ViewColumn, contentDescription = "Focus Mode", tint = if (isSpecialMode) MaterialTheme.colors.primary else LocalContentColor.current) } } - ) + } if (totalAmount <= 0) { Box(modifier = Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { Text("No spending data for selected filter", style = MaterialTheme.typography.body1) } } else { - BoxWithConstraints(modifier = Modifier.weight(1f).fillMaxWidth()) { - val nodeHeightMember = 76.dp - val nodeHeightCategory = 76.dp - val nodeHeightTotal = 42.dp - val horizontalGap = 8.dp - - val memberCount = displayMemberSpendings.size - val categoryCount = displayCategorySpendings.size - - val maxGaps = maxOf(memberCount - 1, categoryCount - 1, 0) - val usableWidth = (maxWidth.value - horizontalGap.value * maxGaps).coerceAtLeast(0f) - val moneyScale = if (totalAmount > 0) usableWidth / totalAmount.toFloat() else 0f - - Canvas(modifier = Modifier.fillMaxSize()) { - val nodeHeightMemberPx = nodeHeightMember.toPx() - val nodeHeightCategoryPx = nodeHeightCategory.toPx() - val nodeHeightTotalPx = nodeHeightTotal.toPx() - val gapPx = horizontalGap.toPx() - - val totalWidthPx = size.width - val usableWidthPx = totalWidthPx - gapPx * maxGaps - val moneyScalePx = if (totalAmount > 0) usableWidthPx / totalAmount else 0.0 - - val totalNodeWidthPx = (totalAmount * moneyScalePx).toFloat() - val totalNodeXPx = (totalWidthPx - totalNodeWidthPx) / 2 - - val topY = 0f - val middleY = (size.height - nodeHeightTotalPx) / 2 - val bottomY = size.height - nodeHeightCategoryPx - - val totalNodeColor = Color(0xFF333333) - - // Flows - var currentXTop = (totalWidthPx - (totalAmount * moneyScalePx + (memberCount - 1).coerceAtLeast(0) * gapPx).toFloat()) / 2 - var currentXInTotalTop = totalNodeXPx - displayMemberSpendings.forEach { (memberId, amount) -> - val member = membersMap[memberId] - val boxWidth = (amount * moneyScalePx).toFloat() - val color = member?.let { - Color(it.r ?: 128, it.g ?: 128, it.b ?: 128) - } ?: Color.Gray - - if (boxWidth > 0.5f) { - drawSankeyFlow( - startX = currentXTop, - startY = topY + nodeHeightMemberPx * 0.5f, - startWidth = boxWidth, - endX = currentXInTotalTop, - endY = middleY + nodeHeightTotalPx * 0.5f, - endWidth = boxWidth, - startColor = color.copy(alpha = 0.5f), - endColor = totalNodeColor.copy(alpha = 0.35f) - ) - } - currentXTop += boxWidth + gapPx - currentXInTotalTop += boxWidth - } - - var currentXBottom = (totalWidthPx - (totalAmount * moneyScalePx + (categoryCount - 1).coerceAtLeast(0) * gapPx).toFloat()) / 2 - var currentXInTotalBottom = totalNodeXPx - displayCategorySpendings.forEach { (catRemoteId, amount) -> - val category = categoriesMap[catRemoteId] - val boxWidth = (amount * moneyScalePx).toFloat() - val color = category?.color?.let { - try { Color(it.toColorInt()) } catch (_: Exception) { Color(0xFF999999) } - } ?: Color(0xFF999999) - - if (boxWidth > 0.5f) { - drawSankeyFlow( - startX = currentXInTotalBottom, - startY = middleY + nodeHeightTotalPx * 0.5f, - startWidth = boxWidth, - endX = currentXBottom, - endY = bottomY + nodeHeightCategoryPx * 0.5f, - endWidth = boxWidth, - startColor = totalNodeColor.copy(alpha = 0.35f), - endColor = color.copy(alpha = 0.5f) - ) - } - currentXInTotalBottom += boxWidth - currentXBottom += boxWidth + gapPx - } - - // Nodes - currentXTop = (totalWidthPx - (totalAmount * moneyScalePx + (memberCount - 1).coerceAtLeast(0) * gapPx).toFloat()) / 2 - displayMemberSpendings.forEach { (memberId, amount) -> - val member = membersMap[memberId] - val width = (amount * moneyScalePx).toFloat() - val color = member?.let { - Color(it.r ?: 128, it.g ?: 128, it.b ?: 128) - } ?: Color.Gray - - if (width > 0.5f) { - drawRoundRect( - color = color, - topLeft = Offset(currentXTop, topY), - size = Size(width, nodeHeightMemberPx), - cornerRadius = CornerRadius(10.dp.toPx()) - ) - } - currentXTop += width + gapPx - } - - drawRect( - color = totalNodeColor, - topLeft = Offset(totalNodeXPx, middleY), - size = Size(totalNodeWidthPx, nodeHeightTotalPx) - ) - - currentXBottom = (totalWidthPx - (totalAmount * moneyScalePx + (categoryCount - 1).coerceAtLeast(0) * gapPx).toFloat()) / 2 - displayCategorySpendings.forEach { (catRemoteId, amount) -> - val category = categoriesMap[catRemoteId] - val width = (amount * moneyScalePx).toFloat() - val color = category?.color?.let { - try { Color(it.toColorInt()) } catch (_: Exception) { Color(0xFF999999) } - } ?: Color(0xFF999999) - - if (width > 0.5f) { - drawRoundRect( - color = color, - topLeft = Offset(currentXBottom, bottomY), - size = Size(width, nodeHeightCategoryPx), - cornerRadius = CornerRadius(10.dp.toPx()) - ) - } - currentXBottom += width + gapPx - } - } - - // Member labels - var currentXTopLabel = (maxWidth.value - (totalAmount.toFloat() * moneyScale + (memberCount - 1).coerceAtLeast(0) * horizontalGap.value)) / 2 - displayMemberSpendings.forEach { (memberId, amount) -> - val member = membersMap[memberId] - val widthValue = amount.toFloat() * moneyScale - val width = widthValue.dp - if (widthValue > 12f) { - Box( - modifier = Modifier - .offset(x = currentXTopLabel.dp, y = 0.dp) - .size(width, nodeHeightMember), - contentAlignment = Alignment.Center - ) { - if (width >= 36.dp) { - Column( - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = member?.name ?: "???", - fontSize = 16.sp, - color = Color.White, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - fontWeight = FontWeight.Bold - ) - Text( - text = formatShortValue(amount), - fontSize = 14.sp, - color = Color.White.copy(alpha = 0.9f) - ) - } - } - } - } - currentXTopLabel += widthValue + horizontalGap.value - } - - // Total label - Box( - modifier = Modifier - .align(Alignment.Center) - .fillMaxWidth() - .height(nodeHeightTotal), - contentAlignment = Alignment.Center - ) { - Text( - text = if (selectedMemberId == -1L) formatShortValue(totalAmount) else "SPENT: ${formatShortValue(totalAmount)}", - fontSize = 16.sp, - color = Color.White, - textAlign = TextAlign.Center, - fontWeight = FontWeight.ExtraBold - ) - } - - // Category labels - var currentXBottomLabel = (maxWidth.value - (totalAmount.toFloat() * moneyScale + (categoryCount - 1).coerceAtLeast(0) * horizontalGap.value)) / 2 - displayCategorySpendings.forEach { (catRemoteId, amount) -> - val category = categoriesMap[catRemoteId] - val widthValue = amount.toFloat() * moneyScale - val width = widthValue.dp - if (widthValue > 12f) { - Box( - modifier = Modifier - .align(Alignment.BottomStart) - .offset(x = currentXBottomLabel.dp) - .size(width, nodeHeightCategory), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = category?.icon ?: "❔", - fontSize = 20.sp - ) - if (width >= 32.dp) { - Text( - text = formatShortValue(amount), - fontSize = 14.sp, - color = Color.White.copy(alpha = 0.9f) - ) - } - } - } - } - currentXBottomLabel += widthValue + horizontalGap.value - } - } + SankeyContent(isSpecialMode, selectedMemberId, totalAmount, displayMemberSpendings, displayCategorySpendings, membersMap, categoriesMap) } Spacer(Modifier.fillMaxWidth().height(32.dp)) } } -private fun DrawScope.drawSankeyFlow( - startX: Float, - startY: Float, - startWidth: Float, - endX: Float, - endY: Float, - endWidth: Float, - startColor: Color, - endColor: Color +private data class NodeLayout(val centerX: Float, val visualWidth: Float) + +@SuppressLint("FrequentlyChangingValue") +@Composable +private fun SankeyContent( + isSpecialMode: Boolean, + selectedMemberId: Long, + totalAmount: Double, + displayMemberSpendings: List>, + displayCategorySpendings: List>, + membersMap: Map, + categoriesMap: Map ) { + val density = LocalDensity.current + val scope = rememberCoroutineScope() + + val topFocalIndex = remember { Animatable(0f) } + val bottomFocalIndex = remember { Animatable(0f) } + + var diagramAreaCoordinates by remember { mutableStateOf(null) } + var totalNodeCoordinates by remember { mutableStateOf(null) } + + LaunchedEffect(isSpecialMode) { + if (!isSpecialMode) { + topFocalIndex.snapTo(0f) + bottomFocalIndex.snapTo(0f) + } + } + + BoxWithConstraints(modifier = Modifier.fillMaxSize().onGloballyPositioned { diagramAreaCoordinates = it }) { + val totalMaxWidthPx = with(density) { maxWidth.toPx() } + val normalGapPx = with(density) { SankeyDimens.NormalGap.toPx() } + val focusGapPx = with(density) { SankeyDimens.FocusGap.toPx() } + val activeMinPx = with(density) { SankeyDimens.ActiveMinWidth.toPx() } + + val moneyScalePx = if (totalAmount > 0) (totalMaxWidthPx - (maxOf(displayMemberSpendings.size, displayCategorySpendings.size) - 1) * normalGapPx).coerceAtLeast(0f) / totalAmount else 0.0 + + fun calculateRowLayout(spendings: List>, focalIndex: Float): List { + val visualWidths = spendings.mapIndexed { i, (_, amount) -> + val propWidthPx = (amount * moneyScalePx).toFloat() + if (!isSpecialMode) propWidthPx + else { + val focusAmount = (1f - abs(i - focalIndex)).coerceIn(0f, 1f) + maxOf(propWidthPx, activeMinPx * focusAmount) + } + } + + return if (!isSpecialMode) { + val totalWidthPx = visualWidths.sum() + (spendings.size - 1).coerceAtLeast(0) * normalGapPx + var currentX = (totalMaxWidthPx - totalWidthPx) / 2 + visualWidths.map { w -> + NodeLayout(currentX + w / 2, w).also { currentX += w + normalGapPx } + } + } else { + val centers = mutableListOf() + var currentX = 0f + visualWidths.forEachIndexed { i, w -> + if (i > 0) currentX += visualWidths[i - 1] / 2 + focusGapPx + w / 2 + else currentX = w / 2 + centers.add(currentX) + } + + val focalPointX = if (spendings.isEmpty()) 0f else { + val idx = focalIndex.coerceIn(0f, (spendings.size - 1).toFloat()) + val low = idx.toInt() + val high = (low + 1).coerceAtMost(spendings.size - 1) + val fract = idx - low + centers[low] * (1 - fract) + centers[high] * fract + } + + centers.mapIndexed { i, c -> + NodeLayout(totalMaxWidthPx / 2 + (c - focalPointX), visualWidths[i]) + } + } + } + + val topLayout = calculateRowLayout(displayMemberSpendings, topFocalIndex.value) + val bottomLayout = calculateRowLayout(displayCategorySpendings, bottomFocalIndex.value) + + Canvas(modifier = Modifier.fillMaxSize()) { + val root = diagramAreaCoordinates ?: return@Canvas + val totalRect = totalNodeCoordinates?.let { + val pos = it.positionInRoot() - root.positionInRoot() + Rect(pos.x, pos.y, pos.x + it.size.width, pos.y + it.size.height) + } ?: return@Canvas + val flowMoneyScalePx = totalRect.width / totalAmount + val totalNodeColor = Color(0xFF333333) + + displayMemberSpendings.forEachIndexed { index, (_, amount) -> + val flowWidth = (amount * flowMoneyScalePx).toFloat() + val layout = topLayout[index] + if (flowWidth > 0.5f) { + val color = membersMap[displayMemberSpendings[index].first]?.let { Color(it.r ?: 128, it.g ?: 128, it.b ?: 128) } ?: Color.Gray + drawSankeyFlow(layout.centerX - layout.visualWidth / 2, SankeyDimens.NodeHeight.toPx() / 2, layout.visualWidth, totalRect.left + displayMemberSpendings.take(index).sumOf { it.second * flowMoneyScalePx }.toFloat(), totalRect.top + totalRect.height / 2, flowWidth, color.copy(alpha = 0.5f), totalNodeColor.copy(alpha = 0.35f)) + } + } + displayCategorySpendings.forEachIndexed { index, (_, amount) -> + val flowWidth = (amount * flowMoneyScalePx).toFloat() + val layout = bottomLayout[index] + if (flowWidth > 0.5f) { + val color = categoriesMap[displayCategorySpendings[index].first]?.color?.let { try { Color(it.toColorInt()) } catch (_: Exception) { Color(0xFF999999) } } ?: Color(0xFF999999) + drawSankeyFlow(totalRect.left + displayCategorySpendings.take(index).sumOf { it.second * flowMoneyScalePx }.toFloat(), totalRect.top + totalRect.height / 2, flowWidth, layout.centerX - layout.visualWidth / 2, size.height - SankeyDimens.NodeHeight.toPx() / 2, layout.visualWidth, totalNodeColor.copy(alpha = 0.35f), color.copy(alpha = 0.5f)) + } + } + } + + Column(modifier = Modifier.fillMaxSize()) { + // TOP Row + Box(modifier = Modifier.fillMaxWidth().height(SankeyDimens.NodeHeight).then(if (isSpecialMode) Modifier.draggable( + orientation = Orientation.Horizontal, + state = rememberDraggableState { delta -> + scope.launch { + val currentIdx = topFocalIndex.value.roundToInt().coerceIn(displayMemberSpendings.indices) + val step = topLayout[currentIdx].visualWidth + focusGapPx + topFocalIndex.snapTo((topFocalIndex.value - delta / step).coerceIn(0f, (displayMemberSpendings.size - 1).toFloat())) + } + }, + onDragStopped = { + topFocalIndex.animateTo(topFocalIndex.value.roundToInt().toFloat(), spring(Spring.DampingRatioLowBouncy, Spring.StiffnessLow)) + } + ) else Modifier)) { + displayMemberSpendings.forEachIndexed { index, (id, amount) -> + val member = membersMap[id] + val layout = topLayout[index] + val wDp = with(density) { layout.visualWidth.toDp() } + val xDp = with(density) { (layout.centerX - layout.visualWidth / 2).toDp() } + + Box(modifier = Modifier.offset(x = xDp).width(wDp).fillMaxHeight() + .then(if (isSpecialMode) Modifier.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) { + scope.launch { topFocalIndex.animateTo(index.toFloat(), spring(Spring.DampingRatioLowBouncy, Spring.StiffnessLow)) } + } else Modifier), contentAlignment = Alignment.Center) { + Column(modifier = Modifier.fillMaxSize().background(color = member?.let { Color(it.r ?: 128, it.g ?: 128, it.b ?: 128) } ?: Color.Gray, shape = RoundedCornerShape(10.dp)), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) { + Text(text = member?.name ?: "???", color = Color.White, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center, modifier = Modifier.wrapContentWidth(unbounded = true)) + if (wDp >= 40.dp) Text(text = formatShortValue(amount), fontSize = 12.sp, color = Color.White.copy(alpha = 0.9f), textAlign = TextAlign.Center) + } + } + } + } + + Box(modifier = Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { + Box(modifier = Modifier.width(with(density) { (totalAmount * moneyScalePx).toFloat().toDp() }).height(SankeyDimens.TotalNodeHeight).background(Color(0xFF333333)).onGloballyPositioned { totalNodeCoordinates = it }, contentAlignment = Alignment.Center) { + Text(text = if (selectedMemberId == -1L) formatShortValue(totalAmount) else "SPENT: ${formatShortValue(totalAmount)}", color = Color.White, fontWeight = FontWeight.ExtraBold, fontSize = 16.sp, modifier = Modifier.padding(horizontal = 8.dp), maxLines = 1) + } + } + + // BOTTOM Row + Box(modifier = Modifier.fillMaxWidth().height(SankeyDimens.NodeHeight).then(if (isSpecialMode) Modifier.draggable( + orientation = Orientation.Horizontal, + state = rememberDraggableState { delta -> + scope.launch { + val currentIdx = bottomFocalIndex.value.roundToInt().coerceIn(displayCategorySpendings.indices) + val step = bottomLayout[currentIdx].visualWidth + focusGapPx + bottomFocalIndex.snapTo((bottomFocalIndex.value - delta / step).coerceIn(0f, (displayCategorySpendings.size - 1).toFloat())) + } + }, + onDragStopped = { + bottomFocalIndex.animateTo(bottomFocalIndex.value.roundToInt().toFloat(), spring(Spring.DampingRatioLowBouncy, Spring.StiffnessLow)) + } + ) else Modifier)) { + displayCategorySpendings.forEachIndexed { index, (id, amount) -> + val category = categoriesMap[id] + val layout = bottomLayout[index] + val wDp = with(density) { layout.visualWidth.toDp() } + val xDp = with(density) { (layout.centerX - layout.visualWidth / 2).toDp() } + + Box(modifier = Modifier.offset(x = xDp).width(wDp).fillMaxHeight() + .then(if (isSpecialMode) Modifier.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) { + scope.launch { bottomFocalIndex.animateTo(index.toFloat(), spring(Spring.DampingRatioLowBouncy, Spring.StiffnessLow)) } + } else Modifier), contentAlignment = Alignment.Center) { + Column(modifier = Modifier.fillMaxSize().background(color = category?.color?.let { try { Color(it.toColorInt()) } catch (_: Exception) { Color(0xFF999999) } } ?: Color(0xFF999999), shape = RoundedCornerShape(10.dp)), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) { + Text(text = category?.icon ?: "❔", fontSize = 20.sp, textAlign = TextAlign.Center, modifier = Modifier.wrapContentWidth(unbounded = true)) + if (wDp >= 40.dp) Text(text = formatShortValue(amount), fontSize = 12.sp, color = Color.White.copy(alpha = 0.9f), textAlign = TextAlign.Center) + } + } + } + } + } + } +} + +private fun DrawScope.drawSankeyFlow(startX: Float, startY: Float, startWidth: Float, endX: Float, endY: Float, endWidth: Float, startColor: Color, endColor: Color) { val path = Path().apply { moveTo(startX, startY) - cubicTo( - startX, startY + (endY - startY) * 0.5f, - endX, endY - (endY - startY) * 0.5f, - endX, endY - ) + cubicTo(startX, startY + (endY - startY) * 0.5f, endX, endY - (endY - startY) * 0.5f, endX, endY) lineTo(endX + endWidth, endY) - cubicTo( - endX + endWidth, endY - (endY - startY) * 0.5f, - startX + startWidth, startY + (endY - startY) * 0.5f, - startX + startWidth, startY - ) + cubicTo(endX + endWidth, endY - (endY - startY) * 0.5f, startX + startWidth, startY + (endY - startY) * 0.5f, startX + startWidth, startY) close() } - drawPath( - path = path, - brush = Brush.verticalGradient( - colors = listOf(startColor, endColor), - startY = startY, - endY = endY - ) - ) + drawPath(path = path, brush = Brush.verticalGradient(colors = listOf(startColor, endColor), startY = startY, endY = endY)) } @Preview(showBackground = true, widthDp = 360, heightDp = 640) @Composable -fun ProjectSankeyDiagramPreview() { - MaterialTheme { - ProjectSankeyDiagram( - projectName = "Test Project", - allMembers = StatisticsMockData.members, - allBills = StatisticsMockData.bills, - customCategories = emptyList(), - onShareReady = {} - ) - } -} +fun ProjectSankeyDiagramPreview() = MaterialTheme { ProjectSankeyDiagram("Test Project", 1L, "1", StatisticsMockData.members, StatisticsMockData.bills, emptyList()) {} } diff --git a/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectSpendingGraph.kt b/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectSpendingGraph.kt index 1db969f..8c69aaf 100644 --- a/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectSpendingGraph.kt +++ b/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectSpendingGraph.kt @@ -43,10 +43,10 @@ fun ProjectSpendingGraph( allBills: List, onShareReady: (String) -> Unit ) { - val shareStatsIntro = stringResource(R.string.share_stats_intro, projectName) + val shareStatsIntro = stringResource(R.string.msg_stats_intro, projectName) if (allBills.isEmpty()) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text("No data to display", style = MaterialTheme.typography.h6) + Text("No data to display", style = MaterialTheme.typography.subtitle1, fontWeight = FontWeight.Bold, color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)) } return } diff --git a/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectStatisticsActivity.kt b/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectStatisticsActivity.kt index 8ff5c6a..5677935 100644 --- a/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectStatisticsActivity.kt +++ b/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectStatisticsActivity.kt @@ -3,6 +3,7 @@ package net.helcel.cowspent.android.statistics import android.content.Context import android.content.Intent import android.os.Bundle +import androidx.activity.enableEdgeToEdge import androidx.activity.compose.setContent import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.lifecycleScope @@ -15,6 +16,7 @@ import net.helcel.cowspent.theme.ThemeUtils class ProjectStatisticsActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() super.onCreate(savedInstanceState) val projectId = intent.getLongExtra(EXTRA_PROJECT_ID, -1L) diff --git a/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectStatisticsScreen.kt b/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectStatisticsScreen.kt index 7e19785..b231c7c 100644 --- a/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectStatisticsScreen.kt +++ b/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectStatisticsScreen.kt @@ -35,7 +35,7 @@ fun ProjectStatisticsScreen( } var currentShareText by remember { mutableStateOf("") } val tabs = listOf( - stringResource(R.string.statistic_title), + stringResource(R.string.title_stats), "Trend", "Sankey" ) @@ -43,7 +43,7 @@ fun ProjectStatisticsScreen( Scaffold( topBar = { TopAppBar( - title = { Text(stringResource(R.string.statistic_title)) }, + title = { Text(stringResource(R.string.title_stats)) }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) @@ -84,6 +84,21 @@ fun ProjectStatisticsScreen( val statsData by produceState(null, proj.id) { value = withContext(Dispatchers.IO) { + val syncedCategories = db.getCategories(proj.id) + val defaultCategories = CategoryUtils.getDefaultCategories(context, proj.id) + + val toEnsure = if (proj.type == ProjectType.LOCAL) { + defaultCategories + } else { + listOfNotNull(defaultCategories.find { it.remoteId == DBBill.CATEGORY_REIMBURSEMENT }) + } + + toEnsure.forEach { def -> + if (syncedCategories.none { it.remoteId == def.remoteId }) { + db.addCategory(def) + } + } + val members = db.getMembersOfProject(proj.id, null) val bills = db.getBillsOfProject(proj.id) val categories = db.getCategories(proj.id) @@ -94,19 +109,12 @@ fun ProjectStatisticsScreen( if (statsData != null) { val data = statsData!! - val defaultCategories = remember(proj.id) { CategoryUtils.getDefaultCategories(context, proj.id) } - val categories = remember(proj.type, data.categories, defaultCategories) { - val hardcoded = if (proj.type == ProjectType.LOCAL) { - defaultCategories - } else { - listOfNotNull(defaultCategories.find { it.remoteId.toInt() == DBBill.CATEGORY_REIMBURSEMENT }) - } - (data.categories + hardcoded).distinctBy { it.remoteId } - } + val categories = data.categories + val categoryNoneLabel = stringResource(R.string.category_none) - val sankeyCategories = remember(proj.id, data.categories, defaultCategories, categoryNoneLabel) { + val sankeyCategories = remember(proj.id, data.categories, categoryNoneLabel) { val noneCategory = DBCategory(0, 0, proj.id, categoryNoneLabel, "❌", "#9E9E9E") - (data.categories + defaultCategories + noneCategory).distinctBy { it.remoteId } + (data.categories + noneCategory).distinctBy { if (it.id == 0L) "none" else it.id.toString() } } when (selectedTab) { @@ -131,6 +139,8 @@ fun ProjectStatisticsScreen( 2 -> { ProjectSankeyDiagram( projectName = proj.name.ifEmpty { proj.remoteId }, + projectId = proj.id, + projectRemoteId = proj.remoteId, allMembers = data.members, allBills = data.bills, customCategories = sankeyCategories, diff --git a/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectStatisticsTable.kt b/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectStatisticsTable.kt index b157c1e..71f21a1 100644 --- a/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectStatisticsTable.kt +++ b/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectStatisticsTable.kt @@ -44,8 +44,8 @@ fun ProjectStatisticsTable( val sdf = remember { SimpleDateFormat("yyyy-MM-dd", Locale.ROOT) } val dateFormat = remember { android.text.format.DateFormat.getDateFormat(context) } - var categoryId by remember { mutableIntStateOf(-1000) } - var paymentModeId by remember { mutableIntStateOf(-1000) } + var categoryId by remember { mutableLongStateOf(-1000L) } + var paymentModeId by remember { mutableLongStateOf(-1000L) } var dateMin by remember { mutableStateOf(null) } var dateMax by remember { mutableStateOf(null) } @@ -57,14 +57,14 @@ fun ProjectStatisticsTable( val paymentModeAll = stringResource(R.string.payment_mode_all) val paymentModeNone = stringResource(R.string.payment_mode_none) - val shareStatsHeader = stringResource(R.string.share_stats_header) - val shareStatsIntro = stringResource(R.string.share_stats_intro, proj.name.ifEmpty { proj.remoteId }) + val shareStatsHeader = stringResource(R.string.msg_stats_header) + val shareStatsIntro = stringResource(R.string.msg_stats_intro, proj.name.ifEmpty { proj.remoteId }) val categories = remember(proj.id, customCategories, categoryAll, categoryNone, categoryReimbursement, categoryAllExceptReimbursement) { - val list = mutableListOf>() - list.add(Triple(-1000, "📋", categoryAll)) - list.add(Triple(-100, "🧾", categoryAllExceptReimbursement)) - list.add(Triple(0, "❌", categoryNone)) + val list = mutableListOf>() + list.add(Triple(-1000L, "📋", categoryAll)) + list.add(Triple(-100L, "🧾", categoryAllExceptReimbursement)) + list.add(Triple(0L, "❌", categoryNone)) val catsToUse = if (proj.type == ProjectType.LOCAL) { CategoryUtils.getDefaultCategories(context, proj.id) @@ -75,15 +75,15 @@ fun ProjectStatisticsTable( } catsToUse.forEach { - list.add(Triple(it.remoteId.toInt(), it.icon, it.name ?: "")) + list.add(Triple(it.id, it.icon, it.name ?: "")) } list.distinctBy { it.first } } val paymentModes = remember(proj.id, customPaymentModes, paymentModeAll, paymentModeNone) { - val list = mutableListOf>() - list.add(Triple(-1000, "💳", paymentModeAll)) - list.add(Triple(0, "❌", paymentModeNone)) + val list = mutableListOf>() + list.add(Triple(-1000L, "💳", paymentModeAll)) + list.add(Triple(0L, "❌", paymentModeNone)) val pmsToUse = if (proj.type == ProjectType.LOCAL) { CategoryUtils.getDefaultPaymentModes(context, proj.id) @@ -94,7 +94,7 @@ fun ProjectStatisticsTable( } pmsToUse.forEach { - list.add(Triple(it.remoteId.toInt(), it.icon, it.name ?: "")) + list.add(Triple(it.id, it.icon, it.name ?: "")) } list.distinctBy { it.first } } @@ -105,10 +105,12 @@ fun ProjectStatisticsTable( val membersPaid = HashMap() val membersSpent = HashMap() + val reimbursementCategoryId = CategoryUtils.getReimbursementCategoryId(customCategories, proj.id, proj.remoteId) + SupportUtil.getStats( allMembers, allBills, membersNbBills, membersBalance, membersPaid, membersSpent, - categoryId, paymentModeId, dateMin, dateMax + categoryId, paymentModeId, reimbursementCategoryId, dateMin, dateMax ) var statsText = shareStatsIntro + "\n\n" @@ -164,12 +166,12 @@ fun ProjectStatisticsTable( EditableExposedDropdownMenu( value = selectedCategory?.third ?: "", - placeholder = stringResource(R.string.setting_category), + placeholder = stringResource(R.string.label_category), expanded = categoryExpanded, onExpandedChange = { categoryExpanded = it }, onDismissRequest = { categoryExpanded = false }, leadingIcon = { - Box(modifier = Modifier.padding(start = 12.dp)) { + Box(modifier = Modifier) { if (selectedCategory != null) { Text(text = selectedCategory.second, fontSize = 20.sp) } else { @@ -198,12 +200,12 @@ fun ProjectStatisticsTable( EditableExposedDropdownMenu( value = selectedPm?.third ?: "", - placeholder = stringResource(R.string.setting_payment_mode), + placeholder = stringResource(R.string.label_mode), expanded = pmExpanded, onExpandedChange = { pmExpanded = it }, onDismissRequest = { pmExpanded = false }, leadingIcon = { - Box(modifier = Modifier.padding(start = 12.dp)) { + Box(modifier = Modifier) { if (selectedPm != null) { Text(text = selectedPm.second, fontSize = 20.sp) } else { @@ -269,17 +271,15 @@ fun ProjectStatisticsTable( Spacer(modifier = Modifier.height(24.dp)) - // Table Header Surface( - color = MaterialTheme.colors.onSurface.copy(alpha = 0.05f), - shape = MaterialTheme.shapes.small, + color = MaterialTheme.colors.background, modifier = Modifier.fillMaxWidth() ) { - Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp)) { - Text(stringResource(R.string.stats_who), modifier = Modifier.weight(2f), fontWeight = FontWeight.Bold, color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f), fontSize = 12.sp) - Text(stringResource(R.string.stats_paid), modifier = Modifier.weight(1.5f), fontWeight = FontWeight.Bold, textAlign = TextAlign.End, color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f), fontSize = 12.sp) - Text(stringResource(R.string.stats_spent), modifier = Modifier.weight(1.5f), fontWeight = FontWeight.Bold, textAlign = TextAlign.End, color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f), fontSize = 12.sp) - Text(stringResource(R.string.stats_balance), modifier = Modifier.weight(1.5f), fontWeight = FontWeight.Bold, textAlign = TextAlign.End, color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f), fontSize = 12.sp) + Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 12.dp)) { + Text(stringResource(R.string.stats_who).uppercase(), modifier = Modifier.weight(2f), fontWeight = FontWeight.Bold, color = MaterialTheme.colors.onSurface, style = MaterialTheme.typography.overline) + Text(stringResource(R.string.stats_paid).uppercase(), modifier = Modifier.weight(1.5f), fontWeight = FontWeight.Bold, textAlign = TextAlign.End, color = MaterialTheme.colors.onSurface, style = MaterialTheme.typography.overline) + Text(stringResource(R.string.stats_spent).uppercase(), modifier = Modifier.weight(1.5f), fontWeight = FontWeight.Bold, textAlign = TextAlign.End, color = MaterialTheme.colors.onSurface, style = MaterialTheme.typography.overline) + Text(stringResource(R.string.stats_balance).uppercase(), modifier = Modifier.weight(1.5f), fontWeight = FontWeight.Bold, textAlign = TextAlign.End, color = MaterialTheme.colors.onSurface, style = MaterialTheme.typography.overline) } } diff --git a/app/src/main/java/net/helcel/cowspent/model/DBAccountProject.kt b/app/src/main/java/net/helcel/cowspent/model/DBAccountProject.kt index b53ccd0..e295969 100644 --- a/app/src/main/java/net/helcel/cowspent/model/DBAccountProject.kt +++ b/app/src/main/java/net/helcel/cowspent/model/DBAccountProject.kt @@ -12,6 +12,6 @@ class DBAccountProject( ) : Serializable { override fun toString(): String { - return "#DBAccountProject$id/$remoteId,$name, $ncUrl, $password, archivedTs=$archivedTs" + return "#DBAccountProject$id/$remoteId,$name, $ncUrl, [SECURE], archivedTs=$archivedTs" } } diff --git a/app/src/main/java/net/helcel/cowspent/model/DBBill.kt b/app/src/main/java/net/helcel/cowspent/model/DBBill.kt index 2ca7d20..66b85c4 100644 --- a/app/src/main/java/net/helcel/cowspent/model/DBBill.kt +++ b/app/src/main/java/net/helcel/cowspent/model/DBBill.kt @@ -1,6 +1,5 @@ package net.helcel.cowspent.model -import android.util.Log import java.io.Serializable import java.util.Calendar import java.util.Locale @@ -16,9 +15,9 @@ open class DBBill( var state: Int, var repeat: String?, var paymentMode: String?, - var categoryRemoteId: Int, + var categoryId: Long, var comment: String?, - var paymentModeRemoteId: Int + var paymentModeId: Long ) : Item, Serializable { var formattedWhat: String = "" @@ -39,7 +38,6 @@ open class DBBill( get() { val cal = Calendar.getInstance() cal.timeInMillis = timestamp * 1000 - Log.v("ll", "[$what] get date ts $timestamp year ${cal[Calendar.YEAR]}") val month = cal[Calendar.MONTH] + 1 val day = cal[Calendar.DAY_OF_MONTH] return "${cal[Calendar.YEAR]}-${String.format(Locale.ROOT, "%02d", month)}-${ @@ -58,7 +56,7 @@ open class DBBill( override fun toString(): String { - return "#DBBill$id/$remoteId,$projectId, $payerId, $amount, $timestamp, $what, $state, $repeat, $paymentMode, $categoryRemoteId" + return "#DBBill$id/$remoteId,$projectId, $payerId, $amount, $timestamp, $what, $state, $repeat, $paymentMode, $categoryId" } override fun isSection(): Boolean { @@ -73,15 +71,15 @@ open class DBBill( const val PAYMODE_TRANSFER = "t" const val PAYMODE_ONLINE_SERVICE = "o" - const val PAYMODE_ID_NONE = 0 - const val PAYMODE_ID_CARD = -1 - const val PAYMODE_ID_CASH = -2 - const val PAYMODE_ID_CHECK = -3 - const val PAYMODE_ID_TRANSFER = -4 - const val PAYMODE_ID_ONLINE_SERVICE = -5 + const val PAYMODE_ID_NONE = 0L + const val PAYMODE_ID_CARD = -1L + const val PAYMODE_ID_CASH = -2L + const val PAYMODE_ID_CHECK = -3L + const val PAYMODE_ID_TRANSFER = -4L + const val PAYMODE_ID_ONLINE_SERVICE = -5L @JvmField - val oldPmIdToNew: Map = object : HashMap() { + val oldPmIdToNew: Map = object : HashMap() { init { put(PAYMODE_NONE, PAYMODE_ID_NONE) put(PAYMODE_CARD, PAYMODE_ID_CARD) @@ -92,19 +90,19 @@ open class DBBill( } } - const val CATEGORY_NONE = 0 - const val CATEGORY_GROCERIES = -1 - const val CATEGORY_LEISURE = -2 - const val CATEGORY_RENT = -3 - const val CATEGORY_BILLS = -4 - const val CATEGORY_CULTURE = -5 - const val CATEGORY_HEALTH = -6 - const val CATEGORY_SHOPPING = -10 - const val CATEGORY_REIMBURSEMENT = -11 - const val CATEGORY_RESTAURANT = -12 - const val CATEGORY_ACCOMMODATION = -13 - const val CATEGORY_TRANSPORT = -14 - const val CATEGORY_SPORT = -15 + const val CATEGORY_NONE = 0L + const val CATEGORY_GROCERIES = -1L + const val CATEGORY_LEISURE = -2L + const val CATEGORY_RENT = -3L + const val CATEGORY_BILLS = -4L + const val CATEGORY_CULTURE = -5L + const val CATEGORY_HEALTH = -6L + const val CATEGORY_SHOPPING = -10L + const val CATEGORY_REIMBURSEMENT = -11L + const val CATEGORY_RESTAURANT = -12L + const val CATEGORY_ACCOMMODATION = -13L + const val CATEGORY_TRANSPORT = -14L + const val CATEGORY_SPORT = -15L const val STATE_OK = 0 const val STATE_ADDED = 1 diff --git a/app/src/main/java/net/helcel/cowspent/model/DBCategory.kt b/app/src/main/java/net/helcel/cowspent/model/DBCategory.kt index ff71c93..bca3ddd 100644 --- a/app/src/main/java/net/helcel/cowspent/model/DBCategory.kt +++ b/app/src/main/java/net/helcel/cowspent/model/DBCategory.kt @@ -8,7 +8,8 @@ class DBCategory( var projectId: Long, var name: String?, var icon: String, - var color: String + var color: String, + var state: Int = DBBill.STATE_OK ) : Serializable { override fun toString(): String { diff --git a/app/src/main/java/net/helcel/cowspent/model/DBPaymentMode.kt b/app/src/main/java/net/helcel/cowspent/model/DBPaymentMode.kt index a35345d..0b14c6e 100644 --- a/app/src/main/java/net/helcel/cowspent/model/DBPaymentMode.kt +++ b/app/src/main/java/net/helcel/cowspent/model/DBPaymentMode.kt @@ -8,7 +8,8 @@ class DBPaymentMode( var projectId: Long, var name: String?, var icon: String, - var color: String + var color: String, + var state: Int = DBBill.STATE_OK ) : Serializable { override fun toString(): String { diff --git a/app/src/main/java/net/helcel/cowspent/model/GroupedBill.kt b/app/src/main/java/net/helcel/cowspent/model/GroupedBill.kt index 51947a4..99847c4 100644 --- a/app/src/main/java/net/helcel/cowspent/model/GroupedBill.kt +++ b/app/src/main/java/net/helcel/cowspent/model/GroupedBill.kt @@ -15,9 +15,9 @@ class GroupedBill( sourceBills.first().state, sourceBills.first().repeat, sourceBills.first().paymentMode, - sourceBills.first().categoryRemoteId, + sourceBills.first().categoryId, sourceBills.first().comment, - sourceBills.first().paymentModeRemoteId + sourceBills.first().paymentModeId ), Serializable { init { this.formattedWhat = sourceBills.first().formattedWhat diff --git a/app/src/main/java/net/helcel/cowspent/persistence/CowspentSQLiteOpenHelper.kt b/app/src/main/java/net/helcel/cowspent/persistence/CowspentSQLiteOpenHelper.kt index cc73377..e4b082e 100644 --- a/app/src/main/java/net/helcel/cowspent/persistence/CowspentSQLiteOpenHelper.kt +++ b/app/src/main/java/net/helcel/cowspent/persistence/CowspentSQLiteOpenHelper.kt @@ -7,15 +7,22 @@ import android.database.Cursor import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteOpenHelper import android.text.TextUtils -import android.util.Log import androidx.annotation.WorkerThread import androidx.preference.PreferenceManager import net.helcel.cowspent.R -import net.helcel.cowspent.android.main.BillsListViewActivity -import net.helcel.cowspent.model.* +import net.helcel.cowspent.model.DBAccountProject +import net.helcel.cowspent.model.DBBill +import net.helcel.cowspent.model.DBBillOwer +import net.helcel.cowspent.model.DBCategory +import net.helcel.cowspent.model.DBCurrency +import net.helcel.cowspent.model.DBMember +import net.helcel.cowspent.model.DBPaymentMode +import net.helcel.cowspent.model.DBProject +import net.helcel.cowspent.model.ProjectType +import net.helcel.cowspent.util.CategoryUtils +import net.helcel.cowspent.util.SecureStorage import net.helcel.cowspent.util.SupportUtil -import java.text.SimpleDateFormat -import java.util.* +import java.util.Locale /** * Helps to add, get, update and delete bills, members, projects with the option to trigger a sync with the server. @@ -124,7 +131,8 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) : "$key_projectid INTEGER, " + "$key_name TEXT, " + "$key_icon TEXT, " + - "$key_color TEXT)" + "$key_color TEXT, " + + "$key_state INTEGER DEFAULT 0)" ) } @@ -136,7 +144,8 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) : "$key_projectid INTEGER, " + "$key_name TEXT, " + "$key_icon TEXT, " + - "$key_color TEXT)" + "$key_color TEXT, " + + "$key_state INTEGER DEFAULT 0)" ) } @@ -153,123 +162,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) : } @SuppressLint("Range") - override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { - if (oldVersion < 2) { - db.execSQL("ALTER TABLE $table_projects ADD COLUMN $key_lastPayerId INTEGER DEFAULT 0") - } - if (oldVersion < 3) { - db.execSQL("ALTER TABLE $table_bills ADD COLUMN $key_repeat TEXT") - } - if (oldVersion < 4) { - db.execSQL("ALTER TABLE $table_projects ADD COLUMN $key_type TEXT") - val projects = getProjectsCustom("", arrayOf(), default_order, db) - for (project in projects) { - val url = project.serverUrl - project.type = if (url == null) ProjectType.LOCAL else ProjectType.COSPEND - updateProject( - project.id, project.name, project.email, - project.password, project.lastPayerId, project.type, - project.lastSyncedTimestamp, project.currencyName, - project.isDeletionDisabled, project.myAccessLevel, project.bearerToken, - project.archivedTs, db - ) - } - } - if (oldVersion < 5) { - createTableAccountProjects(db) - createIndex(db, table_account_projects) - } - if (oldVersion < 6) { - db.execSQL("ALTER TABLE $table_bills ADD COLUMN $key_payment_mode TEXT DEFAULT \"n\"") - db.execSQL("ALTER TABLE $table_bills ADD COLUMN $key_category_id INTEGER DEFAULT 0") - } - if (oldVersion < 7) { - db.execSQL("ALTER TABLE $table_members ADD COLUMN $key_r INTEGER DEFAULT NULL") - db.execSQL("ALTER TABLE $table_members ADD COLUMN $key_g INTEGER DEFAULT NULL") - db.execSQL("ALTER TABLE $table_members ADD COLUMN $key_b INTEGER DEFAULT NULL") - } - if (oldVersion < 8) { - db.execSQL("ALTER TABLE $table_projects ADD COLUMN $key_lastSyncTimestamp INTEGER DEFAULT 0") - } - if (oldVersion < 9) { - createTableCategories(db) - createIndex(db, table_categories) - } - if (oldVersion < 10) { - db.execSQL("ALTER TABLE $table_projects ADD COLUMN $key_currencyName TEXT") - createTableCurrencies(db) - createIndex(db, table_currencies) - } - if (oldVersion < 11) { - db.execSQL("ALTER TABLE $table_bills ADD COLUMN $key_timestamp INTEGER") - val idToTs: MutableMap = HashMap() - val sdfDate = SimpleDateFormat("yyyy-MM-dd", Locale.ROOT) - val cursor = db.query(table_bills, arrayOf(key_id, "DATE"), "", arrayOf(), null, null, null) - val dateNow = Date() - while (cursor.moveToNext()) { - val id = cursor.getLong(cursor.getColumnIndex(key_id)) - val dateStr = cursor.getString(cursor.getColumnIndex("DATE")) - val date = try { - sdfDate.parse(dateStr) - } catch (_: Exception) { - dateNow - } - val timestamp = (date?.time ?: System.currentTimeMillis()) / 1000 - idToTs[id] = timestamp - } - cursor.close() - for (billId in idToTs.keys) { - val timestamp = idToTs[billId]!! - val values = ContentValues() - values.put(key_timestamp, timestamp) - db.update(table_bills, values, "$key_id = ?", arrayOf(billId.toString())) - } - } - if (oldVersion < 12) { - db.execSQL("ALTER TABLE $table_members ADD COLUMN $key_nc_userid TEXT DEFAULT NULL") - db.execSQL("ALTER TABLE $table_members ADD COLUMN $key_avatar TEXT DEFAULT NULL") - } - if (oldVersion < 13) { - db.execSQL("ALTER TABLE $table_bills ADD COLUMN $key_comment TEXT DEFAULT \"\"") - } - if (oldVersion < 14) { - val projects = getProjectsCustom("", arrayOf(), default_order, db) - for (project in projects) { - updateProject( - project.id, project.name, project.email, - project.password, project.lastPayerId, project.type, - 0L, project.currencyName, project.isDeletionDisabled, project.myAccessLevel, project.bearerToken, - project.archivedTs, db - ) - } - } - if (oldVersion < 15) { - db.execSQL("ALTER TABLE $table_bills ADD COLUMN $key_payment_mode_id INTEGER DEFAULT 0") - for (key in DBBill.oldPmIdToNew.keys) { - val values = ContentValues() - values.put(key_payment_mode_id, DBBill.oldPmIdToNew[key]) - db.update(table_bills, values, "$key_payment_mode = ?", arrayOf(key)) - } - createTablePaymentModes(db) - createIndex(db, table_payment_modes) - } - if (oldVersion < 16) { - db.execSQL("ALTER TABLE $table_currencies ADD COLUMN $key_state INTEGER DEFAULT ${DBBill.STATE_OK}") - } - if (oldVersion < 17) { - db.execSQL("ALTER TABLE $table_projects ADD COLUMN $key_deletionDisabled INTEGER") - } - if (oldVersion < 18) { - db.execSQL("ALTER TABLE $table_projects ADD COLUMN $key_myAccessLevel INTEGER DEFAULT ${DBProject.ACCESS_LEVEL_UNKNOWN}") - } - if (oldVersion < 19) { - db.execSQL("ALTER TABLE $table_projects ADD COLUMN $key_bearer_token TEXT") - } - if (oldVersion < 20) { - db.execSQL("ALTER TABLE $table_projects ADD COLUMN $key_archived INTEGER DEFAULT 0") - db.execSQL("ALTER TABLE $table_account_projects ADD COLUMN $key_archived INTEGER DEFAULT 0") - } - } + override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {} override fun onDowngrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { recreateDatabase(db) @@ -312,306 +205,21 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) : db.execSQL("CREATE INDEX IF NOT EXISTS $indexName ON $table($key_id)") } - fun addAccountProject(accountProject: DBAccountProject): Long { - val db = writableDatabase - val values = ContentValues() - values.put(key_remoteId, accountProject.remoteId) - values.put(key_password, accountProject.password) - values.put(key_ncUrl, accountProject.ncUrl) - values.put(key_name, accountProject.name) - values.put(key_archived, accountProject.archivedTs ?: 0L) - return db.insert(table_account_projects, null, values) - } - - val accountProjects: List - get() = getAccountProjectsCustom("", arrayOf(), default_order) - - @WorkerThread - private fun getAccountProjectsCustom(selection: String, selectionArgs: Array, orderBy: String?): List { - return getAccountProjectsCustom(selection, selectionArgs, orderBy, readableDatabase) - } - - @WorkerThread - private fun getAccountProjectsCustom(selection: String, selectionArgs: Array, orderBy: String?, db: SQLiteDatabase): List { - val cursor = db.query(table_account_projects, columnsAccountProjects, selection, selectionArgs, null, null, orderBy) - val accountProjects: MutableList = ArrayList() - while (cursor.moveToNext()) { - accountProjects.add(getAccountProjectFromCursor(cursor)) - } - cursor.close() - return accountProjects - } - - @SuppressLint("Range") - private fun getAccountProjectFromCursor(cursor: Cursor): DBAccountProject { - val archivedTs = cursor.getLong(cursor.getColumnIndex(key_archived)) - return DBAccountProject( - cursor.getLong(cursor.getColumnIndex(key_id)), - cursor.getString(cursor.getColumnIndex(key_remoteId)), - cursor.getString(cursor.getColumnIndex(key_password)), - cursor.getString(cursor.getColumnIndex(key_name)), - cursor.getString(cursor.getColumnIndex(key_ncUrl)), - if (archivedTs > 0) archivedTs else null - ) - } - - fun clearAccountProjects() { - val db = writableDatabase - db.delete(table_account_projects, null, null) - } - - fun addPaymentMode(paymentMode: DBPaymentMode): Long { - val db = writableDatabase - val values = ContentValues() - values.put(key_remoteId, paymentMode.remoteId) - values.put(key_projectid, paymentMode.projectId) - values.put(key_name, paymentMode.name) - values.put(key_icon, paymentMode.icon) - values.put(key_color, paymentMode.color) - return db.insert(table_payment_modes, null, values) - } - - fun getPaymentMode(remoteId: Long, projectId: Long): DBPaymentMode? { - val paymentModes = getPaymentModesCustom( - "$key_remoteId = ? AND $key_projectid = ?", - arrayOf(remoteId.toString(), projectId.toString()), - null - ) - return if (paymentModes.isEmpty()) null else paymentModes[0] - } - - fun getPaymentModes(projectId: Long): List { - return getPaymentModesCustom("$key_projectid = ?", arrayOf(projectId.toString()), null) - } - - @WorkerThread - private fun getPaymentModesCustom(selection: String, selectionArgs: Array, orderBy: String?): List { - return getPaymentModesCustom(selection, selectionArgs, orderBy, readableDatabase) - } - - @WorkerThread - private fun getPaymentModesCustom(selection: String, selectionArgs: Array, orderBy: String?, db: SQLiteDatabase): List { - val cursor = db.query(table_payment_modes, columnsPaymentModes, selection, selectionArgs, null, null, orderBy) - val paymentModes: MutableList = ArrayList() - while (cursor.moveToNext()) { - paymentModes.add(getPaymentModeFromCursor(cursor)) - } - cursor.close() - return paymentModes - } - - @SuppressLint("Range") - private fun getPaymentModeFromCursor(cursor: Cursor): DBPaymentMode { - return DBPaymentMode( - cursor.getLong(cursor.getColumnIndex(key_id)), - cursor.getLong(cursor.getColumnIndex(key_remoteId)), - cursor.getLong(cursor.getColumnIndex(key_projectid)), - cursor.getString(cursor.getColumnIndex(key_name)), - cursor.getString(cursor.getColumnIndex(key_icon)), - cursor.getString(cursor.getColumnIndex(key_color)) - ) - } - - fun updatePaymentMode(id: Long, name: String?, icon: String?, color: String?) { - val db = writableDatabase - val values = ContentValues() - if (name != null) values.put(key_name, name) - if (icon != null) values.put(key_icon, icon) - if (color != null) values.put(key_color, color) - if (values.size() > 0) { - db.update(table_payment_modes, values, "$key_id = ?", arrayOf(id.toString())) - } - } - - fun deletePaymentMode(id: Long) { - val db = writableDatabase - db.delete(table_payment_modes, "$key_id = ?", arrayOf(id.toString())) - } - - fun addCategory(category: DBCategory): Long { - val db = writableDatabase - val values = ContentValues() - values.put(key_remoteId, category.remoteId) - values.put(key_projectid, category.projectId) - values.put(key_name, category.name) - values.put(key_icon, category.icon) - values.put(key_color, category.color) - return db.insert(table_categories, null, values) - } - - fun getCategory(remoteId: Long, projectId: Long): DBCategory? { - val categories = getCategoriesCustom( - "$key_remoteId = ? AND $key_projectid = ?", - arrayOf(remoteId.toString(), projectId.toString()), - null - ) - return if (categories.isEmpty()) null else categories[0] - } - - fun getCategories(projectId: Long): List { - return getCategoriesCustom("$key_projectid = ?", arrayOf(projectId.toString()), null) - } - - @WorkerThread - private fun getCategoriesCustom(selection: String, selectionArgs: Array, orderBy: String?): List { - return getCategoriesCustom(selection, selectionArgs, orderBy, readableDatabase) - } - - @WorkerThread - private fun getCategoriesCustom(selection: String, selectionArgs: Array, orderBy: String?, db: SQLiteDatabase): List { - val cursor = db.query(table_categories, columnsCategories, selection, selectionArgs, null, null, orderBy) - val categories: MutableList = ArrayList() - while (cursor.moveToNext()) { - categories.add(getCategoryFromCursor(cursor)) - } - cursor.close() - return categories - } - - @SuppressLint("Range") - private fun getCategoryFromCursor(cursor: Cursor): DBCategory { - return DBCategory( - cursor.getLong(cursor.getColumnIndex(key_id)), - cursor.getLong(cursor.getColumnIndex(key_remoteId)), - cursor.getLong(cursor.getColumnIndex(key_projectid)), - cursor.getString(cursor.getColumnIndex(key_name)), - cursor.getString(cursor.getColumnIndex(key_icon)), - cursor.getString(cursor.getColumnIndex(key_color)) - ) - } - - fun updateCategory(id: Long, name: String?, icon: String?, color: String?) { - val db = writableDatabase - val values = ContentValues() - if (name != null) values.put(key_name, name) - if (icon != null) values.put(key_icon, icon) - if (color != null) values.put(key_color, color) - if (values.size() > 0) { - db.update(table_categories, values, "$key_id = ?", arrayOf(id.toString())) - } - } - - fun deleteCategory(id: Long) { - val db = writableDatabase - db.delete(table_categories, "$key_id = ?", arrayOf(id.toString())) - } - - fun addCurrency(currency: DBCurrency): Long { - val db = writableDatabase - val values = ContentValues() - values.put(key_remoteId, currency.remoteId) - values.put(key_projectid, currency.projectId) - values.put(key_name, currency.name) - values.put(key_exchangeRate, currency.exchangeRate) - values.put(key_state, currency.state) - return db.insert(table_currencies, null, values) - } - - fun addCurrencyAndSync(m: DBCurrency) { - addCurrency(m) - val proj = getProject(m.projectId) - if (proj != null) syncIfRemote(proj) - } - - fun syncIfRemote(proj: DBProject) { - if (!proj.isLocal) { - val preferences = PreferenceManager.getDefaultSharedPreferences(context) - val offlineMode = preferences.getBoolean(context.getString(R.string.pref_key_offline_mode), false) - if (!offlineMode) { - cowspentServerSyncHelper.scheduleSync(true, proj.id) - } - } - } - - fun getCurrency(remoteId: Long, projectId: Long): DBCurrency? { - val currencies = getCurrenciesCustom( - "$key_remoteId = ? AND $key_projectid = ?", - arrayOf(remoteId.toString(), projectId.toString()), - null - ) - return if (currencies.isEmpty()) null else currencies[0] - } - - fun getCurrency(id: Long): DBCurrency? { - val currencies = getCurrenciesCustom("$key_id = ?", arrayOf(id.toString()), null) - return if (currencies.isEmpty()) null else currencies[0] - } - - fun updateCurrency(id: Long, name: String?, exchangeRate: Double?) { - val db = writableDatabase - val values = ContentValues() - if (name != null) values.put(key_name, name) - if (exchangeRate != null) values.put(key_exchangeRate, exchangeRate) - if (values.size() > 0) { - db.update(table_currencies, values, "$key_id = ?", arrayOf(id.toString())) - } - } - - fun deleteCurrency(id: Long) { - val db = writableDatabase - db.delete(table_currencies, "$key_id = ?", arrayOf(id.toString())) - } - - fun getCurrencies(projectId: Long): List { - return getCurrenciesCustom("$key_projectid = ?", arrayOf(projectId.toString()), null) - } - - @WorkerThread - private fun getCurrenciesCustom(selection: String, selectionArgs: Array, orderBy: String?): List { - return getCurrenciesCustom(selection, selectionArgs, orderBy, readableDatabase) - } - - @WorkerThread - private fun getCurrenciesCustom(selection: String, selectionArgs: Array, orderBy: String?, db: SQLiteDatabase): List { - val cursor = db.query(table_currencies, columnsCurrencies, selection, selectionArgs, null, null, orderBy) - val currencies: MutableList = ArrayList() - while (cursor.moveToNext()) { - currencies.add(getCurrencyFromCursor(cursor)) - } - cursor.close() - return currencies - } - - @SuppressLint("Range") - private fun getCurrencyFromCursor(cursor: Cursor): DBCurrency { - return DBCurrency( - cursor.getLong(cursor.getColumnIndex(key_id)), - cursor.getLong(cursor.getColumnIndex(key_remoteId)), - cursor.getLong(cursor.getColumnIndex(key_projectid)), - cursor.getString(cursor.getColumnIndex(key_name)), - cursor.getDouble(cursor.getColumnIndex(key_exchangeRate)), - cursor.getInt(cursor.getColumnIndex(key_state)) - ) - } - - fun setCurrencyStateSync(currencyId: Long, state: Int) { - setCurrencyState(currencyId, state) - val currency = getCurrency(currencyId) - if (currency != null) { - val project = getProject(currency.projectId) - if (project != null) syncIfRemote(project) - } - } - - fun setCurrencyState(currencyId: Long, state: Int) { - val db = writableDatabase - val values = ContentValues() - values.put(key_state, state) - db.update(table_currencies, values, "$key_id = ?", arrayOf(currencyId.toString())) - } + // --- Projects logic --- fun addProject(project: DBProject): Long { val db = writableDatabase val values = ContentValues() values.put(key_remoteId, project.remoteId) - values.put(key_password, project.password) values.put(key_bearer_token, project.bearerToken) values.put(key_email, project.email) values.put(key_name, project.name) values.put(key_ihmUrl, project.serverUrl) values.put(key_type, project.type.id) values.put(key_archived, project.archivedTs ?: 0L) - return db.insert(table_projects, null, values) + val id = db.insert(table_projects, null, values) + SecureStorage.savePasswordSync(context, "ProjectPassword_$id", project.password) + return id } fun getProject(id: Long): DBProject? { @@ -640,11 +248,13 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) : @SuppressLint("Range") private fun getProjectFromCursor(cursor: Cursor): DBProject { + val id = cursor.getLong(cursor.getColumnIndex(key_id)) val archivedTs = cursor.getLong(cursor.getColumnIndex(key_archived)) + val password = SecureStorage.getPasswordSync(context, "ProjectPassword_$id") ?: "" return DBProject( - cursor.getLong(cursor.getColumnIndex(key_id)), + id, cursor.getString(cursor.getColumnIndex(key_remoteId)), - cursor.getString(cursor.getColumnIndex(key_password)), + password, cursor.getString(cursor.getColumnIndex(key_name)), cursor.getString(cursor.getColumnIndex(key_ihmUrl)), cursor.getString(cursor.getColumnIndex(key_email)), @@ -660,15 +270,6 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) : ) } - fun deleteProject(id: Long) { - val db = writableDatabase - for (b in getBillsOfProject(id)) { - deleteBill(b.id) - } - db.delete(table_members, "$key_projectid = ?", arrayOf(id.toString())) - db.delete(table_projects, "$key_id = ?", arrayOf(id.toString())) - } - fun updateProject( projId: Long, newName: String?, newEmail: String?, newPassword: String?, newLastPayerId: Long?, @@ -681,7 +282,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) : val values = ContentValues() if (newName != null) values.put(key_name, newName) if (newEmail != null) values.put(key_email, newEmail) - if (newPassword != null) values.put(key_password, newPassword) + if (newPassword != null) SecureStorage.savePasswordSync(context, "ProjectPassword_$projId", newPassword) if (newBearerToken != null) values.put(key_bearer_token, newBearerToken) if (newLastPayerId != null) values.put(key_lastPayerId, newLastPayerId) if (newLastSyncedTimestamp != null) values.put(key_lastSyncTimestamp, newLastSyncedTimestamp) @@ -721,7 +322,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) : val values = ContentValues() if (newName != null) values.put(key_name, newName) if (newEmail != null) values.put(key_email, newEmail) - if (newPassword != null) values.put(key_password, newPassword) + if (newPassword != null) SecureStorage.savePasswordSync(context, "ProjectPassword_$projId", newPassword) if (newBearerToken != null) values.put(key_bearer_token, newBearerToken) if (newLastPayerId != null) values.put(key_lastPayerId, newLastPayerId) if (newLastSyncedTimestamp != null) values.put(key_lastSyncTimestamp, newLastSyncedTimestamp) @@ -735,8 +336,19 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) : } } + fun deleteProject(id: Long) { + val db = writableDatabase + for (b in getBillsOfProject(id)) { + deleteBill(b.id) + } + db.delete(table_members, "$key_projectid = ?", arrayOf(id.toString())) + db.delete(table_projects, "$key_id = ?", arrayOf(id.toString())) + SecureStorage.removePasswordSync(context, "ProjectPassword_$id") + } + + // --- Members logic --- + fun addMember(m: DBMember): Long { - if (BillsListViewActivity.DEBUG) { Log.d(TAG, "[add member]") } val db = writableDatabase val values = ContentValues() values.put(key_remoteId, m.remoteId) @@ -793,7 +405,6 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) : m.id, newName, newWeight, newActivated, newState, null, newR, newG, newB, newNcUserId, newAvatar ) - Log.v(TAG, "UPDATE MEMBER AND SYNC") val proj = getProject(m.projectId) if (proj != null) syncIfRemote(proj) } @@ -868,8 +479,9 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) : db.delete(table_members, "$key_id = ?", arrayOf(id.toString())) } + // --- Bills logic --- + fun addBill(b: DBBill): Long { - if (BillsListViewActivity.DEBUG) { Log.d(TAG, "[add bill]") } val db = writableDatabase val values = ContentValues() values.put(key_remoteId, b.remoteId) @@ -881,8 +493,8 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) : values.put(key_state, b.state) values.put(key_repeat, b.repeat) values.put(key_payment_mode, b.paymentMode) - values.put(key_payment_mode_id, b.paymentModeRemoteId) - values.put(key_category_id, b.categoryRemoteId) + values.put(key_payment_mode_id, b.paymentModeId) + values.put(key_category_id, b.categoryId) values.put(key_comment, b.comment) val billId = db.insert(table_bills, null, values) for (bo in b.billOwers) { @@ -903,8 +515,8 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) : newAmount: Double?, newTimestamp: Long?, newWhat: String?, newState: Int?, newRepeat: String?, - newPaymentMode: String?, newPaymentModeRemoteId: Int?, - newCategoryId: Int?, newComment: String? + newPaymentMode: String?, newPaymentModeId: Long?, + newCategoryId: Long?, newComment: String? ) { val db = writableDatabase val values = ContentValues() @@ -916,7 +528,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) : if (newState != null) values.put(key_state, newState) if (newRepeat != null) values.put(key_repeat, newRepeat) if (newPaymentMode != null) values.put(key_payment_mode, newPaymentMode) - if (newPaymentModeRemoteId != null) values.put(key_payment_mode_id, newPaymentModeRemoteId) + if (newPaymentModeId != null) values.put(key_payment_mode_id, newPaymentModeId) if (newCategoryId != null) values.put(key_category_id, newCategoryId) if (newComment != null) values.put(key_comment, newComment) if (values.size() > 0) { @@ -928,14 +540,14 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) : bill: DBBill, newPayerId: Long, newAmount: Double, newTimestamp: Long?, newWhat: String?, newOwersIds: List?, newRepeat: String?, - newPaymentMode: String?, newPaymentModeRemoteId: Int?, - newCategoryId: Int?, + newPaymentMode: String?, newPaymentModeId: Long?, + newCategoryId: Long?, newComment: String? ) { val newState = if (bill.state == DBBill.STATE_ADDED) DBBill.STATE_ADDED else DBBill.STATE_EDITED updateBill( bill.id, null, newPayerId, newAmount, newTimestamp, newWhat, newState, - newRepeat, newPaymentMode, newPaymentModeRemoteId, newCategoryId, newComment + newRepeat, newPaymentMode, newPaymentModeId, newCategoryId, newComment ) val dbBillOwers = getBillowersOfBill(bill.id) val dbBillOwersByMemberId: MutableMap = HashMap() @@ -954,7 +566,6 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) : } } } - Log.v(TAG, "UPDATE BILL AND SYNC") val proj = getProject(bill.projectId) if (proj != null) syncIfRemote(proj) } @@ -975,29 +586,11 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) : return getBillsCustom("$key_payer_id = ?", arrayOf(memberId.toString()), "$key_timestamp ASC") } - @Suppress("unused") - fun getBill(remoteId: Long, projId: Long): DBBill? { - val bills = getBillsCustom( - "$key_remoteId = ? AND $key_projectid = ?", - arrayOf(remoteId.toString(), projId.toString()), - null - ) + fun getBill(id: Long): DBBill? { + val bills = getBillsCustom("$key_id = ?", arrayOf(id.toString()), null) return if (bills.isEmpty()) null else bills[0] } - fun getBill(billId: Long): DBBill? { - val bills = getBillsCustom("$key_id = ?", arrayOf(billId.toString()), null) - return if (bills.isEmpty()) null else bills[0] - } - - fun getCurrenciesOfProjectWithState(projId: Long, state: Int): List { - return getCurrenciesCustom( - "$key_projectid = ? AND $key_state = ?", - arrayOf(projId.toString(), state.toString()), - null - ) - } - @WorkerThread fun searchBills(query: CharSequence?, projectId: Long): List { val andWhere: MutableList = ArrayList() @@ -1095,9 +688,9 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) : cursor.getInt(cursor.getColumnIndex(key_state)), cursor.getString(cursor.getColumnIndex(key_repeat)), cursor.getString(cursor.getColumnIndex(key_payment_mode)), - cursor.getInt(cursor.getColumnIndex(key_category_id)), + cursor.getLong(cursor.getColumnIndex(key_category_id)), cursor.getString(cursor.getColumnIndex(key_comment)), - cursor.getInt(cursor.getColumnIndex(key_payment_mode_id)) + cursor.getLong(cursor.getColumnIndex(key_payment_mode_id)) ) } @@ -1107,8 +700,9 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) : db.delete(table_bills, "$key_id = ?", arrayOf(id.toString())) } + // --- Billowers logic --- + fun addBillower(billId: Long, memberId: Long) { - if (BillsListViewActivity.DEBUG) { Log.d(TAG, "[add billower]") } val db = writableDatabase val values = ContentValues() values.put(key_billId, billId) @@ -1150,10 +744,446 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) : db.delete(table_billowers, "$key_id = ?", arrayOf(id.toString())) } + // --- Categories logic --- + + fun addCategory(category: DBCategory): Long { + val db = writableDatabase + val values = ContentValues() + values.put(key_remoteId, category.remoteId) + values.put(key_projectid, category.projectId) + values.put(key_name, category.name) + values.put(key_icon, category.icon) + values.put(key_color, category.color) + values.put(key_state, category.state) + return db.insert(table_categories, null, values) + } + + fun addCategoryAndSync(cat: DBCategory) { + addCategory(cat) + val proj = getProject(cat.projectId) + if (proj != null) syncIfRemote(proj) + } + + fun getCategory(remoteId: Long, projectId: Long): DBCategory? { + val categories = getCategoriesCustom( + "$key_remoteId = ? AND $key_projectid = ?", + arrayOf(remoteId.toString(), projectId.toString()), + null + ) + return if (categories.isEmpty()) null else categories[0] + } + + fun getCategory(id: Long): DBCategory? { + val categories = getCategoriesCustom("$key_id = ?", arrayOf(id.toString()), null) + return if (categories.isEmpty()) null else categories[0] + } + + fun getCategories(projectId: Long, includeDeleted: Boolean = false): List { + val selection = if (includeDeleted) "$key_projectid = ?" else "$key_projectid = ? AND $key_state != ?" + val selectionArgs = if (includeDeleted) arrayOf(projectId.toString()) else arrayOf(projectId.toString(), DBBill.STATE_DELETED.toString()) + return getCategoriesCustom(selection, selectionArgs, null) + } + + fun getCategoriesOfProjectWithState(projId: Long, state: Int): List { + return getCategoriesCustom( + "$key_projectid = ? AND $key_state = ?", + arrayOf(projId.toString(), state.toString()), + null + ) + } + + @WorkerThread + private fun getCategoriesCustom(selection: String, selectionArgs: Array, orderBy: String?): List { + val db = readableDatabase + val cursor = db.query(table_categories, columnsCategories, selection, selectionArgs, null, null, orderBy) + val categories: MutableList = ArrayList() + while (cursor.moveToNext()) { + categories.add(getCategoryFromCursor(cursor)) + } + cursor.close() + return categories + } + + @SuppressLint("Range") + private fun getCategoryFromCursor(cursor: Cursor): DBCategory { + return DBCategory( + cursor.getLong(cursor.getColumnIndex(key_id)), + cursor.getLong(cursor.getColumnIndex(key_remoteId)), + cursor.getLong(cursor.getColumnIndex(key_projectid)), + cursor.getString(cursor.getColumnIndex(key_name)), + cursor.getString(cursor.getColumnIndex(key_icon)), + cursor.getString(cursor.getColumnIndex(key_color)), + cursor.getInt(cursor.getColumnIndex(key_state)) + ) + } + + fun updateCategory(id: Long, name: String?, icon: String?, color: String?, state: Int? = null, remoteId: Long? = null) { + val db = writableDatabase + val values = ContentValues() + if (name != null) values.put(key_name, name) + if (icon != null) values.put(key_icon, icon) + if (color != null) values.put(key_color, color) + if (state != null) values.put(key_state, state) + if (remoteId != null) values.put(key_remoteId, remoteId) + if (values.size() > 0) { + db.update(table_categories, values, "$key_id = ?", arrayOf(id.toString())) + } + } + + fun updateCategoryAndSync(cat: DBCategory, name: String?, icon: String?, color: String?, remoteId: Long? = null) { + val newState = if (cat.state == DBBill.STATE_ADDED) DBBill.STATE_ADDED else DBBill.STATE_EDITED + updateCategory(cat.id, name, icon, color, newState, remoteId) + val proj = getProject(cat.projectId) + if (proj != null) syncIfRemote(proj) + } + + fun deleteCategoryAndSync(catId: Long) { + val cat = getCategory(catId) + if (cat != null) { + if (cat.state == DBBill.STATE_ADDED) { + deleteCategory(catId) + } else { + updateCategory(catId, null, null, null, DBBill.STATE_DELETED) + } + val proj = getProject(cat.projectId) + if (proj != null) syncIfRemote(proj) + } + } + + fun deleteCategory(id: Long) { + val db = writableDatabase + db.delete(table_categories, "$key_id = ?", arrayOf(id.toString())) + } + + // --- Payment Modes logic --- + + fun addPaymentMode(paymentMode: DBPaymentMode): Long { + val db = writableDatabase + val values = ContentValues() + values.put(key_remoteId, paymentMode.remoteId) + values.put(key_projectid, paymentMode.projectId) + values.put(key_name, paymentMode.name) + values.put(key_icon, paymentMode.icon) + values.put(key_color, paymentMode.color) + values.put(key_state, paymentMode.state) + return db.insert(table_payment_modes, null, values) + } + + fun addPaymentModeAndSync(pm: DBPaymentMode) { + addPaymentMode(pm) + val proj = getProject(pm.projectId) + if (proj != null) syncIfRemote(proj) + } + + fun getPaymentMode(remoteId: Long, projectId: Long): DBPaymentMode? { + val paymentModes = getPaymentModesCustom( + "$key_remoteId = ? AND $key_projectid = ?", + arrayOf(remoteId.toString(), projectId.toString()), + null + ) + return if (paymentModes.isEmpty()) null else paymentModes[0] + } + + fun getPaymentMode(id: Long): DBPaymentMode? { + val pms = getPaymentModesCustom("$key_id = ?", arrayOf(id.toString()), null) + return if (pms.isEmpty()) null else pms[0] + } + + fun getPaymentModes(projectId: Long, includeDeleted: Boolean = false): List { + val selection = if (includeDeleted) "$key_projectid = ?" else "$key_projectid = ? AND $key_state != ?" + val selectionArgs = if (includeDeleted) arrayOf(projectId.toString()) else arrayOf(projectId.toString(), DBBill.STATE_DELETED.toString()) + return getPaymentModesCustom(selection, selectionArgs, null) + } + + fun getPaymentModesOfProjectWithState(projId: Long, state: Int): List { + return getPaymentModesCustom( + "$key_projectid = ? AND $key_state = ?", + arrayOf(projId.toString(), state.toString()), + null + ) + } + + @WorkerThread + private fun getPaymentModesCustom(selection: String, selectionArgs: Array, orderBy: String?): List { + return getPaymentModesCustom(selection, selectionArgs, orderBy, readableDatabase) + } + + @WorkerThread + private fun getPaymentModesCustom(selection: String, selectionArgs: Array, orderBy: String?, db: SQLiteDatabase): List { + val cursor = db.query(table_payment_modes, columnsPaymentModes, selection, selectionArgs, null, null, orderBy) + val paymentModes: MutableList = ArrayList() + while (cursor.moveToNext()) { + paymentModes.add(getPaymentModeFromCursor(cursor)) + } + cursor.close() + return paymentModes + } + + @SuppressLint("Range") + private fun getPaymentModeFromCursor(cursor: Cursor): DBPaymentMode { + return DBPaymentMode( + cursor.getLong(cursor.getColumnIndex(key_id)), + cursor.getLong(cursor.getColumnIndex(key_remoteId)), + cursor.getLong(cursor.getColumnIndex(key_projectid)), + cursor.getString(cursor.getColumnIndex(key_name)), + cursor.getString(cursor.getColumnIndex(key_icon)), + cursor.getString(cursor.getColumnIndex(key_color)), + cursor.getInt(cursor.getColumnIndex(key_state)) + ) + } + + fun updatePaymentMode(id: Long, name: String?, icon: String?, color: String?, state: Int? = null, remoteId: Long? = null) { + val db = writableDatabase + val values = ContentValues() + if (name != null) values.put(key_name, name) + if (icon != null) values.put(key_icon, icon) + if (color != null) values.put(key_color, color) + if (state != null) values.put(key_state, state) + if (remoteId != null) values.put(key_remoteId, remoteId) + if (values.size() > 0) { + db.update(table_payment_modes, values, "$key_id = ?", arrayOf(id.toString())) + } + } + + fun updatePaymentModeAndSync(pm: DBPaymentMode, name: String?, icon: String?, color: String?, remoteId: Long? = null) { + val newState = if (pm.state == DBBill.STATE_ADDED) DBBill.STATE_ADDED else DBBill.STATE_EDITED + updatePaymentMode(pm.id, name, icon, color, newState, remoteId) + val proj = getProject(pm.projectId) + if (proj != null) syncIfRemote(proj) + } + + fun deletePaymentModeAndSync(pmId: Long) { + val pm = getPaymentMode(pmId) + if (pm != null) { + if (pm.state == DBBill.STATE_ADDED) { + deletePaymentMode(pmId) + } else { + updatePaymentMode(pmId, null, null, null, DBBill.STATE_DELETED) + } + val proj = getProject(pm.projectId) + if (proj != null) syncIfRemote(proj) + } + } + + fun deletePaymentMode(id: Long) { + val db = writableDatabase + db.delete(table_payment_modes, "$key_id = ?", arrayOf(id.toString())) + } + + // --- Currencies logic --- + + fun addCurrency(currency: DBCurrency): Long { + val db = writableDatabase + val values = ContentValues() + values.put(key_remoteId, currency.remoteId) + values.put(key_projectid, currency.projectId) + values.put(key_name, currency.name) + values.put(key_exchangeRate, currency.exchangeRate) + values.put(key_state, currency.state) + return db.insert(table_currencies, null, values) + } + + fun addCurrencyAndSync(m: DBCurrency) { + addCurrency(m) + val proj = getProject(m.projectId) + if (proj != null) syncIfRemote(proj) + } + + fun getCurrency(id: Long): DBCurrency? { + val currencies = getCurrenciesCustom("$key_id = ?", arrayOf(id.toString()), null) + return if (currencies.isEmpty()) null else currencies[0] + } + + fun getCurrency(remoteId: Long, projectId: Long): DBCurrency? { + val currencies = getCurrenciesCustom( + "$key_remoteId = ? AND $key_projectid = ?", + arrayOf(remoteId.toString(), projectId.toString()), + null + ) + return if (currencies.isEmpty()) null else currencies[0] + } + + fun getCurrencies(projectId: Long): List { + return getCurrenciesCustom( + "$key_projectid = ? AND $key_state != ?", + arrayOf(projectId.toString(), DBBill.STATE_DELETED.toString()), + null + ) + } + + fun getCurrenciesOfProjectWithState(projId: Long, state: Int): List { + return getCurrenciesCustom( + "$key_projectid = ? AND $key_state = ?", + arrayOf(projId.toString(), state.toString()), + null + ) + } + + @WorkerThread + private fun getCurrenciesCustom(selection: String, selectionArgs: Array, orderBy: String?): List { + return getCurrenciesCustom(selection, selectionArgs, orderBy, readableDatabase) + } + + @WorkerThread + private fun getCurrenciesCustom(selection: String, selectionArgs: Array, orderBy: String?, db: SQLiteDatabase): List { + val cursor = db.query(table_currencies, columnsCurrencies, selection, selectionArgs, null, null, orderBy) + val currencies: MutableList = ArrayList() + while (cursor.moveToNext()) { + currencies.add(getCurrencyFromCursor(cursor)) + } + cursor.close() + return currencies + } + + @SuppressLint("Range") + private fun getCurrencyFromCursor(cursor: Cursor): DBCurrency { + return DBCurrency( + cursor.getLong(cursor.getColumnIndex(key_id)), + cursor.getLong(cursor.getColumnIndex(key_remoteId)), + cursor.getLong(cursor.getColumnIndex(key_projectid)), + cursor.getString(cursor.getColumnIndex(key_name)), + cursor.getDouble(cursor.getColumnIndex(key_exchangeRate)), + cursor.getInt(cursor.getColumnIndex(key_state)) + ) + } + + fun updateCurrency(id: Long, name: String?, exchangeRate: Double?) { + val db = writableDatabase + val values = ContentValues() + if (name != null) values.put(key_name, name) + if (exchangeRate != null) values.put(key_exchangeRate, exchangeRate) + if (values.size() > 0) { + db.update(table_currencies, values, "$key_id = ?", arrayOf(id.toString())) + } + } + + fun setCurrencyState(currencyId: Long, state: Int) { + val db = writableDatabase + val values = ContentValues() + values.put(key_state, state) + db.update(table_currencies, values, "$key_id = ?", arrayOf(currencyId.toString())) + } + + fun setCurrencyStateSync(currencyId: Long, state: Int) { + setCurrencyState(currencyId, state) + val currency = getCurrency(currencyId) + if (currency != null) { + val project = getProject(currency.projectId) + if (project != null) syncIfRemote(project) + } + } + + fun deleteCurrency(id: Long) { + val db = writableDatabase + db.delete(table_currencies, "$key_id = ?", arrayOf(id.toString())) + } + + // --- Common Helpers --- + + fun syncIfRemote(proj: DBProject) { + if (!proj.isLocal) { + val preferences = PreferenceManager.getDefaultSharedPreferences(context) + val offlineMode = preferences.getBoolean(context.getString(R.string.pref_key_offline_mode), false) + if (!offlineMode) { + cowspentServerSyncHelper.scheduleSync(true, proj.id) + } + } + } + + fun ensureDefaultLabels(projectId: Long, projectType: ProjectType) { + val allCats = getCategories(projectId, includeDeleted = true) + val defaultCats = CategoryUtils.getDefaultCategories(context, projectId) + val isLocal = projectType == ProjectType.LOCAL + + // Cleanup existing "fake" Reimbursement categories from DB to avoid duplicates + // and migrate bills to use the virtual constant directly. + allCats.filter { it.remoteId == DBBill.CATEGORY_REIMBURSEMENT }.forEach { cat -> + val db = writableDatabase + val values = ContentValues() + values.put(key_category_id, DBBill.CATEGORY_REIMBURSEMENT) + db.update(table_bills, values, "$key_category_id = ?", arrayOf(cat.id.toString())) + db.delete(table_categories, "$key_id = ?", arrayOf(cat.id.toString())) + } + + // Only ensure other default categories for local projects. + // Reimbursement is now virtual and handled in UI/Sync. + val catsToEnsure = if (isLocal) defaultCats.filter { it.remoteId != DBBill.CATEGORY_REIMBURSEMENT } else emptyList() + + catsToEnsure.forEach { def -> + if (allCats.none { it.remoteId == def.remoteId }) { + if (!isLocal) def.state = DBBill.STATE_ADDED + addCategory(def) + } + } + + val allPms = getPaymentModes(projectId, includeDeleted = true) + val defaultPms = CategoryUtils.getDefaultPaymentModes(context, projectId) + val pmsToEnsure = if (isLocal) defaultPms else emptyList() + + pmsToEnsure.forEach { def -> + if (allPms.none { it.remoteId == def.remoteId }) { + if (!isLocal) def.state = DBBill.STATE_ADDED + addPaymentMode(def) + } + } + } + + // --- AccountProjects logic --- + + fun addAccountProject(accountProject: DBAccountProject): Long { + val db = writableDatabase + val values = ContentValues() + values.put(key_remoteId, accountProject.remoteId) + values.put(key_ncUrl, accountProject.ncUrl) + values.put(key_name, accountProject.name) + values.put(key_archived, accountProject.archivedTs ?: 0L) + val id = db.insert(table_account_projects, null, values) + SecureStorage.savePasswordSync(context, "AccountProjectPassword_$id", accountProject.password) + return id + } + + val accountProjects: List + get() = getAccountProjectsCustom("", arrayOf(), default_order) + + @WorkerThread + private fun getAccountProjectsCustom(selection: String, selectionArgs: Array, orderBy: String?): List { + return getAccountProjectsCustom(selection, selectionArgs, orderBy, readableDatabase) + } + + @WorkerThread + private fun getAccountProjectsCustom(selection: String, selectionArgs: Array, orderBy: String?, db: SQLiteDatabase): List { + val cursor = db.query(table_account_projects, columnsAccountProjects, selection, selectionArgs, null, null, orderBy) + val accountProjects: MutableList = ArrayList() + while (cursor.moveToNext()) { + accountProjects.add(getAccountProjectFromCursor(cursor)) + } + cursor.close() + return accountProjects + } + + @SuppressLint("Range") + private fun getAccountProjectFromCursor(cursor: Cursor): DBAccountProject { + val id = cursor.getLong(cursor.getColumnIndex(key_id)) + val archivedTs = cursor.getLong(cursor.getColumnIndex(key_archived)) + val password = SecureStorage.getPasswordSync(context, "AccountProjectPassword_$id") + return DBAccountProject( + id, + cursor.getString(cursor.getColumnIndex(key_remoteId)), + password, + cursor.getString(cursor.getColumnIndex(key_name)), + cursor.getString(cursor.getColumnIndex(key_ncUrl)), + if (archivedTs > 0) archivedTs else null + ) + } + + fun clearAccountProjects() { + val db = writableDatabase + db.delete(table_account_projects, null, null) + } + @Suppress("ConstPropertyName") companion object { - private val TAG = CowspentSQLiteOpenHelper::class.java.simpleName - private const val database_version = 20 + private const val database_version = 1 private const val database_name = "COWSPENT" private const val table_members = "MEMBERS" const val key_id = "ID" @@ -1188,16 +1218,16 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) : private const val key_repeat = "REPEAT" private const val key_payment_mode = "PAYMENTMODE" private const val key_payment_mode_id = "PAYMENTMODEID" - private const val key_category_id = "CATEGORYID" - private const val key_comment = "COMMENT" + const val key_category_id = "CATEGORYID" + const val key_comment = "COMMENT" private const val table_billowers = "BILLOWERS" private const val key_billId = "BILLID" private const val key_member_id = "MEMBERID" private const val table_account_projects = "ACCOUNTPROJECTS" private const val key_ncUrl = "NCURL" private const val table_categories = "CATEGORIES" - private const val key_icon = "ICON" - private const val key_color = "COLOR" + const val key_icon = "ICON" + const val key_color = "COLOR" private const val table_payment_modes = "PAYMENTMODES" private const val key_latest_bill_ts = "LATEST_BILL_TS" private const val table_currencies = "CURRENCIES" @@ -1225,10 +1255,10 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) : key_id, key_remoteId, key_password, key_name, key_ncUrl, key_archived ) private val columnsCategories = arrayOf( - key_id, key_remoteId, key_projectid, key_name, key_icon, key_color + key_id, key_remoteId, key_projectid, key_name, key_icon, key_color, key_state ) private val columnsPaymentModes = arrayOf( - key_id, key_remoteId, key_projectid, key_name, key_icon, key_color + key_id, key_remoteId, key_projectid, key_name, key_icon, key_color, key_state ) private val columnsCurrencies = arrayOf( key_id, key_remoteId, key_projectid, key_name, key_exchangeRate, key_state diff --git a/app/src/main/java/net/helcel/cowspent/persistence/CowspentServerSyncHelper.kt b/app/src/main/java/net/helcel/cowspent/persistence/CowspentServerSyncHelper.kt index fab690b..d6272ca 100644 --- a/app/src/main/java/net/helcel/cowspent/persistence/CowspentServerSyncHelper.kt +++ b/app/src/main/java/net/helcel/cowspent/persistence/CowspentServerSyncHelper.kt @@ -265,6 +265,121 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen val members = dbHelper.getMembersOfProject(project.id, null) val memberIdToRemoteId = members.associate { it.id to it.remoteId } + val categoriesToAdd = dbHelper.getCategoriesOfProjectWithState(project.id, DBBill.STATE_ADDED) + for (catToAdd in categoriesToAdd) { + try { + val categoriesResponse = client!!.getCategories(project) + val remoteCategories = categoriesResponse.getCategories(project.id) + val matchingRemote = remoteCategories.find { it.name == catToAdd.name } + + if (matchingRemote != null) { + dbHelper.updateCategory(catToAdd.id, null, null, null, DBBill.STATE_OK, matchingRemote.remoteId) + catToAdd.remoteId = matchingRemote.remoteId + } else { + val createResponse = client!!.createRemoteCategory(project, catToAdd) + val newRemoteId = createResponse.remoteCategoryId + if (newRemoteId > 0) { + dbHelper.updateCategory(catToAdd.id, null, null, null, DBBill.STATE_OK, newRemoteId) + catToAdd.remoteId = newRemoteId + } + } + } catch (e: Exception) { + Log.e(TAG, "CATEGORY SYNC FAILED for ${catToAdd.name}", e) + } + } + + val categoriesToEdit = dbHelper.getCategoriesOfProjectWithState(project.id, DBBill.STATE_EDITED) + for (catToEdit in categoriesToEdit) { + try { + client!!.editRemoteCategory(project, catToEdit) + dbHelper.updateCategory(catToEdit.id, null, null, null, DBBill.STATE_OK) + } catch (e: Exception) { + Log.e(TAG, "EDIT CATEGORY FAILED for ${catToEdit.name}, might not exist remotely", e) + // If it fails, we keep the state as EDITED so it tries again next time, + // or we could set it to OK if we think it's a permanent mismatch. + // For now just log it. + } + } + + val categoriesToDelete = dbHelper.getCategoriesOfProjectWithState(project.id, DBBill.STATE_DELETED) + for (catToDel in categoriesToDelete) { + try { + client!!.deleteRemoteCategory(project, catToDel.remoteId) + dbHelper.deleteCategory(catToDel.id) + } catch (e: NextcloudHttpRequestFailedException) { + if (e.statusCode == 404 || e.statusCode == 400) { + Log.d(TAG, "failed to delete category on remote project (code ${e.statusCode}) : delete it locally anyway") + dbHelper.deleteCategory(catToDel.id) + } else { + throw e + } + } catch (e: Exception) { + Log.e(TAG, "DELETE CATEGORY FAILED for ${catToDel.name}", e) + } + } + + val paymentModesToAdd = dbHelper.getPaymentModesOfProjectWithState(project.id, DBBill.STATE_ADDED) + if (paymentModesToAdd.isNotEmpty()) { + try { + val pmsResponse = client!!.getPaymentModes(project) + val remotePms = pmsResponse.getPaymentModes(project.id) + val remotePmsNames = remotePms.map { it.name } + for (pmToAdd in paymentModesToAdd) { + val searchIndex = remotePmsNames.indexOf(pmToAdd.name) + if (searchIndex != -1) { + val remotePm = remotePms[searchIndex] + dbHelper.updatePaymentMode(pmToAdd.id, null, null, null, DBBill.STATE_OK, remotePm.remoteId) + pmToAdd.remoteId = remotePm.remoteId + } else { + val createRemotePaymentModeResponse = client!!.createRemotePaymentMode(project, pmToAdd) + val newRemoteId = createRemotePaymentModeResponse.remotePaymentModeId + if (newRemoteId > 0) { + dbHelper.updatePaymentMode(pmToAdd.id, null, null, null, DBBill.STATE_OK, newRemoteId) + pmToAdd.remoteId = newRemoteId + } + } + } + } catch (e: NextcloudHttpRequestFailedException) { + Log.e(TAG, "GET PAYMENT MODES FAILED : " + e.message) + } + } + + val paymentModesToEdit = dbHelper.getPaymentModesOfProjectWithState(project.id, DBBill.STATE_EDITED) + for (pmToEdit in paymentModesToEdit) { + try { + client!!.editRemotePaymentMode(project, pmToEdit) + dbHelper.updatePaymentMode(pmToEdit.id, null, null, null, DBBill.STATE_OK) + } catch (e: Exception) { + Log.e(TAG, "EDIT PAYMENT MODE FAILED for ${pmToEdit.name}, might not exist remotely", e) + } + } + + val paymentModesToDelete = dbHelper.getPaymentModesOfProjectWithState(project.id, DBBill.STATE_DELETED) + for (pmToDel in paymentModesToDelete) { + try { + client!!.deleteRemotePaymentMode(project, pmToDel.remoteId) + dbHelper.deletePaymentMode(pmToDel.id) + } catch (e: NextcloudHttpRequestFailedException) { + if (e.statusCode == 404 || e.statusCode == 400) { + Log.d(TAG, "failed to delete payment mode on remote project (code ${e.statusCode}) : delete it locally anyway") + dbHelper.deletePaymentMode(pmToDel.id) + } else { + throw e + } + } catch (_: Exception) { + } + } + + val categories = dbHelper.getCategories(project.id) + val categoryIdToRemoteId = categories.associate { it.id to it.remoteId }.toMutableMap() + // Map hardcoded constants to themselves if not in DB + categoryIdToRemoteId[DBBill.CATEGORY_REIMBURSEMENT] = DBBill.CATEGORY_REIMBURSEMENT + categories.filter { it.remoteId < 0 }.forEach { categoryIdToRemoteId[it.remoteId] = it.remoteId } + + val paymentModes = dbHelper.getPaymentModes(project.id) + val paymentModeIdToRemoteId = paymentModes.associate { it.id to it.remoteId }.toMutableMap() + paymentModes.filter { it.remoteId < 0 }.forEach { paymentModeIdToRemoteId[it.remoteId] = it.remoteId } + val toDelete = dbHelper.getBillsOfProjectWithState(project.id, DBBill.STATE_DELETED) for (bToDel in toDelete) { try { @@ -293,12 +408,16 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen val toEdit = dbHelper.getBillsOfProjectWithState(project.id, DBBill.STATE_EDITED) for (bToEdit in toEdit) { try { - val editRemoteBillResponse = client!!.editRemoteBill(project, bToEdit, memberIdToRemoteId) - if (editRemoteBillResponse.stringContent == bToEdit.remoteId.toString()) { + val editRemoteBillResponse = client!!.editRemoteBill(project, bToEdit, memberIdToRemoteId, categoryIdToRemoteId, paymentModeIdToRemoteId) + val returnedRemoteId = editRemoteBillResponse.remoteBillId + if (returnedRemoteId == bToEdit.remoteId || (returnedRemoteId == 0L && !project.getRequestBaseUrl(true).contains("/ocs/v2.php"))) { dbHelper.setBillState(bToEdit.id, DBBill.STATE_OK) - Log.d(TAG, "SUCCESSFUL remote bill edition (${editRemoteBillResponse.stringContent})") + Log.d(TAG, "SUCCESSFUL remote bill edition ($returnedRemoteId)") + } else if (returnedRemoteId > 0) { + dbHelper.setBillState(bToEdit.id, DBBill.STATE_OK) + Log.d(TAG, "SUCCESSFUL remote bill edition ($returnedRemoteId)") } else { - Log.d(TAG, "FAILED to edit remote bill (${editRemoteBillResponse.stringContent})") + Log.d(TAG, "FAILED to edit remote bill ($returnedRemoteId)") } } catch (_: Exception) { Log.d(TAG, "FAILED to edit remote bill: it probably does not exist remotely") @@ -307,8 +426,8 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen val toAdd = dbHelper.getBillsOfProjectWithState(project.id, DBBill.STATE_ADDED) for (bToAdd in toAdd) { - val createRemoteBillResponse = client!!.createRemoteBill(project, bToAdd, memberIdToRemoteId) - val newRemoteId = createRemoteBillResponse.stringContent.toLong() + val createRemoteBillResponse = client!!.createRemoteBill(project, bToAdd, memberIdToRemoteId, categoryIdToRemoteId, paymentModeIdToRemoteId) + val newRemoteId = createRemoteBillResponse.remoteBillId if (newRemoteId > 0) { dbHelper.updateBill( bToAdd.id, newRemoteId, null, @@ -320,48 +439,69 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen } } - val currenciesToDelete = dbHelper.getCurrenciesOfProjectWithState(project.id, DBBill.STATE_DELETED) - for (cToDel in currenciesToDelete) { - try { - val deleteRemoteCurrencyResponse = client!!.deleteRemoteCurrency(project, cToDel.remoteId) - if (deleteRemoteCurrencyResponse.stringContent == "OK") { - Log.d(TAG, "successfully deleted currency on remote project : delete it locally") - dbHelper.deleteCurrency(cToDel.id) - } - } catch (e: IOException) { - if (e.message == "\"Not Found\"") { - Log.d(TAG, "failed to delete currency on remote project : delete it locally anyway") - dbHelper.deleteCurrency(cToDel.id) - } else { - throw e + if (project.type == ProjectType.COSPEND) { + val currenciesToDelete = dbHelper.getCurrenciesOfProjectWithState(project.id, DBBill.STATE_DELETED) + for (cToDel in currenciesToDelete) { + try { + val deleteRemoteCurrencyResponse = client!!.deleteRemoteCurrency(project, cToDel.remoteId) + if (deleteRemoteCurrencyResponse.stringContent == "OK") { + Log.d(TAG, "successfully deleted currency on remote project : delete it locally") + dbHelper.deleteCurrency(cToDel.id) + } + } catch (e: IOException) { + if (e.message == "\"Not Found\"") { + Log.d(TAG, "failed to delete currency on remote project : delete it locally anyway") + dbHelper.deleteCurrency(cToDel.id) + } else { + throw e + } } } + } else { + val currenciesToDelete = dbHelper.getCurrenciesOfProjectWithState(project.id, DBBill.STATE_DELETED) + for (cToDel in currenciesToDelete) { + dbHelper.deleteCurrency(cToDel.id) + } } - val currenciesToEdit = dbHelper.getCurrenciesOfProjectWithState(project.id, DBBill.STATE_EDITED) - for (cToEdit in currenciesToEdit) { - try { - val editRemoteCurrencyResponse = client!!.editRemoteCurrency(project, cToEdit) - if (editRemoteCurrencyResponse.stringContent == cToEdit.remoteId.toString()) { - dbHelper.setCurrencyState(cToEdit.id, DBBill.STATE_OK) - Log.d(TAG, "SUCCESSFUL remote currency edition (${editRemoteCurrencyResponse.stringContent})") - } else { - Log.d(TAG, "FAILED to edit remote currency (${editRemoteCurrencyResponse.stringContent})") - } - } catch (e: IOException) { - if (e.message == "{\"message\": \"Internal Server Error\"}") { - Log.d(TAG, "FAILED to edit remote currency : it does not exist remotely") - } else { - throw e + if (project.type == ProjectType.COSPEND) { + val currenciesToEdit = dbHelper.getCurrenciesOfProjectWithState(project.id, DBBill.STATE_EDITED) + for (cToEdit in currenciesToEdit) { + try { + val editRemoteCurrencyResponse = client!!.editRemoteCurrency(project, cToEdit) + if (editRemoteCurrencyResponse.stringContent == cToEdit.remoteId.toString()) { + dbHelper.setCurrencyState(cToEdit.id, DBBill.STATE_OK) + Log.d(TAG, "SUCCESSFUL remote currency edition (${editRemoteCurrencyResponse.stringContent})") + } else { + Log.d(TAG, "FAILED to edit remote currency (${editRemoteCurrencyResponse.stringContent})") + } + } catch (e: IOException) { + if (e.message == "{\"message\": \"Internal Server Error\"}") { + Log.d(TAG, "FAILED to edit remote currency : it does not exist remotely") + } else { + throw e + } } } + } else { + val currenciesToEdit = dbHelper.getCurrenciesOfProjectWithState(project.id, DBBill.STATE_EDITED) + for (cToEdit in currenciesToEdit) { + dbHelper.setCurrencyState(cToEdit.id, DBBill.STATE_OK) + } } - val currencyToAdd = dbHelper.getCurrenciesOfProjectWithState(project.id, DBBill.STATE_ADDED) - for (cToAdd in currencyToAdd) { - val createRemoteCurrencyResponse = client!!.createRemoteCurrency(project, cToAdd) - val newRemoteId = createRemoteCurrencyResponse.stringContent.toLong() - if (newRemoteId > 0) { + if (project.type == ProjectType.COSPEND) { + val currencyToAdd = dbHelper.getCurrenciesOfProjectWithState(project.id, DBBill.STATE_ADDED) + for (cToAdd in currencyToAdd) { + val createRemoteCurrencyResponse = client!!.createRemoteCurrency(project, cToAdd) + val newRemoteId = createRemoteCurrencyResponse.remoteCurrencyId + if (newRemoteId > 0) { + dbHelper.setCurrencyState(cToAdd.id, DBBill.STATE_OK) + } + } + } else { + val currencyToAdd = dbHelper.getCurrenciesOfProjectWithState(project.id, DBBill.STATE_ADDED) + for (cToAdd in currencyToAdd) { dbHelper.setCurrencyState(cToAdd.id, DBBill.STATE_OK) } } @@ -450,7 +590,7 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen val localPaymentModes = dbHelper.getPaymentModes(project.id) for (localPaymentMode in localPaymentModes) { - if (!remotePaymentModesByRemoteId.containsKey(localPaymentMode.remoteId)) { + if (localPaymentMode.state == DBBill.STATE_OK && !remotePaymentModesByRemoteId.containsKey(localPaymentMode.remoteId)) { dbHelper.deletePaymentMode(localPaymentMode.id) Log.d(TAG, "Delete local pm : $localPaymentMode") } @@ -460,6 +600,7 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen val remoteCategoriesByRemoteId = remoteCategories.associateBy { it.remoteId } for (c in remoteCategories) { + if (c.remoteId == DBBill.CATEGORY_REIMBURSEMENT) continue val localCategory = dbHelper.getCategory(c.remoteId, project.id) if (localCategory == null) { Log.d(TAG, "Add local category : $c") @@ -479,7 +620,7 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen val localCategories = dbHelper.getCategories(project.id) for (localCategory in localCategories) { - if (!remoteCategoriesByRemoteId.containsKey(localCategory.remoteId)) { + if (localCategory.state == DBBill.STATE_OK && !remoteCategoriesByRemoteId.containsKey(localCategory.remoteId)) { dbHelper.deleteCategory(localCategory.id) Log.d(TAG, "Delete local category : $localCategory") } @@ -507,7 +648,7 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen val localCurrencies = dbHelper.getCurrencies(project.id) for (localCurrency in localCurrencies) { - if (!remoteCurrenciesByRemoteId.containsKey(localCurrency.remoteId)) { + if (localCurrency.state == DBBill.STATE_OK && !remoteCurrenciesByRemoteId.containsKey(localCurrency.remoteId)) { dbHelper.deleteCurrency(localCurrency.id) Log.d(TAG, "Delete local currency : $localCurrencies") } @@ -574,13 +715,23 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen val dbMembers = dbHelper.getMembersOfProject(project.id, null) val memberRemoteIdToId = dbMembers.associate { it.remoteId to it.id } + val dbCategories = dbHelper.getCategories(project.id) + val categoriesRemoteIdToId = dbCategories.associate { it.remoteId to it.id }.toMutableMap() + // Map hardcoded constants to their local IDs if they exist in DB, else to themselves + categoriesRemoteIdToId[DBBill.CATEGORY_REIMBURSEMENT] = DBBill.CATEGORY_REIMBURSEMENT + dbCategories.filter { it.remoteId < 0 }.forEach { categoriesRemoteIdToId[it.remoteId] = it.id } + + val dbPaymentModes = dbHelper.getPaymentModes(project.id) + val paymentModesRemoteIdToId = dbPaymentModes.associate { it.remoteId to it.id }.toMutableMap() + dbPaymentModes.filter { it.remoteId < 0 }.forEach { paymentModesRemoteIdToId[it.remoteId] = it.id } + val billsResponse = client!!.getBills(project) val isIHM = project.type == ProjectType.IHATEMONEY val serverSyncTimestamp = if (isIHM) 0L else billsResponse.syncTimestamp val remoteBills: List = if (isIHM) { - billsResponse.getBillsIHM(project.id, memberRemoteIdToId) + billsResponse.getBillsIHM(project.id, memberRemoteIdToId, categoriesRemoteIdToId, paymentModesRemoteIdToId) } else { - billsResponse.getBillsCospend(project.id, memberRemoteIdToId) + billsResponse.getBillsCospend(project.id, memberRemoteIdToId, categoriesRemoteIdToId, paymentModesRemoteIdToId) } val remoteAllBillIds: List = if (isIHM) { remoteBills.map { it.remoteId } @@ -597,7 +748,6 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen dbHelper.addBill(remoteBill) nbPulledNewBills++ newBillsDialogText += "+ ${remoteBill.what}\n" - Log.d(TAG, "Add local bill : $remoteBill") } else { val localBill = localBillsByRemoteId[remoteBill.remoteId]!! if (hasChanged(localBill, remoteBill)) { @@ -605,12 +755,11 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen localBill.id, null, remoteBill.payerId, remoteBill.amount, remoteBill.timestamp, remoteBill.what, DBBill.STATE_OK, remoteBill.repeat, - remoteBill.paymentMode, remoteBill.paymentModeRemoteId, - remoteBill.categoryRemoteId, remoteBill.comment + remoteBill.paymentMode, remoteBill.paymentModeId, + remoteBill.categoryId, remoteBill.comment ) nbPulledUpdatedBills++ updatedBillsDialogText += "✏ ${remoteBill.what}\n" - Log.d(TAG, "Update local bill : $remoteBill") } else { Log.d(TAG, "Nothing to do for bill : $localBill") } @@ -1063,8 +1212,8 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen localBill.what == remoteBill.what && localBill.comment == remoteBill.comment && localBill.paymentMode == remoteBill.paymentMode && - localBill.paymentModeRemoteId == remoteBill.paymentModeRemoteId && - localBill.categoryRemoteId == remoteBill.categoryRemoteId + localBill.paymentModeId == remoteBill.paymentModeId && + localBill.categoryId == remoteBill.categoryId ) { val localRepeat = localBill.repeat ?: DBBill.NON_REPEATED val remoteRepeat = remoteBill.repeat ?: DBBill.NON_REPEATED diff --git a/app/src/main/java/net/helcel/cowspent/theme/ThemeUtils.kt b/app/src/main/java/net/helcel/cowspent/theme/ThemeUtils.kt index dfdcb6c..cb42bc9 100644 --- a/app/src/main/java/net/helcel/cowspent/theme/ThemeUtils.kt +++ b/app/src/main/java/net/helcel/cowspent/theme/ThemeUtils.kt @@ -100,14 +100,19 @@ object ThemeUtils { modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background ) { - Column { + Column(modifier = Modifier.fillMaxSize()) { Box( modifier = Modifier .fillMaxWidth() .background(MaterialTheme.colors.primary) .statusBarsPadding() ) - Box(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .weight(1f) + .navigationBarsPadding() + .imePadding() + ) { content() } } diff --git a/app/src/main/java/net/helcel/cowspent/util/BillFormatter.kt b/app/src/main/java/net/helcel/cowspent/util/BillFormatter.kt index 3af97c3..965fb93 100644 --- a/app/src/main/java/net/helcel/cowspent/util/BillFormatter.kt +++ b/app/src/main/java/net/helcel/cowspent/util/BillFormatter.kt @@ -14,11 +14,11 @@ object BillFormatter { ) { for (bill in bills) { var whatPrefix = "" - val pm = paymentModesMap[bill.paymentModeRemoteId.toLong()] + val pm = paymentModesMap[bill.paymentModeId] if (pm != null) { whatPrefix += pm.icon + " " } else { - when (bill.paymentModeRemoteId) { + when (bill.paymentModeId) { DBBill.PAYMODE_ID_CARD -> whatPrefix += "\uD83D\uDCB3 " DBBill.PAYMODE_ID_CASH -> whatPrefix += "\uD83D\uDCB5 " DBBill.PAYMODE_ID_CHECK -> whatPrefix += "\uD83C\uDFAB " @@ -27,11 +27,11 @@ object BillFormatter { } } - val cat = categoriesMap[bill.categoryRemoteId.toLong()] + val cat = categoriesMap[bill.categoryId] if (cat != null) { whatPrefix += cat.icon + " " } else { - when (bill.categoryRemoteId) { + when (bill.categoryId) { DBBill.CATEGORY_GROCERIES -> whatPrefix += "\uD83D\uDED2 " DBBill.CATEGORY_LEISURE -> whatPrefix += "\uD83C\uDF89 " DBBill.CATEGORY_RENT -> whatPrefix += "\uD83C\uDFE0 " diff --git a/app/src/main/java/net/helcel/cowspent/util/CategoryUtils.kt b/app/src/main/java/net/helcel/cowspent/util/CategoryUtils.kt index 7711be2..618d700 100644 --- a/app/src/main/java/net/helcel/cowspent/util/CategoryUtils.kt +++ b/app/src/main/java/net/helcel/cowspent/util/CategoryUtils.kt @@ -10,28 +10,57 @@ object CategoryUtils { fun getDefaultCategories(context: Context, projectId: Long): List { return listOf( - DBCategory(0, DBBill.CATEGORY_GROCERIES.toLong(), projectId, context.getString(R.string.category_groceries), "\uD83D\uDED2", "#ffaa00"), - DBCategory(0, DBBill.CATEGORY_LEISURE.toLong(), projectId, context.getString(R.string.category_leisure), "\uD83C\uDF89", "#aa55ff"), - DBCategory(0, DBBill.CATEGORY_RENT.toLong(), projectId, context.getString(R.string.category_rent), "\uD83C\uDFE0", "#da8733"), - DBCategory(0, DBBill.CATEGORY_BILLS.toLong(), projectId, context.getString(R.string.category_bills), "\uD83C\uDF29", "#4aa6b0"), - DBCategory(0, DBBill.CATEGORY_CULTURE.toLong(), projectId, context.getString(R.string.category_excursion), "\uD83D\uDEB8", "#0055ff"), - DBCategory(0, DBBill.CATEGORY_HEALTH.toLong(), projectId, context.getString(R.string.category_health), "\uD83D\uDC9A", "#bf090c"), - DBCategory(0, DBBill.CATEGORY_SHOPPING.toLong(), projectId, context.getString(R.string.category_shopping), "\uD83D\uDECD", "#e167d1"), - DBCategory(0, DBBill.CATEGORY_REIMBURSEMENT.toLong(), projectId, context.getString(R.string.category_reimbursement), "\uD83D\uDCB0", "#00ced1"), - DBCategory(0, DBBill.CATEGORY_RESTAURANT.toLong(), projectId, context.getString(R.string.category_restaurant), "\uD83C\uDF74", "#d0d5e1"), - DBCategory(0, DBBill.CATEGORY_ACCOMMODATION.toLong(), projectId, context.getString(R.string.category_accomodation), "\uD83D\uDECC", "#5de1a3"), - DBCategory(0, DBBill.CATEGORY_TRANSPORT.toLong(), projectId, context.getString(R.string.category_transport), "\uD83D\uDE8C", "#6f2ee1"), - DBCategory(0, DBBill.CATEGORY_SPORT.toLong(), projectId, context.getString(R.string.category_sport), "\uD83C\uDFBE", "#69e177") + DBCategory(DBBill.CATEGORY_GROCERIES, + DBBill.CATEGORY_GROCERIES, projectId, context.getString(R.string.category_groceries), "\uD83D\uDED2", "#ffaa00"), + DBCategory(DBBill.CATEGORY_LEISURE, + DBBill.CATEGORY_LEISURE, projectId, context.getString(R.string.category_leisure), "\uD83C\uDF89", "#aa55ff"), + DBCategory(DBBill.CATEGORY_RENT, + DBBill.CATEGORY_RENT, projectId, context.getString(R.string.category_rent), "\uD83C\uDFE0", "#da8733"), + DBCategory(DBBill.CATEGORY_BILLS, + DBBill.CATEGORY_BILLS, projectId, context.getString(R.string.category_bills), "\uD83C\uDF29", "#4aa6b0"), + DBCategory(DBBill.CATEGORY_CULTURE, + DBBill.CATEGORY_CULTURE, projectId, context.getString(R.string.category_excursion), "\uD83D\uDEB8", "#0055ff"), + DBCategory(DBBill.CATEGORY_HEALTH, + DBBill.CATEGORY_HEALTH, projectId, context.getString(R.string.category_health), "\uD83D\uDC9A", "#bf090c"), + DBCategory(DBBill.CATEGORY_SHOPPING, + DBBill.CATEGORY_SHOPPING, projectId, context.getString(R.string.category_shopping), "\uD83D\uDECD", "#e167d1"), + DBCategory(DBBill.CATEGORY_REIMBURSEMENT, + DBBill.CATEGORY_REIMBURSEMENT, projectId, context.getString(R.string.category_reimbursement), "\uD83D\uDCB0", "#00ced1"), + DBCategory(DBBill.CATEGORY_RESTAURANT, + DBBill.CATEGORY_RESTAURANT, projectId, context.getString(R.string.category_restaurant), "\uD83C\uDF74", "#d0d5e1"), + DBCategory(DBBill.CATEGORY_ACCOMMODATION, + DBBill.CATEGORY_ACCOMMODATION, projectId, context.getString(R.string.category_accomodation), "\uD83D\uDECC", "#5de1a3"), + DBCategory(DBBill.CATEGORY_TRANSPORT, + DBBill.CATEGORY_TRANSPORT, projectId, context.getString(R.string.category_transport), "\uD83D\uDE8C", "#6f2ee1"), + DBCategory(DBBill.CATEGORY_SPORT, + DBBill.CATEGORY_SPORT, projectId, context.getString(R.string.category_sport), "\uD83C\uDFBE", "#69e177") ) } fun getDefaultPaymentModes(context: Context, projectId: Long): List { return listOf( - DBPaymentMode(0, DBBill.PAYMODE_ID_CARD.toLong(), projectId, context.getString(R.string.payment_mode_credit_card), "\uD83D\uDCB3", "#ff7f50"), - DBPaymentMode(0, DBBill.PAYMODE_ID_CASH.toLong(), projectId, context.getString(R.string.payment_mode_cash), "\uD83D\uDCB5", "#556b2f"), - DBPaymentMode(0, DBBill.PAYMODE_ID_CHECK.toLong(), projectId, context.getString(R.string.payment_mode_check), "\uD83C\uDFAB", "#a9a9a9"), - DBPaymentMode(0, DBBill.PAYMODE_ID_TRANSFER.toLong(), projectId, context.getString(R.string.payment_mode_transfer), "⇄", "#00ced1"), - DBPaymentMode(0, DBBill.PAYMODE_ID_ONLINE_SERVICE.toLong(), projectId, context.getString(R.string.payment_mode_online), "\uD83C\uDF0E", "#9932cc") + DBPaymentMode(DBBill.PAYMODE_ID_CARD, DBBill.PAYMODE_ID_CARD, projectId, context.getString(R.string.payment_mode_credit_card), "\uD83D\uDCB3", "#ff7f50"), + DBPaymentMode(DBBill.PAYMODE_ID_CASH, DBBill.PAYMODE_ID_CASH, projectId, context.getString(R.string.payment_mode_cash), "\uD83D\uDCB5", "#556b2f"), + DBPaymentMode(DBBill.PAYMODE_ID_CHECK, DBBill.PAYMODE_ID_CHECK, projectId, context.getString(R.string.payment_mode_check), "\uD83C\uDFAB", "#a9a9a9"), + DBPaymentMode(DBBill.PAYMODE_ID_TRANSFER, DBBill.PAYMODE_ID_TRANSFER, projectId, context.getString(R.string.payment_mode_transfer), "⇄", "#00ced1"), + DBPaymentMode(DBBill.PAYMODE_ID_ONLINE_SERVICE, DBBill.PAYMODE_ID_ONLINE_SERVICE, projectId, context.getString(R.string.payment_mode_online), "\uD83C\uDF0E", "#9932cc") ) } + + fun getReimbursementCategoryId(categories: List, projectId: Long, projectRemoteId: String? = null): Long { + return categories.find { + it.projectId == projectId && ( + it.remoteId == DBBill.CATEGORY_REIMBURSEMENT || + (projectRemoteId != null && it.remoteId.toString() == projectRemoteId) + ) + }?.id ?: DBBill.CATEGORY_REIMBURSEMENT + } + + fun getCategoryById(context: Context, id: Long, projectId: Long = 0): DBCategory? { + return getDefaultCategories(context, projectId).find { it.id == id } + } + + fun getPaymentModeById(context: Context, id: Long, projectId: Long = 0): DBPaymentMode? { + return getDefaultPaymentModes(context, projectId).find { it.id == id } + } } diff --git a/app/src/main/java/net/helcel/cowspent/util/CospendClientUtil.kt b/app/src/main/java/net/helcel/cowspent/util/CospendClientUtil.kt index dabbdd0..e5485d7 100644 --- a/app/src/main/java/net/helcel/cowspent/util/CospendClientUtil.kt +++ b/app/src/main/java/net/helcel/cowspent/util/CospendClientUtil.kt @@ -17,7 +17,7 @@ object CospendClientUtil { enum class LoginStatus(@param:StringRes val str: Int) { OK(0), - AUTH_FAILED(R.string.error_username_password_invalid), + AUTH_FAILED(R.string.error_auth), CONNECTION_FAILED(R.string.error_io), NO_NETWORK(R.string.error_no_network), JSON_FAILED(R.string.error_json), diff --git a/app/src/main/java/net/helcel/cowspent/util/ExportUtil.kt b/app/src/main/java/net/helcel/cowspent/util/ExportUtil.kt index c3bfec4..a9df23f 100644 --- a/app/src/main/java/net/helcel/cowspent/util/ExportUtil.kt +++ b/app/src/main/java/net/helcel/cowspent/util/ExportUtil.kt @@ -48,7 +48,7 @@ object ExportUtil { } owersTxt = owersTxt.replace(",$".toRegex(), "") fileContent += "\"${b.what}\",${b.amount},${b.date},${b.timestamp},\"$payerName\"," + - "$payerWeight,$payerActive,\"$owersTxt\",${b.repeat},${b.categoryRemoteId}," + + "$payerWeight,$payerActive,\"$owersTxt\",${b.repeat},${b.categoryId}," + "${b.paymentMode}\n" } diff --git a/app/src/main/java/net/helcel/cowspent/util/NextcloudClient.kt b/app/src/main/java/net/helcel/cowspent/util/NextcloudClient.kt index 2ac2d64..5d0e244 100644 --- a/app/src/main/java/net/helcel/cowspent/util/NextcloudClient.kt +++ b/app/src/main/java/net/helcel/cowspent/util/NextcloudClient.kt @@ -35,13 +35,13 @@ class NextcloudClient( val method = if (useOcsApi) METHOD_GET else METHOD_POST return if (nextcloudAPI != null) { Log.d(javaClass.simpleName, "using SSO to get/sync account projects") - Log.d(javaClass.simpleName, "Sync projects target $target") +// Log.d(javaClass.simpleName, "Sync projects target $target") ServerResponse.AccountProjectsResponse( requestServerWithSSO(nextcloudAPI, target, method, null, useOcsApi), useOcsApi ) } else { - Log.d(javaClass.simpleName, "Sync projects target $target") +// Log.d(javaClass.simpleName, "Sync projects target $target") ServerResponse.AccountProjectsResponse( requestServer(target, method, null, "", true, useOcsApi), useOcsApi @@ -93,6 +93,10 @@ class NextcloudClient( params: Collection?, isOCSRequest: Boolean ): VersatileProjectSyncClient.ResponseData { + var finalTarget = target + if (finalTarget.contains("/ocs/v2.php") && !finalTarget.contains("format=json")) { + finalTarget += if (finalTarget.contains("?")) "&format=json" else "?format=json" + } val result = StringBuilder() val headers: MutableMap> = HashMap() if (isOCSRequest) { @@ -103,13 +107,13 @@ class NextcloudClient( val nextcloudRequest: NextcloudRequest = if (params == null) { NextcloudRequest.Builder() .setMethod(method) - .setUrl(target) + .setUrl(finalTarget) .setHeader(headers) .build() } else { NextcloudRequest.Builder() .setMethod(method) - .setUrl(target) + .setUrl(finalTarget) .setParameter(params) .setHeader(headers) .build() @@ -182,9 +186,13 @@ class NextcloudClient( target: String, method: String, params: JSONObject?, lastETag: String?, needLogin: Boolean, isOCSRequest: Boolean ): VersatileProjectSyncClient.ResponseData { + var finalTarget = target + if (finalTarget.contains("/ocs/v2.php") && !finalTarget.contains("format=json")) { + finalTarget += if (finalTarget.contains("?")) "&format=json" else "?format=json" + } val result = StringBuilder() - val targetURL = url + target.replace("^/".toRegex(), "") - Log.d(javaClass.simpleName, "method and target URL: $method $targetURL") + val targetURL = url + finalTarget.replace("^/".toRegex(), "") +// Log.d(javaClass.simpleName, "method and target URL: $method $targetURL") val httpCon = SupportUtil.getHttpURLConnection(targetURL) httpCon.requestMethod = method if (needLogin) { @@ -194,7 +202,7 @@ class NextcloudClient( ) } httpCon.setRequestProperty("Connection", "Close") - httpCon.setRequestProperty("User-Agent", "cowspent-android/" + SupportUtil.getAppVersionName(context)) + httpCon.setRequestProperty("User-Agent", "Cowspent-android/" + SupportUtil.getAppVersionName(context)) if (lastETag != null && METHOD_GET == method) { httpCon.setRequestProperty("If-None-Match", lastETag) } @@ -206,7 +214,7 @@ class NextcloudClient( var paramData: ByteArray? = null if (params != null) { paramData = params.toString().toByteArray() - Log.d(javaClass.simpleName, "Params: $params") +// Log.d(javaClass.simpleName, "Params: $params") httpCon.setFixedLengthStreamingMode(paramData.size) httpCon.setRequestProperty("Content-Type", application_json) httpCon.doOutput = true @@ -244,8 +252,12 @@ class NextcloudClient( target: String, method: String, params: JSONObject?, lastETag: String?, needLogin: Boolean, isOCSRequest: Boolean ): VersatileProjectSyncClient.ResponseData { + var finalTarget = target + if (finalTarget.contains("/ocs/v2.php") && !finalTarget.contains("format=json")) { + finalTarget += if (finalTarget.contains("?")) "&format=json" else "?format=json" + } var strBase64: String - val targetURL = url + target.replace("^/".toRegex(), "") + val targetURL = url + finalTarget.replace("^/".toRegex(), "") val httpCon = SupportUtil.getHttpURLConnection( targetURL) httpCon.requestMethod = method if (needLogin) { @@ -263,11 +275,11 @@ class NextcloudClient( httpCon.setRequestProperty("OCS-APIRequest", "true") } httpCon.connectTimeout = 10 * 1000 // 10 seconds - Log.d(javaClass.simpleName, "$method $targetURL") +// Log.d(javaClass.simpleName, "$method $targetURL") var paramData: ByteArray? if (params != null) { paramData = params.toString().toByteArray() - Log.d(javaClass.simpleName, "Params: $params") +// Log.d(javaClass.simpleName, "Params: $params") httpCon.setFixedLengthStreamingMode(paramData.size) httpCon.setRequestProperty("Content-Type", application_json) httpCon.doOutput = true diff --git a/app/src/main/java/net/helcel/cowspent/util/SecureStorage.kt b/app/src/main/java/net/helcel/cowspent/util/SecureStorage.kt new file mode 100644 index 0000000..233e26d --- /dev/null +++ b/app/src/main/java/net/helcel/cowspent/util/SecureStorage.kt @@ -0,0 +1,84 @@ +package net.helcel.cowspent.util + +import android.content.Context +import android.util.Base64 +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.google.crypto.tink.Aead +import com.google.crypto.tink.KeyTemplates +import com.google.crypto.tink.RegistryConfiguration +import com.google.crypto.tink.aead.AeadConfig +import com.google.crypto.tink.integration.android.AndroidKeysetManager +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.runBlocking + +private val Context.dataStore: DataStore by preferencesDataStore(name = "secure_prefs") + +object SecureStorage { + private var aead: Aead? = null + + @Synchronized + private fun getAead(context: Context): Aead { + aead?.let { return it } + AeadConfig.register() + val masterKeyUri = "android-keystore://secure_storage_master_key" + val keysetManager = AndroidKeysetManager.Builder() + .withSharedPref(context, "tink_keyset", "secure_storage_keyset_prefs") + .withKeyTemplate(KeyTemplates.get("AES256_GCM")) + .withMasterKeyUri(masterKeyUri) + .build() + val newAead = keysetManager.keysetHandle.getPrimitive(RegistryConfiguration.get(), Aead::class.java) + aead = newAead + return newAead + } + + private fun encrypt(context: Context, value: String): String { + val encrypted = getAead(context).encrypt(value.toByteArray(), null) + return Base64.encodeToString(encrypted, Base64.NO_WRAP) + } + + private fun decrypt(context: Context, encryptedValue: String): String? { + return try { + val decoded = Base64.decode(encryptedValue, Base64.NO_WRAP) + val decrypted = getAead(context).decrypt(decoded, null) + String(decrypted) + } catch (_: Exception) { + null + } + } + + suspend fun savePassword(context: Context, key: String, password: String?) { + if (password == null) { + removePassword(context, key) + } else { + val encrypted = encrypt(context, password) + context.dataStore.edit { it[stringPreferencesKey(key)] = encrypted } + } + } + + suspend fun getPassword(context: Context, key: String): String? { + val encrypted = context.dataStore.data.map { it[stringPreferencesKey(key)] }.first() + return encrypted?.let { decrypt(context, it) } + } + + suspend fun removePassword(context: Context, key: String) { + context.dataStore.edit { it.remove(stringPreferencesKey(key)) } + } + + // Synchronous alternatives for legacy code + fun savePasswordSync(context: Context, key: String, password: String?) = runBlocking { + savePassword(context, key, password) + } + + fun getPasswordSync(context: Context, key: String): String? = runBlocking { + getPassword(context, key) + } + + fun removePasswordSync(context: Context, key: String) = runBlocking { + removePassword(context, key) + } +} diff --git a/app/src/main/java/net/helcel/cowspent/util/ServerResponse.kt b/app/src/main/java/net/helcel/cowspent/util/ServerResponse.kt index c8416ee..d1feac3 100644 --- a/app/src/main/java/net/helcel/cowspent/util/ServerResponse.kt +++ b/app/src/main/java/net/helcel/cowspent/util/ServerResponse.kt @@ -137,6 +137,90 @@ open class ServerResponse( getResponseStringData().toLong() } + class CreateRemoteCategoryResponse( + response: VersatileProjectSyncClient.ResponseData, + isOcsResponse: Boolean + ) : ServerResponse(response, isOcsResponse) { + + @get:Throws(JSONException::class) + val stringContent: String + get() = getResponseStringData() + + @get:Throws(JSONException::class) + val remoteCategoryId: Long + get() { + val dataStr = getResponseStringData() + return try { + dataStr.toLong() + } catch (_: NumberFormatException) { + val obj = JSONObject(dataStr) + obj.optLong("id", obj.optLong("remoteId", 0L)) + } + } + } + + class EditRemoteCategoryResponse( + response: VersatileProjectSyncClient.ResponseData, + isOcsResponse: Boolean + ) : ServerResponse(response, isOcsResponse) { + + @get:Throws(JSONException::class) + val stringContent: String + get() = getResponseStringData() + } + + class DeleteRemoteCategoryResponse( + response: VersatileProjectSyncClient.ResponseData, + isOcsResponse: Boolean + ) : ServerResponse(response, isOcsResponse) { + + @get:Throws(JSONException::class) + val stringContent: String + get() = getResponseStringData() + } + + class CreateRemotePaymentModeResponse( + response: VersatileProjectSyncClient.ResponseData, + isOcsResponse: Boolean + ) : ServerResponse(response, isOcsResponse) { + + @get:Throws(JSONException::class) + val stringContent: String + get() = getResponseStringData() + + @get:Throws(JSONException::class) + val remotePaymentModeId: Long + get() { + val dataStr = getResponseStringData() + return try { + dataStr.toLong() + } catch (_: NumberFormatException) { + val obj = JSONObject(dataStr) + obj.optLong("id", obj.optLong("remoteId", 0L)) + } + } + } + + class EditRemotePaymentModeResponse( + response: VersatileProjectSyncClient.ResponseData, + isOcsResponse: Boolean + ) : ServerResponse(response, isOcsResponse) { + + @get:Throws(JSONException::class) + val stringContent: String + get() = getResponseStringData() + } + + class DeleteRemotePaymentModeResponse( + response: VersatileProjectSyncClient.ResponseData, + isOcsResponse: Boolean + ) : ServerResponse(response, isOcsResponse) { + + @get:Throws(JSONException::class) + val stringContent: String + get() = getResponseStringData() + } + class CreateRemoteCurrencyResponse( response: VersatileProjectSyncClient.ResponseData, isOcsResponse: Boolean @@ -145,6 +229,18 @@ open class ServerResponse( @get:Throws(JSONException::class) val stringContent: String get() = getResponseStringData() + + @get:Throws(JSONException::class) + val remoteCurrencyId: Long + get() { + val dataStr = getResponseStringData() + return try { + dataStr.toLong() + } catch (_: NumberFormatException) { + val obj = JSONObject(dataStr) + obj.optLong("id", obj.optLong("remoteId", 0L)) + } + } } class EditRemoteCurrencyResponse( @@ -196,6 +292,18 @@ open class ServerResponse( @get:Throws(JSONException::class) val stringContent: String get() = getResponseStringData() + + @get:Throws(JSONException::class) + val remoteBillId: Long + get() { + val dataStr = getResponseStringData() + return try { + dataStr.toLong() + } catch (_: NumberFormatException) { + val obj = JSONObject(dataStr) + obj.optLong("id", obj.optLong("remoteId", 0L)) + } + } } class CreateRemoteBillResponse( @@ -206,6 +314,18 @@ open class ServerResponse( @get:Throws(JSONException::class) val stringContent: String get() = getResponseStringData() + + @get:Throws(JSONException::class) + val remoteBillId: Long + get() { + val dataStr = getResponseStringData() + return try { + dataStr.toLong() + } catch (_: NumberFormatException) { + val obj = JSONObject(dataStr) + obj.optLong("id", obj.optLong("remoteId", 0L)) + } + } } class DeleteRemoteBillResponse( @@ -242,13 +362,23 @@ open class ServerResponse( ServerResponse(response, isOcsResponse) { @Throws(JSONException::class) - fun getBillsCospend(projId: Long, memberRemoteIdToId: Map): List { - return getBillsFromJSONObject(getResponseObjectData(), projId, memberRemoteIdToId) + fun getBillsCospend( + projId: Long, + memberRemoteIdToId: Map, + catRemoteIdToId: Map, + pmRemoteIdToId: Map + ): List { + return getBillsFromJSONObject(projId, memberRemoteIdToId, catRemoteIdToId, pmRemoteIdToId) } @Throws(JSONException::class) - fun getBillsIHM(projId: Long, memberRemoteIdToId: Map): List { - return getBillsFromJSONArray(JSONArray(content), projId, memberRemoteIdToId) + fun getBillsIHM( + projId: Long, + memberRemoteIdToId: Map, + catRemoteIdToId: Map, + pmRemoteIdToId: Map + ): List { + return getBillsFromJSONArray(JSONArray(content), projId, memberRemoteIdToId, catRemoteIdToId, pmRemoteIdToId) } @@ -270,6 +400,32 @@ open class ServerResponse( } } + class CategoriesResponse(response: VersatileProjectSyncClient.ResponseData, isOcsResponse: Boolean) : + ServerResponse(response, isOcsResponse) { + + @Throws(JSONException::class) + fun getCategories(projId: Long): List { + return if (isOcsResponse) { + getCategoriesFromJSON(getResponseObjectData(), projId) + } else { + getCategoriesFromJSONArray(getResponseArrayData(), projId) + } + } + } + + class PaymentModesResponse(response: VersatileProjectSyncClient.ResponseData, isOcsResponse: Boolean) : + ServerResponse(response, isOcsResponse) { + + @Throws(JSONException::class) + fun getPaymentModes(projId: Long): List { + return if (isOcsResponse) { + getPaymentModesFromJSON(getResponseObjectData(), projId) + } else { + getPaymentModesFromJSONArray(getResponseArrayData(), projId) + } + } + } + class AccountProjectsResponse( response: VersatileProjectSyncClient.ResponseData, isOcsResponse: Boolean @@ -366,6 +522,26 @@ open class ServerResponse( return members } + @Throws(JSONException::class) + protected fun getCategoriesFromJSONArray(jsonCats: JSONArray, projId: Long): List { + val categories: MutableList = ArrayList() + for (i in 0 until jsonCats.length()) { + val jsonCat = jsonCats.getJSONObject(i) + categories.add(getCategoryFromJSON(jsonCat, projId)) + } + return categories + } + + @Throws(JSONException::class) + protected fun getPaymentModesFromJSONArray(jsonPms: JSONArray, projId: Long): List { + val paymentModes: MutableList = ArrayList() + for (i in 0 until jsonPms.length()) { + val jsonPm = jsonPms.getJSONObject(i) + paymentModes.add(getPaymentModeFromJSON(jsonPm, projId)) + } + return paymentModes + } + @Throws(JSONException::class) protected fun getCategoriesFromJSON(json: JSONObject, projId: Long): List { val categories: MutableList = ArrayList() @@ -382,6 +558,27 @@ open class ServerResponse( return categories } + @Throws(JSONException::class) + protected fun getCategoryFromJSON(json: JSONObject, projId: Long): DBCategory { + var remoteId: Long = 0 + if (json.has("id") && !json.isNull("id")) { + remoteId = json.getLong("id") + } + var name = "" + var color = "" + var icon = "" + if (json.has("color") && !json.isNull("color")) { + color = json.getString("color") + } + if (json.has("icon") && !json.isNull("icon")) { + icon = json.getString("icon") + } + if (json.has("name") && !json.isNull("name")) { + name = json.getString("name") + } + return DBCategory(0, remoteId, projId, name, icon, color) + } + @Throws(JSONException::class) protected fun getCategoryFromJSON(json: JSONObject, remoteIdStr: String, projId: Long): DBCategory { val remoteId = remoteIdStr.toLong() @@ -416,6 +613,27 @@ open class ServerResponse( return paymentModes } + @Throws(JSONException::class) + protected fun getPaymentModeFromJSON(json: JSONObject, projId: Long): DBPaymentMode { + var remoteId: Long = 0 + if (json.has("id") && !json.isNull("id")) { + remoteId = json.getLong("id") + } + var name = "" + var color = "" + var icon = "" + if (json.has("color") && !json.isNull("color")) { + color = json.getString("color") + } + if (json.has("icon") && !json.isNull("icon")) { + icon = json.getString("icon") + } + if (json.has("name") && !json.isNull("name")) { + name = json.getString("name") + } + return DBPaymentMode(0, remoteId, projId, name, icon, color) + } + @Throws(JSONException::class) protected fun getPaymentModeFromJSON(json: JSONObject, remoteIdStr: String, projId: Long): DBPaymentMode { val remoteId = remoteIdStr.toLong() @@ -556,26 +774,30 @@ open class ServerResponse( protected fun getBillsFromJSONArray( json: JSONArray, projId: Long, - memberRemoteIdToId: Map + memberRemoteIdToId: Map, + catRemoteIdToId: Map, + pmRemoteIdToId: Map ): List { val bills: MutableList = ArrayList() for (i in 0 until json.length()) { val jsonBill = json.getJSONObject(i) - bills.add(getBillFromJSON(jsonBill, projId, memberRemoteIdToId)) + bills.add(getBillFromJSON(jsonBill, projId, memberRemoteIdToId, catRemoteIdToId, pmRemoteIdToId)) } return bills } @Throws(JSONException::class) protected fun getBillsFromJSONObject( - json: JSONObject, projId: Long, - memberRemoteIdToId: Map + memberRemoteIdToId: Map, + catRemoteIdToId: Map, + pmRemoteIdToId: Map ): List { val bills: List + val json = getResponseObjectData() if (json.has("bills") && !json.isNull("bills")) { val jsonBills = json.getJSONArray("bills") - bills = getBillsFromJSONArray(jsonBills, projId, memberRemoteIdToId) + bills = getBillsFromJSONArray(jsonBills, projId, memberRemoteIdToId, catRemoteIdToId, pmRemoteIdToId) } else { bills = ArrayList() } @@ -586,7 +808,9 @@ open class ServerResponse( protected fun getBillFromJSON( json: JSONObject, projId: Long, - memberRemoteIdToId: Map + memberRemoteIdToId: Map, + catRemoteIdToId: Map, + pmRemoteIdToId: Map ): DBBill { var remoteId: Long = 0 var payerRemoteId: Long @@ -600,7 +824,7 @@ open class ServerResponse( var repeat = DBBill.NON_REPEATED var paymentMode = DBBill.PAYMODE_NONE var paymentModeRemoteId = DBBill.PAYMODE_ID_NONE - var categoryId = DBBill.CATEGORY_NONE + var categoryRemoteId = DBBill.CATEGORY_NONE if (!json.isNull("id")) { remoteId = json.getLong("id") } @@ -639,23 +863,29 @@ open class ServerResponse( } if (json.has("paymentmode") && !json.isNull("paymentmode")) { paymentMode = json.getString("paymentmode") + } else if (json.has("paymentMode") && !json.isNull("paymentMode")) { + paymentMode = json.getString("paymentMode") } if (json.has("categoryid") && !json.isNull("categoryid")) { - categoryId = json.getInt("categoryid") - Log.d("PLOP", "LOADED CATTTTTTTTTTTT $categoryId") + categoryRemoteId = json.getLong("categoryid") + } else if (json.has("categoryId") && !json.isNull("categoryId")) { + categoryRemoteId = json.getLong("categoryId") } if (json.has("paymentmodeid") && !json.isNull("paymentmodeid")) { - paymentModeRemoteId = json.getInt("paymentmodeid") + paymentModeRemoteId = json.getLong("paymentmodeid") + } else if (json.has("paymentModeId") && !json.isNull("paymentModeId")) { + paymentModeRemoteId = json.getLong("paymentModeId") } - // old MB, new Cospend is ok as Cospend provides the old pm ID - // new MB, old Cospend => set payment mode ID from old one if (DBBill.PAYMODE_NONE != paymentMode && "" != paymentMode && paymentModeRemoteId == DBBill.PAYMODE_ID_NONE) { - Log.d("PaymentMode", "old: $paymentMode and new: 0") paymentModeRemoteId = DBBill.oldPmIdToNew[paymentMode] ?: DBBill.PAYMODE_ID_NONE } + + val categoryId = catRemoteIdToId[categoryRemoteId] ?: 0L + val paymentModeId = pmRemoteIdToId[paymentModeRemoteId] ?: 0L + val bill = DBBill( 0, remoteId, projId, payerId, amount, timestamp, what, - DBBill.STATE_OK, repeat, paymentMode, categoryId, comment, paymentModeRemoteId + DBBill.STATE_OK, repeat, paymentMode, categoryId, comment, paymentModeId ) bill.billOwers = getBillOwersFromJson(json, memberRemoteIdToId) return bill diff --git a/app/src/main/java/net/helcel/cowspent/util/SupportUtil.kt b/app/src/main/java/net/helcel/cowspent/util/SupportUtil.kt index 00766a4..c2b0cf9 100644 --- a/app/src/main/java/net/helcel/cowspent/util/SupportUtil.kt +++ b/app/src/main/java/net/helcel/cowspent/util/SupportUtil.kt @@ -1,11 +1,12 @@ package net.helcel.cowspent.util -import android.app.NotificationChannel -import android.app.NotificationManager import android.content.Context import android.content.pm.PackageManager import android.util.Log -import net.helcel.cowspent.model.* +import net.helcel.cowspent.model.CreditDebt +import net.helcel.cowspent.model.DBBill +import net.helcel.cowspent.model.DBMember +import net.helcel.cowspent.model.Transaction import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper import org.json.JSONException import org.json.JSONObject @@ -86,14 +87,17 @@ object SupportUtil { membersBalance: MutableMap, membersPaid: MutableMap, membersSpent: MutableMap, - catId: Int, paymentModeId: Int, + catId: Long, paymentModeId: Long, dateMin: String?, dateMax: String? ): Int { + val proj = db.getProject(projId) + val categories = db.getCategories(projId) + val reimbursementCategoryId = CategoryUtils.getReimbursementCategoryId(categories, projId, proj?.remoteId) return getStats( db.getMembersOfProject(projId, null), db.getBillsOfProject(projId), membersNbBills, membersBalance, membersPaid, membersSpent, - catId, paymentModeId, dateMin, dateMax + catId, paymentModeId, reimbursementCategoryId, dateMin, dateMax ) } @@ -105,7 +109,8 @@ object SupportUtil { membersBalance: MutableMap, membersPaid: MutableMap, membersSpent: MutableMap, - catId: Int, paymentModeId: Int, + catId: Long, paymentModeId: Long, + reimbursementCategoryId: Long, dateMin: String?, dateMax: String? ): Int { val nbBillsTotal = 0 @@ -123,9 +128,9 @@ object SupportUtil { for (b in dbBills) { // don't take deleted bills and respect category filter if (b.state != DBBill.STATE_DELETED && - ((catId == -1000 || catId == -100 || b.categoryRemoteId == catId) && - (catId != -100 || b.categoryRemoteId != DBBill.CATEGORY_REIMBURSEMENT) && - (paymentModeId == -1000 || b.paymentModeRemoteId == paymentModeId)) && + ((catId == -1000L || catId == -100L || b.categoryId == catId) && + (catId != -100L || b.categoryId != reimbursementCategoryId) && + (paymentModeId == -1000L || b.paymentModeId == paymentModeId)) && (dateMin == null || b.date >= dateMin) && (dateMax == null || b.date <= dateMax) ) { @@ -284,18 +289,6 @@ object SupportUtil { return reduceBalance(crediters, debiters, results) } - @JvmStatic - fun getVersionName(context: Context): String { - var versionName = "0.0.0" - try { - val pInfo = context.packageManager.getPackageInfo(context.packageName, 0) - versionName = pInfo.versionName ?: "0.0.0" - } catch (e: PackageManager.NameNotFoundException) { - e.printStackTrace() - } - return versionName - } - @JvmStatic fun getJsonObject(text: String?): JSONObject? { if (text == null) return null diff --git a/app/src/main/java/net/helcel/cowspent/util/VersatileProjectSyncClient.kt b/app/src/main/java/net/helcel/cowspent/util/VersatileProjectSyncClient.kt index ac0a4df..1918ecb 100644 --- a/app/src/main/java/net/helcel/cowspent/util/VersatileProjectSyncClient.kt +++ b/app/src/main/java/net/helcel/cowspent/util/VersatileProjectSyncClient.kt @@ -11,8 +11,10 @@ import com.nextcloud.android.sso.exceptions.NextcloudHttpRequestFailedException import com.nextcloud.android.sso.exceptions.TokenMismatchException import com.nextcloud.android.sso.model.SingleSignOnAccount import net.helcel.cowspent.model.DBBill +import net.helcel.cowspent.model.DBCategory import net.helcel.cowspent.model.DBCurrency import net.helcel.cowspent.model.DBMember +import net.helcel.cowspent.model.DBPaymentMode import net.helcel.cowspent.model.DBProject import net.helcel.cowspent.model.ProjectType import org.json.JSONException @@ -81,25 +83,18 @@ class VersatileProjectSyncClient( else project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId useOcsApiRequest = cospendVersionGT161 - if (cospendVersionGT161) { - Log.i(TAG, "using new API (weblogin, $username:$password) for getProjectInfo") - } } else if (canAccessProjectWithSSO(project)) { - return if (cospendVersionGT161) { - target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId - Log.i(TAG, "using new API for getProjectInfo") - ServerResponse.ProjectResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_GET, null, null, true), true) - } else { - target = "/index.php/apps/cospend/api-priv/projects/" + project.remoteId - ServerResponse.ProjectResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_GET, null, null, false), false) - } + target = if (cospendVersionGT161) + "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + else + "/index.php/apps/cospend/api-priv/projects/" + project.remoteId + return ServerResponse.ProjectResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_GET, null, null, cospendVersionGT161), cospendVersionGT161) } else { useOcsApiRequest = cospendVersionGT161 target = if (cospendVersionGT161) project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) else project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) - Log.i(TAG, "using public API, target is: ${target}for getProjectInfo") } } else { target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId @@ -172,13 +167,9 @@ class VersatileProjectSyncClient( else project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId useOcsApiRequest = cospendVersionGT161 - if (cospendVersionGT161) { - Log.i(TAG, "using new API (weblogin, $username:$password) for editRemoteProject") - } } else if (canAccessProjectWithSSO(project)) { return if (cospendVersionGT161) { target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId - Log.i(TAG, "using new API for editRemoteProject") ServerResponse.EditRemoteProjectResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_PUT, paramKeys, paramValues, true), true) } else { target = "/index.php/apps/cospend/api-priv/projects/" + project.remoteId @@ -190,7 +181,6 @@ class VersatileProjectSyncClient( project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) else project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) - Log.i(TAG, "using public API, target is: ${target}for editRemoteProject") } } else { target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId @@ -239,13 +229,9 @@ class VersatileProjectSyncClient( else project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/members/" + member.remoteId useOcsApiRequest = cospendVersionGT161 - if (cospendVersionGT161) { - Log.i(TAG, "using new API (weblogin, $username:$password) for editRemoteMember") - } } else if (canAccessProjectWithSSO(project)) { return if (cospendVersionGT161) { target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/members/" + member.remoteId - Log.i(TAG, "using new API for editRemoteMember") ServerResponse.EditRemoteMemberResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_PUT, paramKeys, paramValues, true), true) } else { target = "/index.php/apps/cospend/api-priv/projects/" + project.remoteId + "/members/" + member.remoteId @@ -257,7 +243,6 @@ class VersatileProjectSyncClient( project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/members/" + member.remoteId else project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/members/" + member.remoteId - Log.i(TAG, "using public API, target is: ${target}for editRemoteMember") } } else { target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/members/" + member.remoteId @@ -275,7 +260,13 @@ class VersatileProjectSyncClient( } @Throws(IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class) - fun editRemoteBill(project: DBProject, bill: DBBill, memberIdToRemoteId: Map): ServerResponse.EditRemoteBillResponse { + fun editRemoteBill( + project: DBProject, + bill: DBBill, + memberIdToRemoteId: Map, + categoryIdToRemoteId: Map, + paymentModeIdToRemoteId: Map + ): ServerResponse.EditRemoteBillResponse { val paramKeys: MutableList = ArrayList() val paramValues: MutableList = ArrayList() paramKeys.add("date") @@ -322,8 +313,8 @@ class VersatileProjectSyncClient( payedFor = payedFor.replace(",$".toRegex(), "") paramValues.add(payedFor) paramValues.add(bill.paymentMode ?: "") - paramValues.add(bill.categoryRemoteId.toString()) - paramValues.add(bill.paymentModeRemoteId.toString()) + paramValues.add((categoryIdToRemoteId[bill.categoryId] ?: 0L).toString()) + paramValues.add((paymentModeIdToRemoteId[bill.paymentModeId] ?: 0L).toString()) if (canAccessProjectWithNCLogin(project)) { username = this.username @@ -333,9 +324,6 @@ class VersatileProjectSyncClient( else project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/bills/" + bill.remoteId useOcsApiRequest = cospendVersionGT161 - if (cospendVersionGT161) { - Log.i(TAG, "using new API (weblogin, $username:$password) for editRemoteBill") - } } else if (canAccessProjectWithSSO(project)) { return if (cospendVersionGT161) { target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/bills/" + bill.remoteId @@ -351,7 +339,6 @@ class VersatileProjectSyncClient( project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/bills/" + bill.remoteId else project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/bills/" + bill.remoteId - Log.i(TAG, "using public API, target is: ${target}for editRemoteBill") } } else { target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/bills/" + bill.remoteId @@ -389,9 +376,6 @@ class VersatileProjectSyncClient( else project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId useOcsApiRequest = cospendVersionGT161 - if (cospendVersionGT161) { - Log.i(TAG, "using new API (weblogin, $username:$password) for deleteRemoteProject") - } } else if (canAccessProjectWithSSO(project)) { return if (cospendVersionGT161) { target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId @@ -407,7 +391,6 @@ class VersatileProjectSyncClient( project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) else project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) - Log.i(TAG, "using public API, target is: ${target}for deleteRemoteProject") } } else { target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId @@ -439,9 +422,6 @@ class VersatileProjectSyncClient( else project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/bills/" + billRemoteId useOcsApiRequest = cospendVersionGT161 - if (cospendVersionGT161) { - Log.i(TAG, "using new API (weblogin, $username:$password) for deleteRemoteBill") - } } else if (canAccessProjectWithSSO(project)) { return if (cospendVersionGT161) { target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/bills/" + billRemoteId @@ -457,7 +437,6 @@ class VersatileProjectSyncClient( project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/bills/" + billRemoteId else project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/bills/" + billRemoteId - Log.i(TAG, "using public API, target is: ${target}for deleteRemoteProject") } } else { target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/bills/" + billRemoteId @@ -528,9 +507,7 @@ class VersatileProjectSyncClient( else project.getRequestBaseUrl(false) + "/api-priv/projects" useOcsApiRequest = cospendVersionGT161 - if (cospendVersionGT161) { - Log.i(TAG, "using new API (weblogin, $username:$password) for createAuthenticatedRemoteProject") - } + return ServerResponse.CreateRemoteProjectResponse( requestServer( target, METHOD_POST, paramKeys, paramValues, @@ -541,7 +518,13 @@ class VersatileProjectSyncClient( } @Throws(IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class) - fun createRemoteBill(project: DBProject, bill: DBBill, memberIdToRemoteId: Map): ServerResponse.CreateRemoteBillResponse { + fun createRemoteBill( + project: DBProject, + bill: DBBill, + memberIdToRemoteId: Map, + categoryIdToRemoteId: Map, + paymentModeIdToRemoteId: Map + ): ServerResponse.CreateRemoteBillResponse { val paramKeys: MutableList = ArrayList() val paramValues: MutableList = ArrayList() paramKeys.add("date") @@ -583,8 +566,8 @@ class VersatileProjectSyncClient( payedFor = payedFor.replace(",$".toRegex(), "") paramValues.add(payedFor) paramValues.add(bill.paymentMode ?: "") - paramValues.add(bill.categoryRemoteId.toString()) - paramValues.add(bill.paymentModeRemoteId.toString()) + paramValues.add((categoryIdToRemoteId[bill.categoryId] ?: 0L).toString()) + paramValues.add((paymentModeIdToRemoteId[bill.paymentModeId] ?: 0L).toString()) if (canAccessProjectWithNCLogin(project)) { username = this.username @@ -594,9 +577,6 @@ class VersatileProjectSyncClient( else project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/bills" useOcsApiRequest = cospendVersionGT161 - if (cospendVersionGT161) { - Log.i(TAG, "using new API (weblogin, $username:$password) for createRemoteBill") - } } else if (canAccessProjectWithSSO(project)) { return if (cospendVersionGT161) { target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/bills" @@ -612,7 +592,6 @@ class VersatileProjectSyncClient( project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/bills" else project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/bills" - Log.i(TAG, "using public API, target is: ${target}for createRemoteBill") } } else { target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/bills" @@ -664,13 +643,9 @@ class VersatileProjectSyncClient( else project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/members" useOcsApiRequest = cospendVersionGT161 - if (cospendVersionGT161) { - Log.i(TAG, "using new API (weblogin, $username:$password) for createRemoteMember") - } } else if (canAccessProjectWithSSO(project)) { return if (cospendVersionGT161) { target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/members" - Log.i(TAG, "using new API for createRemoteBill") ServerResponse.CreateRemoteMemberResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_POST, paramKeys, paramValues, true), isOcsResponse=true, isJsonMember=true) } else { target = "/index.php/apps/cospend/api-priv/projects/" + project.remoteId + "/members" @@ -682,7 +657,6 @@ class VersatileProjectSyncClient( project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/members" else project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/members" - Log.i(TAG, "using public API, target is: ${target}for createRemoteBill") } } else { target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/members" @@ -717,9 +691,6 @@ class VersatileProjectSyncClient( else project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/bills?lastchanged=" + tsLastSync useOcsApiRequest = cospendVersionGT161 - if (cospendVersionGT161) { - Log.i(TAG, "using new API (weblogin, $username:$password) for getBills") - } return ServerResponse.BillsResponse( requestServer( target, METHOD_GET, null, null, @@ -738,7 +709,6 @@ class VersatileProjectSyncClient( paramValues.add(tsLastSync.toString()) return if (cospendVersionGT161) { target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/bills" - Log.i(TAG, "using new API for getBills") ServerResponse.BillsResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_GET, paramKeys, paramValues, true), true) } else { target = "/index.php/apps/cospend/api-priv/projects/" + project.remoteId + "/bills" @@ -750,7 +720,6 @@ class VersatileProjectSyncClient( project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/bills?lastChanged=" + tsLastSync else project.getRequestBaseUrl(false) + "/apiv2/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/bills?lastchanged=" + tsLastSync - Log.i(TAG, "using public API, target is: ${target}for getBills") return ServerResponse.BillsResponse( requestServer( target, METHOD_GET, null, null, @@ -774,6 +743,92 @@ class VersatileProjectSyncClient( } } + @Throws(JSONException::class, IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class) + fun getCategories(project: DBProject): ServerResponse.CategoriesResponse { + var target: String + var username: String? = null + var password: String? = null + var bearerToken: String? = null + var useOcsApiRequest = false + if (ProjectType.COSPEND == project.type) { + if (canAccessProjectWithNCLogin(project)) { + username = this.username + password = this.password + target = if (cospendVersionGT161) + project.getRequestBaseUrl(true) + "/api/v1/projects/" + project.remoteId + else + project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/categories" + useOcsApiRequest = cospendVersionGT161 + } else if (canAccessProjectWithSSO(project)) { + target = if (cospendVersionGT161) + "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + else + "/index.php/apps/cospend/api-priv/projects/" + project.remoteId + "/categories" + return ServerResponse.CategoriesResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_GET, null, null, cospendVersionGT161), cospendVersionGT161) + } else { + useOcsApiRequest = cospendVersionGT161 + target = if (cospendVersionGT161) + project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + else + project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/categories" + } + } else { + target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/categories" + username = project.remoteId + password = project.password + bearerToken = project.bearerToken + } + return ServerResponse.CategoriesResponse( + requestServer( + target, METHOD_GET, null, null, + null, username, password, bearerToken, useOcsApiRequest + ), useOcsApiRequest + ) + } + + @Throws(JSONException::class, IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class) + fun getPaymentModes(project: DBProject): ServerResponse.PaymentModesResponse { + var target: String + var username: String? = null + var password: String? = null + var bearerToken: String? = null + var useOcsApiRequest = false + if (ProjectType.COSPEND == project.type) { + if (canAccessProjectWithNCLogin(project)) { + username = this.username + password = this.password + target = if (cospendVersionGT161) + project.getRequestBaseUrl(true) + "/api/v1/projects/" + project.remoteId + else + project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/paymentmodes" + useOcsApiRequest = cospendVersionGT161 + } else if (canAccessProjectWithSSO(project)) { + target = if (cospendVersionGT161) + "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + else + "/index.php/apps/cospend/api-priv/projects/" + project.remoteId + "/paymentmodes" + return ServerResponse.PaymentModesResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_GET, null, null, cospendVersionGT161), cospendVersionGT161) + } else { + useOcsApiRequest = cospendVersionGT161 + target = if (cospendVersionGT161) + project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + else + project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/paymentmodes" + } + } else { + target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/paymentmodes" + username = project.remoteId + password = project.password + bearerToken = project.bearerToken + } + return ServerResponse.PaymentModesResponse( + requestServer( + target, METHOD_GET, null, null, + null, username, password, bearerToken, useOcsApiRequest + ), useOcsApiRequest + ) + } + @Throws(JSONException::class, IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class) fun getMembers(project: DBProject): ServerResponse.MembersResponse { var target: String @@ -790,9 +845,6 @@ class VersatileProjectSyncClient( else project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/members" useOcsApiRequest = cospendVersionGT161 - if (cospendVersionGT161) { - Log.i(TAG, "using new API (weblogin, $username:$password) for getMembers, projectId: " + project.remoteId) - } } else if (canAccessProjectWithSSO(project)) { return if (cospendVersionGT161) { target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/members" @@ -808,7 +860,6 @@ class VersatileProjectSyncClient( project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/members" else project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/members" - Log.i(TAG, "using public API, target is: ${target}for getMembers") } } else { target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/members" @@ -824,6 +875,305 @@ class VersatileProjectSyncClient( ) } + @Throws(IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class) + fun createRemoteCategory(project: DBProject, category: DBCategory): ServerResponse.CreateRemoteCategoryResponse { + val paramKeys: MutableList = ArrayList() + val paramValues: MutableList = ArrayList() + paramKeys.add("name") + paramValues.add(category.name ?: "") + paramKeys.add("icon") + paramValues.add(category.icon) + paramKeys.add("color") + paramValues.add(category.color) + + var target: String + var username: String? = null + var password: String? = null + var bearerToken: String? = null + var useOcsApiRequest = false + if (ProjectType.COSPEND == project.type) { + if (canAccessProjectWithNCLogin(project)) { + username = this.username + password = this.password + target = if (cospendVersionGT161) + project.getRequestBaseUrl(true) + "/api/v1/projects/" + project.remoteId + "/category" + else + project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/categories" + useOcsApiRequest = cospendVersionGT161 + } else if (canAccessProjectWithSSO(project)) { + target = if (cospendVersionGT161) + "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/category" + else + "/index.php/apps/cospend/api-priv/projects/" + project.remoteId + "/categories" + return ServerResponse.CreateRemoteCategoryResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_POST, paramKeys, paramValues, cospendVersionGT161), cospendVersionGT161) + } else { + useOcsApiRequest = cospendVersionGT161 + target = if (cospendVersionGT161) + project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/category" + else + project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/categories" + } + } else { + target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/categories" + username = project.remoteId + password = project.password + bearerToken = project.bearerToken + } + + val response = requestServer( + target, METHOD_POST, paramKeys, paramValues, null, + username, password, bearerToken, useOcsApiRequest + ) + return ServerResponse.CreateRemoteCategoryResponse(response, useOcsApiRequest) + } + + @Throws(IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class) + fun editRemoteCategory(project: DBProject, category: DBCategory): ServerResponse.EditRemoteCategoryResponse { + val paramKeys: MutableList = ArrayList() + val paramValues: MutableList = ArrayList() + paramKeys.add("name") + paramValues.add(category.name ?: "") + paramKeys.add("icon") + paramValues.add(category.icon) + paramKeys.add("color") + paramValues.add(category.color) + + var target: String + var username: String? = null + var password: String? = null + var bearerToken: String? = null + var useOcsApiRequest = false + if (ProjectType.COSPEND == project.type) { + if (canAccessProjectWithNCLogin(project)) { + username = this.username + password = this.password + target = if (cospendVersionGT161) + project.getRequestBaseUrl(true) + "/api/v1/projects/" + project.remoteId + "/category/" + category.remoteId + else + project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/categories/" + category.remoteId + useOcsApiRequest = cospendVersionGT161 + } else if (canAccessProjectWithSSO(project)) { + target = if (cospendVersionGT161) + "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/category/" + category.remoteId + else + "/index.php/apps/cospend/api-priv/projects/" + project.remoteId + "/categories/" + category.remoteId + return ServerResponse.EditRemoteCategoryResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_PUT, paramKeys, paramValues, cospendVersionGT161), cospendVersionGT161) + } else { + useOcsApiRequest = cospendVersionGT161 + target = if (cospendVersionGT161) + project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/category/" + category.remoteId + else + project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/categories/" + category.remoteId + } + } else { + target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/categories/" + category.remoteId + username = project.remoteId + password = project.password + bearerToken = project.bearerToken + } + + return ServerResponse.EditRemoteCategoryResponse( + requestServer( + target, METHOD_PUT, paramKeys, paramValues, null, + username, password, bearerToken, useOcsApiRequest + ), useOcsApiRequest + ) + } + + @Throws(IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class) + fun deleteRemoteCategory(project: DBProject, categoryRemoteId: Long): ServerResponse.DeleteRemoteCategoryResponse { + var target: String + var username: String? = null + var password: String? = null + var bearerToken: String? = null + var useOcsApiRequest = false + if (ProjectType.COSPEND == project.type) { + if (canAccessProjectWithNCLogin(project)) { + username = this.username + password = this.password + target = if (cospendVersionGT161) + project.getRequestBaseUrl(true) + "/api/v1/projects/" + project.remoteId + "/category/" + categoryRemoteId + else + project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/categories/" + categoryRemoteId + useOcsApiRequest = cospendVersionGT161 + } else if (canAccessProjectWithSSO(project)) { + target = if (cospendVersionGT161) + "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/category/" + categoryRemoteId + else + "/index.php/apps/cospend/api-priv/projects/" + project.remoteId + "/categories/" + categoryRemoteId + return ServerResponse.DeleteRemoteCategoryResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_DELETE, null, null, cospendVersionGT161), cospendVersionGT161) + } else { + useOcsApiRequest = cospendVersionGT161 + target = if (cospendVersionGT161) + project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/category/" + categoryRemoteId + else + project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/categories/" + categoryRemoteId + } + } else { + target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/categories/" + categoryRemoteId + username = project.remoteId + password = project.password + bearerToken = project.bearerToken + } + + return ServerResponse.DeleteRemoteCategoryResponse( + requestServer( + target, METHOD_DELETE, null, null, + null, username, password, bearerToken, useOcsApiRequest + ), useOcsApiRequest + ) + } + + @Throws(IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class) + fun createRemotePaymentMode(project: DBProject, paymentMode: DBPaymentMode): ServerResponse.CreateRemotePaymentModeResponse { + val paramKeys: MutableList = ArrayList() + val paramValues: MutableList = ArrayList() + paramKeys.add("name") + paramValues.add(paymentMode.name ?: "") + paramKeys.add("icon") + paramValues.add(paymentMode.icon) + paramKeys.add("color") + paramValues.add(paymentMode.color) + + var target: String + var username: String? = null + var password: String? = null + var bearerToken: String? = null + var useOcsApiRequest = false + if (ProjectType.COSPEND == project.type) { + if (canAccessProjectWithNCLogin(project)) { + username = this.username + password = this.password + target = if (cospendVersionGT161) + project.getRequestBaseUrl(true) + "/api/v1/projects/" + project.remoteId + "/paymentmode" + else + project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/paymentmodes" + useOcsApiRequest = cospendVersionGT161 + } else if (canAccessProjectWithSSO(project)) { + target = if (cospendVersionGT161) + "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/paymentmode" + else + "/index.php/apps/cospend/api-priv/projects/" + project.remoteId + "/paymentmodes" + return ServerResponse.CreateRemotePaymentModeResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_POST, paramKeys, paramValues, cospendVersionGT161), cospendVersionGT161) + } else { + useOcsApiRequest = cospendVersionGT161 + target = if (cospendVersionGT161) + project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/paymentmode" + else + project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/paymentmodes" + } + } else { + target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/paymentmodes" + username = project.remoteId + password = project.password + bearerToken = project.bearerToken + } + + return ServerResponse.CreateRemotePaymentModeResponse( + requestServer( + target, METHOD_POST, paramKeys, paramValues, null, + username, password, bearerToken, useOcsApiRequest + ), useOcsApiRequest + ) + } + + @Throws(IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class) + fun editRemotePaymentMode(project: DBProject, paymentMode: DBPaymentMode): ServerResponse.EditRemotePaymentModeResponse { + val paramKeys: MutableList = ArrayList() + val paramValues: MutableList = ArrayList() + paramKeys.add("name") + paramValues.add(paymentMode.name ?: "") + paramKeys.add("icon") + paramValues.add(paymentMode.icon) + paramKeys.add("color") + paramValues.add(paymentMode.color) + + var target: String + var username: String? = null + var password: String? = null + var bearerToken: String? = null + var useOcsApiRequest = false + if (ProjectType.COSPEND == project.type) { + if (canAccessProjectWithNCLogin(project)) { + username = this.username + password = this.password + target = if (cospendVersionGT161) + project.getRequestBaseUrl(true) + "/api/v1/projects/" + project.remoteId + "/paymentmode/" + paymentMode.remoteId + else + project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/paymentmodes/" + paymentMode.remoteId + useOcsApiRequest = cospendVersionGT161 + } else if (canAccessProjectWithSSO(project)) { + target = if (cospendVersionGT161) + "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/paymentmode/" + paymentMode.remoteId + else + "/index.php/apps/cospend/api-priv/projects/" + project.remoteId + "/paymentmodes/" + paymentMode.remoteId + return ServerResponse.EditRemotePaymentModeResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_PUT, paramKeys, paramValues, cospendVersionGT161), cospendVersionGT161) + } else { + useOcsApiRequest = cospendVersionGT161 + target = if (cospendVersionGT161) + project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/paymentmode/" + paymentMode.remoteId + else + project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/paymentmodes/" + paymentMode.remoteId + } + } else { + target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/paymentmodes/" + paymentMode.remoteId + username = project.remoteId + password = project.password + bearerToken = project.bearerToken + } + + return ServerResponse.EditRemotePaymentModeResponse( + requestServer( + target, METHOD_PUT, paramKeys, paramValues, null, + username, password, bearerToken, useOcsApiRequest + ), useOcsApiRequest + ) + } + + @Throws(IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class) + fun deleteRemotePaymentMode(project: DBProject, paymentModeRemoteId: Long): ServerResponse.DeleteRemotePaymentModeResponse { + var target: String + var username: String? = null + var password: String? = null + var bearerToken: String? = null + var useOcsApiRequest = false + if (ProjectType.COSPEND == project.type) { + if (canAccessProjectWithNCLogin(project)) { + username = this.username + password = this.password + target = if (cospendVersionGT161) + project.getRequestBaseUrl(true) + "/api/v1/projects/" + project.remoteId + "/paymentmode/" + paymentModeRemoteId + else + project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/paymentmodes/" + paymentModeRemoteId + useOcsApiRequest = cospendVersionGT161 + } else if (canAccessProjectWithSSO(project)) { + target = if (cospendVersionGT161) + "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/paymentmode/" + paymentModeRemoteId + else + "/index.php/apps/cospend/api-priv/projects/" + project.remoteId + "/paymentmodes/" + paymentModeRemoteId + return ServerResponse.DeleteRemotePaymentModeResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_DELETE, null, null, cospendVersionGT161), cospendVersionGT161) + } else { + useOcsApiRequest = cospendVersionGT161 + target = if (cospendVersionGT161) + project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/paymentmode/" + paymentModeRemoteId + else + project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/paymentmodes/" + paymentModeRemoteId + } + } else { + target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/paymentmodes/" + paymentModeRemoteId + username = project.remoteId + password = project.password + bearerToken = project.bearerToken + } + + return ServerResponse.DeleteRemotePaymentModeResponse( + requestServer( + target, METHOD_DELETE, null, null, + null, username, password, bearerToken, useOcsApiRequest + ), useOcsApiRequest + ) + } + @Throws(IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class) fun createRemoteCurrency(project: DBProject, currency: DBCurrency): ServerResponse.CreateRemoteCurrencyResponse { val paramKeys: MutableList = ArrayList() @@ -847,9 +1197,6 @@ class VersatileProjectSyncClient( else project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/currency" useOcsApiRequest = cospendVersionGT161 - if (cospendVersionGT161) { - Log.i(TAG, "using new API (weblogin, $username:$password) for createRemoteCurrency") - } } else if (canAccessProjectWithSSO(project)) { return if (cospendVersionGT161) { target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/currency" @@ -865,7 +1212,6 @@ class VersatileProjectSyncClient( project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/currency" else project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/currency" - Log.i(TAG, "using public API, target is: ${target}for createRemoteCurrency") } } else { target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/currency" @@ -904,9 +1250,6 @@ class VersatileProjectSyncClient( else project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/currency/" + currency.remoteId useOcsApiRequest = cospendVersionGT161 - if (cospendVersionGT161) { - Log.i(TAG, "using new API (weblogin, $username:$password) for editRemoteCurrency") - } } else if (canAccessProjectWithSSO(project)) { return if (cospendVersionGT161) { target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/currency/" + currency.remoteId @@ -922,7 +1265,6 @@ class VersatileProjectSyncClient( project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/currency/" + currency.remoteId else project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/currency/" + currency.remoteId - Log.i(TAG, "using public API, target is: ${target}for createRemoteCurrency") } } else { target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/currency/" + currency.remoteId @@ -954,9 +1296,6 @@ class VersatileProjectSyncClient( else project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/currency/" + currencyRemoteId useOcsApiRequest = cospendVersionGT161 - if (cospendVersionGT161) { - Log.i(TAG, "using new API (weblogin, $username:$password) for deleteRemoteCurrency") - } } else if (canAccessProjectWithSSO(project)) { return if (cospendVersionGT161) { target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/currency/" + currencyRemoteId @@ -972,7 +1311,6 @@ class VersatileProjectSyncClient( project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/currency/" + currencyRemoteId else project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/currency/" + currencyRemoteId - Log.i(TAG, "using public API, target is: ${target}for deleteRemoteCurrency") } } else { target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/currency/" + currencyRemoteId @@ -993,6 +1331,10 @@ class VersatileProjectSyncClient( nextcloudAPI: NextcloudAPI, target: String, method: String, paramKeys: List?, paramValues: List?, isOCSRequest: Boolean ): ResponseData { + var finalTarget = target + if (finalTarget.contains("/ocs/v2.php") && !finalTarget.contains("format=json")) { + finalTarget += if (finalTarget.contains("?")) "&format=json" else "?format=json" + } val result = StringBuilder() var params: MutableList? = null if (paramKeys != null && paramValues != null) { @@ -1010,13 +1352,13 @@ class VersatileProjectSyncClient( val nextcloudRequest: NextcloudRequest = if (params == null) { NextcloudRequest.Builder() .setMethod(method) - .setUrl(target) + .setUrl(finalTarget) .setHeader(headers) .build() } else { NextcloudRequest.Builder() .setMethod(method) - .setUrl(target) + .setUrl(finalTarget) .setParameter(params) .setHeader(headers) .build() @@ -1050,8 +1392,12 @@ class VersatileProjectSyncClient( lastETag: String?, username: String?, password: String?, bearerToken: String?, isOCSRequest: Boolean ): ResponseData { + var finalTarget = target + if (finalTarget.contains("/ocs/v2.php") && !finalTarget.contains("format=json")) { + finalTarget += if (finalTarget.contains("?")) "&format=json" else "?format=json" + } val result = StringBuilder() - val httpCon = SupportUtil.getHttpURLConnection(target) + val httpCon = SupportUtil.getHttpURLConnection(finalTarget) httpCon.requestMethod = method if (bearerToken != null) { httpCon.setRequestProperty("Authorization", "Bearer $bearerToken") @@ -1062,7 +1408,7 @@ class VersatileProjectSyncClient( ) } httpCon.setRequestProperty("Connection", "Close") - httpCon.setRequestProperty("User-Agent", "Cowspent/" + SupportUtil.getAppVersionName(context)) + httpCon.setRequestProperty("User-Agent", "Cowspent-android/" + SupportUtil.getAppVersionName(context)) if (lastETag != null && METHOD_GET == method) { httpCon.setRequestProperty("If-None-Match", lastETag) } @@ -1071,7 +1417,6 @@ class VersatileProjectSyncClient( httpCon.setRequestProperty("Accept", "application/json") } httpCon.connectTimeout = 10 * 1000 // 10 seconds - Log.d(javaClass.simpleName, "$method $target") if (paramKeys != null && paramValues != null) { var dataString = "" for (i in paramKeys.indices) { @@ -1084,7 +1429,6 @@ class VersatileProjectSyncClient( dataString += URLEncoder.encode(value, "UTF-8") } val data = dataString.toByteArray() - Log.d(javaClass.simpleName, "Params: $dataString") httpCon.setFixedLengthStreamingMode(data.size) httpCon.setRequestProperty("Content-Type", "application/x-www-form-urlencoded") httpCon.setRequestProperty("Content-Length", data.size.toString()) diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml index 724b0df..036b7f1 100644 --- a/app/src/main/res/drawable/ic_launcher_background.xml +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -2,10 +2,39 @@ - - + android:viewportWidth="72" + android:viewportHeight="72"> + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml index 030fd29..074841e 100644 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -9,27 +9,12 @@ android:scaleY="0.63461536" android:translateX="13.153846" android:translateY="13.153846"> - - - - - - - - - @@ -44,17 +29,6 @@ - - - - - - diff --git a/app/src/main/res/drawable/ic_launcher_monochrome.xml b/app/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 0000000..6b7c66f --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/test.xml b/app/src/main/res/drawable/test.xml deleted file mode 100644 index 1828a39..0000000 --- a/app/src/main/res/drawable/test.xml +++ /dev/null @@ -1,52 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml similarity index 79% rename from app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml rename to app/src/main/res/mipmap-anydpi/ic_launcher.xml index 50ec886..04091bd 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -2,5 +2,5 @@ - + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml similarity index 79% rename from app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml rename to app/src/main/res/mipmap-anydpi/ic_launcher_round.xml index 50ec886..04091bd 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -2,5 +2,5 @@ - + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 00ff0c4..c11e725 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2,231 +2,152 @@ Cowspent - All bills - New bill - App settings - Scan QRCode from Cospend/Cowspent + + + New bill Add project - Save bill - Edit project - Share project - What? - Who paid? - All - None - - Bill edition - Bill values have been changed. What do you want to do? - Save - Discard - Currency manager - Add currency - Currency management is only available for Cospend projects - Comment - Convert current amount from another currency into %s - There is no additional currency - Main currency - Name - Exchange rate - For whom? - Project ID/name - - Repeat this bill every - Mode - Category - - Label missing categories - Label Bills - No more bills to label - Skip - Suggested - No suggestions for this bill - What do you want to do? - Where? - Local - Cospend - ihatemoney - ihatemoney address - Join - Create - Nextcloud address - Password (local) - Password - Project title - E-mail - Warning: creating a Cospend project using Nextcloud account works with Cospend v0.3.4 and higher. - Authenticated project creation - Nothing was changed - Project successfully added to Cowspent + Save + Edit + Share Search Delete - Error - Close - Loading - Project sync failed - Synchronization with project %1$s failed. Error message:\n\n%2$s\n\nThe associated remote project might not exist anymore. If so, you could remove this project from Cowspent. - The Nextcloud instance is probably in maintenance mode + Archive + Unarchive + Export + Stats + Settle + Scan QR Code + Settings + Label missing categories + Logout + Connect + Discard + Members + Labels + Currencies + + + Statistics + Edit project + Label Bills + Manage Labels + About + Settle Project + Share Project + Add Project + Add Category + Add Payment Mode + Nextcloud Account + Web link + Cowspent link + Are you sure? + + + All bills + Categories + Payment Modes + Name + Icon / Emoji + Color + Weight + Activated + Password + E-mail + Server address + Username + Comment + What? + Who paid? + For whom? + Repeat every + Mode + Category + Project ID/name + Project title + Use Nextcloud App Account + + + Unsaved changes + Save changes before leaving? + The remote project will not be deleted. + Sync error + Sync failed for %1$s.\n\n%2$s + Expenses are already balanced. + Project %1$s added + All bills labeled + No suggestions + Requires Cospend v0.3.4+. + Link copied to clipboard + Scan QR code or share the link to join. + Link for web browser access. + Share this link with a Cowspent user. + Settlement for %1$s: + %1$s owes %3$.2f to %2$s + Stats for %1$s: + Member (Paid | Spent | Balance) + Logged in as %1$s + + + Error + Loading + No projects found + No members found + No bills found + At least one member required + Server is in maintenance mode 400 Bad request 401 Unauthorized 403 Forbidden 404 Not Found - Creating remote project + Sync failed: %1$s + Invalid login: %1$s + Wrong username or password + Invalid server response + Request failed + Invalid e-mail + Invalid project ID + Invalid project title + Invalid bill name + Invalid bill date + Payer required + Owers required + No network connection + Server error + Server connection broken + Cannot share this project + + + Connect to Nextcloud account + Last sync: %1$02d:%2$02d Cancel Ok Yes No - Add - Edit bill - New bill - Add project in Cowspent - Edit project - About - Impossible to share this project - Share - Create bills - Settlement bill - Share Cowspent link - Delete project on server - Save project - Add a member - Remove Project - Project %1$s has been removed - Manage project - Manage members - Export project - Last sync: %1$02d:%2$02d - Scan QR code - Choose from Nextcloud account - Import from CSV file - Malformed CSV, bad owers on line %1$d - Malformed CSV, bad date on line %1$d - Malformed CSV, bad column names on line %1$d - This member already exists in this project - Are you sure? - Are you sure you want to delete remote project? - The remote project will NOT be deleted - Activated - Name - Color - Weight - Delete member - Remote project operations require network connectivity - You are not allowed to perform this action + Close - Appearance - Network - Other - About Cowspent - Nextcloud account - Logged in as %1$s - Logout - Server address - WARNING: "http" is unsafe. Please use "https". - Username - Password - Connect - Theme - Offline mode + Appearance + Network + Other + Theme + Offline mode Only sync manually. - Custom color - UI Accent color - Choose color - Color Selection + Custom color + Color Selection + Show archived projects + Beta Features + Enable experimental features. Use at your own risk. + WARNING: "http" is unsafe. Use "https". + Choose Color + System Nextcloud Manual + Light + Dark + Follow system - - Main currency saved - Project saved in %s - New member name - Edit member - Optimal - Who pays? - To whom? - How much? - Total Spent: %s - From - To - Who - Paid - Spent - Balance - Statistics - Settle - Project %1$s was successfully added - Project %1$s was successfully created - Project added - Project created - Statistics - Settle the project - The expenses in this project are already balanced. There is nothing to settle! - Share the project - Scan this QRCode with Cowspent or a scanner app on an Android device where Cowspent is installed. You can also simply send/share the Cowspent link above. - Share this web link to let others access the project with a web browser. - Web link - Cowspent link - Share this link to a Cowspent user. - Cowspent link of %1$s - Share Cowspent link of %1$s - Here is how to settle project %1$s : - %1$s owes %3$.2f to %2$s - Settle the project %1$s - Statistics of project %1$s : - MEMBER_NAME (PAID | SPENT | BALANCE) - Click here to connect to a Nextcloud account - At least one activated member is required to add a bill - Impossible for local projects - Choose a project from your Nextcloud account - There is no Cospend project in your Nextcloud account - Activity in project %1$s - - - No Projects - You don\'t have any projects yet. If you have Cospend installed on a Nextcloud server, you can configure your Nextcloud account. Alternatively, you can manually add projects. - Configure Nextcloud account - Manually add a project (Cospend or local) - No Members - This project doesn\'t have any members. In order to create bills, you need to add at least one member (better two or more). - No Bills - This project doesn\'t have any bills. Click the button below to add your first bill. - - - - - - - Impossible to reach the remote project:\n\n%1$s - Project sync failed: %1$s - Invalid login: %1$s - is the Cospend app activated on the server? - server connection is broken - no network connection - server error - wrong username or password - Invalid e-mail address - Invalid project ID/name - Invalid project title - Invalid bill name - Invalid bill date - Bill should have a payer - Bill should have at least one ower - Error at remote project creation : %s - Error at remote project edition : %s - Please reauthenticate your SSO account in settings - Request failed - - - Version %1$s - Maintainer - Helcel.net - License - GNU GPL3x / CC BY-SA 4.0 - Source code - https://github.com/helcel-net/cowspent - - + nightMode appColor serverColor @@ -236,36 +157,29 @@ colorMode offlineMode showArchived - Light - Dark - Follow system + betaFeatures 1 2 -1 - Use Nextcloud app account - - - QR code could not be scanned. - Manage currencies - Show archived projects - - + No repeat Daily Weekly Fortnightly Monthly Yearly - No payment mode - All payment modes + + None + All Credit card Cash Check - Online service + Online Transfer - No category - All categories + + None + All All except reimbursement Grocery Bar/Party @@ -279,4 +193,86 @@ Accommodation Transport Sport + + + What + Where + Local only + Cospend + IHateMoney + Join existing project + Create new project + Import from file + Choose project + No projects found on this account. + Project + Project added successfully. + You have no projects yet. + Configure Nextcloud account + Add project manually + No members in this project. + No bills found. + Member already exists. + Project: %1$s + Project %1$s removed. + File saved: %1$s + Import failed at row %d + Invalid date format at row %d + Invalid owers at row %d + Add Member + Edit Member + Delete + No changes to save. + + + None (Optimal) + Who pays + To whom + Amount + Share + Create bills + Settlement + + + Choose Currency (%s) + None + All + Currency settings saved. + Main Currency + + + Suggested Categories + Skip + From + To + Member + Paid + Spent + Balance + Total: %1$s + + + Connection failed: %1$s + Creation failed: %1$s + Error updating remote project: %1$s + Network unavailable for remote operation. + Failed to parse QR code. + Authentication token mismatch. Please log in again. + You don\'t have permission to perform this action. + Delete Label + Are you sure you want to delete this label? + + + Version %1$s + Maintainer + Helcel.net + License + GNU GPL3x / CC BY-SA 4.0 + Source code + https://github.com/helcel-net/cowspent + + + Project %1$s + Share %1$s + diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index df6a6ad..a9db115 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip networkTimeout=10000 retries=0 retryBackOffMs=500 diff --git a/metadata/en-US/full_description.txt b/metadata/en-US/full_description.txt index d045db8..0207b25 100644 --- a/metadata/en-US/full_description.txt +++ b/metadata/en-US/full_description.txt @@ -19,8 +19,6 @@ This means you can choose where your data is going and preserve your privacy. * Dark theme and customizable main app color * Share/import projects with link/QRCode * Connect to a Nextcloud account to automatically add projects -* Background sync service with notifications on bills events -* Multi-lingual user-interface (translated on Crowdin: https://crowdin.com/project/cowspent) # Requirements diff --git a/metadata/en-US/images/icon.png b/metadata/en-US/images/icon.png new file mode 100644 index 0000000..14b4dab Binary files /dev/null and b/metadata/en-US/images/icon.png differ diff --git a/metadata/en-US/images/icon.webp b/metadata/en-US/images/icon.webp deleted file mode 100644 index 975d76a..0000000 Binary files a/metadata/en-US/images/icon.webp and /dev/null differ