12 Commits

63 changed files with 3370 additions and 1623 deletions
+2 -1
View File
@@ -52,7 +52,8 @@ jobs:
- name: Build APK - name: Build APK
run: | run: |
VERSION_CODE=$(git rev-list --count HEAD) 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 - name: Release
uses: softprops/action-gh-release@v3 uses: softprops/action-gh-release@v3
+8 -4
View File
@@ -14,7 +14,7 @@ android {
applicationId "net.helcel.cowspent" applicationId "net.helcel.cowspent"
minSdk = 26 minSdk = 26
targetSdk = 37 targetSdk = 37
versionName "1.0b" versionName project.hasProperty('VERSION_NAME') ? project.property('VERSION_NAME') : "1"
versionCode project.hasProperty('VERSION_CODE') ? project.property('VERSION_CODE').toInteger() : 1 versionCode project.hasProperty('VERSION_CODE') ? project.property('VERSION_CODE').toInteger() : 1
} }
@@ -83,6 +83,10 @@ android {
generateLocaleConfig = true generateLocaleConfig = true
} }
packaging {
jniLibs.keepDebugSymbols.add("**/*.so")
}
dependenciesInfo { dependenciesInfo {
// Disables dependency metadata when building APKs (for IzzyOnDroid/F-Droid) // Disables dependency metadata when building APKs (for IzzyOnDroid/F-Droid)
includeInApk = false includeInApk = false
@@ -92,8 +96,8 @@ android {
} }
dependencies { dependencies {
implementation 'androidx.compose.foundation:foundation:1.11.3' implementation 'androidx.compose.foundation:foundation:1.11.4'
implementation 'androidx.compose.runtime:runtime:1.11.3' implementation 'androidx.compose.runtime:runtime:1.11.4'
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.5' coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.5'
implementation 'androidx.preference:preference-ktx:1.2.1' implementation 'androidx.preference:preference-ktx:1.2.1'
@@ -106,7 +110,7 @@ dependencies {
implementation 'com.github.nextcloud:Android-SingleSignOn:1.1.0' implementation 'com.github.nextcloud:Android-SingleSignOn:1.1.0'
implementation 'com.opencsv:opencsv:5.12.0' implementation 'com.opencsv:opencsv:5.12.0'
implementation platform('androidx.compose:compose-bom:2026.06.00') implementation platform('androidx.compose:compose-bom:2026.06.01')
implementation 'androidx.compose.ui:ui' implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.material:material' implementation 'androidx.compose.material:material'
implementation 'androidx.compose.material:material-icons-extended' implementation 'androidx.compose.material:material-icons-extended'
+19 -7
View File
@@ -46,7 +46,7 @@
<activity <activity
android:name="net.helcel.cowspent.android.account.AccountActivity" android:name="net.helcel.cowspent.android.account.AccountActivity"
android:label="@string/settings_server_settings" android:label="@string/title_account"
android:parentActivityName="net.helcel.cowspent.android.settings.PreferencesActivity" android:parentActivityName="net.helcel.cowspent.android.settings.PreferencesActivity"
android:windowSoftInputMode="adjustResize|stateVisible" android:windowSoftInputMode="adjustResize|stateVisible"
android:launchMode="standard" android:launchMode="standard"
@@ -67,7 +67,7 @@
<activity <activity
android:name="net.helcel.cowspent.android.project.create.NewProjectActivity" android:name="net.helcel.cowspent.android.project.create.NewProjectActivity"
android:label="@string/simple_add_project" android:label="@string/title_add_project"
android:parentActivityName="net.helcel.cowspent.android.main.BillsListViewActivity" android:parentActivityName="net.helcel.cowspent.android.main.BillsListViewActivity"
android:windowSoftInputMode="adjustResize" android:windowSoftInputMode="adjustResize"
android:launchMode="standard" android:launchMode="standard"
@@ -94,7 +94,7 @@
<activity <activity
android:name="net.helcel.cowspent.android.project.edit.EditProjectActivity" android:name="net.helcel.cowspent.android.project.edit.EditProjectActivity"
android:label="@string/simple_edit_project" android:label="@string/title_edit_project"
android:parentActivityName="net.helcel.cowspent.android.main.BillsListViewActivity" android:parentActivityName="net.helcel.cowspent.android.main.BillsListViewActivity"
android:windowSoftInputMode="adjustResize" android:windowSoftInputMode="adjustResize"
android:launchMode="standard"> android:launchMode="standard">
@@ -102,7 +102,7 @@
<activity <activity
android:name="net.helcel.cowspent.android.bill_edit.EditBillActivity" android:name="net.helcel.cowspent.android.bill_edit.EditBillActivity"
android:label="@string/simple_edit_bill" android:label="@string/action_edit"
android:parentActivityName="net.helcel.cowspent.android.main.BillsListViewActivity" android:parentActivityName="net.helcel.cowspent.android.main.BillsListViewActivity"
android:windowSoftInputMode="adjustResize" android:windowSoftInputMode="adjustResize"
android:launchMode="standard"> android:launchMode="standard">
@@ -110,20 +110,32 @@
<activity <activity
android:name="net.helcel.cowspent.android.about.AboutActivity" android:name="net.helcel.cowspent.android.about.AboutActivity"
android:label="@string/simple_about" android:label="@string/title_about"
android:parentActivityName="net.helcel.cowspent.android.main.BillsListViewActivity" /> android:parentActivityName="net.helcel.cowspent.android.main.BillsListViewActivity" />
<activity <activity
android:name="net.helcel.cowspent.android.statistics.ProjectStatisticsActivity" android:name="net.helcel.cowspent.android.statistics.ProjectStatisticsActivity"
android:label="@string/statistic_title" android:label="@string/title_stats"
android:parentActivityName="net.helcel.cowspent.android.main.BillsListViewActivity" android:parentActivityName="net.helcel.cowspent.android.main.BillsListViewActivity"
android:theme="@style/AppTheme.NoActionBar" /> android:theme="@style/AppTheme.NoActionBar" />
<activity <activity
android:name="net.helcel.cowspent.android.bill_label.LabelBillsActivity" android:name="net.helcel.cowspent.android.bill_label.LabelBillsActivity"
android:label="@string/label_bills_title" android:label="@string/title_label_bills"
android:parentActivityName="net.helcel.cowspent.android.main.BillsListViewActivity" android:parentActivityName="net.helcel.cowspent.android.main.BillsListViewActivity"
android:theme="@style/AppTheme.NoActionBar" /> android:theme="@style/AppTheme.NoActionBar" />
<activity
android:name="net.helcel.cowspent.android.label.LabelManagementActivity"
android:label="@string/title_labels"
android:parentActivityName="net.helcel.cowspent.android.main.BillsListViewActivity"
android:windowSoftInputMode="adjustResize" />
<activity
android:name="net.helcel.cowspent.android.project.member.MemberManagementActivity"
android:label="@string/action_members"
android:parentActivityName="net.helcel.cowspent.android.main.BillsListViewActivity"
android:windowSoftInputMode="adjustResize" />
</application> </application>
</manifest> </manifest>
@@ -45,7 +45,7 @@ fun AboutScreen(
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text(stringResource(R.string.simple_about)) }, title = { Text(stringResource(R.string.title_about)) },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
@@ -4,39 +4,18 @@
package net.helcel.cowspent.android.account package net.helcel.cowspent.android.account
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.*
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Row import androidx.compose.material.*
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.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Person
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
@@ -99,7 +78,7 @@ fun AccountScreenContent(
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text(stringResource(R.string.settings_server_settings)) }, title = { Text(stringResource(R.string.title_account)) },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
@@ -123,30 +102,70 @@ fun AccountScreenContent(
} }
} else if (isLoggedIn) { } else if (isLoggedIn) {
Text( Text(
text = stringResource(R.string.account_logged_in_as, username), text = "CURRENT ACCOUNT",
style = MaterialTheme.typography.h6 style = MaterialTheme.typography.subtitle1,
) color = MaterialTheme.colors.onSurface,
Text( fontWeight = FontWeight.Bold,
text = serverUrl, modifier = Modifier.padding(bottom = 8.dp)
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
) )
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)) Spacer(modifier = Modifier.height(16.dp))
Button( Button(
onClick = onLogout, onClick = onLogout,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Text(stringResource(R.string.account_logout)) Text(stringResource(R.string.action_logout))
} }
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
} }
if (!isValidatingLogin) { 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( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 8.dp) 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( Switch(
checked = useSso, checked = useSso,
onCheckedChange = { onSsoClick(it) }, onCheckedChange = { onSsoClick(it) },
@@ -163,7 +182,7 @@ fun AccountScreenContent(
OutlinedTextField( OutlinedTextField(
value = serverUrl, value = serverUrl,
onValueChange = onServerUrlChange, onValueChange = onServerUrlChange,
label = { Text(stringResource(R.string.settings_url)) }, placeholder = { Text(stringResource(R.string.label_url)) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.Default.Link, contentDescription = null) }, leadingIcon = { Icon(Icons.Default.Link, contentDescription = null) },
trailingIcon = { trailingIcon = {
@@ -192,7 +211,7 @@ fun AccountScreenContent(
OutlinedTextField( OutlinedTextField(
value = username, value = username,
onValueChange = onUsernameChange, onValueChange = onUsernameChange,
label = { Text(stringResource(R.string.settings_username)) }, placeholder = { Text(stringResource(R.string.label_username)) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.Default.Person, contentDescription = null) }, leadingIcon = { Icon(Icons.Default.Person, contentDescription = null) },
singleLine = true singleLine = true
@@ -204,7 +223,7 @@ fun AccountScreenContent(
OutlinedTextField( OutlinedTextField(
value = password, value = password,
onValueChange = onPasswordChange, onValueChange = onPasswordChange,
label = { Text(stringResource(R.string.settings_password)) }, placeholder = { Text(stringResource(R.string.label_password)) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) }, leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) },
visualTransformation = PasswordVisualTransformation(), visualTransformation = PasswordVisualTransformation(),
@@ -221,7 +240,7 @@ fun AccountScreenContent(
if (isSubmitting) { if (isSubmitting) {
CustomCircularProgressIndicator(size = 24.dp, color = Color.White) CustomCircularProgressIndicator(size = 24.dp, color = Color.White)
} else { } else {
Text(stringResource(R.string.settings_submit)) Text(stringResource(R.string.action_connect))
} }
} }
} }
@@ -15,6 +15,7 @@ import androidx.compose.material.TextButton
import androidx.compose.material.contentColorFor import androidx.compose.material.contentColorFor
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -65,8 +66,10 @@ fun LoginDialogContent(
) { ) {
Column(modifier = Modifier.padding(24.dp)) { Column(modifier = Modifier.padding(24.dp)) {
Text( Text(
text = "Login", text = "LOGIN",
style = MaterialTheme.typography.h6, style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.onSurface,
modifier = Modifier.padding(bottom = 16.dp) modifier = Modifier.padding(bottom = 16.dp)
) )
@@ -26,7 +26,6 @@ import net.helcel.cowspent.model.ProjectType
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.theme.ThemeUtils import net.helcel.cowspent.theme.ThemeUtils
import net.helcel.cowspent.util.BillParser import net.helcel.cowspent.util.BillParser
import net.helcel.cowspent.util.CategoryUtils
import net.helcel.cowspent.util.SupportUtil import net.helcel.cowspent.util.SupportUtil
import java.text.ParseException import java.text.ParseException
import java.time.ZoneId import java.time.ZoneId
@@ -52,23 +51,10 @@ class EditBillActivity : AppCompatActivity() {
ThemeUtils.CowspentTheme { ThemeUtils.CowspentTheme {
val categories = remember { val categories = remember {
val syncedCategories = db.getCategories(bill.projectId) 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
} }
val paymentModes = remember { val paymentModes = remember {
val syncedPaymentModes = db.getPaymentModes(bill.projectId) db.getPaymentModes(bill.projectId)
val defaultPaymentModes = CategoryUtils.getDefaultPaymentModes(this@EditBillActivity, bill.projectId)
if (projectType == ProjectType.LOCAL) {
syncedPaymentModes + defaultPaymentModes
} else {
syncedPaymentModes.ifEmpty { defaultPaymentModes }
}
} }
EditBillScreen( EditBillScreen(
@@ -144,7 +130,7 @@ class EditBillActivity : AppCompatActivity() {
bill = DBBill( bill = DBBill(
first.id, 0, first.projectId, first.payerId, totalAmount, first.id, 0, first.projectId, first.payerId, totalAmount,
first.timestamp, first.what, first.state, first.repeat, first.timestamp, first.what, first.state, first.repeat,
first.paymentMode, first.categoryRemoteId, first.comment, first.paymentModeRemoteId first.paymentMode, first.categoryId, first.comment, first.paymentModeId
) )
val splits = mutableMapOf<Long, Double>() val splits = mutableMapOf<Long, Double>()
@@ -175,8 +161,8 @@ class EditBillActivity : AppCompatActivity() {
bill = DBBill( bill = DBBill(
0, 0, projectId, btd.payerId, btd.amount, 0, 0, projectId, btd.payerId, btd.amount,
timeNowSeconds, btd.what, DBBill.STATE_ADDED, timeNowSeconds, btd.what, DBBill.STATE_ADDED,
btd.repeat, btd.paymentMode, btd.categoryRemoteId, btd.repeat, btd.paymentMode, btd.categoryId,
btd.comment, btd.paymentModeRemoteId btd.comment, btd.paymentModeId
) )
val btdOwers = btd.billOwers val btdOwers = btd.billOwers
val newBillOwers = btdOwers.filter { val newBillOwers = btdOwers.filter {
@@ -195,6 +181,11 @@ class EditBillActivity : AppCompatActivity() {
val project = db.getProject(bill.projectId) val project = db.getProject(bill.projectId)
val currencies = db.getCurrencies(bill.projectId) val currencies = db.getCurrencies(bill.projectId)
if (project != null) {
db.ensureDefaultLabels(bill.projectId, project.type)
}
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
viewModel.currencies = currencies viewModel.currencies = currencies
viewModel.mainCurrencyName = project?.currencyName ?: "" viewModel.mainCurrencyName = project?.currencyName ?: ""
@@ -239,8 +230,8 @@ class EditBillActivity : AppCompatActivity() {
bill = DBBill( bill = DBBill(
0, 0, bill.projectId, viewModel.payerId, viewModel.amountAsDouble, 0, 0, bill.projectId, viewModel.payerId, viewModel.amountAsDouble,
System.currentTimeMillis() / 1000, viewModel.what, DBBill.STATE_ADDED, System.currentTimeMillis() / 1000, viewModel.what, DBBill.STATE_ADDED,
viewModel.repeat, bill.paymentMode, viewModel.categoryRemoteId, viewModel.repeat, bill.paymentMode, viewModel.categoryId,
viewModel.getFinalComment(), viewModel.paymentModeRemoteId viewModel.getFinalComment(), viewModel.paymentModeId
) )
calendar.timeInMillis = System.currentTimeMillis() calendar.timeInMillis = System.currentTimeMillis()
viewModel.timestamp = calendar.timeInMillis / 1000 viewModel.timestamp = calendar.timeInMillis / 1000
@@ -255,22 +246,22 @@ class EditBillActivity : AppCompatActivity() {
return return
} }
viewModel.showDialog( viewModel.showDialog(
title = getString(R.string.save_or_discard_bill_dialog_title), title = getString(R.string.dialog_unsaved_changes_title),
message = getString(R.string.save_or_discard_bill_dialog_message), message = getString(R.string.dialog_unsaved_changes_msg),
positiveText = getString(R.string.save_or_discard_bill_dialog_save), positiveText = getString(R.string.action_save),
onConfirm = { saveBillAsked() }, onConfirm = { saveBillAsked() },
negativeText = getString(R.string.save_or_discard_bill_dialog_discard), negativeText = getString(R.string.action_discard),
onCancel = { finish() } onCancel = { finish() }
) )
} }
private fun saveBillAsked() { private fun saveBillAsked() {
val validationError = viewModel.getValidationError( 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_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.error_invalid_bill_owers),
getString(R.string.simple_error) getString(R.string.error_generic)
) )
if (validationError != null) { if (validationError != null) {
@@ -288,7 +279,7 @@ class EditBillActivity : AppCompatActivity() {
private fun deleteBillAsked() { private fun deleteBillAsked() {
viewModel.showDialog( viewModel.showDialog(
title = getString(R.string.confirm_remove_project_dialog_title), title = getString(R.string.title_confirm),
message = bill.what, message = bill.what,
positiveText = getString(R.string.action_delete), positiveText = getString(R.string.action_delete),
onConfirm = { onConfirm = {
@@ -326,8 +317,8 @@ class EditBillActivity : AppCompatActivity() {
bill.payerId == viewModel.payerId && bill.payerId == viewModel.payerId &&
bill.comment == viewModel.getFinalComment() && bill.comment == viewModel.getFinalComment() &&
bill.repeat == viewModel.repeat && bill.repeat == viewModel.repeat &&
bill.categoryRemoteId == viewModel.categoryRemoteId && bill.categoryId == viewModel.categoryId &&
bill.paymentModeRemoteId == viewModel.paymentModeRemoteId && bill.paymentModeId == viewModel.paymentModeId &&
!owersChanged) !owersChanged)
} }
@@ -386,8 +377,8 @@ class EditBillActivity : AppCompatActivity() {
listOf(memberId), listOf(memberId),
viewModel.repeat, viewModel.repeat,
existingBill.paymentMode, existingBill.paymentMode,
viewModel.paymentModeRemoteId, viewModel.paymentModeId,
viewModel.categoryRemoteId, viewModel.categoryId,
finalComment finalComment
) )
if (firstSavedId == 0L) firstSavedId = billToUseId if (firstSavedId == 0L) firstSavedId = billToUseId
@@ -396,9 +387,11 @@ class EditBillActivity : AppCompatActivity() {
val newBill = DBBill( val newBill = DBBill(
0, 0, bill.projectId, viewModel.payerId, amount, 0, 0, bill.projectId, viewModel.payerId, amount,
viewModel.timestamp, viewModel.what, DBBill.STATE_ADDED, viewModel.repeat, viewModel.timestamp, viewModel.what, DBBill.STATE_ADDED, viewModel.repeat,
bill.paymentMode, viewModel.categoryRemoteId, finalComment, viewModel.paymentModeRemoteId bill.paymentMode, viewModel.categoryId, finalComment, viewModel.paymentModeId
) )
newBill.billOwers = listOf(DBBillOwer(0, 0, memberId)) newBill.billOwers = listOf(DBBillOwer(0, 0, memberId))
newBill.categoryId = viewModel.categoryId
newBill.paymentModeId = viewModel.paymentModeId
val newId = db.addBill(newBill) val newId = db.addBill(newBill)
if (firstSavedId == 0L) firstSavedId = newId if (firstSavedId == 0L) firstSavedId = newId
} }
@@ -441,8 +434,8 @@ class EditBillActivity : AppCompatActivity() {
newOwersIds, newOwersIds,
viewModel.repeat, viewModel.repeat,
bill.paymentMode, bill.paymentMode,
viewModel.paymentModeRemoteId, viewModel.paymentModeId,
viewModel.categoryRemoteId, viewModel.categoryId,
finalComment finalComment
) )
if (groupedBillIds != null) { if (groupedBillIds != null) {
@@ -460,7 +453,7 @@ class EditBillActivity : AppCompatActivity() {
val newBill = DBBill( val newBill = DBBill(
0, 0, bill.projectId, viewModel.payerId, newAmount, 0, 0, bill.projectId, viewModel.payerId, newAmount,
viewModel.timestamp, viewModel.what, DBBill.STATE_ADDED, viewModel.repeat, viewModel.timestamp, viewModel.what, DBBill.STATE_ADDED, viewModel.repeat,
bill.paymentMode, viewModel.categoryRemoteId, finalComment, viewModel.paymentModeRemoteId bill.paymentMode, viewModel.categoryId, finalComment, viewModel.paymentModeId
) )
newOwersIds.forEach { newBill.billOwers += DBBillOwer(0, 0, it) } newOwersIds.forEach { newBill.billOwers += DBBillOwer(0, 0, it) }
val newBillId = db.addBill(newBill) val newBillId = db.addBill(newBill)
@@ -21,6 +21,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource 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.KeyboardType
import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign 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.R
import net.helcel.cowspent.android.helper.* import net.helcel.cowspent.android.helper.*
import net.helcel.cowspent.model.* import net.helcel.cowspent.model.*
import net.helcel.cowspent.util.CategoryUtils
import net.helcel.cowspent.util.SupportUtil import net.helcel.cowspent.util.SupportUtil
import java.util.Date import java.util.Date
import kotlin.math.abs import kotlin.math.abs
@@ -60,7 +62,7 @@ fun EditBillScreen(
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text(stringResource(if (viewModel.isNewBill) R.string.simple_new_bill else R.string.simple_edit_bill)) }, title = { Text(stringResource(if (viewModel.isNewBill) R.string.action_new_bill else R.string.action_edit)) },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
@@ -89,11 +91,11 @@ fun EditBillScreen(
}, },
floatingActionButton = { floatingActionButton = {
if (canEdit) { 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 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 errorOwers = stringResource(R.string.error_invalid_bill_owers)
val errorInvalidForm = stringResource(R.string.simple_error) val errorInvalidForm = stringResource(R.string.error_generic)
FloatingActionButton(onClick = { FloatingActionButton(onClick = {
val validationError = viewModel.getValidationError( val validationError = viewModel.getValidationError(
@@ -107,7 +109,7 @@ fun EditBillScreen(
}) { }) {
Icon( Icon(
Icons.Default.Done, Icons.Default.Done,
contentDescription = stringResource(R.string.action_save_bill) contentDescription = stringResource(R.string.action_save)
) )
} }
} }
@@ -164,6 +166,14 @@ fun BillBasicInfoSection(
onDateClick: () -> Unit, onDateClick: () -> Unit,
onTimeClick: () -> 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 context = LocalContext.current
val currencyDialogTitle = val currencyDialogTitle =
stringResource(R.string.currency_dialog_title, viewModel.mainCurrencyName) stringResource(R.string.currency_dialog_title, viewModel.mainCurrencyName)
@@ -172,7 +182,7 @@ fun BillBasicInfoSection(
value = viewModel.what, value = viewModel.what,
onValueChange = { viewModel.what = it }, onValueChange = { viewModel.what = it },
enabled = canEdit, enabled = canEdit,
placeholder = { Text(stringResource(R.string.setting_what)) }, placeholder = { Text(stringResource(R.string.label_what)) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.Default.Title, contentDescription = null) } leadingIcon = { Icon(Icons.Default.Title, contentDescription = null) }
) )
@@ -262,13 +272,13 @@ fun PayerSection(
EditableExposedDropdownMenu( EditableExposedDropdownMenu(
value = selectedPayer?.name ?: "", value = selectedPayer?.name ?: "",
placeholder = stringResource(R.string.setting_payer), placeholder = stringResource(R.string.label_payer),
expanded = payerExpanded, expanded = payerExpanded,
onExpandedChange = { payerExpanded = it }, onExpandedChange = { payerExpanded = it },
onDismissRequest = { payerExpanded = false }, onDismissRequest = { payerExpanded = false },
enabled = canEdit, enabled = canEdit,
leadingIcon = { leadingIcon = {
Box(modifier = Modifier.padding(start = 12.dp)) { Box(modifier = Modifier) {
if (selectedPayer != null) { if (selectedPayer != null) {
MemberAvatar( MemberAvatar(
member = selectedPayer, member = selectedPayer,
@@ -340,8 +350,10 @@ fun OwerSelectionSection(
} }
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text( Text(
stringResource(R.string.setting_owers), fontSize = 12.sp, text = stringResource(R.string.label_owers).uppercase(),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f) style = MaterialTheme.typography.subtitle1,
color = MaterialTheme.colors.onSurface,
fontWeight = FontWeight.Bold
) )
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
@@ -447,19 +459,28 @@ fun BillAdditionalDetailsSection(
paymentModes: List<DBPaymentMode>, paymentModes: List<DBPaymentMode>,
canEdit: Boolean 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 = androidx.compose.ui.platform.LocalContext.current
var categoryExpanded by remember { mutableStateOf(false) } var categoryExpanded by remember { mutableStateOf(false) }
val selectedCategory = val selectedCategory =
categories.find { it.remoteId.toInt() == viewModel.categoryRemoteId } categories.find { it.id == viewModel.categoryId } ?: CategoryUtils.getCategoryById(context, viewModel.categoryId)
EditableExposedDropdownMenu( EditableExposedDropdownMenu(
value = selectedCategory?.name ?: "", value = selectedCategory?.name ?: "",
placeholder = stringResource(R.string.setting_category), placeholder = stringResource(R.string.label_category),
expanded = categoryExpanded, expanded = categoryExpanded,
onExpandedChange = { categoryExpanded = it }, onExpandedChange = { categoryExpanded = it },
onDismissRequest = { categoryExpanded = false }, onDismissRequest = { categoryExpanded = false },
enabled = canEdit, enabled = canEdit,
leadingIcon = { leadingIcon = {
Box(modifier = Modifier.padding(start = 12.dp)) { Box(modifier = Modifier) {
if (selectedCategory != null) { if (selectedCategory != null) {
Text(text = selectedCategory.icon, fontSize = 20.sp) Text(text = selectedCategory.icon, fontSize = 20.sp)
} else { } else {
@@ -469,16 +490,26 @@ fun BillAdditionalDetailsSection(
}, },
content = { content = {
DropdownMenuItem(onClick = { DropdownMenuItem(onClick = {
viewModel.categoryRemoteId = 0 viewModel.categoryId = 0
categoryExpanded = false categoryExpanded = false
}) { }) {
Icon(Icons.Default.Close, tint = Color.Red, contentDescription = null) Icon(Icons.Default.Close, tint = Color.Red, contentDescription = null)
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(12.dp))
Text(stringResource(R.string.category_none)) 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 = { DropdownMenuItem(onClick = {
viewModel.categoryRemoteId = category.remoteId.toInt() viewModel.categoryId = category.id
categoryExpanded = false categoryExpanded = false
}) { }) {
Text(text = category.icon, fontSize = 20.sp) Text(text = category.icon, fontSize = 20.sp)
@@ -493,17 +524,17 @@ fun BillAdditionalDetailsSection(
var pmExpanded by remember { mutableStateOf(false) } var pmExpanded by remember { mutableStateOf(false) }
val selectedPm = val selectedPm =
paymentModes.find { it.remoteId.toInt() == viewModel.paymentModeRemoteId } paymentModes.find { it.id == viewModel.paymentModeId } ?: CategoryUtils.getPaymentModeById(context, viewModel.paymentModeId)
EditableExposedDropdownMenu( EditableExposedDropdownMenu(
value = selectedPm?.name ?: "", value = selectedPm?.name ?: "",
placeholder = stringResource(R.string.setting_payment_mode), placeholder = stringResource(R.string.label_mode),
expanded = pmExpanded, expanded = pmExpanded,
onExpandedChange = { pmExpanded = it }, onExpandedChange = { pmExpanded = it },
onDismissRequest = { pmExpanded = false }, onDismissRequest = { pmExpanded = false },
enabled = canEdit, enabled = canEdit,
leadingIcon = { leadingIcon = {
Box(modifier = Modifier.padding(start = 12.dp)) { Box(modifier = Modifier) {
if (selectedPm != null) { if (selectedPm != null) {
Text(text = selectedPm.icon, fontSize = 20.sp) Text(text = selectedPm.icon, fontSize = 20.sp)
} else { } else {
@@ -513,7 +544,7 @@ fun BillAdditionalDetailsSection(
}, },
content = { content = {
DropdownMenuItem(onClick = { DropdownMenuItem(onClick = {
viewModel.paymentModeRemoteId = 0 viewModel.paymentModeId = 0
pmExpanded = false pmExpanded = false
}) { }) {
Icon(Icons.Default.Close, tint = Color.Red, contentDescription = null) Icon(Icons.Default.Close, tint = Color.Red, contentDescription = null)
@@ -522,7 +553,7 @@ fun BillAdditionalDetailsSection(
} }
paymentModes.forEach { pm -> paymentModes.forEach { pm ->
DropdownMenuItem(onClick = { DropdownMenuItem(onClick = {
viewModel.paymentModeRemoteId = pm.remoteId.toInt() viewModel.paymentModeId = pm.id
pmExpanded = false pmExpanded = false
}) { }) {
Text(text = pm.icon, fontSize = 20.sp) Text(text = pm.icon, fontSize = 20.sp)
@@ -548,7 +579,7 @@ fun BillAdditionalDetailsSection(
EditableExposedDropdownMenu( EditableExposedDropdownMenu(
value = selectedRepeat?.second ?: "", value = selectedRepeat?.second ?: "",
placeholder = stringResource(R.string.setting_project_repetition), placeholder = stringResource(R.string.label_repeat),
expanded = repeatExpanded, expanded = repeatExpanded,
onExpandedChange = { repeatExpanded = it }, onExpandedChange = { repeatExpanded = it },
onDismissRequest = { repeatExpanded = false }, onDismissRequest = { repeatExpanded = false },
@@ -572,7 +603,7 @@ fun BillAdditionalDetailsSection(
value = viewModel.comment, value = viewModel.comment,
onValueChange = { viewModel.comment = it }, onValueChange = { viewModel.comment = it },
enabled = canEdit, enabled = canEdit,
placeholder = { Text(stringResource(R.string.setting_comment)) }, placeholder = { Text(stringResource(R.string.label_comment)) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = false, singleLine = false,
leadingIcon = { leadingIcon = {
@@ -25,8 +25,8 @@ class EditBillViewModel : ViewModel() {
var timestamp by mutableLongStateOf(0L) var timestamp by mutableLongStateOf(0L)
var payerId by mutableLongStateOf(0L) var payerId by mutableLongStateOf(0L)
var repeat by mutableStateOf(DBBill.NON_REPEATED) var repeat by mutableStateOf(DBBill.NON_REPEATED)
var paymentModeRemoteId by mutableIntStateOf(0) var paymentModeId by mutableLongStateOf(0L)
var categoryRemoteId by mutableIntStateOf(0) var categoryId by mutableLongStateOf(0L)
var isNewBill by mutableStateOf(false) var isNewBill by mutableStateOf(false)
var currencies by mutableStateOf<List<DBCurrency>>(emptyList()) var currencies by mutableStateOf<List<DBCurrency>>(emptyList())
@@ -166,8 +166,8 @@ class EditBillViewModel : ViewModel() {
timestamp = bill.timestamp timestamp = bill.timestamp
payerId = bill.payerId payerId = bill.payerId
repeat = bill.repeat ?: DBBill.NON_REPEATED repeat = bill.repeat ?: DBBill.NON_REPEATED
paymentModeRemoteId = bill.paymentModeRemoteId paymentModeId = bill.paymentModeId
categoryRemoteId = bill.categoryRemoteId categoryId = bill.categoryId
val rawComment = bill.comment ?: "" val rawComment = bill.comment ?: ""
@@ -13,7 +13,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.theme.ThemeUtils import net.helcel.cowspent.theme.ThemeUtils
import net.helcel.cowspent.util.CategoryUtils
import net.helcel.cowspent.model.DBBill import net.helcel.cowspent.model.DBBill
import net.helcel.cowspent.model.ProjectType import net.helcel.cowspent.model.ProjectType
@@ -39,24 +38,20 @@ class LabelBillsActivity : AppCompatActivity() {
val members = db.getMembersOfProject(projectId, null) val members = db.getMembersOfProject(projectId, null)
val allBills = db.getBillsOfProject(projectId) val allBills = db.getBillsOfProject(projectId)
val billsToLabel = 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.categoryRemoteId != 0 && it.state != DBBill.STATE_DELETED } val allCategorized = allBills.filter { it.categoryId != 0L && it.state != DBBill.STATE_DELETED }
val syncedCategories = db.getCategories(projectId) db.ensureDefaultLabels(projectId, projectType)
val defaultCategories = CategoryUtils.getDefaultCategories(this@LabelBillsActivity, projectId)
val hardcoded = if (projectType == ProjectType.LOCAL) { // Reload from DB to get real IDs
defaultCategories val categories = db.getCategories(projectId)
} else {
listOfNotNull(defaultCategories.find { it.remoteId.toInt() == DBBill.CATEGORY_REIMBURSEMENT })
}
val categories = syncedCategories + hardcoded
Quadruple(members, billsToLabel, categories, allCategorized) Quadruple(members, billsToLabel, categories, allCategorized)
} }
viewModel.billsToLabel = billsToLabel viewModel.billsToLabel = billsToLabel
viewModel.categories = categories viewModel.categories = categories
viewModel.categoriesMap = categories.associateBy { it.remoteId } viewModel.categoriesMap = categories.associateBy { it.id }
viewModel.allCategorizedBills = allCategorized viewModel.allCategorizedBills = allCategorized
viewModel.updateSuggestions() viewModel.updateSuggestions()
@@ -42,7 +42,7 @@ fun LabelBillsScreen(
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text(stringResource(R.string.label_bills_title)) }, title = { Text(stringResource(R.string.title_label_bills)) },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
@@ -64,10 +64,11 @@ fun LabelBillsScreen(
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = stringResource(R.string.label_bills_suggested), text = stringResource(R.string.label_bills_suggested).uppercase(),
style = MaterialTheme.typography.subtitle1, style = MaterialTheme.typography.subtitle1,
color = MaterialTheme.colors.onSurface,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth().padding(top = 8.dp)
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
@@ -83,7 +84,7 @@ fun LabelBillsScreen(
CategoryButton( CategoryButton(
icon = category.icon, icon = category.icon,
name = category.name ?: "", name = category.name ?: "",
onClick = { viewModel.labelCurrentBill(db, category.remoteId.toInt()) } onClick = { viewModel.labelCurrentBill(db, category.id) }
) )
} }
} }
@@ -93,7 +94,7 @@ fun LabelBillsScreen(
} }
} else { } else {
Text( Text(
text = stringResource(R.string.label_bills_no_suggestions), text = stringResource(R.string.msg_no_suggestions),
style = MaterialTheme.typography.caption, style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.4f), color = MaterialTheme.colors.onSurface.copy(alpha = 0.4f),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -105,10 +106,11 @@ fun LabelBillsScreen(
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = stringResource(R.string.setting_category), text = stringResource(R.string.label_category).uppercase(),
style = MaterialTheme.typography.subtitle1, style = MaterialTheme.typography.subtitle1,
color = MaterialTheme.colors.onSurface,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth().padding(top = 8.dp)
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
@@ -124,7 +126,7 @@ fun LabelBillsScreen(
CategoryButton( CategoryButton(
icon = category.icon, icon = category.icon,
name = category.name ?: "", 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)) Spacer(modifier = Modifier.height(8.dp))
} else { } else {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { 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() { fun LabelBillsScreenPreview() {
val viewModel = LabelBillsViewModel().apply { val viewModel = LabelBillsViewModel().apply {
billsToLabel = listOf( 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( val cats = listOf(
DBCategory(1, 1, 1, "Groceries", "🛒", ""), DBCategory(1L, 1L, 1L, "Groceries", "🛒", ""),
DBCategory(2, 2, 1, "Leisure", "🥳", ""), DBCategory(2L, 2L, 1L, "Leisure", "🥳", ""),
DBCategory(3, 3, 1, "Rent", "🏠", ""), DBCategory(3L, 3L, 1L, "Rent", "🏠", ""),
DBCategory(4, 4, 1, "Bills", "💸", "") DBCategory(4L, 4L, 1L, "Bills", "💸", "")
) )
categories = cats categories = cats
categoriesMap = cats.associateBy { it.remoteId } categoriesMap = cats.associateBy { it.id }
} }
val members = listOf( val members = listOf(
DBMember(1L, 0, 1L, "Alice", true, 1.0, 0, 255, 100, 100, null, null), DBMember(1L, 0, 1L, "Alice", true, 1.0, 0, 255, 100, 100, null, null),
@@ -43,18 +43,18 @@ class LabelBillsViewModel : ViewModel() {
otherName == name || (name.length > 3 && otherName.contains(name)) || (otherName.length > 3 && name.contains(otherName)) 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 } .mapValues { it.value.size }
.toList() .toList()
.sortedByDescending { it.second } .sortedByDescending { it.second }
.take(2) .take(2)
suggestedCategories = counts.mapNotNull { (catId, _) -> suggestedCategories = counts.mapNotNull { (catId, _) ->
categoriesMap[catId.toLong()] categoriesMap[catId]
} }
} }
fun labelCurrentBill(db: CowspentSQLiteOpenHelper, categoryId: Int) { fun labelCurrentBill(db: CowspentSQLiteOpenHelper, categoryId: Long) {
currentBill?.let { bill -> currentBill?.let { bill ->
db.updateBillAndSync( db.updateBillAndSync(
bill = bill, bill = bill,
@@ -65,11 +65,11 @@ class LabelBillsViewModel : ViewModel() {
newOwersIds = bill.billOwersIds, newOwersIds = bill.billOwersIds,
newRepeat = bill.repeat, newRepeat = bill.repeat,
newPaymentMode = bill.paymentMode, newPaymentMode = bill.paymentMode,
newPaymentModeRemoteId = bill.paymentModeRemoteId, newPaymentModeId = bill.paymentModeId,
newCategoryId = categoryId, newCategoryId = categoryId,
newComment = bill.comment newComment = bill.comment
) )
bill.categoryRemoteId = categoryId bill.categoryId = categoryId
onBillProcessed?.invoke(bill.id) onBillProcessed?.invoke(bill.id)
moveToNext() moveToNext()
} }
@@ -87,11 +87,11 @@ class LabelBillsViewModel : ViewModel() {
val start = currentBillIndex val start = currentBillIndex
var next = (start + 1) % billsToLabel.size 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 next = (next + 1) % billsToLabel.size
} }
currentBillIndex = if (billsToLabel[next].categoryRemoteId == 0) { currentBillIndex = if (billsToLabel[next].categoryId == 0L) {
next next
} else { } else {
billsToLabel.size billsToLabel.size
@@ -33,9 +33,11 @@ class ManageCurrenciesActivity : AppCompatActivity() {
if (message.isEmpty()) { if (message.isEmpty()) {
showToast(this@ManageCurrenciesActivity,getString(R.string.currency_saved_success), Toast.LENGTH_LONG) showToast(this@ManageCurrenciesActivity,getString(R.string.currency_saved_success), Toast.LENGTH_LONG)
} else { } else {
viewModel.showDialog(title=getString(R.string.error_edit_remote_project_helper, message), viewModel.showDialog(
message=getString(R.string.currency_manager), title = getString(R.string.dialog_sync_error_title),
positiveText = getString(android.R.string.ok)) message = getString(R.string.error_edit_remote_project_helper, message),
positiveText = getString(android.R.string.ok)
)
} }
} }
override fun onScheduled() {} override fun onScheduled() {}
@@ -96,7 +96,7 @@ fun ManageCurrenciesScreen(
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text(stringResource(R.string.currency_manager)) }, title = { Text(stringResource(R.string.action_currencies)) },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
@@ -125,11 +125,11 @@ fun ManageCurrenciesScreen(
// Section 1: Main Currency // Section 1: Main Currency
Text( Text(
text = stringResource(R.string.main_currency).uppercase(), text = stringResource(R.string.main_currency).uppercase(),
style = MaterialTheme.typography.overline, style = MaterialTheme.typography.subtitle1,
color = MaterialTheme.colors.primary, color = MaterialTheme.colors.onSurface,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 8.dp)
) )
Spacer(modifier = Modifier.height(8.dp))
Card( Card(
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
elevation = 2.dp, elevation = 2.dp,
@@ -153,11 +153,11 @@ fun ManageCurrenciesScreen(
val isEditing = viewModel.editingCurrencyId != null val isEditing = viewModel.editingCurrencyId != null
Text( Text(
text = (if (isEditing) "Edit exchange rate" else "Add exchange rate").uppercase(), text = (if (isEditing) "Edit exchange rate" else "Add exchange rate").uppercase(),
style = MaterialTheme.typography.overline, style = MaterialTheme.typography.subtitle1,
color = if (isEditing) MaterialTheme.colors.secondary else MaterialTheme.colors.primary, color = MaterialTheme.colors.onSurface,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 8.dp)
) )
Spacer(modifier = Modifier.height(8.dp))
// Integrated Add/Edit Ribbon // Integrated Add/Edit Ribbon
Surface( Surface(
@@ -236,9 +236,10 @@ fun ManageCurrenciesScreen(
Text( Text(
text = "SAVED RATES", text = "SAVED RATES",
style = MaterialTheme.typography.overline, style = MaterialTheme.typography.subtitle1,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.4f), color = MaterialTheme.colors.onSurface,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 8.dp)
) )
} }
@@ -160,20 +160,27 @@ private fun DrawerHeader(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(MaterialTheme.colors.primary) .background(MaterialTheme.colors.primary)
.padding(16.dp, 4.dp) .padding(4.dp, 4.dp)
) { ) {
Column { Column {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Image( Box(modifier = Modifier.size(48.dp)) {
painter = painterResource(id = R.drawable.ic_launcher_foreground), Image(
contentDescription = null, painter = painterResource(id = R.drawable.ic_launcher_background),
modifier = Modifier.size(48.dp) 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)) Spacer(modifier = Modifier.width(8.dp))
Text( Text(
stringResource(id = R.string.app_name), text = stringResource(id = R.string.app_name),
color = MaterialTheme.colors.onPrimary, color = MaterialTheme.colors.onPrimary,
fontSize = 18.sp, style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
@@ -200,6 +207,7 @@ private fun DrawerHeader(
} }
if (lastSyncText.isNotEmpty()) { if (lastSyncText.isNotEmpty()) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(Modifier.width(16.dp))
Icon( Icon(
Icons.Default.Sync, Icons.Default.Sync,
contentDescription = null, contentDescription = null,
@@ -291,7 +299,7 @@ fun DrawerItem(
Icon(icon, contentDescription = null, tint = contentColor.copy(alpha = 0.6f * alpha)) 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 ?: "" val itemText = text ?: member?.name ?: ""
Text( Text(
@@ -230,7 +230,12 @@ fun AlertDialogContent(
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
} }
if (title != null) { 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
)
} }
} }
} }
@@ -188,12 +188,13 @@ fun ColorPicker(
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
val hueBrush = remember { val hueBrush = remember(lightness, chroma) {
Brush.horizontalGradient( val hueSteps = 36 // More steps for smoother gradient
listOf( val colors = (0..hueSteps).map { step ->
Color.Red, Color.Yellow, Color.Green, Color.Cyan, Color.Blue, Color.Magenta, Color.Red val h = (step.toFloat() / hueSteps) * 360f
) Color(mLCHtoRBG(lightness, chroma, h))
) }
Brush.horizontalGradient(colors)
} }
LchSlider( LchSlider(
label = "H", label = "H",
@@ -134,7 +134,7 @@ fun QrCodeScannerScreen(
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text(stringResource(R.string.scan_qrcode)) }, title = { Text(stringResource(R.string.action_scan_qrcode)) },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
@@ -0,0 +1,46 @@
package net.helcel.cowspent.android.label
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import net.helcel.cowspent.theme.ThemeUtils
class LabelManagementActivity : AppCompatActivity() {
private val viewModel: LabelManagementViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
val projectId = intent.getLongExtra(EXTRA_PROJECT_ID, 0L)
if (projectId == 0L) {
finish()
return
}
viewModel.loadLabels(projectId)
setContent {
ThemeUtils.CowspentTheme {
LabelManagementScreen(
viewModel = viewModel,
onBack = { finish() }
)
}
}
}
companion object {
const val EXTRA_PROJECT_ID = "EXTRA_PROJECT_ID"
fun createIntent(context: Context, projectId: Long): Intent {
return Intent(context, LabelManagementActivity::class.java).apply {
putExtra(EXTRA_PROJECT_ID, projectId)
}
}
}
}
@@ -0,0 +1,399 @@
package net.helcel.cowspent.android.label
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.graphics.toColorInt
import net.helcel.cowspent.R
import net.helcel.cowspent.android.helper.ColorPicker
import net.helcel.cowspent.android.helper.StatefulAlertDialog
import net.helcel.cowspent.model.DBBill
import net.helcel.cowspent.model.DBCategory
import net.helcel.cowspent.model.DBPaymentMode
@Composable
fun LabelManagementScreen(
viewModel: LabelManagementViewModel,
onBack: () -> Unit
) {
LabelManagementScreenContent(
categories = viewModel.categories,
paymentModes = viewModel.paymentModes,
dialogState = viewModel.dialogState,
onBack = onBack,
onAddCategory = viewModel::addCategory,
onUpdateCategory = viewModel::updateCategory,
onDeleteCategory = viewModel::deleteCategory,
onAddPaymentMode = viewModel::addPaymentMode,
onUpdatePaymentMode = viewModel::updatePaymentMode,
onDeletePaymentMode = viewModel::deletePaymentMode,
onDismissDialog = { viewModel.dialogState = null },
onShowDialog = { viewModel.dialogState = it }
)
}
@Composable
fun LabelManagementScreenContent(
categories: List<DBCategory>,
paymentModes: List<DBPaymentMode>,
dialogState: net.helcel.cowspent.android.helper.DialogState?,
onBack: () -> Unit,
onAddCategory: (String, String, String) -> Unit,
onUpdateCategory: (DBCategory, String, String, String) -> Unit,
onDeleteCategory: (Long) -> Unit,
onAddPaymentMode: (String, String, String) -> Unit,
onUpdatePaymentMode: (DBPaymentMode, String, String, String) -> Unit,
onDeletePaymentMode: (Long) -> Unit,
onDismissDialog: () -> Unit,
onShowDialog: (net.helcel.cowspent.android.helper.DialogState) -> Unit,
initialTab: Int = 0
) {
var selectedTab by remember { mutableIntStateOf(initialTab) }
val tabs = listOf(
stringResource(R.string.label_categories),
stringResource(R.string.label_payment_modes)
)
val deleteLabelTitle = stringResource(R.string.delete_label_confirmation_title)
val deleteLabelMessage = stringResource(R.string.delete_label_confirmation_message)
val yesText = stringResource(R.string.simple_yes)
val noText = stringResource(R.string.simple_no)
var showEditDialog by remember { mutableStateOf(false) }
var editingCategory by remember { mutableStateOf<DBCategory?>(null) }
var editingPaymentMode by remember { mutableStateOf<DBPaymentMode?>(null) }
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.title_labels)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
},
backgroundColor = MaterialTheme.colors.primary,
elevation = 0.dp
)
},
floatingActionButton = {
FloatingActionButton(onClick = {
if (selectedTab == 0) {
editingCategory = null
showEditDialog = true
} else {
editingPaymentMode = null
showEditDialog = true
}
}) {
Icon(Icons.Default.Add, contentDescription = null)
}
}
) { padding ->
Column(modifier = Modifier.padding(padding)) {
TabRow(selectedTabIndex = selectedTab) {
tabs.forEachIndexed { index, title ->
Tab(
selected = selectedTab == index,
onClick = { selectedTab = index },
text = { Text(title) }
)
}
}
if (selectedTab == 0) {
CategoryList(
categories = categories.filter { it.remoteId != DBBill.CATEGORY_REIMBURSEMENT },
onEdit = {
editingCategory = it
showEditDialog = true
},
onDelete = { category ->
onShowDialog(net.helcel.cowspent.android.helper.DialogState(
title = deleteLabelTitle,
message = deleteLabelMessage,
positiveText = yesText,
negativeText = noText,
onConfirm = { onDeleteCategory(category.id) }
))
}
)
} else {
PaymentModeList(
paymentModes = paymentModes,
onEdit = {
editingPaymentMode = it
showEditDialog = true
},
onDelete = { pm ->
onShowDialog(net.helcel.cowspent.android.helper.DialogState(
title = deleteLabelTitle,
message = deleteLabelMessage,
positiveText = yesText,
negativeText = noText,
onConfirm = { onDeletePaymentMode(pm.id) }
))
}
)
}
}
}
if (showEditDialog) {
if (selectedTab == 0) {
EditLabelDialog(
title = if (editingCategory == null) stringResource(R.string.title_add_category) else stringResource(R.string.action_edit),
initialName = editingCategory?.name ?: "",
initialIcon = editingCategory?.icon ?: "",
initialColor = editingCategory?.color ?: "#FF0000",
onDismiss = { showEditDialog = false },
onSave = { name, icon, color ->
if (editingCategory == null) {
onAddCategory(name, icon, color)
} else {
onUpdateCategory(editingCategory!!, name, icon, color)
}
showEditDialog = false
}
)
} else {
EditLabelDialog(
title = if (editingPaymentMode == null) stringResource(R.string.title_add_payment_mode) else stringResource(R.string.action_edit),
initialName = editingPaymentMode?.name ?: "",
initialIcon = editingPaymentMode?.icon ?: "",
initialColor = editingPaymentMode?.color ?: "#00FF00",
onDismiss = { showEditDialog = false },
onSave = { name, icon, color ->
if (editingPaymentMode == null) {
onAddPaymentMode(name, icon, color)
} else {
onUpdatePaymentMode(editingPaymentMode!!, name, icon, color)
}
showEditDialog = false
}
)
}
}
StatefulAlertDialog(
state = dialogState,
onDismissRequest = onDismissDialog
)
}
@Composable
fun CategoryList(
categories: List<DBCategory>,
onEdit: (DBCategory) -> Unit,
onDelete: (DBCategory) -> Unit
) {
LazyColumn {
items(categories) { category ->
LabelItem(
name = category.name ?: "",
icon = category.icon,
color = category.color,
onEdit = { onEdit(category) },
onDelete = { onDelete(category) }
)
}
}
}
@Composable
fun PaymentModeList(
paymentModes: List<DBPaymentMode>,
onEdit: (DBPaymentMode) -> Unit,
onDelete: (DBPaymentMode) -> Unit
) {
LazyColumn {
items(paymentModes) { pm ->
LabelItem(
name = pm.name ?: "",
icon = pm.icon,
color = pm.color,
onEdit = { onEdit(pm) },
onDelete = { onDelete(pm) }
)
}
}
}
@Composable
fun LabelItem(
name: String,
icon: String,
color: String,
onEdit: () -> Unit,
onDelete: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onEdit() }
.padding(16.dp, 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(24.dp)
.clip(CircleShape)
.background(parseColor(color)),
contentAlignment = Alignment.Center
) {
Text(text = icon, fontSize = 12.sp)
}
Spacer(modifier = Modifier.width(32.dp))
Text(text = name, modifier = Modifier.weight(1f), style = MaterialTheme.typography.subtitle1)
IconButton(onClick = onEdit) {
Icon(Icons.Default.Edit, contentDescription = null, tint = MaterialTheme.colors.onSurface.copy(alpha = 0.6f))
}
IconButton(onClick = onDelete) {
Icon(Icons.Default.Delete, contentDescription = null, tint = MaterialTheme.colors.error.copy(alpha = 0.6f))
}
}
}
@Composable
fun EditLabelDialog(
title: String,
initialName: String,
initialIcon: String,
initialColor: String,
onDismiss: () -> Unit,
onSave: (String, String, String) -> Unit
) {
var name by remember { mutableStateOf(initialName) }
var icon by remember { mutableStateOf(initialIcon) }
var color by remember { mutableStateOf(initialColor) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
OutlinedTextField(
value = name,
onValueChange = { name = it },
placeholder = { Text(stringResource(R.string.label_name)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = icon,
onValueChange = { if (it.length <= 2) icon = it },
placeholder = { Text(stringResource(R.string.label_icon)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Spacer(modifier = Modifier.height(16.dp))
Text(text = stringResource(R.string.label_color), style = MaterialTheme.typography.caption)
ColorPicker(
initialColor = parseColorInt(color),
onColorChanged = { color = String.format("#%06X", 0xFFFFFF and it) }
)
}
},
confirmButton = {
Button(
onClick = { onSave(name, icon, color) },
enabled = name.isNotBlank()
) {
Text(stringResource(R.string.action_save))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.simple_cancel))
}
}
)
}
fun parseColor(colorString: String): Color {
return try {
Color(colorString.toColorInt())
} catch (_: Exception) {
Color.Gray
}
}
fun parseColorInt(colorString: String): Int {
return try {
colorString.toColorInt()
} catch (_: Exception) {
Color.Gray.toArgb()
}
}
@Preview(showBackground = true)
@Composable
fun LabelManagementCategoriesPreview() {
MaterialTheme {
LabelManagementScreenContent(
categories = listOf(
DBCategory(1L, 1L, 1L, "Groceries", "🛒", "#FF0000"),
DBCategory(2L, 2L, 1L, "Rent", "🏠", "#00FF00")
),
paymentModes = emptyList(),
dialogState = null,
onBack = {},
onAddCategory = { _, _, _ -> },
onUpdateCategory = { _, _, _, _ -> },
onDeleteCategory = { _ -> },
onAddPaymentMode = { _, _, _ -> },
onUpdatePaymentMode = { _, _, _, _ -> },
onDeletePaymentMode = { _ -> },
onDismissDialog = {},
onShowDialog = {},
initialTab = 0
)
}
}
@Preview(showBackground = true)
@Composable
fun LabelManagementPaymentModesPreview() {
MaterialTheme {
LabelManagementScreenContent(
categories = emptyList(),
paymentModes = listOf(
DBPaymentMode(1L, 1L, 1L, "Cash", "💵", "#0000FF"),
DBPaymentMode(2L, 2L, 1L, "Credit Card", "💳", "#FFFF00")
),
dialogState = null,
onBack = {},
onAddCategory = { _, _, _ -> },
onUpdateCategory = { _, _, _, _ -> },
onDeleteCategory = { _ -> },
onAddPaymentMode = { _, _, _ -> },
onUpdatePaymentMode = { _, _, _, _ -> },
onDeletePaymentMode = { _ -> },
onDismissDialog = {},
onShowDialog = {},
initialTab = 1
)
}
}
@@ -0,0 +1,95 @@
package net.helcel.cowspent.android.label
import android.app.Application
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.helcel.cowspent.android.helper.DialogState
import net.helcel.cowspent.model.DBCategory
import net.helcel.cowspent.model.DBPaymentMode
import net.helcel.cowspent.model.DBBill
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
class LabelManagementViewModel(application: Application) : AndroidViewModel(application) {
private val db = CowspentSQLiteOpenHelper.getInstance(application)
var projectId by mutableLongStateOf(0L)
var categories by mutableStateOf<List<DBCategory>>(emptyList())
var paymentModes by mutableStateOf<List<DBPaymentMode>>(emptyList())
var dialogState by mutableStateOf<DialogState?>(null)
fun loadLabels(projId: Long) {
projectId = projId
viewModelScope.launch {
val project = withContext(Dispatchers.IO) { db.getProject(projId) }
if (project != null) {
withContext(Dispatchers.IO) {
db.ensureDefaultLabels(projId, project.type)
}
}
categories = withContext(Dispatchers.IO) { db.getCategories(projId) }
paymentModes = withContext(Dispatchers.IO) { db.getPaymentModes(projId) }
}
}
fun addCategory(name: String, icon: String, color: String) {
viewModelScope.launch {
withContext(Dispatchers.IO) {
db.addCategoryAndSync(DBCategory(0, 0, projectId, name, icon, color, DBBill.STATE_ADDED))
}
loadLabels(projectId)
}
}
fun updateCategory(cat: DBCategory, name: String, icon: String, color: String) {
viewModelScope.launch {
withContext(Dispatchers.IO) {
db.updateCategoryAndSync(cat, name, icon, color)
}
loadLabels(projectId)
}
}
fun deleteCategory(id: Long) {
viewModelScope.launch {
withContext(Dispatchers.IO) {
db.deleteCategoryAndSync(id)
}
loadLabels(projectId)
}
}
fun addPaymentMode(name: String, icon: String, color: String) {
viewModelScope.launch {
withContext(Dispatchers.IO) {
db.addPaymentModeAndSync(DBPaymentMode(0, 0, projectId, name, icon, color, DBBill.STATE_ADDED))
}
loadLabels(projectId)
}
}
fun updatePaymentMode(pm: DBPaymentMode, name: String, icon: String, color: String) {
viewModelScope.launch {
withContext(Dispatchers.IO) {
db.updatePaymentModeAndSync(pm, name, icon, color)
}
loadLabels(projectId)
}
}
fun deletePaymentMode(id: Long) {
viewModelScope.launch {
withContext(Dispatchers.IO) {
db.deletePaymentModeAndSync(id)
}
loadLabels(projectId)
}
}
}
@@ -29,6 +29,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import net.helcel.cowspent.R import net.helcel.cowspent.R
@@ -44,15 +45,25 @@ fun EmptyProjectsState(onConfigureNextcloud: () -> Unit, onAddManually: () -> Un
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally 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)) Spacer(modifier = Modifier.height(8.dp))
Text(stringResource(R.string.no_projects_text)) Text(
Spacer(modifier = Modifier.height(16.dp)) text = stringResource(R.string.no_projects_text),
Button(onClick = onConfigureNextcloud) { 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)) Text(stringResource(R.string.configure_account_choice))
} }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Button(onClick = onAddManually) { Button(onClick = onAddManually, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(R.string.add_project_choice)) Text(stringResource(R.string.add_project_choice))
} }
} }
@@ -65,9 +76,19 @@ fun EmptyMembersState() {
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally 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)) Spacer(modifier = Modifier.height(8.dp))
Text(stringResource(R.string.no_members_text)) Text(
text = stringResource(R.string.no_members_text),
style = MaterialTheme.typography.body2,
textAlign = TextAlign.Center,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
)
} }
} }
@@ -78,9 +99,19 @@ fun EmptyBillsState() {
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally 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)) Spacer(modifier = Modifier.height(8.dp))
Text(stringResource(R.string.no_bills_text)) Text(
text = stringResource(R.string.no_bills_text),
style = MaterialTheme.typography.body2,
textAlign = TextAlign.Center,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
)
} }
} }
@@ -122,15 +153,21 @@ fun BillItemRow(bill: DBBill, payer: DBMember?, onClick: () -> Unit) {
Icons.Default.Receipt, Icons.Default.Receipt,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(40.dp), modifier = Modifier.size(40.dp),
tint = MaterialTheme.colors.primary tint = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
) )
} }
Spacer(modifier = Modifier.width(16.dp)) Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) { 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(
text = bill.formattedSubtitle.ifEmpty { bill.comment ?: "" }, text = bill.formattedSubtitle.ifEmpty { bill.comment ?: "" },
style = MaterialTheme.typography.caption, style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
@@ -151,13 +188,16 @@ fun SectionHeader(title: String) {
color = MaterialTheme.colors.background, color = MaterialTheme.colors.background,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Divider(thickness = 2.dp) Column {
Text( Divider(thickness = 1.dp, color = MaterialTheme.colors.onSurface.copy(alpha = 0.08f))
text = title, Text(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), text = title.uppercase(),
style = MaterialTheme.typography.caption.copy(fontWeight = FontWeight.Bold), modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
color = MaterialTheme.colors.primary style = MaterialTheme.typography.subtitle1,
) fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.onSurface
)
}
} }
} }
@@ -168,7 +208,18 @@ fun EmptyState() {
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Text(stringResource(R.string.no_bills_title), style = MaterialTheme.typography.h6) Text(
Text(stringResource(R.string.no_bills_text), modifier = Modifier.padding(16.dp)) text = stringResource(R.string.error_no_bills).uppercase(),
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.onSurface
)
Text(
text = stringResource(R.string.no_bills_text),
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.body2,
textAlign = TextAlign.Center,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
)
} }
} }
@@ -2,7 +2,6 @@ package net.helcel.cowspent.android.main
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Intent import android.content.Intent
import android.widget.Toast
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize 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.graphics.RectangleShape
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog 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.helper.StatefulAlertDialog
import net.helcel.cowspent.android.project.ProjectOptionsDialogContent import net.helcel.cowspent.android.project.ProjectOptionsDialogContent
import net.helcel.cowspent.android.project.ProjectShareDialogContent 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.project.settle.ProjectSettlementDialogContent
import net.helcel.cowspent.android.statistics.ProjectStatisticsActivity import net.helcel.cowspent.android.statistics.ProjectStatisticsActivity
import net.helcel.cowspent.model.DBBill import net.helcel.cowspent.model.DBBill
import net.helcel.cowspent.model.DBMember import net.helcel.cowspent.model.DBMember
import net.helcel.cowspent.model.DBProject import net.helcel.cowspent.model.DBProject
import net.helcel.cowspent.model.ProjectType
import net.helcel.cowspent.model.SectionItem import net.helcel.cowspent.model.SectionItem
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.util.IRefreshBillsListCallback import net.helcel.cowspent.util.IRefreshBillsListCallback
@@ -103,7 +101,6 @@ fun BillsListScreen(
val pullRefreshState = rememberPullRefreshState(viewModel.isRefreshing, onRefresh) val pullRefreshState = rememberPullRefreshState(viewModel.isRefreshing, onRefresh)
val context = LocalContext.current val context = LocalContext.current
val memberAlreadyExistsError = stringResource(R.string.member_already_exists)
val sharedPreferences = remember { PreferenceManager.getDefaultSharedPreferences(context) } val sharedPreferences = remember { PreferenceManager.getDefaultSharedPreferences(context) }
val showArchived = sharedPreferences.getBoolean(stringResource(R.string.pref_key_show_archived), false) val showArchived = sharedPreferences.getBoolean(stringResource(R.string.pref_key_show_archived), false)
@@ -137,6 +134,10 @@ fun BillsListScreen(
onProjectAction(projectOptionsProjectId, 3) onProjectAction(projectOptionsProjectId, 3)
viewModel.showProjectOptionsDialogByProjectId = null viewModel.showProjectOptionsDialogByProjectId = null
}, },
onManageLabels = {
onProjectAction(projectOptionsProjectId, 8)
viewModel.showProjectOptionsDialogByProjectId = null
},
onStatistics = { onStatistics = {
onProjectAction(projectOptionsProjectId, 4) onProjectAction(projectOptionsProjectId, 4)
viewModel.showProjectOptionsDialogByProjectId = null viewModel.showProjectOptionsDialogByProjectId = null
@@ -155,6 +156,7 @@ fun BillsListScreen(
}, },
onDismiss = { viewModel.showProjectOptionsDialogByProjectId = null }, onDismiss = { viewModel.showProjectOptionsDialogByProjectId = null },
isArchived = proj?.isArchived == true, isArchived = proj?.isArchived == true,
projectType = proj?.type ?: ProjectType.LOCAL,
accessLevel = proj?.myAccessLevel ?: DBProject.ACCESS_LEVEL_ADMIN, accessLevel = proj?.myAccessLevel ?: DBProject.ACCESS_LEVEL_ADMIN,
isShareable = proj?.isShareable() ?: true isShareable = proj?.isShareable() ?: true
) )
@@ -208,114 +210,17 @@ fun BillsListScreen(
val manageMembersProjectId = viewModel.showMemberManagementDialogByProjectId val manageMembersProjectId = viewModel.showMemberManagementDialogByProjectId
if (manageMembersProjectId != null) { if (manageMembersProjectId != null) {
val members = viewModel.members // Use members from ViewModel as they are already loaded viewModel.showMemberManagementDialogByProjectId = null
Dialog(
onDismissRequest = { viewModel.showMemberManagementDialogByProjectId = null },
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
MemberManagementDialogContent(
members = members,
onAddMember = {
viewModel.showAddMemberDialogByProjectId = manageMembersProjectId
viewModel.showMemberManagementDialogByProjectId = null
},
onEditMember = { member ->
viewModel.showEditMemberDialogByProjectId = member.id
viewModel.showMemberManagementDialogByProjectId = null
},
onDismiss = { viewModel.showMemberManagementDialogByProjectId = null }
)
}
} }
val addMemberProjectId = viewModel.showAddMemberDialogByProjectId val addMemberProjectId = viewModel.showAddMemberDialogByProjectId
if (addMemberProjectId != null) { if (addMemberProjectId != null) {
Dialog( viewModel.showAddMemberDialogByProjectId = null
onDismissRequest = { viewModel.showAddMemberDialogByProjectId = null },
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
MemberAddDialogContent(
onAdd = { memberName ->
val memberNames =
db.getMembersOfProject(addMemberProjectId, null).map { it.name }
if (memberNames.contains(memberName)) {
Toast.makeText(
context,
memberAlreadyExistsError,
Toast.LENGTH_SHORT
).show()
} else {
val color = net.helcel.cowspent.android.helper.TextDrawable.getColorFromName(memberName)
db.addMemberAndSync(
DBMember(
0,
0,
addMemberProjectId,
memberName,
true,
1.0,
DBBill.STATE_ADDED,
android.graphics.Color.red(color),
android.graphics.Color.green(color),
android.graphics.Color.blue(color),
null,
null
)
)
refreshCallback.refreshLists(false)
viewModel.showAddMemberDialogByProjectId = null
viewModel.showMemberManagementDialogByProjectId = addMemberProjectId
}
},
onDismiss = {
viewModel.showAddMemberDialogByProjectId = null
viewModel.showMemberManagementDialogByProjectId = addMemberProjectId
}
)
}
} }
val editMemberId = viewModel.showEditMemberDialogByProjectId val editMemberId = viewModel.showEditMemberDialogByProjectId
if (editMemberId != null) { if (editMemberId != null) {
val memberToEdit = remember(editMemberId, viewModel.members) { viewModel.showEditMemberDialogByProjectId = null
viewModel.members.find { it.id == editMemberId }
}
if (memberToEdit != null) {
Dialog(
onDismissRequest = { viewModel.showEditMemberDialogByProjectId = null },
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
MemberEditDialogContent(
member = memberToEdit,
onSave = { name, weight, isActivated, r, g, b ->
db.updateMemberAndSync(
memberToEdit,
name,
weight,
isActivated,
r,
g,
b,
"",
""
)
refreshCallback.refreshLists(false)
viewModel.showEditMemberDialogByProjectId = null
viewModel.showMemberManagementDialogByProjectId = memberToEdit.projectId
},
onDelete = {
db.deleteMember(editMemberId)
refreshCallback.refreshLists(false)
viewModel.showEditMemberDialogByProjectId = null
viewModel.showMemberManagementDialogByProjectId = memberToEdit.projectId
},
onDismiss = {
viewModel.showEditMemberDialogByProjectId = null
viewModel.showMemberManagementDialogByProjectId = memberToEdit.projectId
}
)
}
}
} }
val shareProjectId = viewModel.showShareDialogByProjectId val shareProjectId = viewModel.showShareDialogByProjectId
@@ -324,8 +229,8 @@ fun BillsListScreen(
viewModel.projects.find { it.id == shareProjectId } viewModel.projects.find { it.id == shareProjectId }
} }
if (proj != null) { if (proj != null) {
val shareIntentTitle = stringResource(R.string.share_share_intent_title, proj.name) val shareIntentTitle = stringResource(R.string.share_intent_title, proj.name)
val shareChooserTitle = stringResource(R.string.share_share_chooser_title, proj.name) val shareChooserTitle = stringResource(R.string.share_chooser_title, proj.name)
Dialog( Dialog(
onDismissRequest = { viewModel.showShareDialogByProjectId = null }, onDismissRequest = { viewModel.showShareDialogByProjectId = null },
properties = DialogProperties(usePlatformDefaultWidth = false) properties = DialogProperties(usePlatformDefaultWidth = false)
@@ -358,7 +263,13 @@ fun BillsListScreen(
TextField( TextField(
value = viewModel.searchQuery, value = viewModel.searchQuery,
onValueChange = { viewModel.searchQuery = it }, 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 modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.focusRequester(focusRequester), .focusRequester(focusRequester),
@@ -381,8 +292,20 @@ fun BillsListScreen(
) )
} else { } else {
Column { Column {
if (viewModel.title.isNotEmpty()) Text(viewModel.title) if (viewModel.title.isNotEmpty()) {
else Text(stringResource(R.string.app_name)) 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
)
}
} }
} }
}, },
@@ -425,7 +348,7 @@ fun BillsListScreen(
val selectedProject = viewModel.projects.find { it.id == viewModel.selectedProjectId } val selectedProject = viewModel.projects.find { it.id == viewModel.selectedProjectId }
if (selectedProject != null && !selectedProject.isArchived) { if (selectedProject != null && !selectedProject.isArchived) {
FloatingActionButton(onClick = onAddBillClick) { 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))
} }
} }
}, },
@@ -5,6 +5,7 @@ import android.content.Intent
import net.helcel.cowspent.R import net.helcel.cowspent.R
import net.helcel.cowspent.model.* import net.helcel.cowspent.model.*
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.util.CategoryUtils
import net.helcel.cowspent.util.IRefreshBillsListCallback import net.helcel.cowspent.util.IRefreshBillsListCallback
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
@@ -65,11 +66,11 @@ object BillsListUtils {
memberIdToName: Map<Long, String> memberIdToName: Map<Long, String>
) { ) {
val projectName = proj.name.ifEmpty { proj.remoteId } 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) { for (t in transactions) {
val amount = round(t.amount * 100.0) / 100.0 val amount = round(t.amount * 100.0) / 100.0
text += "\n" + context.getString( text += "\n" + context.getString(
R.string.share_settle_sentence, R.string.msg_settle_sentence,
memberIdToName[t.owerMemberId], memberIdToName[t.owerMemberId],
memberIdToName[t.receiverMemberId], memberIdToName[t.receiverMemberId],
amount amount
@@ -80,12 +81,12 @@ object BillsListUtils {
shareIntent.type = "text/plain" shareIntent.type = "text/plain"
shareIntent.putExtra( shareIntent.putExtra(
Intent.EXTRA_SUBJECT, Intent.EXTRA_SUBJECT,
context.getString(R.string.share_settle_title, projectName) context.getString(R.string.title_settle)
) )
shareIntent.putExtra(Intent.EXTRA_TEXT, text) shareIntent.putExtra(Intent.EXTRA_TEXT, text)
val chooserIntent = Intent.createChooser( val chooserIntent = Intent.createChooser(
shareIntent, shareIntent,
context.getString(R.string.share_settle_title, projectName) context.getString(R.string.title_settle)
) )
context.startActivity(chooserIntent) context.startActivity(chooserIntent)
} }
@@ -98,6 +99,10 @@ object BillsListUtils {
context: Context context: Context
) { ) {
val timestamp = System.currentTimeMillis() / 1000 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) { for (t in transactions) {
val owerId = t.owerMemberId val owerId = t.owerMemberId
val receiverId = t.receiverMemberId val receiverId = t.receiverMemberId
@@ -106,7 +111,7 @@ object BillsListUtils {
0, 0, projectId, owerId, amount, 0, 0, projectId, owerId, amount,
timestamp, context.getString(R.string.settle_bill_what), timestamp, context.getString(R.string.settle_bill_what),
DBBill.STATE_ADDED, DBBill.NON_REPEATED, DBBill.STATE_ADDED, DBBill.NON_REPEATED,
DBBill.PAYMODE_NONE, DBBill.CATEGORY_NONE, DBBill.PAYMODE_NONE, reimbursementCategoryId,
"", DBBill.PAYMODE_ID_NONE "", DBBill.PAYMODE_ID_NONE
) )
bill.billOwers += DBBillOwer(0, 0, receiverId) bill.billOwers += DBBillOwer(0, 0, receiverId)
@@ -21,9 +21,7 @@ import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AddCircleOutline
import androidx.compose.material.icons.filled.Sync import androidx.compose.material.icons.filled.Sync
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.edit import androidx.core.content.edit
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
@@ -45,10 +43,12 @@ import net.helcel.cowspent.model.Category
import net.helcel.cowspent.model.DBBill import net.helcel.cowspent.model.DBBill
import net.helcel.cowspent.model.DBProject import net.helcel.cowspent.model.DBProject
import net.helcel.cowspent.model.GroupedBill import net.helcel.cowspent.model.GroupedBill
import net.helcel.cowspent.model.ProjectType
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.persistence.CowspentServerSyncHelper import net.helcel.cowspent.persistence.CowspentServerSyncHelper
import net.helcel.cowspent.theme.ThemeUtils import net.helcel.cowspent.theme.ThemeUtils
import net.helcel.cowspent.util.BillFormatter import net.helcel.cowspent.util.BillFormatter
import net.helcel.cowspent.util.CategoryUtils
import net.helcel.cowspent.util.CospendClientUtil import net.helcel.cowspent.util.CospendClientUtil
import net.helcel.cowspent.util.ExportUtil import net.helcel.cowspent.util.ExportUtil
import net.helcel.cowspent.util.ICallback import net.helcel.cowspent.util.ICallback
@@ -114,18 +114,8 @@ class BillsListViewActivity :
Log.d(TAG, "CREATED project id: $pid") Log.d(TAG, "CREATED project id: $pid")
lifecycleScope.launch { lifecycleScope.launch {
val addedProj = withContext(Dispatchers.IO) { db.getProject(pid) } val addedProj = withContext(Dispatchers.IO) { db.getProject(pid) }
val message: String val message = getString(R.string.msg_project_added, addedProj?.name?.ifEmpty { addedProj.remoteId } ?: pid.toString())
val title: String showToast(this@BillsListViewActivity, message)
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)
} }
} }
} }
@@ -141,7 +131,7 @@ class BillsListViewActivity :
} }
if (!db.cowspentServerSyncHelper.isSyncPossible) { if (!db.cowspentServerSyncHelper.isSyncPossible) {
if (CowspentServerSyncHelper.isNextcloudAccountConfigured(applicationContext)) { 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)
} }
} }
} }
@@ -220,7 +210,7 @@ class BillsListViewActivity :
lifecycleScope.launch { lifecycleScope.launch {
val members = withContext(Dispatchers.IO) { db.getActivatedMembersOfProject(selectedProjectId) } val members = withContext(Dispatchers.IO) { db.getActivatedMembersOfProject(selectedProjectId) }
if (members.isEmpty()) { if (members.isEmpty()) {
showToast(this@BillsListViewActivity, getString(R.string.add_bill_impossible_no_member)) showToast(this@BillsListViewActivity, getString(R.string.error_no_members))
} else { } else {
val proj = withContext(Dispatchers.IO) { db.getProject(selectedProjectId) } val proj = withContext(Dispatchers.IO) { db.getProject(selectedProjectId) }
val createIntent = Intent(applicationContext, EditBillActivity::class.java).apply { val createIntent = Intent(applicationContext, EditBillActivity::class.java).apply {
@@ -254,13 +244,23 @@ class BillsListViewActivity :
onProjectAction = { pid, actionIndex -> onProjectAction = { pid, actionIndex ->
when (actionIndex) { when (actionIndex) {
0 -> onEditProjectClick(pid) 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) 2 -> onManageMembersClick(pid)
3 -> onManageCurrenciesClick(pid) 3 -> onManageCurrenciesClick(pid)
4 -> onProjectStatisticsClick(pid) 4 -> onProjectStatisticsClick(pid)
5 -> onSettleProjectClick(pid) 5 -> onSettleProjectClick(pid)
6 -> onShareProjectClick(pid) 6 -> onShareProjectClick(pid)
7 -> onExportProjectClick(pid) 7 -> onExportProjectClick(pid)
8 -> {
startActivity(net.helcel.cowspent.android.label.LabelManagementActivity.createIntent(this@BillsListViewActivity, pid))
}
} }
}, },
onAccountSwitcherClick = { onAccountSwitcherClick = {
@@ -382,17 +382,10 @@ class BillsListViewActivity :
private fun onEditProjectClick(projectId: Long) { private fun onEditProjectClick(projectId: Long) {
if (projectId == 0L) return if (projectId == 0L) return
lifecycleScope.launch { val intent = Intent(applicationContext, EditProjectActivity::class.java).apply {
val proj = withContext(Dispatchers.IO) { db.getProject(projectId) } putExtra(EditProjectActivity.PARAM_PROJECT_ID, 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))
}
} }
editProjectLauncher.launch(intent)
} }
private fun onRemoveProjectClick(projectId: Long) { private fun onRemoveProjectClick(projectId: Long) {
@@ -401,8 +394,8 @@ class BillsListViewActivity :
val proj = withContext(Dispatchers.IO) { db.getProject(projectId) } ?: return@launch val proj = withContext(Dispatchers.IO) { db.getProject(projectId) } ?: return@launch
viewModel.showDialog( viewModel.showDialog(
title = getString(R.string.confirm_remove_project_dialog_title), title = getString(R.string.title_confirm),
message = if (!proj.isLocal) getString(R.string.confirm_remove_project_dialog_message) else null, message = if (!proj.isLocal) getString(R.string.dialog_confirm_remove_project_msg) else null,
positiveText = getString(R.string.simple_yes), positiveText = getString(R.string.simple_yes),
onConfirm = { onConfirm = {
lifecycleScope.launch { lifecycleScope.launch {
@@ -423,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) { fun onManageMembersClick(projectId: Long) {
if (projectId == 0L) return if (projectId == 0L) return
lifecycleScope.launch { lifecycleScope.launch {
@@ -432,7 +457,7 @@ class BillsListViewActivity :
return@launch return@launch
} }
viewModel.showMemberManagementDialogByProjectId = projectId startActivity(net.helcel.cowspent.android.project.member.MemberManagementActivity.createIntent(this@BillsListViewActivity, projectId))
} }
} }
@@ -459,7 +484,7 @@ class BillsListViewActivity :
lifecycleScope.launch { lifecycleScope.launch {
val proj = withContext(Dispatchers.IO) { db.getProject(projectId) } val proj = withContext(Dispatchers.IO) { db.getProject(projectId) }
if (projectId != 0L && proj?.isShareable() == true) viewModel.showShareDialogByProjectId = 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)
} }
} }
@@ -529,11 +554,13 @@ class BillsListViewActivity :
val members = db.getMembersOfProject(proj.id, null) val members = db.getMembersOfProject(proj.id, null)
val bills = db.getBillsOfProject(proj.id) val bills = db.getBillsOfProject(proj.id)
val categories = db.getCategories(proj.id)
val reimbursementCategoryId = CategoryUtils.getReimbursementCategoryId(categories, proj.id, proj.remoteId)
val balances = HashMap<Long, Double>() val balances = HashMap<Long, Double>()
SupportUtil.getStats( SupportUtil.getStats(
members, bills, members, bills,
mutableMapOf(), balances, mutableMapOf(), mutableMapOf(), mutableMapOf(), balances, mutableMapOf(), mutableMapOf(),
-1000, -1000, null, null -1000L, -1000L, reimbursementCategoryId, null, null
) )
Triple(proj, members, balances) Triple(proj, members, balances)
} }
@@ -560,7 +587,7 @@ class BillsListViewActivity :
} else { } else {
val lastSyncTimestamp = proj.lastSyncedTimestamp ?: 0 val lastSyncTimestamp = proj.lastSyncedTimestamp ?: 0
val cal = Calendar.getInstance().apply { timeInMillis = lastSyncTimestamp * 1000 } 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 viewModel.lastSyncText = text
} }
} }
@@ -600,13 +627,13 @@ class BillsListViewActivity :
mid == null || mid == it.payerId || it.billOwersIds.contains(mid) 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 projectMembers = db.getMembersOfProject(projId, null)
val memberMap = projectMembers.associateBy { it.id } val memberMap = projectMembers.associateBy { it.id }
val projectPaymentModes = db.getPaymentModes(projId).associateBy { it.remoteId } val projectPaymentModes = db.getPaymentModes(projId).associateBy { it.id }
val projectCategories = db.getCategories(projId).associateBy { it.remoteId } val projectCategories = db.getCategories(projId).associateBy { it.id }
BillFormatter.formatBills( BillFormatter.formatBills(
bills, bills,
@@ -658,7 +685,7 @@ class BillsListViewActivity :
lifecycleScope.launch { lifecycleScope.launch {
val members = withContext(Dispatchers.IO) { db.getActivatedMembersOfProject(selectedProjectId) } val members = withContext(Dispatchers.IO) { db.getActivatedMembersOfProject(selectedProjectId) }
if (members.isEmpty()) { if (members.isEmpty()) {
showToast(this@BillsListViewActivity, getString(R.string.add_bill_impossible_no_member)) showToast(this@BillsListViewActivity, getString(R.string.error_no_member))
} else { } else {
val projType = withContext(Dispatchers.IO) { db.getProject(selectedProjectId)?.type?.id } val projType = withContext(Dispatchers.IO) { db.getProject(selectedProjectId)?.type?.id }
val intent = Intent(applicationContext, EditBillActivity::class.java).apply { val intent = Intent(applicationContext, EditBillActivity::class.java).apply {
@@ -672,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() { private fun updateUsernameInDrawer() {
if (!CowspentServerSyncHelper.isNextcloudAccountConfigured(this)) { if (!CowspentServerSyncHelper.isNextcloudAccountConfigured(this)) {
val text = getString(R.string.drawer_no_account) val text = getString(R.string.drawer_no_account)
@@ -772,8 +790,8 @@ class BillsListViewActivity :
lifecycleScope.launch { lifecycleScope.launch {
val project = withContext(Dispatchers.IO) { db.getProject(projectId) } ?: return@launch val project = withContext(Dispatchers.IO) { db.getProject(projectId) } ?: return@launch
viewModel.showDialog( viewModel.showDialog(
title = getString(R.string.sync_error_dialog_title), title = getString(R.string.dialog_sync_error_title),
message = getString(R.string.sync_error_dialog_full_content, project.name, errorMessage), message = getString(R.string.dialog_sync_error_msg, project.name, errorMessage),
positiveText = getString(R.string.simple_close), positiveText = getString(R.string.simple_close),
icon = Icons.Default.Sync icon = Icons.Default.Sync
) )
@@ -793,7 +811,7 @@ class BillsListViewActivity :
} }
MainConstants.BROADCAST_SSO_TOKEN_MISMATCH -> { MainConstants.BROADCAST_SSO_TOKEN_MISMATCH -> {
viewModel.showDialog( 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), message = getString(R.string.error_token_mismatch),
positiveText = getString(R.string.simple_close), positiveText = getString(R.string.simple_close),
icon = Icons.Default.Sync icon = Icons.Default.Sync
@@ -118,8 +118,8 @@ object ProjectImportHelper {
val payerWeight = if (columns.containsKey("payer_weight")) line[columns["payer_weight"]!!].toDouble() else 1.0 val 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 owersStr = if (columns.containsKey("owers")) line[columns["owers"]!!] else ""
val payerActive = columns.containsKey("payer_active") && line[columns["payer_active"]!!] == "1" 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 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"]!!].toInt() else 0 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 val pm = if (columns.containsKey("paymentmode")) line[columns["paymentmode"]!!] else null
membersActive[payerName] = payerActive membersActive[payerName] = payerActive
@@ -149,9 +149,17 @@ object ProjectImportHelper {
val memberNameToId = mutableMapOf<String, Long>() val memberNameToId = mutableMapOf<String, Long>()
val pid = db.addProject(DBProject(0, projectRemoteId, "", projectRemoteId, null, null, null, ProjectType.LOCAL, 0L, mainCurrencyName, false, DBProject.ACCESS_LEVEL_UNKNOWN, null)) 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)) } val pmRemoteToLocal = mutableMapOf<Long, Long>()
categories.forEach { db.addCategory(DBCategory(0, it.remoteId, pid, it.name, it.icon, it.color)) } paymentModes.forEach {
val newId = db.addPaymentMode(DBPaymentMode(0, it.remoteId, pid, it.name, it.icon, it.color))
pmRemoteToLocal[it.remoteId] = newId
}
val catRemoteToLocal = mutableMapOf<Long, Long>()
categories.forEach {
val newId = db.addCategory(DBCategory(0, it.remoteId, pid, it.name, it.icon, it.color))
catRemoteToLocal[it.remoteId] = newId
}
currencies.forEach { db.addCurrency(DBCurrency(0, 0, pid, it.name, it.exchangeRate, DBBill.STATE_OK)) } currencies.forEach { db.addCurrency(DBCurrency(0, 0, pid, it.name, it.exchangeRate, DBBill.STATE_OK)) }
membersWeight.keys.forEach { mName -> membersWeight.keys.forEach { mName ->
@@ -160,7 +168,9 @@ object ProjectImportHelper {
bills.forEach { b -> bills.forEach { b ->
val payerId = memberNameToId[billRemoteIdToPayerName[b.remoteId]] ?: 0L 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 -> billRemoteIdToOwerStr[b.remoteId]?.split(", ")?.filter { it.isNotEmpty() }?.forEach { ower ->
memberNameToId[ower.trim()]?.let { owerId -> db.addBillower(billId, owerId) } memberNameToId[ower.trim()]?.let { owerId -> db.addBillower(billId, owerId) }
} }
@@ -4,19 +4,21 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.material.* import androidx.compose.material.*
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Label
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource 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.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import net.helcel.cowspent.R import net.helcel.cowspent.R
import net.helcel.cowspent.model.DBProject import net.helcel.cowspent.model.DBProject
import net.helcel.cowspent.model.ProjectType
@Composable @Composable
fun ProjectOptionsDialogContent( fun ProjectOptionsDialogContent(
@@ -24,12 +26,14 @@ fun ProjectOptionsDialogContent(
onRemoveProject: () -> Unit, onRemoveProject: () -> Unit,
onManageMembers: () -> Unit, onManageMembers: () -> Unit,
onManageCurrencies: () -> Unit, onManageCurrencies: () -> Unit,
onManageLabels: () -> Unit,
onStatistics: () -> Unit, onStatistics: () -> Unit,
onSettle: () -> Unit, onSettle: () -> Unit,
onShareProject: () -> Unit, onShareProject: () -> Unit,
onExportProject: () -> Unit, onExportProject: () -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
isArchived: Boolean = false, isArchived: Boolean = false,
projectType: ProjectType = ProjectType.LOCAL,
accessLevel: Int = DBProject.ACCESS_LEVEL_ADMIN, accessLevel: Int = DBProject.ACCESS_LEVEL_ADMIN,
isShareable: Boolean = true isShareable: Boolean = true
) { ) {
@@ -44,46 +48,71 @@ fun ProjectOptionsDialogContent(
.fillMaxWidth() .fillMaxWidth()
) { ) {
Text( Text(
text = stringResource(R.string.choose_project_management_action), text = stringResource(R.string.choose_project_management_action).uppercase(),
style = MaterialTheme.typography.h6, style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.onSurface,
modifier = Modifier.padding(bottom = 8.dp) modifier = Modifier.padding(bottom = 8.dp)
) )
val options = mutableListOf<ProjectOption>()
val isMaintainer = accessLevel >= DBProject.ACCESS_LEVEL_MAINTAINER || accessLevel == DBProject.ACCESS_LEVEL_UNKNOWN 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 isParticipant = accessLevel >= DBProject.ACCESS_LEVEL_PARTICIPANT || accessLevel == DBProject.ACCESS_LEVEL_UNKNOWN
val row1 = mutableListOf<ProjectOption>()
val row2 = mutableListOf<ProjectOption>()
val row3 = mutableListOf<ProjectOption>()
if (!isArchived && isMaintainer) { if (!isArchived && isMaintainer) {
options.add(ProjectOption(stringResource(R.string.action_edit_project), Icons.Default.Edit, onEditProject)) row1.add(ProjectOption(stringResource(R.string.action_edit), 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))
} }
if (isShareable && isParticipant) { 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 // Row 2: Manage Member, Manage Labels, Manage Currencies
for (i in options.indices step 2) { if (!isArchived && isMaintainer) {
Row(modifier = Modifier.fillMaxWidth()) { row2.add(ProjectOption(stringResource(R.string.action_members), Icons.Default.Group, onManageMembers))
ProjectOptionItem( if (false && (projectType == ProjectType.LOCAL || projectType == ProjectType.COSPEND)) {
option = options[i], row2.add(ProjectOption(stringResource(R.string.action_labels), Icons.AutoMirrored.Filled.Label, onManageLabels))
modifier = Modifier.weight(1f) }
) row2.add(ProjectOption(stringResource(R.string.action_currencies), Icons.Default.MonetizationOn, onManageCurrencies))
if (i + 1 < options.size) { }
// 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( ProjectOptionItem(
option = options[i + 1], option = option,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
} else { }
Spacer(modifier = Modifier.weight(1f))
if (emptySpace > 0) {
Spacer(modifier = Modifier.weight(emptySpace / 2f))
} }
} }
} }
@@ -145,10 +174,59 @@ fun ProjectOptionsDialogPreview() {
onManageCurrencies = {}, onManageCurrencies = {},
onStatistics = {}, onStatistics = {},
onSettle = {}, onSettle = {},
onManageLabels = {},
onShareProject = {}, onShareProject = {},
onExportProject = {}, onExportProject = {},
onDismiss = {}, onDismiss = {},
isArchived = false, isArchived = false,
projectType = ProjectType.COSPEND,
accessLevel = DBProject.ACCESS_LEVEL_ADMIN,
isShareable = true
)
}
}
@Preview(showBackground = true)
@Composable
fun ProjectOptionsDialogPreview2() {
MaterialTheme {
ProjectOptionsDialogContent(
onEditProject = {},
onRemoveProject = {},
onManageMembers = {},
onManageCurrencies = {},
onStatistics = {},
onSettle = {},
onManageLabels = {},
onShareProject = {},
onExportProject = {},
onDismiss = {},
isArchived = true,
projectType = ProjectType.COSPEND,
accessLevel = DBProject.ACCESS_LEVEL_ADMIN,
isShareable = true
)
}
}
@Preview(showBackground = true)
@Composable
fun ProjectOptionsDialogPreview3() {
MaterialTheme {
ProjectOptionsDialogContent(
onEditProject = {},
onRemoveProject = {},
onManageMembers = {},
onManageCurrencies = {},
onStatistics = {},
onSettle = {},
onManageLabels = {},
onShareProject = {},
onExportProject = {},
onDismiss = {},
isArchived = false,
projectType = ProjectType.LOCAL,
accessLevel = DBProject.ACCESS_LEVEL_ADMIN, accessLevel = DBProject.ACCESS_LEVEL_ADMIN,
isShareable = true isShareable = true
) )
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
@@ -31,8 +32,6 @@ import androidx.compose.material.Text
import androidx.compose.material.contentColorFor import androidx.compose.material.contentColorFor
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContentCopy 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.material.icons.filled.Share
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
@@ -42,10 +41,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -69,7 +66,7 @@ fun ProjectShareDialogContent(
) { ) {
val context = LocalContext.current val context = LocalContext.current
val clipboardManager = LocalClipboardManager.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 shareUrl = remember { proj.getShareUrl() }
val publicWebUrl = remember { proj.getPublicWebUrl() } val publicWebUrl = remember { proj.getPublicWebUrl() }
@@ -93,35 +90,24 @@ fun ProjectShareDialogContent(
.heightIn(max = 650.dp) .heightIn(max = 650.dp)
.padding(16.dp) .padding(16.dp)
) { ) {
Row( Text(
verticalAlignment = Alignment.CenterVertically, text = stringResource(R.string.title_share).uppercase(),
modifier = Modifier.padding(bottom = 8.dp) style = MaterialTheme.typography.subtitle1,
) { fontWeight = FontWeight.Bold,
Icon( color = MaterialTheme.colors.onSurface,
Icons.Default.Share, modifier = Modifier.padding(bottom = 16.dp)
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
)
}
Column( Column(
modifier = Modifier modifier = Modifier
.weight(1f, fill = false) .weight(1f, fill = false)
.verticalScroll(rememberScrollState()), .verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(24.dp)
) { ) {
ShareCard( ShareCard(
title = stringResource(R.string.share_project_public_url_title), title = stringResource(R.string.title_share_web),
url = publicWebUrl, url = publicWebUrl,
description = stringResource(R.string.share_project_public_url_dialog_message), description = stringResource(R.string.msg_share_web),
icon = Icons.Default.Link,
onUrlClick = { onUrlClick = {
val i = Intent(Intent.ACTION_VIEW).apply { val i = Intent(Intent.ACTION_VIEW).apply {
data = publicWebUrl.toUri() data = publicWebUrl.toUri()
@@ -130,22 +116,21 @@ fun ProjectShareDialogContent(
}, },
onCopyClick = { onCopyClick = {
clipboardManager.setText(AnnotatedString(publicWebUrl)) 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( ShareCard(
title = stringResource(R.string.share_project_public_qrcode_title), title = stringResource(R.string.title_share_qr),
url = shareUrl, url = shareUrl,
description = stringResource(R.string.share_project_dialog_message), description = stringResource(R.string.msg_share_qr),
icon = Icons.Default.QrCode,
qrBitmap = qrBitmap, qrBitmap = qrBitmap,
onUrlClick = { onUrlClick = {
Toast.makeText(context, qrCodeLinkWarn, Toast.LENGTH_SHORT).show() Toast.makeText(context, qrCodeLinkWarn, Toast.LENGTH_SHORT).show()
}, },
onCopyClick = { onCopyClick = {
clipboardManager.setText(AnnotatedString(shareUrl)) 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, title: String,
url: String, url: String,
description: String, description: String,
icon: ImageVector,
onUrlClick: () -> Unit, onUrlClick: () -> Unit,
onCopyClick: () -> Unit, onCopyClick: () -> Unit,
qrBitmap: Bitmap? = null qrBitmap: Bitmap? = null
) { ) {
Surface( Column(
shape = MaterialTheme.shapes.medium,
color = colorResource(R.color.fg_default_low).copy(alpha = 0.04f),
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Column( Text(
modifier = Modifier.padding(8.dp), text = title.uppercase(),
horizontalAlignment = Alignment.CenterHorizontally style = MaterialTheme.typography.subtitle1,
) { fontWeight = FontWeight.Bold,
Row( color = MaterialTheme.colors.onSurface,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.padding(bottom = 8.dp)
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)
)
}
if (qrBitmap != null) { if (qrBitmap != null) {
Spacer(modifier = Modifier.height(8.dp)) Box(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 12.dp),
contentAlignment = Alignment.Center
) {
Box( Box(
modifier = Modifier.size(128.dp) modifier = Modifier
.size(128.dp)
.clip(MaterialTheme.shapes.small) .clip(MaterialTheme.shapes.small)
.background(Color.White), .background(Color.White),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
@@ -209,55 +182,53 @@ private fun ShareCard(
Image( Image(
bitmap = qrBitmap.asImageBitmap(), bitmap = qrBitmap.asImageBitmap(),
contentDescription = null, contentDescription = null,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxSize()
) )
} }
} }
}
Spacer(modifier = Modifier.height(8.dp)) Surface(
shape = MaterialTheme.shapes.small,
Surface( color = MaterialTheme.colors.onSurface.copy(alpha = 0.05f),
shape = MaterialTheme.shapes.small, modifier = Modifier.fillMaxWidth()
color = MaterialTheme.colors.surface, ) {
modifier = Modifier.fillMaxWidth() Row(
modifier = Modifier
.clickable { onUrlClick() }
.padding(start = 12.dp, end = 4.dp),
verticalAlignment = Alignment.CenterVertically
) { ) {
Row( Text(
modifier = Modifier text = url,
.clickable { onUrlClick() } modifier = Modifier.weight(1f),
.padding(start = 12.dp, end = 4.dp), color = MaterialTheme.colors.primary,
verticalAlignment = Alignment.CenterVertically style = MaterialTheme.typography.body2,
) { maxLines = 1,
Text( overflow = TextOverflow.Ellipsis,
text = url, fontWeight = FontWeight.Medium
modifier = Modifier.weight(1f), )
color = MaterialTheme.colors.primary, IconButton(onClick = onCopyClick) {
style = MaterialTheme.typography.body2, Icon(
maxLines = 1, Icons.Default.ContentCopy,
overflow = TextOverflow.Ellipsis, contentDescription = "Copy",
fontWeight = FontWeight.Medium 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) { CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
Text( Text(
text = description, text = description,
style = MaterialTheme.typography.caption, style = MaterialTheme.typography.caption,
textAlign = TextAlign.Center, textAlign = TextAlign.Start,
lineHeight = 16.sp, lineHeight = 16.sp,
modifier = Modifier.padding(horizontal = 8.dp) modifier = Modifier.padding(horizontal = 4.dp)
) )
}
} }
} }
} }
@@ -291,7 +262,7 @@ private fun DialogActions(
Icon(Icons.Default.Share, contentDescription = null, modifier = Modifier.size(16.dp)) Icon(Icons.Default.Share, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text( Text(
text = stringResource(R.string.simple_share_share).uppercase(), text = stringResource(R.string.action_share).uppercase(),
style = MaterialTheme.typography.button, style = MaterialTheme.typography.button,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
@@ -213,7 +213,7 @@ class NewProjectActivity : AppCompatActivity() {
val rid = viewModel.projectId val rid = viewModel.projectId
if (!isCospendScheme && (rid == "" || rid.contains(",") || rid.contains("/"))) { 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 return
} }
@@ -320,7 +320,6 @@ class NewProjectActivity : AppCompatActivity() {
pid pid
) )
} }
showToast(getString(R.string.project_added_success), Toast.LENGTH_LONG)
return pid return pid
} }
@@ -19,6 +19,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@@ -42,7 +43,7 @@ fun NewProjectScreen(
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text(stringResource(R.string.action_add_project)) }, title = { Text(stringResource(R.string.title_add_project)) },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
@@ -55,7 +56,7 @@ fun NewProjectScreen(
floatingActionButton = { floatingActionButton = {
if (viewModel.isFormValid()) { if (viewModel.isFormValid()) {
FloatingActionButton(onClick = onOkPressed) { FloatingActionButton(onClick = onOkPressed) {
Icon(Icons.Default.Done, contentDescription = stringResource(R.string.action_save_bill)) Icon(Icons.Default.Done, contentDescription = stringResource(R.string.action_save))
} }
} }
} }
@@ -70,11 +71,11 @@ fun NewProjectScreen(
) { ) {
// What to do // What to do
SectionRow( SectionRow(
label = stringResource(R.string.new_project_what_todo) label = stringResource(R.string.new_project_action)
) { ) {
Row { Row {
ToggleButton( ToggleButton(
text = stringResource(R.string.todo_join_label), text = stringResource(R.string.todo_join),
selected = !viewModel.whatTodoIsCreate, selected = !viewModel.whatTodoIsCreate,
onClick = { onClick = {
viewModel.whatTodoIsCreate = false viewModel.whatTodoIsCreate = false
@@ -84,7 +85,7 @@ fun NewProjectScreen(
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
ToggleButton( ToggleButton(
text = stringResource(R.string.todo_create_label), text = stringResource(R.string.todo_create),
selected = viewModel.whatTodoIsCreate, selected = viewModel.whatTodoIsCreate,
onClick = { viewModel.whatTodoIsCreate = true } onClick = { viewModel.whatTodoIsCreate = true }
) )
@@ -99,20 +100,20 @@ fun NewProjectScreen(
Row { Row {
if (viewModel.whatTodoIsCreate) { if (viewModel.whatTodoIsCreate) {
ToggleButton( ToggleButton(
text = stringResource(R.string.where_local_short), text = stringResource(R.string.where_local),
selected = viewModel.projectType == ProjectType.LOCAL, selected = viewModel.projectType == ProjectType.LOCAL,
onClick = { viewModel.projectType = ProjectType.LOCAL } onClick = { viewModel.projectType = ProjectType.LOCAL }
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
} }
ToggleButton( ToggleButton(
text = stringResource(R.string.where_cospend_short), text = stringResource(R.string.where_cospend),
selected = viewModel.projectType == ProjectType.COSPEND, selected = viewModel.projectType == ProjectType.COSPEND,
onClick = { viewModel.projectType = ProjectType.COSPEND } onClick = { viewModel.projectType = ProjectType.COSPEND }
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
ToggleButton( ToggleButton(
text = stringResource(R.string.where_ihatemoney_short), text = stringResource(R.string.where_ihatemoney),
selected = viewModel.projectType == ProjectType.IHATEMONEY, selected = viewModel.projectType == ProjectType.IHATEMONEY,
onClick = { viewModel.projectType = ProjectType.IHATEMONEY } onClick = { viewModel.projectType = ProjectType.IHATEMONEY }
) )
@@ -131,7 +132,7 @@ fun NewProjectScreen(
} else { } else {
Spacer(modifier = Modifier.width(16.dp)) Spacer(modifier = Modifier.width(16.dp))
Button(onClick = onScanQrCode) { Button(onClick = onScanQrCode) {
Text(text = stringResource(R.string.scan_qrcode)) Text(text = stringResource(R.string.action_scan_qrcode))
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
Icon( Icon(
Icons.Default.QrCode2, Icons.Default.QrCode2,
@@ -148,13 +149,8 @@ fun NewProjectScreen(
OutlinedTextField( OutlinedTextField(
value = viewModel.projectUrl, value = viewModel.projectUrl,
onValueChange = { viewModel.projectUrl = it }, onValueChange = { viewModel.projectUrl = it },
label = { placeholder = {
Text( Text(stringResource(R.string.label_url))
stringResource(
if (viewModel.projectType == ProjectType.COSPEND) R.string.setting_cospend_project_url
else R.string.setting_ihatemoney_project_url
)
)
}, },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.Default.Link, contentDescription = null) } leadingIcon = { Icon(Icons.Default.Link, contentDescription = null) }
@@ -166,7 +162,7 @@ fun NewProjectScreen(
OutlinedTextField( OutlinedTextField(
value = viewModel.projectId, value = viewModel.projectId,
onValueChange = { viewModel.projectId = it }, onValueChange = { viewModel.projectId = it },
label = { Text(stringResource(R.string.setting_project_id)) }, placeholder = { Text(stringResource(R.string.label_project_id)) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.AutoMirrored.Filled.LibraryBooks, contentDescription = null) } leadingIcon = { Icon(Icons.AutoMirrored.Filled.LibraryBooks, contentDescription = null) }
) )
@@ -177,7 +173,7 @@ fun NewProjectScreen(
OutlinedTextField( OutlinedTextField(
value = viewModel.projectPassword, value = viewModel.projectPassword,
onValueChange = { viewModel.projectPassword = it }, onValueChange = { viewModel.projectPassword = it },
label = { Text(stringResource(R.string.setting_new_project_password)) }, placeholder = { Text(stringResource(R.string.label_password)) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) } leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) }
) )
@@ -188,7 +184,7 @@ fun NewProjectScreen(
OutlinedTextField( OutlinedTextField(
value = viewModel.projectName, value = viewModel.projectName,
onValueChange = { viewModel.projectName = it }, onValueChange = { viewModel.projectName = it },
label = { Text(stringResource(R.string.setting_new_project_name)) }, placeholder = { Text(stringResource(R.string.label_project_title)) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.Default.Title, contentDescription = null) } leadingIcon = { Icon(Icons.Default.Title, contentDescription = null) }
) )
@@ -198,7 +194,7 @@ fun NewProjectScreen(
OutlinedTextField( OutlinedTextField(
value = viewModel.projectEmail, value = viewModel.projectEmail,
onValueChange = { viewModel.projectEmail = it }, onValueChange = { viewModel.projectEmail = it },
label = { Text(stringResource(R.string.setting_new_project_email)) }, placeholder = { Text(stringResource(R.string.label_email)) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.Default.Email, contentDescription = null) } leadingIcon = { Icon(Icons.Default.Email, contentDescription = null) }
) )
@@ -210,8 +206,8 @@ fun NewProjectScreen(
if (viewModel.showAuthWarningDialog) { if (viewModel.showAuthWarningDialog) {
AlertDialog( AlertDialog(
onDismissRequest = { viewModel.showAuthWarningDialog = false }, onDismissRequest = { viewModel.showAuthWarningDialog = false },
title = { Text(stringResource(R.string.auth_project_creation_title)) }, title = { Text(stringResource(R.string.app_name)) },
text = { Text(stringResource(R.string.warning_auth_project_creation)) }, text = { Text(stringResource(R.string.msg_auth_warning)) },
confirmButton = { confirmButton = {
TextButton(onClick = { TextButton(onClick = {
viewModel.showAuthWarningDialog = false viewModel.showAuthWarningDialog = false
@@ -265,12 +261,12 @@ fun NewProjectScreen(
if (viewModel.isCreatingRemoteProject) { if (viewModel.isCreatingRemoteProject) {
AlertDialog( AlertDialog(
onDismissRequest = { }, onDismissRequest = { },
title = { Text(stringResource(R.string.simple_loading)) }, title = { Text(stringResource(R.string.error_loading)) },
text = { text = {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator() CircularProgressIndicator()
Spacer(modifier = Modifier.width(16.dp)) Spacer(modifier = Modifier.width(16.dp))
Text(stringResource(R.string.creating_remote_project)) Text(stringResource(R.string.action_add_project))
} }
}, },
confirmButton = {} confirmButton = {}
@@ -280,7 +276,7 @@ fun NewProjectScreen(
if (viewModel.errorDialogMessage != null) { if (viewModel.errorDialogMessage != null) {
AlertDialog( AlertDialog(
onDismissRequest = { viewModel.errorDialogMessage = null }, onDismissRequest = { viewModel.errorDialogMessage = null },
title = { Text(stringResource(R.string.simple_error)) }, title = { Text(stringResource(R.string.error_generic)) },
text = { Text(viewModel.errorDialogMessage!!) }, text = { Text(viewModel.errorDialogMessage!!) },
confirmButton = { confirmButton = {
TextButton(onClick = { viewModel.errorDialogMessage = null }) { TextButton(onClick = { viewModel.errorDialogMessage = null }) {
@@ -296,11 +292,15 @@ fun SectionRow(
label: String, label: String,
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
Row(verticalAlignment = Alignment.Top) { Column(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) {
Column { Text(
Text(label, fontSize = 12.sp, color = MaterialTheme.colors.onSurface) text = label.uppercase(),
content() style = MaterialTheme.typography.subtitle1,
} color = MaterialTheme.colors.onSurface,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 8.dp)
)
content()
} }
} }
@@ -137,7 +137,7 @@ class EditProjectActivity : AppCompatActivity() {
private fun onDeleteRemote() { private fun onDeleteRemote() {
viewModel.showDialog( viewModel.showDialog(
message = getString(R.string.confirm_delete_project_dialog_title), message = getString(R.string.title_confirm),
positiveText = getString(R.string.simple_yes), positiveText = getString(R.string.simple_yes),
onConfirm = { onConfirm = {
if (!db.cowspentServerSyncHelper.deleteRemoteProject(project.id, deleteCallBack)) { if (!db.cowspentServerSyncHelper.deleteRemoteProject(project.id, deleteCallBack)) {
@@ -16,6 +16,7 @@ import androidx.compose.material.icons.filled.Title
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import net.helcel.cowspent.R import net.helcel.cowspent.R
@@ -61,24 +62,26 @@ fun EditProjectScreen(
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text(stringResource(R.string.simple_edit_project)) }, title = { Text(stringResource(R.string.title_edit_project)) },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
} }
}, },
actions = { actions = {
IconButton(onClick = onDeleteRemote) { if (!viewModel.isLocal) {
Icon(Icons.Default.DeleteForever, contentDescription = stringResource(R.string.menu_delete_project_remote)) IconButton(onClick = onDeleteRemote) {
Icon(Icons.Default.DeleteForever, contentDescription = stringResource(R.string.action_delete))
}
} }
}, },
backgroundColor = MaterialTheme.colors.surface, backgroundColor = MaterialTheme.colors.primary,
elevation = 0.dp elevation = 0.dp
) )
}, },
floatingActionButton = { floatingActionButton = {
FloatingActionButton(onClick = onSave) { 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 -> ) { padding ->
@@ -91,43 +94,59 @@ fun EditProjectScreen(
.fillMaxSize() .fillMaxSize()
.verticalScroll(scrollState) .verticalScroll(scrollState)
) { ) {
Text(
text = "GENERAL",
style = MaterialTheme.typography.subtitle1,
color = MaterialTheme.colors.onSurface,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 8.dp)
)
OutlinedTextField( OutlinedTextField(
value = viewModel.name, value = viewModel.name,
onValueChange = { viewModel.name = it }, onValueChange = { viewModel.name = it },
label = { Text(stringResource(R.string.setting_new_project_name)) }, placeholder = { Text(stringResource(R.string.label_project_title)) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.Default.Title, contentDescription = null) } leadingIcon = { Icon(Icons.Default.Title, contentDescription = null) }
) )
Spacer(modifier = Modifier.height(8.dp)) if (!viewModel.isLocal) {
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField( OutlinedTextField(
value = viewModel.password, value = viewModel.email,
onValueChange = { viewModel.password = it }, onValueChange = { viewModel.email = it },
label = { Text(stringResource(R.string.setting_password)) }, placeholder = { Text(stringResource(R.string.label_email)) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) } 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( OutlinedTextField(
value = viewModel.newPassword, value = viewModel.password,
onValueChange = { viewModel.newPassword = it }, onValueChange = { viewModel.password = it },
label = { Text(stringResource(R.string.setting_new_project_password)) }, placeholder = { Text("Old " + stringResource(R.string.label_password)) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.Default.LockOpen, contentDescription = null) } leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) }
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField( OutlinedTextField(
value = viewModel.email, value = viewModel.newPassword,
onValueChange = { viewModel.email = it }, onValueChange = { viewModel.newPassword = it },
label = { Text(stringResource(R.string.setting_new_project_email)) }, placeholder = { Text("New " + stringResource(R.string.label_password)) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.Default.Email, contentDescription = null) } leadingIcon = { Icon(Icons.Default.LockOpen, contentDescription = null) }
) )
}
} }
} }
} }
@@ -13,6 +13,7 @@ class EditProjectViewModel : ViewModel() {
var password by mutableStateOf("") var password by mutableStateOf("")
var newPassword by mutableStateOf("") var newPassword by mutableStateOf("")
var email by mutableStateOf("") var email by mutableStateOf("")
var isLocal by mutableStateOf(false)
var dialogState by mutableStateOf<DialogState?>(null) var dialogState by mutableStateOf<DialogState?>(null)
@@ -53,5 +54,6 @@ class EditProjectViewModel : ViewModel() {
password = project.password password = project.password
newPassword = project.password newPassword = project.password
email = project.email?.let { if (it == "null") "" else it } ?: "" email = project.email?.let { if (it == "null") "" else it } ?: ""
isLocal = project.isLocal
} }
} }
@@ -6,6 +6,7 @@ import androidx.compose.material.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource 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.KeyboardCapitalization
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
@@ -29,15 +30,17 @@ fun MemberAddDialogContent(
.padding(16.dp) .padding(16.dp)
) { ) {
Text( Text(
text = stringResource(R.string.add_member_dialog_title), text = stringResource(R.string.add_member_dialog_title).uppercase(),
style = MaterialTheme.typography.h6, style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.onSurface,
modifier = Modifier.padding(bottom = 16.dp) modifier = Modifier.padding(bottom = 16.dp)
) )
OutlinedTextField( OutlinedTextField(
value = name, value = name,
onValueChange = { name = it }, onValueChange = { name = it },
label = { Text(stringResource(R.string.member_edit_name)) }, placeholder = { Text(stringResource(R.string.label_name)) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences), keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences),
singleLine = true singleLine = true
@@ -13,6 +13,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource 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.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -57,8 +58,10 @@ fun MemberEditDialogContent(
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
) { ) {
Text( Text(
text = stringResource(R.string.edit_member_dialog_title), text = stringResource(R.string.edit_member_dialog_title).uppercase(),
style = MaterialTheme.typography.h6, style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.onSurface,
modifier = Modifier.padding(bottom = 16.dp) modifier = Modifier.padding(bottom = 16.dp)
) )
@@ -66,7 +69,7 @@ fun MemberEditDialogContent(
OutlinedTextField( OutlinedTextField(
value = name, value = name,
onValueChange = { name = it }, onValueChange = { name = it },
label = { Text(stringResource(R.string.member_edit_name)) }, placeholder = { Text(stringResource(R.string.label_name)) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
leadingIcon = { leadingIcon = {
Icon(Icons.Default.Person, contentDescription = null) Icon(Icons.Default.Person, contentDescription = null)
@@ -80,7 +83,7 @@ fun MemberEditDialogContent(
OutlinedTextField( OutlinedTextField(
value = weight, value = weight,
onValueChange = { weight = it }, onValueChange = { weight = it },
label = { Text(stringResource(R.string.member_edit_weight)) }, placeholder = { Text(stringResource(R.string.label_weight)) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
leadingIcon = { leadingIcon = {
Icon(Icons.Default.LineWeight, contentDescription = null) Icon(Icons.Default.LineWeight, contentDescription = null)
@@ -98,9 +101,17 @@ fun MemberEditDialogContent(
.clickable { isActivated = !isActivated } .clickable { isActivated = !isActivated }
.padding(vertical = 8.dp) .padding(vertical = 8.dp)
) { ) {
Icon(Icons.Default.Block, contentDescription = null) Icon(
Spacer(modifier = Modifier.width(16.dp)) imageVector = Icons.Default.Block,
Text(stringResource(R.string.member_edit_toggle), modifier = Modifier.weight(1f)) 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 }) Checkbox(checked = isActivated, onCheckedChange = { isActivated = it })
} }
@@ -113,9 +124,17 @@ fun MemberEditDialogContent(
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 8.dp) .padding(vertical = 8.dp)
) { ) {
Icon(Icons.Default.Palette, contentDescription = null) Icon(
Spacer(modifier = Modifier.width(16.dp)) imageVector = Icons.Default.Palette,
Text(stringResource(R.string.member_edit_color), modifier = Modifier.weight(1f)) 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( Box(
modifier = Modifier modifier = Modifier
.size(40.dp) .size(40.dp)
@@ -0,0 +1,122 @@
package net.helcel.cowspent.android.project.member
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import net.helcel.cowspent.R
import net.helcel.cowspent.model.DBBill
import net.helcel.cowspent.model.DBMember
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.theme.ThemeUtils
class MemberManagementActivity : AppCompatActivity() {
private val viewModel: MemberManagementViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
val projectId = intent.getLongExtra(EXTRA_PROJECT_ID, 0)
if (projectId == 0L) {
finish()
return
}
val db = CowspentSQLiteOpenHelper.getInstance(this)
viewModel.loadMembers(projectId)
setContent {
ThemeUtils.CowspentTheme {
var showAddMemberDialog by remember { mutableStateOf(false) }
var editingMember by remember { mutableStateOf<DBMember?>(null) }
MemberManagementScreen(
members = viewModel.members,
onAddMember = { showAddMemberDialog = true },
onEditMember = { editingMember = it },
onToggleMember = { member, isActivated ->
db.updateMemberAndSync(member, member.name, member.weight, isActivated, member.r, member.g, member.b, "", "")
viewModel.loadMembers(projectId)
},
onBack = { finish() }
)
if (showAddMemberDialog) {
Dialog(
onDismissRequest = { showAddMemberDialog = false },
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
MemberAddDialogContent(
onAdd = { memberName ->
val memberNames = db.getMembersOfProject(projectId, null).map { it.name }
if (memberNames.contains(memberName)) {
Toast.makeText(this, R.string.member_already_exists, Toast.LENGTH_SHORT).show()
} else {
val color = net.helcel.cowspent.android.helper.TextDrawable.getColorFromName(memberName)
db.addMemberAndSync(
DBMember(
0, 0, projectId, memberName, true, 1.0,
DBBill.STATE_ADDED,
android.graphics.Color.red(color),
android.graphics.Color.green(color),
android.graphics.Color.blue(color),
null, null
)
)
viewModel.loadMembers(projectId)
showAddMemberDialog = false
}
},
onDismiss = { showAddMemberDialog = false }
)
}
}
if (editingMember != null) {
val member = editingMember!!
Dialog(
onDismissRequest = { editingMember = null },
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
MemberEditDialogContent(
member = member,
onSave = { name, weight, isActivated, r, g, b ->
db.updateMemberAndSync(member, name, weight, isActivated, r, g, b, "", "")
viewModel.loadMembers(projectId)
editingMember = null
},
onDelete = {
db.deleteMember(member.id)
viewModel.loadMembers(projectId)
editingMember = null
},
onDismiss = { editingMember = null }
)
}
}
}
}
}
companion object {
const val EXTRA_PROJECT_ID = "project_id"
fun createIntent(context: Context, projectId: Long): Intent {
return Intent(context, MemberManagementActivity::class.java).apply {
putExtra(EXTRA_PROJECT_ID, projectId)
}
}
}
}
@@ -1,127 +0,0 @@
package net.helcel.cowspent.android.project.member
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.contentColorFor
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import net.helcel.cowspent.R
import net.helcel.cowspent.android.helper.MemberAvatar
import net.helcel.cowspent.android.helper.lazyVerticalScrollbar
import net.helcel.cowspent.model.DBMember
@Composable
fun MemberManagementDialogContent(
members: List<DBMember>,
onAddMember: () -> Unit,
onEditMember: (DBMember) -> Unit,
onDismiss: () -> Unit
) {
val listState = rememberLazyListState()
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colors.surface,
contentColor = contentColorFor(MaterialTheme.colors.surface)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 500.dp)
.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(R.string.fab_manage_members),
style = MaterialTheme.typography.h6
)
IconButton(onClick = onAddMember) {
Icon(Icons.Default.Add,modifier=Modifier.size(32.dp),
contentDescription = stringResource(R.string.fab_add_member))
}
}
Spacer(modifier = Modifier.height(8.dp))
LazyColumn(
state = listState,
modifier = Modifier
.weight(1f, fill = false)
.lazyVerticalScrollbar(listState)
) {
items(members) { member ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onEditMember(member) }
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
MemberAvatar(
member = member,
size = 40.dp
)
Spacer(modifier = Modifier.width(16.dp))
Text(
text = member.name,
style = MaterialTheme.typography.body1
)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.simple_close).uppercase())
}
}
}
}
}
@Preview(showBackground = true)
@Composable
fun MemberManagementDialogContentPreview() {
MaterialTheme {
MemberManagementDialogContent(
members = listOf(
DBMember(1, 0, 1, "Alice", true, 1.0, 0, 255, 100, 100, null, null),
DBMember(2, 0, 1, "Bob", true, 1.0, 0, 100, 255, 100, null, null),
DBMember(3, 0, 1, "Charlie", false, 1.0, 0, 100, 100, 255, null, null)
),
onAddMember = {},
onEditMember = {},
onDismiss = {}
)
}
}
@@ -0,0 +1,162 @@
package net.helcel.cowspent.android.project.member
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import net.helcel.cowspent.R
import net.helcel.cowspent.android.helper.MemberAvatar
import net.helcel.cowspent.android.helper.lazyVerticalScrollbar
import net.helcel.cowspent.model.DBMember
@Composable
fun MemberManagementScreen(
members: List<DBMember>,
onAddMember: () -> Unit,
onEditMember: (DBMember) -> Unit,
onToggleMember: (DBMember, Boolean) -> Unit,
onBack: () -> Unit
) {
val listState = rememberLazyListState()
val activatedMembers = members.filter { it.isActivated }
val deactivatedMembers = members.filter { !it.isActivated }
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.action_members)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
},
backgroundColor = MaterialTheme.colors.primary,
elevation = 0.dp
)
},
floatingActionButton = {
FloatingActionButton(onClick = onAddMember) {
Icon(Icons.Default.Add, contentDescription = stringResource(R.string.action_save))
}
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.lazyVerticalScrollbar(listState)
) {
item {
Text(
text = "ACTIVATED MEMBERS",
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.onSurface,
modifier = Modifier.padding(16.dp)
)
}
items(activatedMembers) { member ->
MemberRow(
member = member,
onEditMember = onEditMember,
onToggleMember = { onToggleMember(member, it) }
)
}
if (deactivatedMembers.isNotEmpty()) {
item {
Text(
text = "DEACTIVATED MEMBERS",
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
modifier = Modifier.padding(start = 16.dp, top = 24.dp, end = 16.dp, bottom = 16.dp)
)
}
items(deactivatedMembers) { member ->
MemberRow(
member = member,
onEditMember = onEditMember,
onToggleMember = { onToggleMember(member, it) },
alpha = 0.6f
)
}
}
}
}
}
}
@Composable
fun MemberRow(
member: DBMember,
onEditMember: (DBMember) -> Unit,
onToggleMember: (Boolean) -> Unit,
alpha: Float = 1f
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onEditMember(member) }
.padding(horizontal = 16.dp, vertical = 0.dp),
verticalAlignment = Alignment.CenterVertically
) {
MemberAvatar(
member = member,
size = 24.dp,
alpha = alpha
)
Spacer(modifier = Modifier.width(32.dp))
Text(
text = member.name,
style = MaterialTheme.typography.subtitle1,
modifier = Modifier.weight(1f),
color = MaterialTheme.colors.onSurface.copy(alpha = alpha)
)
IconButton(onClick = { onToggleMember(!member.isActivated) }) {
Icon(
imageVector = if (member.isActivated) Icons.Default.Delete else Icons.Default.Add,
contentDescription = if (member.isActivated) "Deactivate" else "Activate",
tint = if (member.isActivated) MaterialTheme.colors.error.copy(alpha = 0.6f) else MaterialTheme.colors.primary.copy(alpha = 0.6f)
)
}
}
}
@Preview(showBackground = true)
@Composable
fun MemberManagementScreenPreview() {
MaterialTheme {
MemberManagementScreen(
members = listOf(
DBMember(1, 0, 1, "Alice", true, 1.0, 0, 255, 100, 100, null, null),
DBMember(2, 0, 1, "Bob", true, 1.0, 0, 100, 255, 100, null, null),
DBMember(3, 0, 1, "Charlie", false, 1.0, 0, 100, 100, 255, null, null)
),
onAddMember = {},
onEditMember = {},
onToggleMember = { _, _ -> },
onBack = {}
)
}
}
@@ -0,0 +1,40 @@
package net.helcel.cowspent.android.project.member
import android.app.Application
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.helcel.cowspent.android.helper.DialogState
import net.helcel.cowspent.model.DBMember
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
class MemberManagementViewModel(application: Application) : AndroidViewModel(application) {
private val db = CowspentSQLiteOpenHelper.getInstance(application)
var projectId by mutableLongStateOf(0L)
var members by mutableStateOf<List<DBMember>>(emptyList())
var dialogState by mutableStateOf<DialogState?>(null)
fun loadMembers(projId: Long) {
projectId = projId
viewModelScope.launch {
members = withContext(Dispatchers.IO) { db.getMembersOfProject(projId, null) }
}
}
fun deleteMember(id: Long) {
viewModelScope.launch {
withContext(Dispatchers.IO) {
db.deleteMember(id)
}
loadMembers(projectId)
}
}
}
@@ -129,8 +129,10 @@ fun ProjectSettlementUI(
.padding(16.dp) .padding(16.dp)
) { ) {
Text( Text(
text = stringResource(R.string.settle_dialog_title), text = stringResource(R.string.title_settle).uppercase(),
style = MaterialTheme.typography.h6, style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.onSurface,
modifier = Modifier.padding(bottom = 16.dp) modifier = Modifier.padding(bottom = 16.dp)
) )
@@ -174,7 +176,7 @@ private fun BalancedStateMessage() {
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(
text = stringResource(R.string.settle_dialog_balanced), text = stringResource(R.string.dialog_balanced_msg),
style = MaterialTheme.typography.body1 style = MaterialTheme.typography.body1
) )
} }
@@ -279,9 +281,9 @@ private fun TableHeaderText(
Text( Text(
text = text.uppercase(), text = text.uppercase(),
modifier = modifier, modifier = modifier,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.overline, style = MaterialTheme.typography.overline,
color = colorResource(R.color.fg_default_low), color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
textAlign = textAlign, textAlign = textAlign,
letterSpacing = 0.8.sp letterSpacing = 0.8.sp
) )
@@ -135,10 +135,10 @@ fun SettingsScreen(
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
) { ) {
// Appearance // Appearance
SettingsCategory(stringResource(R.string.settings_appearance_category)) SettingsCategory(stringResource(R.string.settings_appearance))
SettingsSwitchPreference( SettingsSwitchPreference(
title = stringResource(R.string.setting_show_archived), title = stringResource(R.string.settings_show_archived),
icon = Icons.Default.Archive, icon = Icons.Default.Archive,
checked = showArchived, checked = showArchived,
onCheckedChange = { onCheckedChange = {
@@ -160,7 +160,7 @@ fun SettingsScreen(
) )
SettingsListPreference( SettingsListPreference(
title = stringResource(R.string.settings_night_mode_title), title = stringResource(R.string.settings_night_mode),
icon = Icons.Default.Brightness2, icon = Icons.Default.Brightness2,
value = nightMode, value = nightMode,
entries = mapOf( entries = mapOf(
@@ -178,7 +178,7 @@ fun SettingsScreen(
) )
SettingsListPreference( SettingsListPreference(
title = stringResource(R.string.settings_color_mode_title), title = stringResource(R.string.settings_color_mode),
icon = Icons.Default.Palette, icon = Icons.Default.Palette,
value = colorMode, value = colorMode,
entries = mapOf( entries = mapOf(
@@ -203,8 +203,8 @@ fun SettingsScreen(
if (colorMode == "manual") { if (colorMode == "manual") {
SettingsColorPreference( SettingsColorPreference(
title = stringResource(R.string.settings_color_title), title = stringResource(R.string.settings_color_custom),
summary = stringResource(R.string.settings_color_summary), summary = stringResource(R.string.settings_color_custom),
icon = Icons.Default.Palette, icon = Icons.Default.Palette,
initialColor = appColor, initialColor = appColor,
onColorSelected = { onColorSelected = {
@@ -218,10 +218,10 @@ fun SettingsScreen(
} }
// Network // Network
SettingsCategory(stringResource(R.string.settings_network_category)) SettingsCategory(stringResource(R.string.settings_network))
SettingsSwitchPreference( SettingsSwitchPreference(
title = stringResource(R.string.settings_offline_mode_title), title = stringResource(R.string.settings_offline_mode),
summary = stringResource(R.string.settings_offline_mode_summary), summary = stringResource(R.string.settings_offline_mode_summary),
icon = Icons.Default.Sync, icon = Icons.Default.Sync,
checked = offlineMode, checked = offlineMode,
@@ -234,16 +234,16 @@ fun SettingsScreen(
) )
SettingsPreference( SettingsPreference(
title = stringResource(R.string.settings_server_settings), title = stringResource(R.string.title_account),
icon = Icons.Default.AccountCircle, icon = Icons.Default.AccountCircle,
onClick = onAccountSettingsClick onClick = onAccountSettingsClick
) )
// Other // Other
SettingsCategory(stringResource(R.string.settings_other_category)) SettingsCategory(stringResource(R.string.settings_other))
SettingsPreference( SettingsPreference(
title = stringResource(R.string.settings_about), title = stringResource(R.string.title_about),
icon = Icons.Default.Info, icon = Icons.Default.Info,
onClick = onAboutClick onClick = onAboutClick
) )
@@ -255,9 +255,9 @@ fun SettingsScreen(
fun SettingsCategory(title: String) { fun SettingsCategory(title: String) {
Text( Text(
text = title.uppercase(), text = title.uppercase(),
modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 8.dp), modifier = Modifier.padding(start = 16.dp, top = 24.dp, bottom = 8.dp),
color = MaterialTheme.colors.primary, color = MaterialTheme.colors.onSurface,
style = MaterialTheme.typography.overline, style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
} }
@@ -71,6 +71,7 @@ import net.helcel.cowspent.android.helper.formatShortValue
import net.helcel.cowspent.model.DBBill import net.helcel.cowspent.model.DBBill
import net.helcel.cowspent.model.DBCategory import net.helcel.cowspent.model.DBCategory
import net.helcel.cowspent.model.DBMember import net.helcel.cowspent.model.DBMember
import net.helcel.cowspent.util.CategoryUtils
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.roundToInt import kotlin.math.roundToInt
@@ -87,38 +88,43 @@ private object SankeyDimens {
@Composable @Composable
fun ProjectSankeyDiagram( fun ProjectSankeyDiagram(
projectName: String, projectName: String,
projectId: Long,
projectRemoteId: String?,
allMembers: List<DBMember>, allMembers: List<DBMember>,
allBills: List<DBBill>, allBills: List<DBBill>,
customCategories: List<DBCategory>, customCategories: List<DBCategory>,
onShareReady: (String) -> Unit 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 selectedMemberId by remember { mutableLongStateOf(-1L) }
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
val activeBills = remember(allBills) { val reimbursementCategoryId = remember(customCategories, projectId, projectRemoteId) {
allBills.filter { it.state != DBBill.STATE_DELETED && it.categoryRemoteId != DBBill.CATEGORY_REIMBURSEMENT } 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 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()) { if (activeBills.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { 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 return
} }
val spendings = remember(activeBills, selectedMemberId, membersMap) { val spendings = remember(activeBills, selectedMemberId, membersMap) {
val spentMap = mutableMapOf<Long, Double>().apply { membersMap.keys.forEach { put(it, 0.0) } } val spentMap = mutableMapOf<Long, Double>().apply { membersMap.keys.forEach { put(it, 0.0) } }
val catMap = mutableMapOf<Int, Double>() val catMap = mutableMapOf<Long, Double>()
activeBills.forEach { bill -> activeBills.forEach { bill ->
val totalWeight = bill.billOwers.sumOf { membersMap[it.memberId]?.weight ?: 1.0 } val totalWeight = bill.billOwers.sumOf { membersMap[it.memberId]?.weight ?: 1.0 }
if (totalWeight > 0) { if (totalWeight > 0) {
if (selectedMemberId == -1L) { 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 -> bill.billOwers.forEach { bo ->
val weight = membersMap[bo.memberId]?.weight ?: 1.0 val weight = membersMap[bo.memberId]?.weight ?: 1.0
spentMap[bo.memberId] = (spentMap[bo.memberId] ?: 0.0) + (bill.amount / totalWeight) * weight spentMap[bo.memberId] = (spentMap[bo.memberId] ?: 0.0) + (bill.amount / totalWeight) * weight
@@ -126,7 +132,7 @@ fun ProjectSankeyDiagram(
} else { } else {
bill.billOwers.find { it.memberId == selectedMemberId }?.let { bo -> bill.billOwers.find { it.memberId == selectedMemberId }?.let { bo ->
val weight = membersMap[bo.memberId]?.weight ?: 1.0 val weight = membersMap[bo.memberId]?.weight ?: 1.0
catMap[bill.categoryRemoteId] = (catMap[bill.categoryRemoteId] ?: 0.0) + (bill.amount / totalWeight) * weight catMap[bill.categoryId] = (catMap[bill.categoryId] ?: 0.0) + (bill.amount / totalWeight) * weight
spentMap[selectedMemberId] = (spentMap[selectedMemberId] ?: 0.0) + (bill.amount / totalWeight) * weight spentMap[selectedMemberId] = (spentMap[selectedMemberId] ?: 0.0) + (bill.amount / totalWeight) * weight
} }
} }
@@ -215,9 +221,9 @@ private fun SankeyContent(
selectedMemberId: Long, selectedMemberId: Long,
totalAmount: Double, totalAmount: Double,
displayMemberSpendings: List<Pair<Long, Double>>, displayMemberSpendings: List<Pair<Long, Double>>,
displayCategorySpendings: List<Pair<Int, Double>>, displayCategorySpendings: List<Pair<Long, Double>>,
membersMap: Map<Long, DBMember>, membersMap: Map<Long, DBMember>,
categoriesMap: Map<Int, DBCategory> categoriesMap: Map<Long, DBCategory>
) { ) {
val density = LocalDensity.current val density = LocalDensity.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -399,4 +405,4 @@ private fun DrawScope.drawSankeyFlow(startX: Float, startY: Float, startWidth: F
@Preview(showBackground = true, widthDp = 360, heightDp = 640) @Preview(showBackground = true, widthDp = 360, heightDp = 640)
@Composable @Composable
fun ProjectSankeyDiagramPreview() = MaterialTheme { ProjectSankeyDiagram("Test Project", StatisticsMockData.members, StatisticsMockData.bills, emptyList()) {} } fun ProjectSankeyDiagramPreview() = MaterialTheme { ProjectSankeyDiagram("Test Project", 1L, "1", StatisticsMockData.members, StatisticsMockData.bills, emptyList()) {} }
@@ -43,10 +43,10 @@ fun ProjectSpendingGraph(
allBills: List<DBBill>, allBills: List<DBBill>,
onShareReady: (String) -> Unit onShareReady: (String) -> Unit
) { ) {
val shareStatsIntro = stringResource(R.string.share_stats_intro, projectName) val shareStatsIntro = stringResource(R.string.msg_stats_intro, projectName)
if (allBills.isEmpty()) { if (allBills.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { 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 return
} }
@@ -35,7 +35,7 @@ fun ProjectStatisticsScreen(
} }
var currentShareText by remember { mutableStateOf("") } var currentShareText by remember { mutableStateOf("") }
val tabs = listOf( val tabs = listOf(
stringResource(R.string.statistic_title), stringResource(R.string.title_stats),
"Trend", "Trend",
"Sankey" "Sankey"
) )
@@ -43,7 +43,7 @@ fun ProjectStatisticsScreen(
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text(stringResource(R.string.statistic_title)) }, title = { Text(stringResource(R.string.title_stats)) },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
@@ -84,6 +84,21 @@ fun ProjectStatisticsScreen(
val statsData by produceState<StatisticsData?>(null, proj.id) { val statsData by produceState<StatisticsData?>(null, proj.id) {
value = withContext(Dispatchers.IO) { 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 members = db.getMembersOfProject(proj.id, null)
val bills = db.getBillsOfProject(proj.id) val bills = db.getBillsOfProject(proj.id)
val categories = db.getCategories(proj.id) val categories = db.getCategories(proj.id)
@@ -94,19 +109,12 @@ fun ProjectStatisticsScreen(
if (statsData != null) { if (statsData != null) {
val data = statsData!! val data = statsData!!
val defaultCategories = remember(proj.id) { CategoryUtils.getDefaultCategories(context, proj.id) } val categories = data.categories
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 categoryNoneLabel = stringResource(R.string.category_none) 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") 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) { when (selectedTab) {
@@ -131,6 +139,8 @@ fun ProjectStatisticsScreen(
2 -> { 2 -> {
ProjectSankeyDiagram( ProjectSankeyDiagram(
projectName = proj.name.ifEmpty { proj.remoteId }, projectName = proj.name.ifEmpty { proj.remoteId },
projectId = proj.id,
projectRemoteId = proj.remoteId,
allMembers = data.members, allMembers = data.members,
allBills = data.bills, allBills = data.bills,
customCategories = sankeyCategories, customCategories = sankeyCategories,
@@ -44,8 +44,8 @@ fun ProjectStatisticsTable(
val sdf = remember { SimpleDateFormat("yyyy-MM-dd", Locale.ROOT) } val sdf = remember { SimpleDateFormat("yyyy-MM-dd", Locale.ROOT) }
val dateFormat = remember { android.text.format.DateFormat.getDateFormat(context) } val dateFormat = remember { android.text.format.DateFormat.getDateFormat(context) }
var categoryId by remember { mutableIntStateOf(-1000) } var categoryId by remember { mutableLongStateOf(-1000L) }
var paymentModeId by remember { mutableIntStateOf(-1000) } var paymentModeId by remember { mutableLongStateOf(-1000L) }
var dateMin by remember { mutableStateOf<String?>(null) } var dateMin by remember { mutableStateOf<String?>(null) }
var dateMax by remember { mutableStateOf<String?>(null) } var dateMax by remember { mutableStateOf<String?>(null) }
@@ -57,14 +57,14 @@ fun ProjectStatisticsTable(
val paymentModeAll = stringResource(R.string.payment_mode_all) val paymentModeAll = stringResource(R.string.payment_mode_all)
val paymentModeNone = stringResource(R.string.payment_mode_none) val paymentModeNone = stringResource(R.string.payment_mode_none)
val shareStatsHeader = stringResource(R.string.share_stats_header) val shareStatsHeader = stringResource(R.string.msg_stats_header)
val shareStatsIntro = stringResource(R.string.share_stats_intro, proj.name.ifEmpty { proj.remoteId }) val shareStatsIntro = stringResource(R.string.msg_stats_intro, proj.name.ifEmpty { proj.remoteId })
val categories = remember(proj.id, customCategories, categoryAll, categoryNone, categoryReimbursement, categoryAllExceptReimbursement) { val categories = remember(proj.id, customCategories, categoryAll, categoryNone, categoryReimbursement, categoryAllExceptReimbursement) {
val list = mutableListOf<Triple<Int, String, String>>() val list = mutableListOf<Triple<Long, String, String>>()
list.add(Triple(-1000, "📋", categoryAll)) list.add(Triple(-1000L, "📋", categoryAll))
list.add(Triple(-100, "🧾", categoryAllExceptReimbursement)) list.add(Triple(-100L, "🧾", categoryAllExceptReimbursement))
list.add(Triple(0, "", categoryNone)) list.add(Triple(0L, "", categoryNone))
val catsToUse = if (proj.type == ProjectType.LOCAL) { val catsToUse = if (proj.type == ProjectType.LOCAL) {
CategoryUtils.getDefaultCategories(context, proj.id) CategoryUtils.getDefaultCategories(context, proj.id)
@@ -75,15 +75,15 @@ fun ProjectStatisticsTable(
} }
catsToUse.forEach { 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 } list.distinctBy { it.first }
} }
val paymentModes = remember(proj.id, customPaymentModes, paymentModeAll, paymentModeNone) { val paymentModes = remember(proj.id, customPaymentModes, paymentModeAll, paymentModeNone) {
val list = mutableListOf<Triple<Int, String, String>>() val list = mutableListOf<Triple<Long, String, String>>()
list.add(Triple(-1000, "💳", paymentModeAll)) list.add(Triple(-1000L, "💳", paymentModeAll))
list.add(Triple(0, "", paymentModeNone)) list.add(Triple(0L, "", paymentModeNone))
val pmsToUse = if (proj.type == ProjectType.LOCAL) { val pmsToUse = if (proj.type == ProjectType.LOCAL) {
CategoryUtils.getDefaultPaymentModes(context, proj.id) CategoryUtils.getDefaultPaymentModes(context, proj.id)
@@ -94,7 +94,7 @@ fun ProjectStatisticsTable(
} }
pmsToUse.forEach { 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 } list.distinctBy { it.first }
} }
@@ -105,10 +105,12 @@ fun ProjectStatisticsTable(
val membersPaid = HashMap<Long, Double>() val membersPaid = HashMap<Long, Double>()
val membersSpent = HashMap<Long, Double>() val membersSpent = HashMap<Long, Double>()
val reimbursementCategoryId = CategoryUtils.getReimbursementCategoryId(customCategories, proj.id, proj.remoteId)
SupportUtil.getStats( SupportUtil.getStats(
allMembers, allBills, allMembers, allBills,
membersNbBills, membersBalance, membersPaid, membersSpent, membersNbBills, membersBalance, membersPaid, membersSpent,
categoryId, paymentModeId, dateMin, dateMax categoryId, paymentModeId, reimbursementCategoryId, dateMin, dateMax
) )
var statsText = shareStatsIntro + "\n\n" var statsText = shareStatsIntro + "\n\n"
@@ -164,12 +166,12 @@ fun ProjectStatisticsTable(
EditableExposedDropdownMenu( EditableExposedDropdownMenu(
value = selectedCategory?.third ?: "", value = selectedCategory?.third ?: "",
placeholder = stringResource(R.string.setting_category), placeholder = stringResource(R.string.label_category),
expanded = categoryExpanded, expanded = categoryExpanded,
onExpandedChange = { categoryExpanded = it }, onExpandedChange = { categoryExpanded = it },
onDismissRequest = { categoryExpanded = false }, onDismissRequest = { categoryExpanded = false },
leadingIcon = { leadingIcon = {
Box(modifier = Modifier.padding(start = 12.dp)) { Box(modifier = Modifier) {
if (selectedCategory != null) { if (selectedCategory != null) {
Text(text = selectedCategory.second, fontSize = 20.sp) Text(text = selectedCategory.second, fontSize = 20.sp)
} else { } else {
@@ -198,12 +200,12 @@ fun ProjectStatisticsTable(
EditableExposedDropdownMenu( EditableExposedDropdownMenu(
value = selectedPm?.third ?: "", value = selectedPm?.third ?: "",
placeholder = stringResource(R.string.setting_payment_mode), placeholder = stringResource(R.string.label_mode),
expanded = pmExpanded, expanded = pmExpanded,
onExpandedChange = { pmExpanded = it }, onExpandedChange = { pmExpanded = it },
onDismissRequest = { pmExpanded = false }, onDismissRequest = { pmExpanded = false },
leadingIcon = { leadingIcon = {
Box(modifier = Modifier.padding(start = 12.dp)) { Box(modifier = Modifier) {
if (selectedPm != null) { if (selectedPm != null) {
Text(text = selectedPm.second, fontSize = 20.sp) Text(text = selectedPm.second, fontSize = 20.sp)
} else { } else {
@@ -269,17 +271,15 @@ fun ProjectStatisticsTable(
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
// Table Header
Surface( Surface(
color = MaterialTheme.colors.onSurface.copy(alpha = 0.05f), color = MaterialTheme.colors.background,
shape = MaterialTheme.shapes.small,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp)) { Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 12.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_who).uppercase(), modifier = Modifier.weight(2f), fontWeight = FontWeight.Bold, color = MaterialTheme.colors.onSurface, style = MaterialTheme.typography.overline)
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_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), 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).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), 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).uppercase(), modifier = Modifier.weight(1.5f), fontWeight = FontWeight.Bold, textAlign = TextAlign.End, color = MaterialTheme.colors.onSurface, style = MaterialTheme.typography.overline)
} }
} }
@@ -16,9 +16,9 @@ open class DBBill(
var state: Int, var state: Int,
var repeat: String?, var repeat: String?,
var paymentMode: String?, var paymentMode: String?,
var categoryRemoteId: Int, var categoryId: Long,
var comment: String?, var comment: String?,
var paymentModeRemoteId: Int var paymentModeId: Long
) : Item, Serializable { ) : Item, Serializable {
var formattedWhat: String = "" var formattedWhat: String = ""
@@ -39,7 +39,6 @@ open class DBBill(
get() { get() {
val cal = Calendar.getInstance() val cal = Calendar.getInstance()
cal.timeInMillis = timestamp * 1000 cal.timeInMillis = timestamp * 1000
Log.v("ll", "[$what] get date ts $timestamp year ${cal[Calendar.YEAR]}")
val month = cal[Calendar.MONTH] + 1 val month = cal[Calendar.MONTH] + 1
val day = cal[Calendar.DAY_OF_MONTH] val day = cal[Calendar.DAY_OF_MONTH]
return "${cal[Calendar.YEAR]}-${String.format(Locale.ROOT, "%02d", month)}-${ return "${cal[Calendar.YEAR]}-${String.format(Locale.ROOT, "%02d", month)}-${
@@ -58,7 +57,7 @@ open class DBBill(
override fun toString(): String { 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 { override fun isSection(): Boolean {
@@ -73,15 +72,15 @@ open class DBBill(
const val PAYMODE_TRANSFER = "t" const val PAYMODE_TRANSFER = "t"
const val PAYMODE_ONLINE_SERVICE = "o" const val PAYMODE_ONLINE_SERVICE = "o"
const val PAYMODE_ID_NONE = 0 const val PAYMODE_ID_NONE = 0L
const val PAYMODE_ID_CARD = -1 const val PAYMODE_ID_CARD = -1L
const val PAYMODE_ID_CASH = -2 const val PAYMODE_ID_CASH = -2L
const val PAYMODE_ID_CHECK = -3 const val PAYMODE_ID_CHECK = -3L
const val PAYMODE_ID_TRANSFER = -4 const val PAYMODE_ID_TRANSFER = -4L
const val PAYMODE_ID_ONLINE_SERVICE = -5 const val PAYMODE_ID_ONLINE_SERVICE = -5L
@JvmField @JvmField
val oldPmIdToNew: Map<String, Int> = object : HashMap<String, Int>() { val oldPmIdToNew: Map<String, Long> = object : HashMap<String, Long>() {
init { init {
put(PAYMODE_NONE, PAYMODE_ID_NONE) put(PAYMODE_NONE, PAYMODE_ID_NONE)
put(PAYMODE_CARD, PAYMODE_ID_CARD) put(PAYMODE_CARD, PAYMODE_ID_CARD)
@@ -92,19 +91,19 @@ open class DBBill(
} }
} }
const val CATEGORY_NONE = 0 const val CATEGORY_NONE = 0L
const val CATEGORY_GROCERIES = -1 const val CATEGORY_GROCERIES = -1L
const val CATEGORY_LEISURE = -2 const val CATEGORY_LEISURE = -2L
const val CATEGORY_RENT = -3 const val CATEGORY_RENT = -3L
const val CATEGORY_BILLS = -4 const val CATEGORY_BILLS = -4L
const val CATEGORY_CULTURE = -5 const val CATEGORY_CULTURE = -5L
const val CATEGORY_HEALTH = -6 const val CATEGORY_HEALTH = -6L
const val CATEGORY_SHOPPING = -10 const val CATEGORY_SHOPPING = -10L
const val CATEGORY_REIMBURSEMENT = -11 const val CATEGORY_REIMBURSEMENT = -11L
const val CATEGORY_RESTAURANT = -12 const val CATEGORY_RESTAURANT = -12L
const val CATEGORY_ACCOMMODATION = -13 const val CATEGORY_ACCOMMODATION = -13L
const val CATEGORY_TRANSPORT = -14 const val CATEGORY_TRANSPORT = -14L
const val CATEGORY_SPORT = -15 const val CATEGORY_SPORT = -15L
const val STATE_OK = 0 const val STATE_OK = 0
const val STATE_ADDED = 1 const val STATE_ADDED = 1
@@ -8,7 +8,8 @@ class DBCategory(
var projectId: Long, var projectId: Long,
var name: String?, var name: String?,
var icon: String, var icon: String,
var color: String var color: String,
var state: Int = DBBill.STATE_OK
) : Serializable { ) : Serializable {
override fun toString(): String { override fun toString(): String {
@@ -8,7 +8,8 @@ class DBPaymentMode(
var projectId: Long, var projectId: Long,
var name: String?, var name: String?,
var icon: String, var icon: String,
var color: String var color: String,
var state: Int = DBBill.STATE_OK
) : Serializable { ) : Serializable {
override fun toString(): String { override fun toString(): String {
@@ -15,9 +15,9 @@ class GroupedBill(
sourceBills.first().state, sourceBills.first().state,
sourceBills.first().repeat, sourceBills.first().repeat,
sourceBills.first().paymentMode, sourceBills.first().paymentMode,
sourceBills.first().categoryRemoteId, sourceBills.first().categoryId,
sourceBills.first().comment, sourceBills.first().comment,
sourceBills.first().paymentModeRemoteId sourceBills.first().paymentModeId
), Serializable { ), Serializable {
init { init {
this.formattedWhat = sourceBills.first().formattedWhat this.formattedWhat = sourceBills.first().formattedWhat
File diff suppressed because it is too large Load Diff
@@ -29,9 +29,11 @@ import net.helcel.cowspent.android.account.AccountActivity
import net.helcel.cowspent.android.main.BillsListViewActivity import net.helcel.cowspent.android.main.BillsListViewActivity
import net.helcel.cowspent.android.main.MainConstants import net.helcel.cowspent.android.main.MainConstants
import net.helcel.cowspent.model.DBBill import net.helcel.cowspent.model.DBBill
import net.helcel.cowspent.model.DBCategory
import net.helcel.cowspent.model.DBProject import net.helcel.cowspent.model.DBProject
import net.helcel.cowspent.model.ProjectType import net.helcel.cowspent.model.ProjectType
import net.helcel.cowspent.util.CospendClientUtil.LoginStatus import net.helcel.cowspent.util.CospendClientUtil.LoginStatus
import net.helcel.cowspent.util.CategoryUtils
import net.helcel.cowspent.util.ICallback import net.helcel.cowspent.util.ICallback
import net.helcel.cowspent.util.IProjectCreationCallback import net.helcel.cowspent.util.IProjectCreationCallback
import net.helcel.cowspent.util.NextcloudClient import net.helcel.cowspent.util.NextcloudClient
@@ -265,6 +267,121 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
val members = dbHelper.getMembersOfProject(project.id, null) val members = dbHelper.getMembersOfProject(project.id, null)
val memberIdToRemoteId = members.associate { it.id to it.remoteId } 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) val toDelete = dbHelper.getBillsOfProjectWithState(project.id, DBBill.STATE_DELETED)
for (bToDel in toDelete) { for (bToDel in toDelete) {
try { try {
@@ -293,12 +410,16 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
val toEdit = dbHelper.getBillsOfProjectWithState(project.id, DBBill.STATE_EDITED) val toEdit = dbHelper.getBillsOfProjectWithState(project.id, DBBill.STATE_EDITED)
for (bToEdit in toEdit) { for (bToEdit in toEdit) {
try { try {
val editRemoteBillResponse = client!!.editRemoteBill(project, bToEdit, memberIdToRemoteId) val editRemoteBillResponse = client!!.editRemoteBill(project, bToEdit, memberIdToRemoteId, categoryIdToRemoteId, paymentModeIdToRemoteId)
if (editRemoteBillResponse.stringContent == bToEdit.remoteId.toString()) { val returnedRemoteId = editRemoteBillResponse.remoteBillId
if (returnedRemoteId == bToEdit.remoteId || (returnedRemoteId == 0L && !project.getRequestBaseUrl(true).contains("/ocs/v2.php"))) {
dbHelper.setBillState(bToEdit.id, DBBill.STATE_OK) 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 { } else {
Log.d(TAG, "FAILED to edit remote bill (${editRemoteBillResponse.stringContent})") Log.d(TAG, "FAILED to edit remote bill ($returnedRemoteId)")
} }
} catch (_: Exception) { } catch (_: Exception) {
Log.d(TAG, "FAILED to edit remote bill: it probably does not exist remotely") Log.d(TAG, "FAILED to edit remote bill: it probably does not exist remotely")
@@ -307,8 +428,8 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
val toAdd = dbHelper.getBillsOfProjectWithState(project.id, DBBill.STATE_ADDED) val toAdd = dbHelper.getBillsOfProjectWithState(project.id, DBBill.STATE_ADDED)
for (bToAdd in toAdd) { for (bToAdd in toAdd) {
val createRemoteBillResponse = client!!.createRemoteBill(project, bToAdd, memberIdToRemoteId) val createRemoteBillResponse = client!!.createRemoteBill(project, bToAdd, memberIdToRemoteId, categoryIdToRemoteId, paymentModeIdToRemoteId)
val newRemoteId = createRemoteBillResponse.stringContent.toLong() val newRemoteId = createRemoteBillResponse.remoteBillId
if (newRemoteId > 0) { if (newRemoteId > 0) {
dbHelper.updateBill( dbHelper.updateBill(
bToAdd.id, newRemoteId, null, bToAdd.id, newRemoteId, null,
@@ -375,7 +496,7 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
val currencyToAdd = dbHelper.getCurrenciesOfProjectWithState(project.id, DBBill.STATE_ADDED) val currencyToAdd = dbHelper.getCurrenciesOfProjectWithState(project.id, DBBill.STATE_ADDED)
for (cToAdd in currencyToAdd) { for (cToAdd in currencyToAdd) {
val createRemoteCurrencyResponse = client!!.createRemoteCurrency(project, cToAdd) val createRemoteCurrencyResponse = client!!.createRemoteCurrency(project, cToAdd)
val newRemoteId = createRemoteCurrencyResponse.stringContent.toLong() val newRemoteId = createRemoteCurrencyResponse.remoteCurrencyId
if (newRemoteId > 0) { if (newRemoteId > 0) {
dbHelper.setCurrencyState(cToAdd.id, DBBill.STATE_OK) dbHelper.setCurrencyState(cToAdd.id, DBBill.STATE_OK)
} }
@@ -471,7 +592,7 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
val localPaymentModes = dbHelper.getPaymentModes(project.id) val localPaymentModes = dbHelper.getPaymentModes(project.id)
for (localPaymentMode in localPaymentModes) { for (localPaymentMode in localPaymentModes) {
if (!remotePaymentModesByRemoteId.containsKey(localPaymentMode.remoteId)) { if (localPaymentMode.state == DBBill.STATE_OK && !remotePaymentModesByRemoteId.containsKey(localPaymentMode.remoteId)) {
dbHelper.deletePaymentMode(localPaymentMode.id) dbHelper.deletePaymentMode(localPaymentMode.id)
Log.d(TAG, "Delete local pm : $localPaymentMode") Log.d(TAG, "Delete local pm : $localPaymentMode")
} }
@@ -481,6 +602,7 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
val remoteCategoriesByRemoteId = remoteCategories.associateBy { it.remoteId } val remoteCategoriesByRemoteId = remoteCategories.associateBy { it.remoteId }
for (c in remoteCategories) { for (c in remoteCategories) {
if (c.remoteId == DBBill.CATEGORY_REIMBURSEMENT) continue
val localCategory = dbHelper.getCategory(c.remoteId, project.id) val localCategory = dbHelper.getCategory(c.remoteId, project.id)
if (localCategory == null) { if (localCategory == null) {
Log.d(TAG, "Add local category : $c") Log.d(TAG, "Add local category : $c")
@@ -500,7 +622,7 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
val localCategories = dbHelper.getCategories(project.id) val localCategories = dbHelper.getCategories(project.id)
for (localCategory in localCategories) { for (localCategory in localCategories) {
if (!remoteCategoriesByRemoteId.containsKey(localCategory.remoteId)) { if (localCategory.state == DBBill.STATE_OK && !remoteCategoriesByRemoteId.containsKey(localCategory.remoteId)) {
dbHelper.deleteCategory(localCategory.id) dbHelper.deleteCategory(localCategory.id)
Log.d(TAG, "Delete local category : $localCategory") Log.d(TAG, "Delete local category : $localCategory")
} }
@@ -528,7 +650,7 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
val localCurrencies = dbHelper.getCurrencies(project.id) val localCurrencies = dbHelper.getCurrencies(project.id)
for (localCurrency in localCurrencies) { for (localCurrency in localCurrencies) {
if (!remoteCurrenciesByRemoteId.containsKey(localCurrency.remoteId)) { if (localCurrency.state == DBBill.STATE_OK && !remoteCurrenciesByRemoteId.containsKey(localCurrency.remoteId)) {
dbHelper.deleteCurrency(localCurrency.id) dbHelper.deleteCurrency(localCurrency.id)
Log.d(TAG, "Delete local currency : $localCurrencies") Log.d(TAG, "Delete local currency : $localCurrencies")
} }
@@ -595,13 +717,23 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
val dbMembers = dbHelper.getMembersOfProject(project.id, null) val dbMembers = dbHelper.getMembersOfProject(project.id, null)
val memberRemoteIdToId = dbMembers.associate { it.remoteId to it.id } 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 billsResponse = client!!.getBills(project)
val isIHM = project.type == ProjectType.IHATEMONEY val isIHM = project.type == ProjectType.IHATEMONEY
val serverSyncTimestamp = if (isIHM) 0L else billsResponse.syncTimestamp val serverSyncTimestamp = if (isIHM) 0L else billsResponse.syncTimestamp
val remoteBills: List<DBBill> = if (isIHM) { val remoteBills: List<DBBill> = if (isIHM) {
billsResponse.getBillsIHM(project.id, memberRemoteIdToId) billsResponse.getBillsIHM(project.id, memberRemoteIdToId, categoriesRemoteIdToId, paymentModesRemoteIdToId)
} else { } else {
billsResponse.getBillsCospend(project.id, memberRemoteIdToId) billsResponse.getBillsCospend(project.id, memberRemoteIdToId, categoriesRemoteIdToId, paymentModesRemoteIdToId)
} }
val remoteAllBillIds: List<Long> = if (isIHM) { val remoteAllBillIds: List<Long> = if (isIHM) {
remoteBills.map { it.remoteId } remoteBills.map { it.remoteId }
@@ -618,7 +750,6 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
dbHelper.addBill(remoteBill) dbHelper.addBill(remoteBill)
nbPulledNewBills++ nbPulledNewBills++
newBillsDialogText += "+ ${remoteBill.what}\n" newBillsDialogText += "+ ${remoteBill.what}\n"
Log.d(TAG, "Add local bill : $remoteBill")
} else { } else {
val localBill = localBillsByRemoteId[remoteBill.remoteId]!! val localBill = localBillsByRemoteId[remoteBill.remoteId]!!
if (hasChanged(localBill, remoteBill)) { if (hasChanged(localBill, remoteBill)) {
@@ -626,12 +757,11 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
localBill.id, null, remoteBill.payerId, localBill.id, null, remoteBill.payerId,
remoteBill.amount, remoteBill.timestamp, remoteBill.amount, remoteBill.timestamp,
remoteBill.what, DBBill.STATE_OK, remoteBill.repeat, remoteBill.what, DBBill.STATE_OK, remoteBill.repeat,
remoteBill.paymentMode, remoteBill.paymentModeRemoteId, remoteBill.paymentMode, remoteBill.paymentModeId,
remoteBill.categoryRemoteId, remoteBill.comment remoteBill.categoryId, remoteBill.comment
) )
nbPulledUpdatedBills++ nbPulledUpdatedBills++
updatedBillsDialogText += "${remoteBill.what}\n" updatedBillsDialogText += "${remoteBill.what}\n"
Log.d(TAG, "Update local bill : $remoteBill")
} else { } else {
Log.d(TAG, "Nothing to do for bill : $localBill") Log.d(TAG, "Nothing to do for bill : $localBill")
} }
@@ -1084,8 +1214,8 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
localBill.what == remoteBill.what && localBill.what == remoteBill.what &&
localBill.comment == remoteBill.comment && localBill.comment == remoteBill.comment &&
localBill.paymentMode == remoteBill.paymentMode && localBill.paymentMode == remoteBill.paymentMode &&
localBill.paymentModeRemoteId == remoteBill.paymentModeRemoteId && localBill.paymentModeId == remoteBill.paymentModeId &&
localBill.categoryRemoteId == remoteBill.categoryRemoteId localBill.categoryId == remoteBill.categoryId
) { ) {
val localRepeat = localBill.repeat ?: DBBill.NON_REPEATED val localRepeat = localBill.repeat ?: DBBill.NON_REPEATED
val remoteRepeat = remoteBill.repeat ?: DBBill.NON_REPEATED val remoteRepeat = remoteBill.repeat ?: DBBill.NON_REPEATED
@@ -14,11 +14,11 @@ object BillFormatter {
) { ) {
for (bill in bills) { for (bill in bills) {
var whatPrefix = "" var whatPrefix = ""
val pm = paymentModesMap[bill.paymentModeRemoteId.toLong()] val pm = paymentModesMap[bill.paymentModeId]
if (pm != null) { if (pm != null) {
whatPrefix += pm.icon + " " whatPrefix += pm.icon + " "
} else { } else {
when (bill.paymentModeRemoteId) { when (bill.paymentModeId) {
DBBill.PAYMODE_ID_CARD -> whatPrefix += "\uD83D\uDCB3 " DBBill.PAYMODE_ID_CARD -> whatPrefix += "\uD83D\uDCB3 "
DBBill.PAYMODE_ID_CASH -> whatPrefix += "\uD83D\uDCB5 " DBBill.PAYMODE_ID_CASH -> whatPrefix += "\uD83D\uDCB5 "
DBBill.PAYMODE_ID_CHECK -> whatPrefix += "\uD83C\uDFAB " 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) { if (cat != null) {
whatPrefix += cat.icon + " " whatPrefix += cat.icon + " "
} else { } else {
when (bill.categoryRemoteId) { when (bill.categoryId) {
DBBill.CATEGORY_GROCERIES -> whatPrefix += "\uD83D\uDED2 " DBBill.CATEGORY_GROCERIES -> whatPrefix += "\uD83D\uDED2 "
DBBill.CATEGORY_LEISURE -> whatPrefix += "\uD83C\uDF89 " DBBill.CATEGORY_LEISURE -> whatPrefix += "\uD83C\uDF89 "
DBBill.CATEGORY_RENT -> whatPrefix += "\uD83C\uDFE0 " DBBill.CATEGORY_RENT -> whatPrefix += "\uD83C\uDFE0 "
@@ -10,28 +10,57 @@ object CategoryUtils {
fun getDefaultCategories(context: Context, projectId: Long): List<DBCategory> { fun getDefaultCategories(context: Context, projectId: Long): List<DBCategory> {
return listOf( return listOf(
DBCategory(0, DBBill.CATEGORY_GROCERIES.toLong(), projectId, context.getString(R.string.category_groceries), "\uD83D\uDED2", "#ffaa00"), DBCategory(DBBill.CATEGORY_GROCERIES,
DBCategory(0, DBBill.CATEGORY_LEISURE.toLong(), projectId, context.getString(R.string.category_leisure), "\uD83C\uDF89", "#aa55ff"), DBBill.CATEGORY_GROCERIES, projectId, context.getString(R.string.category_groceries), "\uD83D\uDED2", "#ffaa00"),
DBCategory(0, DBBill.CATEGORY_RENT.toLong(), projectId, context.getString(R.string.category_rent), "\uD83C\uDFE0", "#da8733"), DBCategory(DBBill.CATEGORY_LEISURE,
DBCategory(0, DBBill.CATEGORY_BILLS.toLong(), projectId, context.getString(R.string.category_bills), "\uD83C\uDF29", "#4aa6b0"), DBBill.CATEGORY_LEISURE, projectId, context.getString(R.string.category_leisure), "\uD83C\uDF89", "#aa55ff"),
DBCategory(0, DBBill.CATEGORY_CULTURE.toLong(), projectId, context.getString(R.string.category_excursion), "\uD83D\uDEB8", "#0055ff"), DBCategory(DBBill.CATEGORY_RENT,
DBCategory(0, DBBill.CATEGORY_HEALTH.toLong(), projectId, context.getString(R.string.category_health), "\uD83D\uDC9A", "#bf090c"), DBBill.CATEGORY_RENT, projectId, context.getString(R.string.category_rent), "\uD83C\uDFE0", "#da8733"),
DBCategory(0, DBBill.CATEGORY_SHOPPING.toLong(), projectId, context.getString(R.string.category_shopping), "\uD83D\uDECD", "#e167d1"), DBCategory(DBBill.CATEGORY_BILLS,
DBCategory(0, DBBill.CATEGORY_REIMBURSEMENT.toLong(), projectId, context.getString(R.string.category_reimbursement), "\uD83D\uDCB0", "#00ced1"), DBBill.CATEGORY_BILLS, projectId, context.getString(R.string.category_bills), "\uD83C\uDF29", "#4aa6b0"),
DBCategory(0, DBBill.CATEGORY_RESTAURANT.toLong(), projectId, context.getString(R.string.category_restaurant), "\uD83C\uDF74", "#d0d5e1"), DBCategory(DBBill.CATEGORY_CULTURE,
DBCategory(0, DBBill.CATEGORY_ACCOMMODATION.toLong(), projectId, context.getString(R.string.category_accomodation), "\uD83D\uDECC", "#5de1a3"), DBBill.CATEGORY_CULTURE, projectId, context.getString(R.string.category_excursion), "\uD83D\uDEB8", "#0055ff"),
DBCategory(0, DBBill.CATEGORY_TRANSPORT.toLong(), projectId, context.getString(R.string.category_transport), "\uD83D\uDE8C", "#6f2ee1"), DBCategory(DBBill.CATEGORY_HEALTH,
DBCategory(0, DBBill.CATEGORY_SPORT.toLong(), projectId, context.getString(R.string.category_sport), "\uD83C\uDFBE", "#69e177") DBBill.CATEGORY_HEALTH, projectId, context.getString(R.string.category_health), "\uD83D\uDC9A", "#bf090c"),
DBCategory(DBBill.CATEGORY_SHOPPING,
DBBill.CATEGORY_SHOPPING, projectId, context.getString(R.string.category_shopping), "\uD83D\uDECD", "#e167d1"),
DBCategory(DBBill.CATEGORY_REIMBURSEMENT,
DBBill.CATEGORY_REIMBURSEMENT, projectId, context.getString(R.string.category_reimbursement), "\uD83D\uDCB0", "#00ced1"),
DBCategory(DBBill.CATEGORY_RESTAURANT,
DBBill.CATEGORY_RESTAURANT, projectId, context.getString(R.string.category_restaurant), "\uD83C\uDF74", "#d0d5e1"),
DBCategory(DBBill.CATEGORY_ACCOMMODATION,
DBBill.CATEGORY_ACCOMMODATION, projectId, context.getString(R.string.category_accomodation), "\uD83D\uDECC", "#5de1a3"),
DBCategory(DBBill.CATEGORY_TRANSPORT,
DBBill.CATEGORY_TRANSPORT, projectId, context.getString(R.string.category_transport), "\uD83D\uDE8C", "#6f2ee1"),
DBCategory(DBBill.CATEGORY_SPORT,
DBBill.CATEGORY_SPORT, projectId, context.getString(R.string.category_sport), "\uD83C\uDFBE", "#69e177")
) )
} }
fun getDefaultPaymentModes(context: Context, projectId: Long): List<DBPaymentMode> { fun getDefaultPaymentModes(context: Context, projectId: Long): List<DBPaymentMode> {
return listOf( return listOf(
DBPaymentMode(0, DBBill.PAYMODE_ID_CARD.toLong(), projectId, context.getString(R.string.payment_mode_credit_card), "\uD83D\uDCB3", "#ff7f50"), DBPaymentMode(DBBill.PAYMODE_ID_CARD, DBBill.PAYMODE_ID_CARD, 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(DBBill.PAYMODE_ID_CASH, DBBill.PAYMODE_ID_CASH, 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(DBBill.PAYMODE_ID_CHECK, DBBill.PAYMODE_ID_CHECK, 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(DBBill.PAYMODE_ID_TRANSFER, DBBill.PAYMODE_ID_TRANSFER, 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_ONLINE_SERVICE, DBBill.PAYMODE_ID_ONLINE_SERVICE, projectId, context.getString(R.string.payment_mode_online), "\uD83C\uDF0E", "#9932cc")
) )
} }
fun getReimbursementCategoryId(categories: List<DBCategory>, projectId: Long, projectRemoteId: String? = null): Long {
return categories.find {
it.projectId == projectId && (
it.remoteId == DBBill.CATEGORY_REIMBURSEMENT ||
(projectRemoteId != null && it.remoteId.toString() == projectRemoteId)
)
}?.id ?: DBBill.CATEGORY_REIMBURSEMENT
}
fun getCategoryById(context: Context, id: Long, projectId: Long = 0): DBCategory? {
return getDefaultCategories(context, projectId).find { it.id == id }
}
fun getPaymentModeById(context: Context, id: Long, projectId: Long = 0): DBPaymentMode? {
return getDefaultPaymentModes(context, projectId).find { it.id == id }
}
} }
@@ -17,7 +17,7 @@ object CospendClientUtil {
enum class LoginStatus(@param:StringRes val str: Int) { enum class LoginStatus(@param:StringRes val str: Int) {
OK(0), OK(0),
AUTH_FAILED(R.string.error_username_password_invalid), AUTH_FAILED(R.string.error_auth),
CONNECTION_FAILED(R.string.error_io), CONNECTION_FAILED(R.string.error_io),
NO_NETWORK(R.string.error_no_network), NO_NETWORK(R.string.error_no_network),
JSON_FAILED(R.string.error_json), JSON_FAILED(R.string.error_json),
@@ -48,7 +48,7 @@ object ExportUtil {
} }
owersTxt = owersTxt.replace(",$".toRegex(), "") owersTxt = owersTxt.replace(",$".toRegex(), "")
fileContent += "\"${b.what}\",${b.amount},${b.date},${b.timestamp},\"$payerName\"," + 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" "${b.paymentMode}\n"
} }
@@ -93,23 +93,30 @@ class NextcloudClient(
params: Collection<QueryParam>?, params: Collection<QueryParam>?,
isOCSRequest: Boolean isOCSRequest: Boolean
): VersatileProjectSyncClient.ResponseData { ): 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 result = StringBuilder()
val headers: MutableMap<String, List<String>> = HashMap() val headers: MutableMap<String, List<String>> = HashMap()
if (isOCSRequest) { if (isOCSRequest) {
val acceptHeader: MutableList<String> = ArrayList() val acceptHeader: MutableList<String> = ArrayList()
acceptHeader.add("application/json") acceptHeader.add("application/json")
headers["Accept"] = acceptHeader headers["Accept"] = acceptHeader
val ocsHeader: MutableList<String> = ArrayList()
ocsHeader.add("true")
headers["OCS-APIRequest"] = ocsHeader
} }
val nextcloudRequest: NextcloudRequest = if (params == null) { val nextcloudRequest: NextcloudRequest = if (params == null) {
NextcloudRequest.Builder() NextcloudRequest.Builder()
.setMethod(method) .setMethod(method)
.setUrl(target) .setUrl(finalTarget)
.setHeader(headers) .setHeader(headers)
.build() .build()
} else { } else {
NextcloudRequest.Builder() NextcloudRequest.Builder()
.setMethod(method) .setMethod(method)
.setUrl(target) .setUrl(finalTarget)
.setParameter(params) .setParameter(params)
.setHeader(headers) .setHeader(headers)
.build() .build()
@@ -182,8 +189,12 @@ class NextcloudClient(
target: String, target: String,
method: String, params: JSONObject?, lastETag: String?, needLogin: Boolean, isOCSRequest: Boolean method: String, params: JSONObject?, lastETag: String?, needLogin: Boolean, isOCSRequest: Boolean
): VersatileProjectSyncClient.ResponseData { ): 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 result = StringBuilder()
val targetURL = url + target.replace("^/".toRegex(), "") val targetURL = url + finalTarget.replace("^/".toRegex(), "")
// Log.d(javaClass.simpleName, "method and target URL: $method $targetURL") // Log.d(javaClass.simpleName, "method and target URL: $method $targetURL")
val httpCon = SupportUtil.getHttpURLConnection(targetURL) val httpCon = SupportUtil.getHttpURLConnection(targetURL)
httpCon.requestMethod = method httpCon.requestMethod = method
@@ -194,7 +205,7 @@ class NextcloudClient(
) )
} }
httpCon.setRequestProperty("Connection", "Close") 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) { if (lastETag != null && METHOD_GET == method) {
httpCon.setRequestProperty("If-None-Match", lastETag) httpCon.setRequestProperty("If-None-Match", lastETag)
} }
@@ -244,8 +255,12 @@ class NextcloudClient(
target: String, target: String,
method: String, params: JSONObject?, lastETag: String?, needLogin: Boolean, isOCSRequest: Boolean method: String, params: JSONObject?, lastETag: String?, needLogin: Boolean, isOCSRequest: Boolean
): VersatileProjectSyncClient.ResponseData { ): 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 var strBase64: String
val targetURL = url + target.replace("^/".toRegex(), "") val targetURL = url + finalTarget.replace("^/".toRegex(), "")
val httpCon = SupportUtil.getHttpURLConnection( targetURL) val httpCon = SupportUtil.getHttpURLConnection( targetURL)
httpCon.requestMethod = method httpCon.requestMethod = method
if (needLogin) { if (needLogin) {
@@ -68,7 +68,7 @@ open class ServerResponse(
} }
val rawData = JSONObject(content) val rawData = JSONObject(content)
val data = rawData.getJSONObject("ocs") val data = rawData.getJSONObject("ocs")
return data.getString("data") return data.get("data").toString()
} }
class ProjectResponse(response: VersatileProjectSyncClient.ResponseData, isOcsResponse: Boolean) : class ProjectResponse(response: VersatileProjectSyncClient.ResponseData, isOcsResponse: Boolean) :
@@ -137,6 +137,90 @@ open class ServerResponse(
getResponseStringData().toLong() 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( class CreateRemoteCurrencyResponse(
response: VersatileProjectSyncClient.ResponseData, response: VersatileProjectSyncClient.ResponseData,
isOcsResponse: Boolean isOcsResponse: Boolean
@@ -145,6 +229,18 @@ open class ServerResponse(
@get:Throws(JSONException::class) @get:Throws(JSONException::class)
val stringContent: String val stringContent: String
get() = getResponseStringData() 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( class EditRemoteCurrencyResponse(
@@ -196,6 +292,18 @@ open class ServerResponse(
@get:Throws(JSONException::class) @get:Throws(JSONException::class)
val stringContent: String val stringContent: String
get() = getResponseStringData() 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( class CreateRemoteBillResponse(
@@ -206,6 +314,18 @@ open class ServerResponse(
@get:Throws(JSONException::class) @get:Throws(JSONException::class)
val stringContent: String val stringContent: String
get() = getResponseStringData() 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( class DeleteRemoteBillResponse(
@@ -242,13 +362,23 @@ open class ServerResponse(
ServerResponse(response, isOcsResponse) { ServerResponse(response, isOcsResponse) {
@Throws(JSONException::class) @Throws(JSONException::class)
fun getBillsCospend(projId: Long, memberRemoteIdToId: Map<Long, Long>): List<DBBill> { fun getBillsCospend(
return getBillsFromJSONObject(getResponseObjectData(), projId, memberRemoteIdToId) projId: Long,
memberRemoteIdToId: Map<Long, Long>,
catRemoteIdToId: Map<Long, Long>,
pmRemoteIdToId: Map<Long, Long>
): List<DBBill> {
return getBillsFromJSONObject(projId, memberRemoteIdToId, catRemoteIdToId, pmRemoteIdToId)
} }
@Throws(JSONException::class) @Throws(JSONException::class)
fun getBillsIHM(projId: Long, memberRemoteIdToId: Map<Long, Long>): List<DBBill> { fun getBillsIHM(
return getBillsFromJSONArray(JSONArray(content), projId, memberRemoteIdToId) projId: Long,
memberRemoteIdToId: Map<Long, Long>,
catRemoteIdToId: Map<Long, Long>,
pmRemoteIdToId: Map<Long, Long>
): List<DBBill> {
return getBillsFromJSONArray(JSONArray(content), projId, memberRemoteIdToId, catRemoteIdToId, pmRemoteIdToId)
} }
@@ -270,6 +400,32 @@ open class ServerResponse(
} }
} }
class CategoriesResponse(response: VersatileProjectSyncClient.ResponseData, isOcsResponse: Boolean) :
ServerResponse(response, isOcsResponse) {
@Throws(JSONException::class)
fun getCategories(projId: Long): List<DBCategory> {
return if (isOcsResponse) {
getCategoriesFromJSON(getResponseObjectData(), projId)
} else {
getCategoriesFromJSONArray(getResponseArrayData(), projId)
}
}
}
class PaymentModesResponse(response: VersatileProjectSyncClient.ResponseData, isOcsResponse: Boolean) :
ServerResponse(response, isOcsResponse) {
@Throws(JSONException::class)
fun getPaymentModes(projId: Long): List<DBPaymentMode> {
return if (isOcsResponse) {
getPaymentModesFromJSON(getResponseObjectData(), projId)
} else {
getPaymentModesFromJSONArray(getResponseArrayData(), projId)
}
}
}
class AccountProjectsResponse( class AccountProjectsResponse(
response: VersatileProjectSyncClient.ResponseData, response: VersatileProjectSyncClient.ResponseData,
isOcsResponse: Boolean isOcsResponse: Boolean
@@ -366,6 +522,26 @@ open class ServerResponse(
return members return members
} }
@Throws(JSONException::class)
protected fun getCategoriesFromJSONArray(jsonCats: JSONArray, projId: Long): List<DBCategory> {
val categories: MutableList<DBCategory> = ArrayList()
for (i in 0 until jsonCats.length()) {
val jsonCat = jsonCats.getJSONObject(i)
categories.add(getCategoryFromJSON(jsonCat, projId))
}
return categories
}
@Throws(JSONException::class)
protected fun getPaymentModesFromJSONArray(jsonPms: JSONArray, projId: Long): List<DBPaymentMode> {
val paymentModes: MutableList<DBPaymentMode> = ArrayList()
for (i in 0 until jsonPms.length()) {
val jsonPm = jsonPms.getJSONObject(i)
paymentModes.add(getPaymentModeFromJSON(jsonPm, projId))
}
return paymentModes
}
@Throws(JSONException::class) @Throws(JSONException::class)
protected fun getCategoriesFromJSON(json: JSONObject, projId: Long): List<DBCategory> { protected fun getCategoriesFromJSON(json: JSONObject, projId: Long): List<DBCategory> {
val categories: MutableList<DBCategory> = ArrayList() val categories: MutableList<DBCategory> = ArrayList()
@@ -382,6 +558,27 @@ open class ServerResponse(
return categories 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) @Throws(JSONException::class)
protected fun getCategoryFromJSON(json: JSONObject, remoteIdStr: String, projId: Long): DBCategory { protected fun getCategoryFromJSON(json: JSONObject, remoteIdStr: String, projId: Long): DBCategory {
val remoteId = remoteIdStr.toLong() val remoteId = remoteIdStr.toLong()
@@ -416,6 +613,27 @@ open class ServerResponse(
return paymentModes 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) @Throws(JSONException::class)
protected fun getPaymentModeFromJSON(json: JSONObject, remoteIdStr: String, projId: Long): DBPaymentMode { protected fun getPaymentModeFromJSON(json: JSONObject, remoteIdStr: String, projId: Long): DBPaymentMode {
val remoteId = remoteIdStr.toLong() val remoteId = remoteIdStr.toLong()
@@ -556,26 +774,30 @@ open class ServerResponse(
protected fun getBillsFromJSONArray( protected fun getBillsFromJSONArray(
json: JSONArray, json: JSONArray,
projId: Long, projId: Long,
memberRemoteIdToId: Map<Long, Long> memberRemoteIdToId: Map<Long, Long>,
catRemoteIdToId: Map<Long, Long>,
pmRemoteIdToId: Map<Long, Long>
): List<DBBill> { ): List<DBBill> {
val bills: MutableList<DBBill> = ArrayList() val bills: MutableList<DBBill> = ArrayList()
for (i in 0 until json.length()) { for (i in 0 until json.length()) {
val jsonBill = json.getJSONObject(i) val jsonBill = json.getJSONObject(i)
bills.add(getBillFromJSON(jsonBill, projId, memberRemoteIdToId)) bills.add(getBillFromJSON(jsonBill, projId, memberRemoteIdToId, catRemoteIdToId, pmRemoteIdToId))
} }
return bills return bills
} }
@Throws(JSONException::class) @Throws(JSONException::class)
protected fun getBillsFromJSONObject( protected fun getBillsFromJSONObject(
json: JSONObject,
projId: Long, projId: Long,
memberRemoteIdToId: Map<Long, Long> memberRemoteIdToId: Map<Long, Long>,
catRemoteIdToId: Map<Long, Long>,
pmRemoteIdToId: Map<Long, Long>
): List<DBBill> { ): List<DBBill> {
val bills: List<DBBill> val bills: List<DBBill>
val json = getResponseObjectData()
if (json.has("bills") && !json.isNull("bills")) { if (json.has("bills") && !json.isNull("bills")) {
val jsonBills = json.getJSONArray("bills") val jsonBills = json.getJSONArray("bills")
bills = getBillsFromJSONArray(jsonBills, projId, memberRemoteIdToId) bills = getBillsFromJSONArray(jsonBills, projId, memberRemoteIdToId, catRemoteIdToId, pmRemoteIdToId)
} else { } else {
bills = ArrayList() bills = ArrayList()
} }
@@ -586,7 +808,9 @@ open class ServerResponse(
protected fun getBillFromJSON( protected fun getBillFromJSON(
json: JSONObject, json: JSONObject,
projId: Long, projId: Long,
memberRemoteIdToId: Map<Long, Long> memberRemoteIdToId: Map<Long, Long>,
catRemoteIdToId: Map<Long, Long>,
pmRemoteIdToId: Map<Long, Long>
): DBBill { ): DBBill {
var remoteId: Long = 0 var remoteId: Long = 0
var payerRemoteId: Long var payerRemoteId: Long
@@ -600,7 +824,7 @@ open class ServerResponse(
var repeat = DBBill.NON_REPEATED var repeat = DBBill.NON_REPEATED
var paymentMode = DBBill.PAYMODE_NONE var paymentMode = DBBill.PAYMODE_NONE
var paymentModeRemoteId = DBBill.PAYMODE_ID_NONE var paymentModeRemoteId = DBBill.PAYMODE_ID_NONE
var categoryId = DBBill.CATEGORY_NONE var categoryRemoteId = DBBill.CATEGORY_NONE
if (!json.isNull("id")) { if (!json.isNull("id")) {
remoteId = json.getLong("id") remoteId = json.getLong("id")
} }
@@ -639,23 +863,29 @@ open class ServerResponse(
} }
if (json.has("paymentmode") && !json.isNull("paymentmode")) { if (json.has("paymentmode") && !json.isNull("paymentmode")) {
paymentMode = json.getString("paymentmode") paymentMode = json.getString("paymentmode")
} else if (json.has("paymentMode") && !json.isNull("paymentMode")) {
paymentMode = json.getString("paymentMode")
} }
if (json.has("categoryid") && !json.isNull("categoryid")) { if (json.has("categoryid") && !json.isNull("categoryid")) {
categoryId = json.getInt("categoryid") categoryRemoteId = json.getLong("categoryid")
Log.d("PLOP", "LOADED CATTTTTTTTTTTT $categoryId") } else if (json.has("categoryId") && !json.isNull("categoryId")) {
categoryRemoteId = json.getLong("categoryId")
} }
if (json.has("paymentmodeid") && !json.isNull("paymentmodeid")) { 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) { 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 paymentModeRemoteId = DBBill.oldPmIdToNew[paymentMode] ?: DBBill.PAYMODE_ID_NONE
} }
val categoryId = catRemoteIdToId[categoryRemoteId] ?: 0L
val paymentModeId = pmRemoteIdToId[paymentModeRemoteId] ?: 0L
val bill = DBBill( val bill = DBBill(
0, remoteId, projId, payerId, amount, timestamp, what, 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) bill.billOwers = getBillOwersFromJson(json, memberRemoteIdToId)
return bill return bill
@@ -87,14 +87,17 @@ object SupportUtil {
membersBalance: MutableMap<Long, Double>, membersBalance: MutableMap<Long, Double>,
membersPaid: MutableMap<Long, Double>, membersPaid: MutableMap<Long, Double>,
membersSpent: MutableMap<Long, Double>, membersSpent: MutableMap<Long, Double>,
catId: Int, paymentModeId: Int, catId: Long, paymentModeId: Long,
dateMin: String?, dateMax: String? dateMin: String?, dateMax: String?
): Int { ): Int {
val proj = db.getProject(projId)
val categories = db.getCategories(projId)
val reimbursementCategoryId = CategoryUtils.getReimbursementCategoryId(categories, projId, proj?.remoteId)
return getStats( return getStats(
db.getMembersOfProject(projId, null), db.getMembersOfProject(projId, null),
db.getBillsOfProject(projId), db.getBillsOfProject(projId),
membersNbBills, membersBalance, membersPaid, membersSpent, membersNbBills, membersBalance, membersPaid, membersSpent,
catId, paymentModeId, dateMin, dateMax catId, paymentModeId, reimbursementCategoryId, dateMin, dateMax
) )
} }
@@ -106,7 +109,8 @@ object SupportUtil {
membersBalance: MutableMap<Long, Double>, membersBalance: MutableMap<Long, Double>,
membersPaid: MutableMap<Long, Double>, membersPaid: MutableMap<Long, Double>,
membersSpent: MutableMap<Long, Double>, membersSpent: MutableMap<Long, Double>,
catId: Int, paymentModeId: Int, catId: Long, paymentModeId: Long,
reimbursementCategoryId: Long,
dateMin: String?, dateMax: String? dateMin: String?, dateMax: String?
): Int { ): Int {
val nbBillsTotal = 0 val nbBillsTotal = 0
@@ -124,9 +128,9 @@ object SupportUtil {
for (b in dbBills) { for (b in dbBills) {
// don't take deleted bills and respect category filter // don't take deleted bills and respect category filter
if (b.state != DBBill.STATE_DELETED && if (b.state != DBBill.STATE_DELETED &&
((catId == -1000 || catId == -100 || b.categoryRemoteId == catId) && ((catId == -1000L || catId == -100L || b.categoryId == catId) &&
(catId != -100 || b.categoryRemoteId != DBBill.CATEGORY_REIMBURSEMENT) && (catId != -100L || b.categoryId != reimbursementCategoryId) &&
(paymentModeId == -1000 || b.paymentModeRemoteId == paymentModeId)) && (paymentModeId == -1000L || b.paymentModeId == paymentModeId)) &&
(dateMin == null || b.date >= dateMin) && (dateMin == null || b.date >= dateMin) &&
(dateMax == null || b.date <= dateMax) (dateMax == null || b.date <= dateMax)
) { ) {
@@ -285,18 +289,6 @@ object SupportUtil {
return reduceBalance(crediters, debiters, results) 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 @JvmStatic
fun getJsonObject(text: String?): JSONObject? { fun getJsonObject(text: String?): JSONObject? {
if (text == null) return null if (text == null) return null
@@ -11,8 +11,10 @@ import com.nextcloud.android.sso.exceptions.NextcloudHttpRequestFailedException
import com.nextcloud.android.sso.exceptions.TokenMismatchException import com.nextcloud.android.sso.exceptions.TokenMismatchException
import com.nextcloud.android.sso.model.SingleSignOnAccount import com.nextcloud.android.sso.model.SingleSignOnAccount
import net.helcel.cowspent.model.DBBill import net.helcel.cowspent.model.DBBill
import net.helcel.cowspent.model.DBCategory
import net.helcel.cowspent.model.DBCurrency import net.helcel.cowspent.model.DBCurrency
import net.helcel.cowspent.model.DBMember import net.helcel.cowspent.model.DBMember
import net.helcel.cowspent.model.DBPaymentMode
import net.helcel.cowspent.model.DBProject import net.helcel.cowspent.model.DBProject
import net.helcel.cowspent.model.ProjectType import net.helcel.cowspent.model.ProjectType
import org.json.JSONException import org.json.JSONException
@@ -82,13 +84,11 @@ class VersatileProjectSyncClient(
project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId
useOcsApiRequest = cospendVersionGT161 useOcsApiRequest = cospendVersionGT161
} else if (canAccessProjectWithSSO(project)) { } else if (canAccessProjectWithSSO(project)) {
return if (cospendVersionGT161) { target = if (cospendVersionGT161)
target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId
ServerResponse.ProjectResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_GET, null, null, true), true) else
} else { "/index.php/apps/cospend/api-priv/projects/" + project.remoteId
target = "/index.php/apps/cospend/api-priv/projects/" + project.remoteId return ServerResponse.ProjectResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_GET, null, null, true), true)
ServerResponse.ProjectResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_GET, null, null, false), false)
}
} else { } else {
useOcsApiRequest = cospendVersionGT161 useOcsApiRequest = cospendVersionGT161
target = if (cospendVersionGT161) target = if (cospendVersionGT161)
@@ -260,7 +260,13 @@ class VersatileProjectSyncClient(
} }
@Throws(IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class) @Throws(IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class)
fun editRemoteBill(project: DBProject, bill: DBBill, memberIdToRemoteId: Map<Long, Long>): ServerResponse.EditRemoteBillResponse { fun editRemoteBill(
project: DBProject,
bill: DBBill,
memberIdToRemoteId: Map<Long, Long>,
categoryIdToRemoteId: Map<Long, Long>,
paymentModeIdToRemoteId: Map<Long, Long>
): ServerResponse.EditRemoteBillResponse {
val paramKeys: MutableList<String> = ArrayList() val paramKeys: MutableList<String> = ArrayList()
val paramValues: MutableList<String> = ArrayList() val paramValues: MutableList<String> = ArrayList()
paramKeys.add("date") paramKeys.add("date")
@@ -307,8 +313,8 @@ class VersatileProjectSyncClient(
payedFor = payedFor.replace(",$".toRegex(), "") payedFor = payedFor.replace(",$".toRegex(), "")
paramValues.add(payedFor) paramValues.add(payedFor)
paramValues.add(bill.paymentMode ?: "") paramValues.add(bill.paymentMode ?: "")
paramValues.add(bill.categoryRemoteId.toString()) paramValues.add((categoryIdToRemoteId[bill.categoryId] ?: 0L).toString())
paramValues.add(bill.paymentModeRemoteId.toString()) paramValues.add((paymentModeIdToRemoteId[bill.paymentModeId] ?: 0L).toString())
if (canAccessProjectWithNCLogin(project)) { if (canAccessProjectWithNCLogin(project)) {
username = this.username username = this.username
@@ -512,7 +518,13 @@ class VersatileProjectSyncClient(
} }
@Throws(IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class) @Throws(IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class)
fun createRemoteBill(project: DBProject, bill: DBBill, memberIdToRemoteId: Map<Long, Long>): ServerResponse.CreateRemoteBillResponse { fun createRemoteBill(
project: DBProject,
bill: DBBill,
memberIdToRemoteId: Map<Long, Long>,
categoryIdToRemoteId: Map<Long, Long>,
paymentModeIdToRemoteId: Map<Long, Long>
): ServerResponse.CreateRemoteBillResponse {
val paramKeys: MutableList<String> = ArrayList() val paramKeys: MutableList<String> = ArrayList()
val paramValues: MutableList<String> = ArrayList() val paramValues: MutableList<String> = ArrayList()
paramKeys.add("date") paramKeys.add("date")
@@ -554,8 +566,8 @@ class VersatileProjectSyncClient(
payedFor = payedFor.replace(",$".toRegex(), "") payedFor = payedFor.replace(",$".toRegex(), "")
paramValues.add(payedFor) paramValues.add(payedFor)
paramValues.add(bill.paymentMode ?: "") paramValues.add(bill.paymentMode ?: "")
paramValues.add(bill.categoryRemoteId.toString()) paramValues.add((categoryIdToRemoteId[bill.categoryId] ?: 0L).toString())
paramValues.add(bill.paymentModeRemoteId.toString()) paramValues.add((paymentModeIdToRemoteId[bill.paymentModeId] ?: 0L).toString())
if (canAccessProjectWithNCLogin(project)) { if (canAccessProjectWithNCLogin(project)) {
username = this.username username = this.username
@@ -634,7 +646,6 @@ class VersatileProjectSyncClient(
} else if (canAccessProjectWithSSO(project)) { } else if (canAccessProjectWithSSO(project)) {
return if (cospendVersionGT161) { return if (cospendVersionGT161) {
target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/members" 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) ServerResponse.CreateRemoteMemberResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_POST, paramKeys, paramValues, true), isOcsResponse=true, isJsonMember=true)
} else { } else {
target = "/index.php/apps/cospend/api-priv/projects/" + project.remoteId + "/members" target = "/index.php/apps/cospend/api-priv/projects/" + project.remoteId + "/members"
@@ -698,7 +709,6 @@ class VersatileProjectSyncClient(
paramValues.add(tsLastSync.toString()) paramValues.add(tsLastSync.toString())
return if (cospendVersionGT161) { return if (cospendVersionGT161) {
target = "/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/bills" 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) ServerResponse.BillsResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_GET, paramKeys, paramValues, true), true)
} else { } else {
target = "/index.php/apps/cospend/api-priv/projects/" + project.remoteId + "/bills" target = "/index.php/apps/cospend/api-priv/projects/" + project.remoteId + "/bills"
@@ -733,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) @Throws(JSONException::class, IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class)
fun getMembers(project: DBProject): ServerResponse.MembersResponse { fun getMembers(project: DBProject): ServerResponse.MembersResponse {
var target: String var target: String
@@ -779,6 +875,305 @@ class VersatileProjectSyncClient(
) )
} }
@Throws(IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class)
fun createRemoteCategory(project: DBProject, category: DBCategory): ServerResponse.CreateRemoteCategoryResponse {
val paramKeys: MutableList<String> = ArrayList()
val paramValues: MutableList<String> = ArrayList()
paramKeys.add("name")
paramValues.add(category.name ?: "")
paramKeys.add("icon")
paramValues.add(category.icon)
paramKeys.add("color")
paramValues.add(category.color)
var target: String
var username: String? = null
var password: String? = null
var bearerToken: String? = null
var useOcsApiRequest = false
if (ProjectType.COSPEND == project.type) {
if (canAccessProjectWithNCLogin(project)) {
username = this.username
password = this.password
target = if (cospendVersionGT161)
project.getRequestBaseUrl(true) + "/api/v1/projects/" + project.remoteId + "/category"
else
project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/categories"
useOcsApiRequest = cospendVersionGT161
} else if (canAccessProjectWithSSO(project)) {
target = if (cospendVersionGT161)
"/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/category"
else
"/index.php/apps/cospend/api-priv/projects/" + project.remoteId + "/categories"
return ServerResponse.CreateRemoteCategoryResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_POST, paramKeys, paramValues, cospendVersionGT161), cospendVersionGT161)
} else {
useOcsApiRequest = cospendVersionGT161
target = if (cospendVersionGT161)
project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/category"
else
project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/categories"
}
} else {
target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/categories"
username = project.remoteId
password = project.password
bearerToken = project.bearerToken
}
val response = requestServer(
target, METHOD_POST, paramKeys, paramValues, null,
username, password, bearerToken, useOcsApiRequest
)
return ServerResponse.CreateRemoteCategoryResponse(response, useOcsApiRequest)
}
@Throws(IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class)
fun editRemoteCategory(project: DBProject, category: DBCategory): ServerResponse.EditRemoteCategoryResponse {
val paramKeys: MutableList<String> = ArrayList()
val paramValues: MutableList<String> = ArrayList()
paramKeys.add("name")
paramValues.add(category.name ?: "")
paramKeys.add("icon")
paramValues.add(category.icon)
paramKeys.add("color")
paramValues.add(category.color)
var target: String
var username: String? = null
var password: String? = null
var bearerToken: String? = null
var useOcsApiRequest = false
if (ProjectType.COSPEND == project.type) {
if (canAccessProjectWithNCLogin(project)) {
username = this.username
password = this.password
target = if (cospendVersionGT161)
project.getRequestBaseUrl(true) + "/api/v1/projects/" + project.remoteId + "/category/" + category.remoteId
else
project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/categories/" + category.remoteId
useOcsApiRequest = cospendVersionGT161
} else if (canAccessProjectWithSSO(project)) {
target = if (cospendVersionGT161)
"/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/category/" + category.remoteId
else
"/index.php/apps/cospend/api-priv/projects/" + project.remoteId + "/categories/" + category.remoteId
return ServerResponse.EditRemoteCategoryResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_PUT, paramKeys, paramValues, cospendVersionGT161), cospendVersionGT161)
} else {
useOcsApiRequest = cospendVersionGT161
target = if (cospendVersionGT161)
project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/category/" + category.remoteId
else
project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/categories/" + category.remoteId
}
} else {
target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/categories/" + category.remoteId
username = project.remoteId
password = project.password
bearerToken = project.bearerToken
}
return ServerResponse.EditRemoteCategoryResponse(
requestServer(
target, METHOD_PUT, paramKeys, paramValues, null,
username, password, bearerToken, useOcsApiRequest
), useOcsApiRequest
)
}
@Throws(IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class)
fun deleteRemoteCategory(project: DBProject, categoryRemoteId: Long): ServerResponse.DeleteRemoteCategoryResponse {
var target: String
var username: String? = null
var password: String? = null
var bearerToken: String? = null
var useOcsApiRequest = false
if (ProjectType.COSPEND == project.type) {
if (canAccessProjectWithNCLogin(project)) {
username = this.username
password = this.password
target = if (cospendVersionGT161)
project.getRequestBaseUrl(true) + "/api/v1/projects/" + project.remoteId + "/category/" + categoryRemoteId
else
project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/categories/" + categoryRemoteId
useOcsApiRequest = cospendVersionGT161
} else if (canAccessProjectWithSSO(project)) {
target = if (cospendVersionGT161)
"/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/category/" + categoryRemoteId
else
"/index.php/apps/cospend/api-priv/projects/" + project.remoteId + "/categories/" + categoryRemoteId
return ServerResponse.DeleteRemoteCategoryResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_DELETE, null, null, cospendVersionGT161), cospendVersionGT161)
} else {
useOcsApiRequest = cospendVersionGT161
target = if (cospendVersionGT161)
project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/category/" + categoryRemoteId
else
project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/categories/" + categoryRemoteId
}
} else {
target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/categories/" + categoryRemoteId
username = project.remoteId
password = project.password
bearerToken = project.bearerToken
}
return ServerResponse.DeleteRemoteCategoryResponse(
requestServer(
target, METHOD_DELETE, null, null,
null, username, password, bearerToken, useOcsApiRequest
), useOcsApiRequest
)
}
@Throws(IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class)
fun createRemotePaymentMode(project: DBProject, paymentMode: DBPaymentMode): ServerResponse.CreateRemotePaymentModeResponse {
val paramKeys: MutableList<String> = ArrayList()
val paramValues: MutableList<String> = ArrayList()
paramKeys.add("name")
paramValues.add(paymentMode.name ?: "")
paramKeys.add("icon")
paramValues.add(paymentMode.icon)
paramKeys.add("color")
paramValues.add(paymentMode.color)
var target: String
var username: String? = null
var password: String? = null
var bearerToken: String? = null
var useOcsApiRequest = false
if (ProjectType.COSPEND == project.type) {
if (canAccessProjectWithNCLogin(project)) {
username = this.username
password = this.password
target = if (cospendVersionGT161)
project.getRequestBaseUrl(true) + "/api/v1/projects/" + project.remoteId + "/paymentmode"
else
project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/paymentmodes"
useOcsApiRequest = cospendVersionGT161
} else if (canAccessProjectWithSSO(project)) {
target = if (cospendVersionGT161)
"/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/paymentmode"
else
"/index.php/apps/cospend/api-priv/projects/" + project.remoteId + "/paymentmodes"
return ServerResponse.CreateRemotePaymentModeResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_POST, paramKeys, paramValues, cospendVersionGT161), cospendVersionGT161)
} else {
useOcsApiRequest = cospendVersionGT161
target = if (cospendVersionGT161)
project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/paymentmode"
else
project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/paymentmodes"
}
} else {
target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/paymentmodes"
username = project.remoteId
password = project.password
bearerToken = project.bearerToken
}
return ServerResponse.CreateRemotePaymentModeResponse(
requestServer(
target, METHOD_POST, paramKeys, paramValues, null,
username, password, bearerToken, useOcsApiRequest
), useOcsApiRequest
)
}
@Throws(IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class)
fun editRemotePaymentMode(project: DBProject, paymentMode: DBPaymentMode): ServerResponse.EditRemotePaymentModeResponse {
val paramKeys: MutableList<String> = ArrayList()
val paramValues: MutableList<String> = ArrayList()
paramKeys.add("name")
paramValues.add(paymentMode.name ?: "")
paramKeys.add("icon")
paramValues.add(paymentMode.icon)
paramKeys.add("color")
paramValues.add(paymentMode.color)
var target: String
var username: String? = null
var password: String? = null
var bearerToken: String? = null
var useOcsApiRequest = false
if (ProjectType.COSPEND == project.type) {
if (canAccessProjectWithNCLogin(project)) {
username = this.username
password = this.password
target = if (cospendVersionGT161)
project.getRequestBaseUrl(true) + "/api/v1/projects/" + project.remoteId + "/paymentmode/" + paymentMode.remoteId
else
project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/paymentmodes/" + paymentMode.remoteId
useOcsApiRequest = cospendVersionGT161
} else if (canAccessProjectWithSSO(project)) {
target = if (cospendVersionGT161)
"/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/paymentmode/" + paymentMode.remoteId
else
"/index.php/apps/cospend/api-priv/projects/" + project.remoteId + "/paymentmodes/" + paymentMode.remoteId
return ServerResponse.EditRemotePaymentModeResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_PUT, paramKeys, paramValues, cospendVersionGT161), cospendVersionGT161)
} else {
useOcsApiRequest = cospendVersionGT161
target = if (cospendVersionGT161)
project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/paymentmode/" + paymentMode.remoteId
else
project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/paymentmodes/" + paymentMode.remoteId
}
} else {
target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/paymentmodes/" + paymentMode.remoteId
username = project.remoteId
password = project.password
bearerToken = project.bearerToken
}
return ServerResponse.EditRemotePaymentModeResponse(
requestServer(
target, METHOD_PUT, paramKeys, paramValues, null,
username, password, bearerToken, useOcsApiRequest
), useOcsApiRequest
)
}
@Throws(IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class)
fun deleteRemotePaymentMode(project: DBProject, paymentModeRemoteId: Long): ServerResponse.DeleteRemotePaymentModeResponse {
var target: String
var username: String? = null
var password: String? = null
var bearerToken: String? = null
var useOcsApiRequest = false
if (ProjectType.COSPEND == project.type) {
if (canAccessProjectWithNCLogin(project)) {
username = this.username
password = this.password
target = if (cospendVersionGT161)
project.getRequestBaseUrl(true) + "/api/v1/projects/" + project.remoteId + "/paymentmode/" + paymentModeRemoteId
else
project.getRequestBaseUrl(false) + "/api-priv/projects/" + project.remoteId + "/paymentmodes/" + paymentModeRemoteId
useOcsApiRequest = cospendVersionGT161
} else if (canAccessProjectWithSSO(project)) {
target = if (cospendVersionGT161)
"/ocs/v2.php/apps/cospend/api/v1/projects/" + project.remoteId + "/paymentmode/" + paymentModeRemoteId
else
"/index.php/apps/cospend/api-priv/projects/" + project.remoteId + "/paymentmodes/" + paymentModeRemoteId
return ServerResponse.DeleteRemotePaymentModeResponse(requestServerWithSSO(nextcloudAPI!!, target, METHOD_DELETE, null, null, cospendVersionGT161), cospendVersionGT161)
} else {
useOcsApiRequest = cospendVersionGT161
target = if (cospendVersionGT161)
project.getRequestBaseUrl(true) + "/api/v1/public/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/paymentmode/" + paymentModeRemoteId
else
project.getRequestBaseUrl(false) + "/api/projects/" + project.remoteId + "/" + getEncodedPassword(project.password) + "/paymentmodes/" + paymentModeRemoteId
}
} else {
target = project.serverUrl!!.replace("/+$".toRegex(), "") + "/api/projects/" + project.remoteId + "/paymentmodes/" + paymentModeRemoteId
username = project.remoteId
password = project.password
bearerToken = project.bearerToken
}
return ServerResponse.DeleteRemotePaymentModeResponse(
requestServer(
target, METHOD_DELETE, null, null,
null, username, password, bearerToken, useOcsApiRequest
), useOcsApiRequest
)
}
@Throws(IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class) @Throws(IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class)
fun createRemoteCurrency(project: DBProject, currency: DBCurrency): ServerResponse.CreateRemoteCurrencyResponse { fun createRemoteCurrency(project: DBProject, currency: DBCurrency): ServerResponse.CreateRemoteCurrencyResponse {
val paramKeys: MutableList<String> = ArrayList() val paramKeys: MutableList<String> = ArrayList()
@@ -936,6 +1331,10 @@ class VersatileProjectSyncClient(
nextcloudAPI: NextcloudAPI, target: String, method: String, nextcloudAPI: NextcloudAPI, target: String, method: String,
paramKeys: List<String>?, paramValues: List<String>?, isOCSRequest: Boolean paramKeys: List<String>?, paramValues: List<String>?, isOCSRequest: Boolean
): ResponseData { ): 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 result = StringBuilder()
var params: MutableList<QueryParam>? = null var params: MutableList<QueryParam>? = null
if (paramKeys != null && paramValues != null) { if (paramKeys != null && paramValues != null) {
@@ -949,17 +1348,20 @@ class VersatileProjectSyncClient(
val acceptHeader: MutableList<String> = ArrayList() val acceptHeader: MutableList<String> = ArrayList()
acceptHeader.add("application/json") acceptHeader.add("application/json")
headers["Accept"] = acceptHeader headers["Accept"] = acceptHeader
val ocsHeader: MutableList<String> = ArrayList()
ocsHeader.add("true")
headers["OCS-APIRequest"] = ocsHeader
} }
val nextcloudRequest: NextcloudRequest = if (params == null) { val nextcloudRequest: NextcloudRequest = if (params == null) {
NextcloudRequest.Builder() NextcloudRequest.Builder()
.setMethod(method) .setMethod(method)
.setUrl(target) .setUrl(finalTarget)
.setHeader(headers) .setHeader(headers)
.build() .build()
} else { } else {
NextcloudRequest.Builder() NextcloudRequest.Builder()
.setMethod(method) .setMethod(method)
.setUrl(target) .setUrl(finalTarget)
.setParameter(params) .setParameter(params)
.setHeader(headers) .setHeader(headers)
.build() .build()
@@ -993,8 +1395,12 @@ class VersatileProjectSyncClient(
lastETag: String?, username: String?, password: String?, lastETag: String?, username: String?, password: String?,
bearerToken: String?, isOCSRequest: Boolean bearerToken: String?, isOCSRequest: Boolean
): ResponseData { ): 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 result = StringBuilder()
val httpCon = SupportUtil.getHttpURLConnection(target) val httpCon = SupportUtil.getHttpURLConnection(finalTarget)
httpCon.requestMethod = method httpCon.requestMethod = method
if (bearerToken != null) { if (bearerToken != null) {
httpCon.setRequestProperty("Authorization", "Bearer $bearerToken") httpCon.setRequestProperty("Authorization", "Bearer $bearerToken")
@@ -1005,7 +1411,7 @@ class VersatileProjectSyncClient(
) )
} }
httpCon.setRequestProperty("Connection", "Close") 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) { if (lastETag != null && METHOD_GET == method) {
httpCon.setRequestProperty("If-None-Match", lastETag) httpCon.setRequestProperty("If-None-Match", lastETag)
} }
@@ -2,10 +2,39 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp" android:width="108dp"
android:height="108dp" android:height="108dp"
android:viewportWidth="100" android:viewportWidth="72"
android:viewportHeight="100"> android:viewportHeight="72">
<!-- Matches #0F172A Base --> <group android:translateY="-0.33333334">
<path <group android:scaleX="0.63461536"
android:pathData="M0,0h100v100h-100z" android:scaleY="0.63461536"
android:fillColor="#0F172A"/> android:translateX="13.153846"
android:translateY="13.153846">
<path
android:fillColor="#FFD700"
android:pathData="M36,2 C17.22,2 2,17.22 2,36 C2,54.78 17.22,70 36,70 C54.78,70 70,54.78 70,36 C70,17.22 54.78,2 36,2 Z" />
<!-- Inner Face (Expanded path) -->
<path
android:fillColor="#DAA520"
android:pathData="M36,8 C20.54,8 8,20.54 8,36 C8,51.46 20.54,64 36,64 C51.46,64 64,51.46 64,36 C64,20.54 51.46,8 36,8 Z" />
<!-- Center Detail (Pure path replacement for the center circle) -->
<path
android:fillColor="#FFD700"
android:pathData="M36,22 C28.27,22 22,28.27 22,36 C22,43.73 28.27,50 36,50 C43.73,50 50,43.73 50,36 C50,28.27 43.73,22 36,22 Z" />
<!-- Massive Background Coin Base -->
<path
android:pathData="M 256,76 A 180,180 0 1,1 255.9,76 Z"
android:fillColor="#FBBF24"/>
<!-- Coin Decorative Dotted Inner Rim -->
<path
android:pathData="M 256,101 A 155,155 0 1,1 255.9,101 Z"
android:strokeColor="#D97706"
android:strokeWidth="4"/>
</group>
</group>
</vector> </vector>
@@ -9,20 +9,6 @@
android:scaleY="0.63461536" android:scaleY="0.63461536"
android:translateX="13.153846" android:translateX="13.153846"
android:translateY="13.153846"> android:translateY="13.153846">
<path
android:fillColor="#FFD700"
android:pathData="M36,2 C17.22,2 2,17.22 2,36 C2,54.78 17.22,70 36,70 C54.78,70 70,54.78 70,36 C70,17.22 54.78,2 36,2 Z" />
<!-- Inner Face (Expanded path) -->
<path
android:fillColor="#DAA520"
android:pathData="M36,8 C20.54,8 8,20.54 8,36 C8,51.46 20.54,64 36,64 C51.46,64 64,51.46 64,36 C64,20.54 51.46,8 36,8 Z" />
<!-- Center Detail (Pure path replacement for the center circle) -->
<path
android:fillColor="#FFD700"
android:pathData="M36,22 C28.27,22 22,28.27 22,36 C22,43.73 28.27,50 36,50 C43.73,50 50,43.73 50,36 C50,28.27 43.73,22 36,22 Z" />
<group android:name="color"> <group android:name="color">
<path android:fillColor="#fff" android:pathData="m15,12.0211h0c0,.6116.3056,1.1827.8145,1.5219l4.2449,2.83c.5764.3843,1.774.8721,1.688,1.5596l-1.7051,6.7284c-.028.2241-.0143.4514.0405.6705l1.8343,7.3371c.0548.2191.0685.4464.0405.6705l-.9152,7.322c-.028.2241-.0143.4514.0405.6705l1.7456,6.9825c.1104.4414.3813.8259.7599,1.0783l1.7312,1.1541c.433.2887.7225.7481.7961,1.2632l-.2598,3.7979s-.2916,3.1088,2.3012,6.051c1.6332,1.8533,5.7148,3.8846,8.2656,3.8847,3.3409,0,5.9547-1.9378,7.3949-3.6465.8914-1.0576,2.5398-2.4978,3.2821-5.8244.2014-.9023-.0996-3.6967-.0996-3.6967,0-.2477.4009-1.311.4985-1.5386l1.3063-2.5077c.0158-.0369.0264-.0595.0389-.0795.5394-.8642.7885-1.8786.8851-2.8927,0,0,.9043-4.9747,1.1585-6.6406.005-.0325-.4996-5.5158-.4953-5.5485l1.5496-6.8172c.0386-.2315.0323-.4683-.0187-.6975l-1.5364-6.9139c-.2151-.9678.3801-1.9308,1.3419-2.1712l1.6416-.4104c.4075-.1019.7676-.341,1.0196-.677l2.2445-2.9927c.2375-.3166.3658-.7017.3658-1.0974v-.1102c0-1.1701-1.1152-1.1434-2.2575-.8896l-6.191.4868c-.3593.0798-.7343.0494-1.0761-.0873l-4.3602-1.7441c-.0767-.0307-.1555-.0562-.2357-.0762l-7.3628-1.8407c-.3375-.0844-.692-.0708-1.022.0393l-5.3714,1.7905c-.0821.0274-.1622.0605-.2396.0992l-3.4104,1.7052c-.3092.1546-.656.2184-1,.184l-7.4623-.7462c-1.0768-.1077-2.0111.7379-2.0111,1.82Z"/> <path android:fillColor="#fff" android:pathData="m15,12.0211h0c0,.6116.3056,1.1827.8145,1.5219l4.2449,2.83c.5764.3843,1.774.8721,1.688,1.5596l-1.7051,6.7284c-.028.2241-.0143.4514.0405.6705l1.8343,7.3371c.0548.2191.0685.4464.0405.6705l-.9152,7.322c-.028.2241-.0143.4514.0405.6705l1.7456,6.9825c.1104.4414.3813.8259.7599,1.0783l1.7312,1.1541c.433.2887.7225.7481.7961,1.2632l-.2598,3.7979s-.2916,3.1088,2.3012,6.051c1.6332,1.8533,5.7148,3.8846,8.2656,3.8847,3.3409,0,5.9547-1.9378,7.3949-3.6465.8914-1.0576,2.5398-2.4978,3.2821-5.8244.2014-.9023-.0996-3.6967-.0996-3.6967,0-.2477.4009-1.311.4985-1.5386l1.3063-2.5077c.0158-.0369.0264-.0595.0389-.0795.5394-.8642.7885-1.8786.8851-2.8927,0,0,.9043-4.9747,1.1585-6.6406.005-.0325-.4996-5.5158-.4953-5.5485l1.5496-6.8172c.0386-.2315.0323-.4683-.0187-.6975l-1.5364-6.9139c-.2151-.9678.3801-1.9308,1.3419-2.1712l1.6416-.4104c.4075-.1019.7676-.341,1.0196-.677l2.2445-2.9927c.2375-.3166.3658-.7017.3658-1.0974v-.1102c0-1.1701-1.1152-1.1434-2.2575-.8896l-6.191.4868c-.3593.0798-.7343.0494-1.0761-.0873l-4.3602-1.7441c-.0767-.0307-.1555-.0562-.2357-.0762l-7.3628-1.8407c-.3375-.0844-.692-.0708-1.022.0393l-5.3714,1.7905c-.0821.0274-.1622.0605-.2396.0992l-3.4104,1.7052c-.3092.1546-.656.2184-1,.184l-7.4623-.7462c-1.0768-.1077-2.0111.7379-2.0111,1.82Z"/>
@@ -43,17 +29,6 @@
</group> </group>
<!-- Massive Background Coin Base -->
<path
android:pathData="M 256,76 A 180,180 0 1,1 255.9,76 Z"
android:fillColor="#FBBF24"/>
<!-- Coin Decorative Dotted Inner Rim -->
<path
android:pathData="M 256,101 A 155,155 0 1,1 255.9,101 Z"
android:strokeColor="#D97706"
android:strokeWidth="4"/>
</group> </group>
</group> </group>
</group> </group>
+216 -217
View File
@@ -2,225 +2,150 @@
<resources xmlns:tools="http://schemas.android.com/tools"> <resources xmlns:tools="http://schemas.android.com/tools">
<string name="app_name">Cowspent</string> <string name="app_name">Cowspent</string>
<string name="label_all_bills">All bills</string>
<string name="action_create_bill">New bill</string> <!-- Actions -->
<string name="action_settings">App settings</string> <string name="action_new_bill">New bill</string>
<string name="action_scan_qrcode">Scan QRCode from Cospend/Cowspent</string>
<string name="action_add_project">Add project</string> <string name="action_add_project">Add project</string>
<string name="action_save_bill">Save bill</string> <string name="action_save">Save</string>
<string name="action_edit_project">Edit project</string> <string name="action_edit">Edit</string>
<string name="action_share_project">Share project</string> <string name="action_share">Share</string>
<string name="setting_what">What?</string>
<string name="setting_payer">Who paid?</string>
<string name="setting_all">All</string>
<string name="setting_none">None</string>
<string name="save_or_discard_bill_dialog_title">Bill edition</string>
<string name="save_or_discard_bill_dialog_message">Bill values have been changed. What do you want to do?</string>
<string name="save_or_discard_bill_dialog_save">Save</string>
<string name="save_or_discard_bill_dialog_discard">Discard</string>
<string name="currency_manager">Currency manager</string>
<string name="setting_comment">Comment</string>
<string name="currency_dialog_title">Convert current amount from another currency into %s</string>
<string name="main_currency">Main currency</string>
<string name="setting_owers">For whom?</string>
<string name="setting_project_id">Project ID/name</string>
<string name="setting_project_repetition">Repeat this bill every</string>
<string name="setting_payment_mode">Mode</string>
<string name="setting_category">Category</string>
<string name="action_label_bills">Label missing categories</string>
<string name="label_bills_title">Label Bills</string>
<string name="label_bills_no_more">No more bills to label</string>
<string name="label_bills_skip">Skip</string>
<string name="label_bills_suggested">Suggested</string>
<string name="label_bills_no_suggestions">No suggestions for this bill</string>
<string name="new_project_what_todo">What do you want to do?</string>
<string name="new_project_where">Where?</string>
<string name="where_local_short">Local</string>
<string name="where_cospend_short">Cospend</string>
<string name="where_ihatemoney_short">ihatemoney</string>
<string name="setting_ihatemoney_project_url">ihatemoney address</string>
<string name="todo_join_label">Join</string>
<string name="todo_create_label">Create</string>
<string name="setting_cospend_project_url">Nextcloud address</string>
<string name="setting_password">Password (local)</string>
<string name="setting_new_project_password">Password</string>
<string name="setting_new_project_name">Project title</string>
<string name="setting_new_project_email">E-mail</string>
<string name="warning_auth_project_creation">Warning: creating a Cospend project using Nextcloud account works with Cospend v0.3.4 and higher.</string>
<string name="auth_project_creation_title">Authenticated project creation</string>
<string name="project_edition_no_change">Nothing was changed</string>
<string name="project_added_success">Project successfully added to Cowspent</string>
<string name="action_search">Search</string> <string name="action_search">Search</string>
<string name="action_delete">Delete</string> <string name="action_delete">Delete</string>
<string name="simple_error">Error</string> <string name="action_archive">Archive</string>
<string name="simple_close">Close</string> <string name="action_unarchive">Unarchive</string>
<string name="simple_loading">Loading</string> <string name="action_export">Export</string>
<string name="sync_error_dialog_title">Project sync failed</string> <string name="action_stats">Stats</string>
<string name="sync_error_dialog_full_content">Synchronization with project %1$s failed. Error message:\n\n%2$s\n\nThe associated remote project might not exist anymore. If so, you could remove this project from Cowspent.</string> <string name="action_settle">Settle</string>
<string name="error_maintenance_mode">The Nextcloud instance is probably in maintenance mode</string> <string name="action_scan_qrcode">Scan QR Code</string>
<string name="action_settings">Settings</string>
<string name="action_label_bills">Label missing categories</string>
<string name="action_logout">Logout</string>
<string name="action_connect">Connect</string>
<string name="action_discard">Discard</string>
<string name="action_members">Members</string>
<string name="action_labels">Labels</string>
<string name="action_currencies">Currencies</string>
<!-- Titles -->
<string name="title_stats">Statistics</string>
<string name="title_edit_project">Edit project</string>
<string name="title_label_bills">Label Bills</string>
<string name="title_labels">Manage Labels</string>
<string name="title_about">About</string>
<string name="title_settle">Settle Project</string>
<string name="title_share">Share Project</string>
<string name="title_add_project">Add Project</string>
<string name="title_add_category">Add Category</string>
<string name="title_add_payment_mode">Add Payment Mode</string>
<string name="title_account">Nextcloud Account</string>
<string name="title_share_web">Web link</string>
<string name="title_share_qr">Cowspent link</string>
<string name="title_confirm">Are you sure?</string>
<!-- Labels and Fields -->
<string name="label_all_bills">All bills</string>
<string name="label_categories">Categories</string>
<string name="label_payment_modes">Payment Modes</string>
<string name="label_name">Name</string>
<string name="label_icon">Icon / Emoji</string>
<string name="label_color">Color</string>
<string name="label_weight">Weight</string>
<string name="label_activated">Activated</string>
<string name="label_password">Password</string>
<string name="label_email">E-mail</string>
<string name="label_url">Server address</string>
<string name="label_username">Username</string>
<string name="label_comment">Comment</string>
<string name="label_what">What?</string>
<string name="label_payer">Who paid?</string>
<string name="label_owers">For whom?</string>
<string name="label_repeat">Repeat every</string>
<string name="label_mode">Mode</string>
<string name="label_category">Category</string>
<string name="label_project_id">Project ID/name</string>
<string name="label_project_title">Project title</string>
<string name="label_use_sso">Use Nextcloud App Account</string>
<!-- Dialogs and Messages -->
<string name="dialog_unsaved_changes_title">Unsaved changes</string>
<string name="dialog_unsaved_changes_msg">Save changes before leaving?</string>
<string name="dialog_confirm_remove_project_msg">The remote project will not be deleted.</string>
<string name="dialog_sync_error_title">Sync error</string>
<string name="dialog_sync_error_msg">Sync failed for %1$s.\n\n%2$s</string>
<string name="dialog_balanced_msg">Expenses are already balanced.</string>
<string name="msg_project_added">Project %1$s added</string>
<string name="msg_bill_labeled_done">All bills labeled</string>
<string name="msg_no_suggestions">No suggestions</string>
<string name="msg_auth_warning">Requires Cospend v0.3.4+.</string>
<string name="msg_link_copied">Link copied to clipboard</string>
<string name="msg_share_qr">Scan QR code or share the link to join.</string>
<string name="msg_share_web">Link for web browser access.</string>
<string name="msg_share_qr_warn">Share this link with a Cowspent user.</string>
<string name="msg_settle_intro">Settlement for %1$s:</string>
<string name="msg_settle_sentence">%1$s owes %3$.2f to %2$s</string>
<string name="msg_stats_intro">Stats for %1$s:</string>
<string name="msg_stats_header">Member (Paid | Spent | Balance)</string>
<string name="msg_logged_in_as">Logged in as %1$s</string>
<!-- Errors -->
<string name="error_generic">Error</string>
<string name="error_loading">Loading</string>
<string name="error_no_projects">No projects found</string>
<string name="error_no_members">No members found</string>
<string name="error_no_bills">No bills found</string>
<string name="error_no_member">At least one member required</string>
<string name="error_maintenance_mode">Server is in maintenance mode</string>
<string name="error_400">400 Bad request</string> <string name="error_400">400 Bad request</string>
<string name="error_401">401 Unauthorized</string> <string name="error_401">401 Unauthorized</string>
<string name="error_403">403 Forbidden</string> <string name="error_403">403 Forbidden</string>
<string name="error_404">404 Not Found</string> <string name="error_404">404 Not Found</string>
<string name="creating_remote_project">Creating remote project</string> <string name="error_sync">Sync failed: %1$s</string>
<string name="error_invalid_login">Invalid login: %1$s</string>
<string name="error_auth">Wrong username or password</string>
<string name="error_json">Invalid server response</string>
<string name="error_req_failed">Request failed</string>
<string name="error_invalid_email">Invalid e-mail</string>
<string name="error_invalid_project_id">Invalid project ID</string>
<string name="error_invalid_project_name">Invalid project title</string>
<string name="error_invalid_bill_name">Invalid bill name</string>
<string name="error_invalid_bill_date">Invalid bill date</string>
<string name="error_invalid_bill_payer">Payer required</string>
<string name="error_invalid_bill_owers">Owers required</string>
<string name="error_no_network">No network connection</string>
<string name="error_server">Server error</string>
<string name="error_io">Server connection broken</string>
<string name="error_share_impossible">Cannot share this project</string>
<!-- Drawer / Common UI -->
<string name="drawer_no_account">Connect to Nextcloud account</string>
<string name="drawer_last_sync">Last sync: %1$02d:%2$02d</string>
<string name="simple_cancel">Cancel</string> <string name="simple_cancel">Cancel</string>
<string name="simple_ok" tools:ignore="ButtonCase,Typos">Ok</string> <string name="simple_ok" tools:ignore="ButtonCase,Typos">Ok</string>
<string name="simple_yes">Yes</string> <string name="simple_yes">Yes</string>
<string name="simple_no">No</string> <string name="simple_no">No</string>
<string name="simple_edit_bill">Edit bill</string> <string name="simple_close">Close</string>
<string name="simple_new_bill">New bill</string>
<string name="simple_add_project">Add project in Cowspent</string>
<string name="simple_edit_project">Edit project</string>
<string name="simple_about">About</string>
<string name="share_impossible">Impossible to share this project</string>
<string name="simple_settle_share">Share</string>
<string name="simple_create_bills">Create bills</string>
<string name="settle_bill_what">Settlement bill</string>
<string name="simple_share_share">Share Cowspent link</string>
<string name="menu_delete_project_remote">Delete project on server</string>
<string name="menu_save_project">Save project</string>
<string name="fab_add_member">Add a member</string>
<string name="fab_rm_project">Remove Project</string>
<string name="remove_project_confirmation">Project %1$s has been removed</string>
<string name="choose_project_management_action">Manage project</string>
<string name="fab_manage_members">Manage members</string>
<string name="fab_export_project">Export project</string>
<string name="drawer_last_sync_text">Last sync: %1$02d:%2$02d</string>
<string name="scan_qrcode">Scan QR code</string>
<string name="new_project_from_nextcloud_tooltip">Choose from Nextcloud account</string>
<string name="import_tooltip">Import from CSV file</string>
<string name="import_error_owers">Malformed CSV, bad owers on line %1$d</string>
<string name="import_error_date">Malformed CSV, bad date on line %1$d</string>
<string name="import_error_header">Malformed CSV, bad column names on line %1$d</string>
<string name="member_already_exists">This member already exists in this project</string>
<string name="confirm_remove_project_dialog_title">Are you sure?</string>
<string name="confirm_delete_project_dialog_title">Are you sure you want to delete remote project?</string>
<string name="confirm_remove_project_dialog_message">The remote project will NOT be deleted</string>
<string name="member_edit_toggle">Activated</string>
<string name="member_edit_name">Name</string>
<string name="member_edit_color">Color</string>
<string name="member_edit_weight">Weight</string>
<string name="member_edit_delete">Delete member</string>
<string name="remote_project_operation_no_network">Remote project operations require network connectivity</string>
<string name="insufficient_access_level">You are not allowed to perform this action</string>
<!-- Settings --> <!-- Settings -->
<string name="settings_appearance_category">Appearance</string> <string name="settings_appearance">Appearance</string>
<string name="settings_network_category">Network</string> <string name="settings_network">Network</string>
<string name="settings_other_category">Other</string> <string name="settings_other">Other</string>
<string name="settings_about">About Cowspent</string> <string name="settings_night_mode">Theme</string>
<string name="settings_server_settings">Nextcloud account</string> <string name="settings_offline_mode">Offline mode</string>
<string name="account_logged_in_as">Logged in as %1$s</string>
<string name="account_logout">Logout</string>
<string name="settings_url">Server address</string>
<string name="settings_url_warn_http">WARNING: "http" is unsafe. Please use "https".</string>
<string name="settings_username">Username</string>
<string name="settings_password">Password</string>
<string name="settings_submit">Connect</string>
<string name="settings_night_mode_title">Theme</string>
<string name="settings_offline_mode_title">Offline mode</string>
<string name="settings_offline_mode_summary">Only sync manually.</string> <string name="settings_offline_mode_summary">Only sync manually.</string>
<string name="settings_color_title">Custom color</string> <string name="settings_color_custom">Custom color</string>
<string name="settings_color_summary">UI Accent color</string> <string name="settings_color_mode">Color Selection</string>
<string name="settings_colorpicker_title">Choose color</string> <string name="settings_show_archived">Show archived projects</string>
<string name="settings_color_mode_title">Color Selection</string> <string name="settings_url_warn_http">WARNING: "http" is unsafe. Use "https".</string>
<string name="settings_colorpicker_title">Choose Color</string>
<string name="pref_value_color_system">System</string> <string name="pref_value_color_system">System</string>
<string name="pref_value_color_server">Nextcloud</string> <string name="pref_value_color_server">Nextcloud</string>
<string name="pref_value_color_manual">Manual</string> <string name="pref_value_color_manual">Manual</string>
<string name="pref_value_theme_light">Light</string>
<string name="pref_value_theme_dark">Dark</string>
<string name="pref_value_theme_system">Follow system</string>
<!-- Constants (Do not translate) -->
<string name="currency_saved_success">Main currency saved</string>
<string name="file_saved_success">Project saved in %s</string>
<string name="add_member_dialog_title">New member name</string>
<string name="edit_member_dialog_title">Edit member</string>
<string name="center_none">Optimal</string>
<string name="settle_who">Who pays?</string>
<string name="settle_to_whom">To whom?</string>
<string name="settle_how_much">How much?</string>
<string name="total">Total Spent: %s</string>
<string name="stats_date_min">From</string>
<string name="stats_date_max">To</string>
<string name="stats_who">Who</string>
<string name="stats_paid">Paid</string>
<string name="stats_spent">Spent</string>
<string name="stats_balance">Balance</string>
<string name="fab_statistics">Statistics</string>
<string name="fab_settle">Settle</string>
<string name="project_add_success_message">Project %1$s was successfully added</string>
<string name="project_create_success_message">Project %1$s was successfully created</string>
<string name="project_add_success_title">Project added</string>
<string name="project_create_success_title">Project created</string>
<string name="statistic_title">Statistics</string>
<string name="settle_dialog_title">Settle the project</string>
<string name="settle_dialog_balanced">The expenses in this project are already balanced. There is nothing to settle!</string>
<string name="share_dialog_title">Share the project</string>
<string name="share_project_dialog_message">Scan this QRCode with Cowspent or a scanner app on an Android device where Cowspent is installed. You can also simply send/share the Cowspent link above.</string>
<string name="share_project_public_url_dialog_message">Share this web link to let others access the project with a web browser.</string>
<string name="share_project_public_url_title">Web link</string>
<string name="share_project_public_qrcode_title">Cowspent link</string>
<string name="qrcode_link_open_attempt_warning">Share this link to a Cowspent user.</string>
<string name="share_share_intent_title">Cowspent link of %1$s</string>
<string name="share_share_chooser_title">Share Cowspent link of %1$s</string>
<string name="share_settle_intro">Here is how to settle project %1$s :</string>
<string name="share_settle_sentence">%1$s owes %3$.2f to %2$s</string>
<string name="share_settle_title">Settle the project %1$s</string>
<string name="share_stats_intro">Statistics of project %1$s :</string>
<string name="share_stats_header">MEMBER_NAME (PAID | SPENT | BALANCE)</string>
<string name="drawer_no_account">Click here to connect to a Nextcloud account</string>
<string name="add_bill_impossible_no_member">At least one activated member is required to add a bill</string>
<string name="edit_project_local_impossible">Impossible for local projects</string>
<string name="choose_account_project_dialog_title">Choose a project from your Nextcloud account</string>
<string name="choose_account_project_dialog_impossible">There is no Cospend project in your Nextcloud account</string>
<string name="activity_dialog_title">Activity in project %1$s</string>
<!-- Bills list corner cases -->
<string name="no_projects_title">No Projects</string>
<string name="no_projects_text">You don\'t have any projects yet. If you have Cospend installed on a Nextcloud server, you can configure your Nextcloud account. Alternatively, you can manually add projects.</string>
<string name="configure_account_choice">Configure Nextcloud account</string>
<string name="add_project_choice">Manually add a project (Cospend or local)</string>
<string name="no_members_title">No Members</string>
<string name="no_members_text">This project doesn\'t have any members. In order to create bills, you need to add at least one member (better two or more).</string>
<string name="no_bills_title">No Bills</string>
<string name="no_bills_text">This project doesn\'t have any bills. Click the button below to add your first bill.</string>
<!-- Certificates -->
<!-- Network -->
<!-- Error -->
<string name="error_project_connect_check">Impossible to reach the remote project:\n\n%1$s</string>
<string name="error_sync">Project sync failed: %1$s</string>
<string name="error_invalid_login">Invalid login: %1$s</string>
<string name="error_json">is the Cospend app activated on the server?</string>
<string name="error_io">server connection is broken</string>
<string name="error_no_network">no network connection</string>
<string name="error_server">server error</string>
<string name="error_username_password_invalid">wrong username or password</string>
<string name="error_invalid_email">Invalid e-mail address</string>
<string name="error_invalid_project_remote_id">Invalid project ID/name</string>
<string name="error_invalid_project_name">Invalid project title</string>
<string name="error_invalid_bill_what">Invalid bill name</string>
<string name="error_invalid_bill_date">Invalid bill date</string>
<string name="error_invalid_bill_payerid">Bill should have a payer</string>
<string name="error_invalid_bill_owers">Bill should have at least one ower</string>
<string name="error_create_remote_project_helper">Error at remote project creation : %s</string>
<string name="error_edit_remote_project_helper">Error at remote project edition : %s</string>
<string name="error_token_mismatch">Please reauthenticate your SSO account in settings</string>
<string name="error_req_failed">Request failed</string>
<!-- About -->
<string name="about_version">Version %1$s</string>
<string name="about_maintainer_title">Maintainer</string>
<string name="about_maintainer" translatable="false">Helcel.net</string>
<string name="about_license_title">License</string>
<string name="about_license" translatable="false">GNU GPL3x / CC BY-SA 4.0</string>
<string name="about_source_title">Source code</string>
<string name="about_source" translatable="false">https://github.com/helcel-net/cowspent</string>
<!-- Key / Values for preferences -->
<string name="pref_key_night_mode" translatable="false">nightMode</string> <string name="pref_key_night_mode" translatable="false">nightMode</string>
<string name="pref_key_color" translatable="false">appColor</string> <string name="pref_key_color" translatable="false">appColor</string>
<string name="pref_key_server_color" translatable="false">serverColor</string> <string name="pref_key_server_color" translatable="false">serverColor</string>
@@ -230,36 +155,28 @@
<string name="pref_key_color_mode" translatable="false">colorMode</string> <string name="pref_key_color_mode" translatable="false">colorMode</string>
<string name="pref_key_offline_mode" translatable="false">offlineMode</string> <string name="pref_key_offline_mode" translatable="false">offlineMode</string>
<string name="pref_key_show_archived" translatable="false">showArchived</string> <string name="pref_key_show_archived" translatable="false">showArchived</string>
<string name="pref_value_theme_light">Light</string>
<string name="pref_value_theme_dark">Dark</string>
<string name="pref_value_theme_system">Follow system</string>
<string name="pref_value_night_mode_no" translatable="false">1</string> <string name="pref_value_night_mode_no" translatable="false">1</string>
<string name="pref_value_night_mode_yes" translatable="false">2</string> <string name="pref_value_night_mode_yes" translatable="false">2</string>
<string name="pref_value_night_mode_system" translatable="false">-1</string> <string name="pref_value_night_mode_system" translatable="false">-1</string>
<string name="use_sso_toggle">Use Nextcloud app account</string>
<!-- QR Code Scanner -->
<string name="error_scanning_bill_qr_code">QR code could not be scanned.</string>
<string name="fab_manage_currencies">Manage currencies</string>
<string name="setting_show_archived">Show archived projects</string>
<!-- Enums and Lists -->
<string name="repeat_no">No repeat</string> <string name="repeat_no">No repeat</string>
<string name="repeat_day">Daily</string> <string name="repeat_day">Daily</string>
<string name="repeat_week">Weekly</string> <string name="repeat_week">Weekly</string>
<string name="repeat_fortnight">Fortnightly</string> <string name="repeat_fortnight">Fortnightly</string>
<string name="repeat_month">Monthly</string> <string name="repeat_month">Monthly</string>
<string name="repeat_year">Yearly</string> <string name="repeat_year">Yearly</string>
<string name="payment_mode_none">No payment mode</string>
<string name="payment_mode_all">All payment modes</string> <string name="payment_mode_none">None</string>
<string name="payment_mode_all">All</string>
<string name="payment_mode_credit_card">Credit card</string> <string name="payment_mode_credit_card">Credit card</string>
<string name="payment_mode_cash">Cash</string> <string name="payment_mode_cash">Cash</string>
<string name="payment_mode_check">Check</string> <string name="payment_mode_check">Check</string>
<string name="payment_mode_online">Online service</string> <string name="payment_mode_online">Online</string>
<string name="payment_mode_transfer">Transfer</string> <string name="payment_mode_transfer">Transfer</string>
<string name="category_none">No category</string>
<string name="category_all">All categories</string> <string name="category_none">None</string>
<string name="category_all">All</string>
<string name="category_all_except_reimbursement">All except reimbursement</string> <string name="category_all_except_reimbursement">All except reimbursement</string>
<string name="category_groceries">Grocery</string> <string name="category_groceries">Grocery</string>
<string name="category_leisure">Bar/Party</string> <string name="category_leisure">Bar/Party</string>
@@ -273,4 +190,86 @@
<string name="category_accomodation">Accommodation</string> <string name="category_accomodation">Accommodation</string>
<string name="category_transport">Transport</string> <string name="category_transport">Transport</string>
<string name="category_sport">Sport</string> <string name="category_sport">Sport</string>
<!-- Project specific -->
<string name="new_project_action">What</string>
<string name="new_project_where">Where</string>
<string name="where_local">Local only</string>
<string name="where_cospend">Cospend</string>
<string name="where_ihatemoney">IHateMoney</string>
<string name="todo_join">Join existing project</string>
<string name="todo_create">Create new project</string>
<string name="import_tooltip">Import from file</string>
<string name="choose_account_project_dialog_title">Choose project</string>
<string name="choose_account_project_dialog_impossible">No projects found on this account.</string>
<string name="choose_project_management_action">Project</string>
<string name="project_added_success">Project added successfully.</string>
<string name="no_projects_text">You have no projects yet.</string>
<string name="configure_account_choice">Configure Nextcloud account</string>
<string name="add_project_choice">Add project manually</string>
<string name="no_members_text">No members in this project.</string>
<string name="no_bills_text">No bills found.</string>
<string name="member_already_exists">Member already exists.</string>
<string name="activity_dialog_title">Project: %1$s</string>
<string name="remove_project_confirmation">Project %1$s removed.</string>
<string name="file_saved_success">File saved: %1$s</string>
<string name="import_error_header">Import failed at row %d</string>
<string name="import_error_date">Invalid date format at row %d</string>
<string name="import_error_owers">Invalid owers at row %d</string>
<string name="add_member_dialog_title">Add Member</string>
<string name="edit_member_dialog_title">Edit Member</string>
<string name="member_edit_delete">Delete</string>
<string name="project_edition_no_change">No changes to save.</string>
<!-- Settlement -->
<string name="center_none">None (Optimal)</string>
<string name="settle_who">Who pays</string>
<string name="settle_to_whom">To whom</string>
<string name="settle_how_much">Amount</string>
<string name="simple_settle_share">Share</string>
<string name="simple_create_bills">Create bills</string>
<string name="settle_bill_what">Settlement</string>
<!-- Currencies -->
<string name="currency_dialog_title">Choose Currency (%s)</string>
<string name="setting_none">None</string>
<string name="setting_all">All</string>
<string name="currency_saved_success">Currency settings saved.</string>
<string name="main_currency">Main Currency</string>
<!-- Statistics -->
<string name="label_bills_suggested">Suggested Categories</string>
<string name="label_bills_skip">Skip</string>
<string name="stats_date_min">From</string>
<string name="stats_date_max">To</string>
<string name="stats_who">Member</string>
<string name="stats_paid">Paid</string>
<string name="stats_spent">Spent</string>
<string name="stats_balance">Balance</string>
<string name="total">Total: %1$s</string>
<!-- Errors Extra -->
<string name="error_project_connect_check">Connection failed: %1$s</string>
<string name="error_create_remote_project_helper">Creation failed: %1$s</string>
<string name="error_edit_remote_project_helper">Error updating remote project: %1$s</string>
<string name="remote_project_operation_no_network">Network unavailable for remote operation.</string>
<string name="error_scanning_bill_qr_code">Failed to parse QR code.</string>
<string name="error_token_mismatch">Authentication token mismatch. Please log in again.</string>
<string name="insufficient_access_level">You don\'t have permission to perform this action.</string>
<string name="delete_label_confirmation_title">Delete Label</string>
<string name="delete_label_confirmation_message">Are you sure you want to delete this label?</string>
<!-- About -->
<string name="about_version">Version %1$s</string>
<string name="about_maintainer_title">Maintainer</string>
<string name="about_maintainer" translatable="false">Helcel.net</string>
<string name="about_license_title">License</string>
<string name="about_license" translatable="false">GNU GPL3x / CC BY-SA 4.0</string>
<string name="about_source_title">Source code</string>
<string name="about_source" translatable="false">https://github.com/helcel-net/cowspent</string>
<!-- New constants for backward compatibility or shared use -->
<string name="share_intent_title">Project %1$s</string>
<string name="share_chooser_title">Share %1$s</string>
</resources> </resources>
-2
View File
@@ -19,8 +19,6 @@ This means you can choose where your data is going and preserve your privacy.
* Dark theme and customizable main app color * Dark theme and customizable main app color
* Share/import projects with link/QRCode * Share/import projects with link/QRCode
* Connect to a Nextcloud account to automatically add projects * 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 # Requirements