Author SHA1 Message Date
Renovate Bot d0ab7f6286 Update plugin org.jetbrains.kotlin.plugin.compose to v2.4.21 2026-10-09 02:01:52 +00:00
sora c2da40ba4d Update .github/workflows/build.yml 2026-10-03 19:38:47 +02:00
sora 6e45507b22 Merge pull request 'Update plugin org.jetbrains.kotlin.plugin.serialization to v2.4.20' (#15) from renovate/org.jetbrains.kotlin.plugin.serialization-2.x into main
Reviewed-on: #15
2026-10-03 19:33:49 +02:00
sora 99d05680c5 Merge pull request 'Update dependency androidx.compose.runtime:runtime to v1.12.1' (#17) from renovate/androidx.compose.runtime-runtime-1.x into main
Reviewed-on: #17
2026-10-03 19:33:35 +02:00
Renovate Bot d89d99ceff Update plugin org.jetbrains.kotlin.plugin.serialization to v2.4.20 2026-10-01 02:01:46 +00:00
Renovate Bot 40be1b79f2 Update dependency androidx.compose.runtime:runtime to v1.12.1 2026-10-01 02:01:44 +00:00
sora e807d6fa66 Merge pull request 'Update dependency androidx.work:work-runtime-ktx to v2.12.0' (#20) from renovate/androidx.work-work-runtime-ktx-2.x into main
Reviewed-on: #20
2026-09-30 23:03:02 +02:00
sora 8ae8df6ef8 Merge pull request 'Update dependency org.robolectric:robolectric to v4.17' (#19) from renovate/org.robolectric-robolectric-4.x into main
Reviewed-on: #19
2026-09-30 23:02:53 +02:00
sora 791f3572b6 Merge pull request 'Update dependency androidx.compose:compose-bom to v2026.09.00' (#18) from renovate/androidx.compose-compose-bom-2026.x into main
Reviewed-on: #18
2026-09-30 23:02:34 +02:00
sora ce8053ff2c Merge pull request 'Update dependency androidx.compose.foundation:foundation to v1.12.1' (#16) from renovate/androidx.compose.foundation-foundation-1.x into main
Reviewed-on: #16
2026-09-30 23:02:15 +02:00
sora f5f4c083f0 Merge pull request 'Update plugin org.jetbrains.kotlin.plugin.compose to v2.4.20' (#14) from renovate/org.jetbrains.kotlin.plugin.compose-2.x into main
Reviewed-on: #14
2026-09-30 23:01:39 +02:00
sora 6800fc6c41 Direct Settlement 2026-09-30 22:51:10 +02:00
sora 642b3b9c68 Fix keyboard (and add calculator bar) 2026-09-30 22:41:10 +02:00
sora 291694eaf4 Allow Comma values 2026-09-30 22:18:25 +02:00
Renovate Bot 1f56757d30 Update dependency androidx.work:work-runtime-ktx to v2.12.0 2026-09-24 02:02:58 +00:00
sora b518c2a8c2 Update .github/workflows/build.yml 2026-09-20 09:00:12 +02:00
sora 5054525cfd Update README.md 2026-09-19 18:11:05 +02:00
sora 47b74de720 Upload files to ".github/images" 2026-09-19 17:59:41 +02:00
sora 86384dd826 Upload files to ".github/images" 2026-09-19 17:50:35 +02:00
sora 625747389b Update .github/workflows/build.yml 2026-09-19 12:20:29 +02:00
sora bdc39d3c7e Update PRIVACY.md 2026-09-19 10:27:15 +02:00
sora 12b424679d Update .github/workflows/build.yml 2026-09-18 23:40:59 +02:00
sora 3ba5ee4d78 Add app/src/main/assets/adi-registration.properties 2026-09-18 12:20:29 +02:00
sora c01399f45d moved signing key to the end 2026-09-16 23:57:15 +02:00
gtcrowdandGitHub a9cd70ea07 Update README.md to include the signing key
For security and also to have a source of true for AppVerifierBG and https://github.com/privacyguides/verified-apps/
2026-09-11 10:24:33 +00:00
Renovate Bot 73dc64a688 Update dependency org.robolectric:robolectric to v4.17 2026-09-11 02:01:48 +00:00
Renovate Bot 3b22037496 Update dependency androidx.compose:compose-bom to v2026.09.00 2026-09-11 02:01:46 +00:00
sora c21a2b05ef Merge remote-tracking branch 'gh/pr/22' into fix/large-project-sync-feedback 2026-09-11 01:15:34 +02:00
KirillandGitHub 7ffda1c2f9 Russian translations proposal 2026-09-10 15:05:27 +03:00
Renovate Bot 206c756ad7 Update dependency androidx.compose.foundation:foundation to v1.12.1 2026-09-10 02:01:21 +00:00
sora 405ff5c525 Warn if logout/delete would impact unsynced data 2026-09-10 01:58:45 +02:00
sora 49a275c53e Fix sync of archived 2026-09-10 01:30:25 +02:00
sora b7037d8074 Restore deleted 2026-09-10 01:29:37 +02:00
sora 98b7bcaaf7 webview cleanup 2026-09-10 01:28:19 +02:00
sora 50c82068a4 Login/Logout Improvements 2026-09-10 00:37:09 +02:00
sora a83601c0c0 sync trigger logic improvements 2026-09-10 00:35:43 +02:00
sora 5bc83e22ba Add local delete of archived projects 2026-09-10 00:34:02 +02:00
sora d6ee50bc34 Sync Flag lifecycle 2026-09-10 00:26:55 +02:00
sora 06f31571a3 Fix db crash on big projects 2026-09-10 00:26:16 +02:00
sora c70bb808c1 UI fix of button overlap in label lists 2026-09-09 10:23:37 +02:00
sora d2d713c616 Autofocus race condition with UI 2026-09-09 09:20:47 +02:00
sora d7a51a797f Settings wording 2026-09-09 09:02:06 +02:00
sora de5f9b09a1 PartialSync avoids Members and Project sync 2026-09-09 08:56:05 +02:00
sora 2ec5c00df1 Import/Export Improvements 2026-09-09 08:53:41 +02:00
sora 561d3a4ed8 Avoid deletion of non-synced bills 2026-09-09 00:36:20 +02:00
sora d836be88c6 Sync improvements 2026-09-09 00:33:54 +02:00
sora b179edf086 UI Feedback 2026-09-09 00:28:53 +02:00
sora dde1042701 Batching 2026-09-09 00:27:13 +02:00
Renovate Bot a66d046b3f Update plugin org.jetbrains.kotlin.plugin.compose to v2.4.20 2026-09-08 02:01:15 +00:00
sora a67ce253f9 Removed unused callback 2026-09-06 19:55:31 +02:00
59 changed files with 1663 additions and 858 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

+20 -2
View File
@@ -34,6 +34,11 @@ jobs:
gpg -d --passphrase "${{ secrets.RELEASE_KEYSTORE_PASSWORD }}" --batch keystore.asc > app/keystore.properties
gpg -d --passphrase "${{ secrets.RELEASE_KEYSTORE_PASSWORD }}" --batch key.asc > app/key.jks
- name: Generate Dynamic Release Notes
run: |
mkdir -p tmp/whatsnew
echo "Various improvements for you to experience..." > tmp/whatsnew/whatsnew-en-US
- name: create and checkout branch
if: github.event_name == 'pull_request'
env:
@@ -62,7 +67,9 @@ jobs:
else
VERSION_NAME="${VERSION_BASE}"
fi
./gradlew assembleSignedRelease -PVERSION_CODE=$VERSION_CODE -PVERSION_NAME=$VERSION_NAME
./gradlew bundleSignedRelease assembleSignedRelease -PVERSION_CODE=$VERSION_CODE -PVERSION_NAME=$VERSION_NAME
# ./gradlew assembleSignedRelease -PVERSION_CODE=$VERSION_CODE -PVERSION_NAME=$VERSION_NAME
# ./gradlew bundleSignedRelease -PVERSION_CODE=$VERSION_CODE -PVERSION_NAME=$VERSION_NAME
- name: Upload APK
uses: actions/upload-artifact@v7
@@ -82,4 +89,15 @@ jobs:
echo "false"
else
echo "true"
fi
fi
- name: Upload to Google Play
uses: r0adkll/upload-google-play@v1
# if: startsWith(github.ref, 'refs/tags/')
with:
serviceAccountJsonPlainText: ${{ secrets.SERVICE_ACCOUNT_JSON }}
packageName: net.helcel.cowspent
releaseFiles: app/build/outputs/bundle/signedRelease/app-signedRelease.aab
tracks: ${{ startsWith(github.ref, 'refs/tags/') && 'production' || 'internal' }}
status: completed
whatsNewDirectory: tmp/whatsnew
+70 -8
View File
@@ -1,12 +1,74 @@
# Cowspent Privacy Policy
# Privacy Policy for Cowspent
Cowspent does not collect any personal data.
This is a Free/Libre software so you can verify no data is collected
by reading the source code or asking someone to do it.
**App:** Cowspent (`net.helcel.cowspent`)
**Developer:** Helcel
**Effective date:** 19 September 2026
Members and bills are synchronized with
the Cospend server of your choice.
## What we collect
CAMERA permission is used to read QR-codes.
Nothing. We operate no servers and receive no data from the app. Cowspent
contains no analytics, advertising, or tracking libraries.
GET\_ACCOUNTS permission is used by Single-Sign-On library to access accounts from Nextcloud Files app.
## Data you enter and where it goes
Cowspent stores your projects, members, bills, categories, and app settings on
your device.
If you connect the app to a Cospend or IHateMoney server, this data is
synchronised with **the server you choose**: either your own or one operated by
a third party. That server's operator controls that copy of your data and their
privacy policy applies to it. We are not involved in that transfer and cannot
access it.
Your server address, username, and password or access token are stored on your
device and sent only to the server you configured, over the connection it
provides. If you sign in through the Nextcloud Files app, authentication is
handled by that app's Single Sign-On and we never see your password.
## Sharing projects
Project share links and QR codes are generated by your server. Anyone who
obtains a link or code may be able to access the project, so share them only
with people you trust.
## Permissions
- `INTERNET`, `ACCESS_NETWORK_STATE` — synchronising with your chosen server.
- `CAMERA` — scanning QR codes to import a project. Images are processed on the
device, never stored or transmitted.
- `GET_ACCOUNTS` — used by the Nextcloud Single Sign-On library to list accounts
from the Nextcloud Files app.
## Retention and deletion
Data on your device remains until you delete it. Clearing the app's data in
Android Settings, or uninstalling Cowspent, removes the local copy and the
stored credentials. Data already synchronised to a server must be deleted on
that server, by you or its administrator.
## Children
Cowspent is suitable for all ages and collects no data from any user, including
children.
## Security
Credentials and project data are held in the app's private storage, protected by
the Android sandbox. Use an HTTPS server address so that synchronisation is
encrypted in transit.
## Open source
Cowspent is released under the GNU General Public License v3.0. The full source
is available at https://github.com/helcel-net/cowspent, so these statements can
be independently verified.
## Changes
Any change affecting privacy will be published in this document before or with
the release that introduces it.
## Contact
Email: net-helcel+privacy@gmail.com
Issues: https://github.com/helcel-net/cowspent/issues
+19 -4
View File
@@ -12,8 +12,6 @@ Which is originally a fork of [PhoneTrack-Android](https://gitlab.com/eneiluj/ph
Which is itself a fork of [Nextcloud Notes for Android](https://github.com/stefan-niedermann/nextcloud-notes).
Many thanks to their developers :heart: !
</p>
<a href="https://ko-fi.com/I2I615VP5M"><img src="https://ko-fi.com/img/githubbutton_sm.svg" alt="ko-fi"></a>
@@ -24,6 +22,7 @@ Many thanks to their developers :heart: !
<a href="https://github.com/helcel-net/cowspent/actions/workflows/build.yml">
<img src="https://github.com/helcel-net/cowspent/actions/workflows/build.yml/badge.svg?branch=main" alt="Build Status">
</a>
</div>
## 🌄 Screenshots
@@ -57,18 +56,27 @@ Extra Features:
- Support for Cospend archived-status (hide archived projects by default)
- Modern UI and code with Kotlin and Jetpack-Compose
## 📳 Installation
<div style="display: flex; justify-content: center; align-items: center; flex-direction: row;">
<a href="https://apt.izzysoft.de/fdroid/index/apk/net.helcel.cowspent">
<img width="200" height="80" alt="Izzy Download" src=".github/images/izzy.png">
<img width="200" height="84" alt="Izzy Download" src=".github/images/izzy.png">
</a>
<a href="https://github.com/helcel-net/cowspent/releases/latest">
<img width="200" height="84" alt="APK Download" src=".github/images/apk.png">
</a>
<a future-href="https://f-droid.org/en/packages/net.helcel.cowspent">
<img width="200" height="84" alt="Fdroid Download" src=".github/images/fdroid.png">
</a>
<a href="https://play.google.com/store/apps/details?id=net.helcel.cowspent">
<img width="200" height="84" alt="GooglePlay Download" src=".github/images/playstore.png">
</a>
</div>
Note:
- Fdroid: requested, see https://gitlab.com/fdroid/rfp/-/work_items/4412
- PlayStore: alpha test restriction by Google. To access, join https://groups.google.com/g/helcel-android-test
## ⚙️ Permissions
- `CAMERA`: recommended for scanning barcodes from camera
@@ -88,6 +96,13 @@ Cowspent is a user-driven project. We welcome any contribution, big or small.
Thanks to all contributors, the developers of our dependencies, and our users.
## Signinig key
```
net.helcel.cowspent
D3:EE:68:A8:5A:7F:35:A1:14:D2:74:B4:50:AA:CE:8C:ED:68:EA:C8:1A:A2:22:E2:7F:F5:0E:98:4C:C6:29
```
## 📝 License
```
+7 -7
View File
@@ -1,7 +1,7 @@
plugins {
id 'com.android.application' version '9.3.2'
id 'org.jetbrains.kotlin.plugin.serialization' version '2.4.20'
id 'org.jetbrains.kotlin.plugin.compose' version '2.4.10'
id 'org.jetbrains.kotlin.plugin.compose' version '2.4.21'
id 'jacoco'
}
@@ -118,12 +118,12 @@ android {
}
dependencies {
implementation 'androidx.compose.foundation:foundation:1.12.0'
implementation 'androidx.compose.runtime:runtime:1.12.0'
implementation 'androidx.compose.foundation:foundation:1.12.1'
implementation 'androidx.compose.runtime:runtime:1.12.1'
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.5'
implementation 'androidx.preference:preference-ktx:1.2.1'
implementation 'androidx.work:work-runtime-ktx:2.11.2'
implementation 'androidx.work:work-runtime-ktx:2.12.0'
implementation 'com.google.zxing:core:3.5.4'
implementation 'androidx.camera:camera-camera2:1.6.2'
@@ -132,7 +132,7 @@ dependencies {
implementation 'com.github.nextcloud:Android-SingleSignOn:1.3.4'
implementation 'com.opencsv:opencsv:5.12.0'
implementation platform('androidx.compose:compose-bom:2026.08.00')
implementation platform('androidx.compose:compose-bom:2026.09.00')
implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.material:material'
implementation 'androidx.compose.material:material-icons-extended'
@@ -147,12 +147,12 @@ dependencies {
testImplementation 'junit:junit:4.13.2'
testImplementation 'io.mockk:mockk:1.14.11'
testImplementation 'org.robolectric:robolectric:4.16.1'
testImplementation 'org.robolectric:robolectric:4.17'
testImplementation 'androidx.test:core:1.7.0'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0'
testImplementation 'androidx.compose.ui:ui-test-junit4'
androidTestImplementation platform('androidx.compose:compose-bom:2026.08.00')
androidTestImplementation platform('androidx.compose:compose-bom:2026.09.00')
androidTestImplementation 'androidx.test.ext:junit:1.3.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0'
androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
@@ -0,0 +1 @@
CUE6FCSBR6JIAAAAAAAAAAAAAA
@@ -1,7 +1,6 @@
package net.helcel.cowspent.android.account
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.os.Build
@@ -38,6 +37,8 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.helcel.cowspent.R
import net.helcel.cowspent.android.main.MainConstants
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.persistence.CowspentServerSyncHelper
import net.helcel.cowspent.theme.ThemeUtils
import net.helcel.cowspent.util.CospendClientUtil
import net.helcel.cowspent.util.CospendClientUtil.LoginStatus
@@ -108,8 +109,7 @@ class AccountActivity : AppCompatActivity() {
viewModel.useSso = false
preferences.edit { putBoolean(SETTINGS_USE_SSO, false) }
}
},
onLogout = { viewModel.logout() }
}
)
}
@@ -147,7 +147,7 @@ class AccountActivity : AppCompatActivity() {
allowFileAccess = false
javaScriptEnabled = true
domStorageEnabled = true
userAgentString = getWebLoginUserAgent(context)
userAgentString = getWebLoginUserAgent()
}
webViewClient = object : WebViewClient() {
@Deprecated("Deprecated in Java")
@@ -188,6 +188,12 @@ class AccountActivity : AppCompatActivity() {
}
}
private fun startInitialAccountSync() {
if (!CowspentServerSyncHelper.isNextcloudAccountConfigured(applicationContext)) return
CowspentSQLiteOpenHelper.getInstance(applicationContext)
.cowspentServerSyncHelper.runAccountProjectsSync()
}
@Deprecated("Deprecated in Java")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
@@ -207,6 +213,8 @@ class AccountActivity : AppCompatActivity() {
viewModel.serverUrl = ssoAccount.url
viewModel.username = ssoAccount.userId
startInitialAccountSync()
val resultData = Intent()
resultData.putExtra(MainConstants.CREDENTIALS_CHANGED, CREDENTIALS_CHANGED)
setResult(RESULT_OK, resultData)
@@ -234,13 +242,9 @@ class AccountActivity : AppCompatActivity() {
}
}
private fun getWebLoginUserAgent(context: Context): String {
val defaultUA = try {
android.webkit.WebSettings.getDefaultUserAgent(context)
} catch (_: Exception) {
Build.MANUFACTURER + " " + Build.MODEL
}
return "$defaultUA Cowspent/Android"
private fun getWebLoginUserAgent(): String {
val manufacturer = Build.MANUFACTURER.replaceFirstChar { it.titlecase(Locale.ROOT) }
return "$manufacturer ${Build.MODEL} (Cowspent/Android)"
}
private fun parseAndLoginFromWebView(dataString: String) {
@@ -312,6 +316,8 @@ class AccountActivity : AppCompatActivity() {
remove(SETTINGS_KEY_LAST_MODIFIED)
}
startInitialAccountSync()
val data = Intent()
data.putExtra(MainConstants.CREDENTIALS_CHANGED, CREDENTIALS_CHANGED)
setResult(RESULT_OK, data)
@@ -14,6 +14,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
@@ -28,8 +29,7 @@ fun AccountScreen(
viewModel: AccountViewModel,
onBack: () -> Unit,
onConnect: () -> Unit,
onSsoClick: (Boolean) -> Unit,
onLogout: () -> Unit
onSsoClick: (Boolean) -> Unit
) {
AccountScreenContent(
isLoggedIn = viewModel.isLoggedIn,
@@ -51,7 +51,57 @@ fun AccountScreen(
onBack = onBack,
onConnect = onConnect,
onSsoClick = onSsoClick,
onLogout = onLogout
onLogout = { viewModel.requestLogout() }
)
viewModel.logoutImpact?.let { impact ->
LogoutConfirmationDialog(
impact = impact,
onDismiss = { viewModel.cancelLogout() },
onConfirm = { viewModel.confirmLogout() }
)
}
}
@Composable
fun LogoutConfirmationDialog(
impact: AccountViewModel.LogoutImpact,
onDismiss: () -> Unit,
onConfirm: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.logout_confirm_title)) },
text = {
Column {
Text(
if (impact.projects > 0) {
pluralStringResource(
R.plurals.logout_confirm_projects, impact.projects, impact.projects
)
} else {
stringResource(R.string.logout_confirm_no_projects)
}
)
if (impact.unsyncedBills > 0) {
Spacer(Modifier.height(12.dp))
Text(
text = pluralStringResource(
R.plurals.warning_unsynced_bills,
impact.unsyncedBills,
impact.unsyncedBills
),
fontWeight = FontWeight.Bold
)
}
}
},
confirmButton = {
TextButton(onClick = onConfirm) { Text(stringResource(R.string.action_logout)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.simple_cancel)) }
}
)
}
@@ -2,9 +2,11 @@ package net.helcel.cowspent.android.account
import android.app.Application
import android.content.SharedPreferences
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.annotation.VisibleForTesting
import androidx.core.content.edit
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
@@ -12,10 +14,17 @@ import androidx.preference.PreferenceManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.helcel.cowspent.model.DBProject
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.util.CospendClientUtil
import net.helcel.cowspent.util.SecureStorage
class AccountViewModel(application: Application) : AndroidViewModel(application) {
private companion object {
const val TAG = "AccountViewModel"
}
private val preferences: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(application)
var useSso by mutableStateOf(preferences.getBoolean(AccountActivity.SETTINGS_USE_SSO, false))
@@ -41,6 +50,9 @@ class AccountViewModel(application: Application) : AndroidViewModel(application)
var showWebView by mutableStateOf(false)
var isLoggedIn by mutableStateOf(false)
/** Non-null while the logout confirmation is up, carrying what it would cost. */
var logoutImpact by mutableStateOf<LogoutImpact?>(null)
private set
var isValidatingLogin by mutableStateOf(false)
@@ -88,8 +100,63 @@ class AccountViewModel(application: Application) : AndroidViewModel(application)
}
}
private fun projectsTheAccountProvided(db: CowspentSQLiteOpenHelper): List<DBProject> {
val offered = db.accountProjects
val cospendPath = "/index.php/apps/cospend"
return db.projects.filter { project ->
offered.any {
it.remoteId == project.remoteId &&
project.serverUrl?.replace("/+$".toRegex(), "") ==
it.ncUrl.replace("/+$".toRegex(), "") + cospendPath
}
}
}
data class LogoutImpact(val projects: Int, val unsyncedBills: Int)
private fun measureLogoutImpact(): LogoutImpact {
val db = CowspentSQLiteOpenHelper.getInstance(getApplication())
val projects = projectsTheAccountProvided(db)
val unsynced = projects.sumOf { db.countUnsyncedBills(it.id) }
return LogoutImpact(projects.size, unsynced)
}
fun requestLogout() {
viewModelScope.launch {
logoutImpact = withContext(Dispatchers.IO) {
try {
measureLogoutImpact()
} catch (e: Exception) {
// Never let a failed count block signing out - just ask without the detail.
Log.e(TAG, "Could not measure what logging out would remove", e)
LogoutImpact(0, 0)
}
}
}
}
fun cancelLogout() {
logoutImpact = null
}
fun confirmLogout() {
logoutImpact = null
logout()
}
private fun forgetProjectsTheAccountProvided() {
try {
val db = CowspentSQLiteOpenHelper.getInstance(getApplication())
projectsTheAccountProvided(db).forEach { db.deleteProject(it.id) }
db.clearAccountProjects()
} catch (e: Exception) {
Log.e(TAG, "Could not remove the account's projects on logout", e)
}
}
@VisibleForTesting
fun logout() {
viewModelScope.launch {
withContext(Dispatchers.IO) { forgetProjectsTheAccountProvided() }
SecureStorage.removePassword(getApplication(), AccountActivity.SETTINGS_PASSWORD)
}
preferences.edit {
@@ -1,11 +1,14 @@
package net.helcel.cowspent.android.bill_edit
import android.annotation.SuppressLint
import android.os.Build
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
@@ -19,18 +22,24 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.draw.scale
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
@@ -62,11 +71,19 @@ fun EditBillScreen(
val canEdit = accessLevel == DBProject.ACCESS_LEVEL_UNKNOWN || accessLevel >= DBProject.ACCESS_LEVEL_PARTICIPANT
val context = LocalContext.current
val focusRequester = remember { FocusRequester() }
var amountFocused by remember { mutableStateOf(false) }
var amountSelection by remember { mutableStateOf(TextRange(viewModel.amount.length)) }
LaunchedEffect(Unit) {
if (viewModel.isNewBill) {
focusRequester.requestFocus()
}
// The activity reads the project off the main thread and only then reports whether this is a
// new bill, so the flag arrives after the first composition. Keying on it rather than on Unit
// is what makes the effect run at all.
LaunchedEffect(viewModel.isNewBill) {
if (!viewModel.isNewBill) return@LaunchedEffect
// The field has to be laid out before it can take focus, which is not yet true on the
// pass that composed it. Waiting for the next frame makes this hold whether the flag was
// already set or arrived later.
withFrameNanos { }
focusRequester.requestFocus()
}
StatefulAlertDialog(
@@ -112,16 +129,21 @@ fun EditBillScreen(
val errorOwers = stringResource(R.string.error_invalid_bill_owers)
val errorInvalidForm = stringResource(R.string.error_generic)
FloatingActionButton(onClick = {
val validationError = viewModel.getValidationError(
errorWhat, errorDate, errorPayer, errorOwers, errorInvalidForm
)
if (validationError == null) {
onSave()
} else {
showToast(context, validationError)
}
}) {
FloatingActionButton(
// Keep the button clear of the operator row pinned above the keyboard.
modifier = Modifier.padding(
bottom = if (amountFocused) MathOperatorRowHeight else 0.dp
),
onClick = {
val validationError = viewModel.getValidationError(
errorWhat, errorDate, errorPayer, errorOwers, errorInvalidForm
)
if (validationError == null) {
onSave()
} else {
showToast(context, validationError)
}
}) {
Icon(
Icons.Default.Done,
contentDescription = stringResource(R.string.action_save)
@@ -135,42 +157,138 @@ fun EditBillScreen(
modifier = Modifier
.padding(padding)
.imePadding()
.padding(16.dp)
.fillMaxSize()
.verticalScroll(scrollState)
) {
BillBasicInfoSection(
viewModel = viewModel,
canEdit = canEdit,
onDateClick = onDateClick,
onTimeClick = onTimeClick,
amountFocusRequester = focusRequester
)
Column(
modifier = Modifier
.weight(1f)
.padding(16.dp)
.verticalScroll(scrollState)
) {
BillBasicInfoSection(
viewModel = viewModel,
canEdit = canEdit,
onDateClick = onDateClick,
onTimeClick = onTimeClick,
amountFocusRequester = focusRequester,
amountSelection = amountSelection,
onAmountSelectionChange = { amountSelection = it },
onAmountFocusChange = { amountFocused = it }
)
Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(8.dp))
PayerSection(
viewModel = viewModel,
canEdit = canEdit
)
PayerSection(
viewModel = viewModel,
canEdit = canEdit
)
Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(8.dp))
OwerSelectionSection(
viewModel = viewModel,
canEdit = canEdit
)
OwerSelectionSection(
viewModel = viewModel,
canEdit = canEdit
)
Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(8.dp))
BillAdditionalDetailsSection(
viewModel = viewModel,
categories = categories,
paymentModes = paymentModes,
canEdit = canEdit
)
BillAdditionalDetailsSection(
viewModel = viewModel,
categories = categories,
paymentModes = paymentModes,
canEdit = canEdit
)
Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(8.dp))
}
if (amountFocused && canEdit) {
MathOperatorRow(onInsert = { op ->
val text = viewModel.amount
val start = amountSelection.min.coerceIn(0, text.length)
val end = amountSelection.max.coerceIn(0, text.length)
viewModel.amount = text.replaceRange(start, end, op)
amountSelection = TextRange(start + op.length)
viewModel.updateSplits()
})
}
}
}
}
private val MathOperatorRowHeight = 54.dp
private data class KeyboardColors(val tray: Color, val edge: Color, val key: Color, val label: Color)
@Composable
private fun keyboardColors(): KeyboardColors {
val dark = isSystemInDarkTheme()
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val (tray, edge) = when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE -> if (dark) {
colorResource(android.R.color.system_surface_container_dark) to
colorResource(android.R.color.system_surface_container_high_dark)
} else {
colorResource(android.R.color.system_surface_container_light) to
colorResource(android.R.color.system_surface_container_high_light)
}
else -> if (dark) {
colorResource(android.R.color.system_neutral1_900) to
colorResource(android.R.color.system_neutral1_800)
} else {
colorResource(android.R.color.system_neutral1_50) to
colorResource(android.R.color.system_neutral1_100)
}
}
if (dark) KeyboardColors(
tray = tray,
edge = edge,
key = colorResource(android.R.color.system_accent2_700),
label = colorResource(android.R.color.system_neutral1_50)
) else KeyboardColors(
tray = tray,
edge = edge,
key = colorResource(android.R.color.system_accent2_100),
label = colorResource(android.R.color.system_neutral1_900)
)
} else {
if (dark) KeyboardColors(Color(0xFF1B1B1F), Color(0xFF2B2B2F), Color(0xFF43474E), Color(0xFFE3E2E6))
else KeyboardColors(Color(0xFFEEEDF6), Color(0xFFE7E7F0), Color(0xFFDDE2F9), Color(0xFF1B1B1F))
}
}
/** Operator keys shown above the numeric keypad, which has no room for them itself. */
@Composable
fun MathOperatorRow(onInsert: (String) -> Unit) {
val colors = keyboardColors()
Surface(color = colors.tray) {
// Flat pill-shaped keys, styled like the keyboard's own function keys (e.g. its minus).
val keyShape = RoundedCornerShape(percent = 50)
Row(
modifier = Modifier
.fillMaxWidth()
.height(MathOperatorRowHeight)
.drawBehind {
// A thin edge in the same tone the keyboard uses for its own top border.
drawLine(colors.edge, Offset(0f, 0f), Offset(size.width, 0f), 1.dp.toPx())
}
.padding(horizontal = 3.dp, vertical = 5.dp)
) {
listOf("(" to "(", ")" to ")", "+" to "+", "−" to "-", "×" to "*", "÷" to "/").forEach { (label, op) ->
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.padding(horizontal = 3.dp)
.clip(keyShape)
.background(colors.key)
.clickable { onInsert(op) },
contentAlignment = Alignment.Center
) {
Text(label, fontSize = 24.sp, color = colors.label)
}
}
}
}
}
@@ -181,7 +299,10 @@ fun BillBasicInfoSection(
canEdit: Boolean,
onDateClick: () -> Unit,
onTimeClick: () -> Unit,
amountFocusRequester: FocusRequester
amountFocusRequester: FocusRequester,
amountSelection: TextRange = TextRange(viewModel.amount.length),
onAmountSelectionChange: (TextRange) -> Unit = {},
onAmountFocusChange: (Boolean) -> Unit = {}
) {
Text(
text = "GENERAL",
@@ -197,18 +318,32 @@ fun BillBasicInfoSection(
stringResource(R.string.currency_dialog_title, viewModel.mainCurrencyName)
OutlinedTextField(
value = viewModel.amount,
onValueChange = { nv->
val filteredValue = nv.filter { it in "0123456789.+-*/" }
viewModel.amount = filteredValue
viewModel.updateSplits()
value = TextFieldValue(
text = viewModel.amount,
selection = TextRange(
amountSelection.start.coerceIn(0, viewModel.amount.length),
amountSelection.end.coerceIn(0, viewModel.amount.length)
)
),
onValueChange = { nv ->
val filteredValue = nv.text.filter { it in "0123456789.,+-*/()" }
onAmountSelectionChange(
if (filteredValue == nv.text) nv.selection else TextRange(filteredValue.length)
)
if (filteredValue != viewModel.amount) {
viewModel.amount = filteredValue
viewModel.updateSplits()
}
},
enabled = canEdit,
placeholder = { Text("0") },
modifier = Modifier.fillMaxWidth().focusRequester(amountFocusRequester),
modifier = Modifier
.fillMaxWidth()
.focusRequester(amountFocusRequester)
.onFocusChanged { onAmountFocusChange(it.isFocused) },
leadingIcon = {
val currencyToShow = viewModel.selectedCurrencyName.ifEmpty {
viewModel.mainCurrencyName.ifEmpty { "$" }
val currencyToShow = viewModel.selectedCurrencyName.ifEmpty {
viewModel.mainCurrencyName.ifEmpty { "$" }
}
TextIconDisplay(
textIcon = TextIcon.Symbol(currencyToShow),
@@ -220,8 +355,8 @@ fun BillBasicInfoSection(
enabled = canEdit,
onClick = {
val mainLabel = viewModel.mainCurrencyName.ifEmpty { "$" }
val options = listOf("$mainLabel | Base") + viewModel.currencies.map {
"${it.name} | 1 $mainLabel = ${it.exchangeRate} ${it.name}"
val options = listOf("$mainLabel | Base") + viewModel.currencies.map {
"${it.name} | 1 $mainLabel = ${it.exchangeRate} ${it.name}"
}
viewModel.showDialog(
title = currencyDialogTitle,
@@ -239,7 +374,7 @@ fun BillBasicInfoSection(
Icon(Icons.Default.SwapHoriz, contentDescription = "Change Currency")
}
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone, imeAction = ImeAction.Next),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal, imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) })
)
@@ -252,8 +387,8 @@ fun BillBasicInfoSection(
placeholder = { Text(stringResource(R.string.label_what)) },
modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.Default.Title, contentDescription = null) },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) })
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() })
)
Spacer(modifier = Modifier.height(8.dp))
@@ -463,11 +598,11 @@ fun OwerSelectionSection(
} else {
viewModel.owersCustomSplit[member.id] ?: ""
}
BasicTextField(
value = value,
onValueChange = { nv ->
val filteredValue = nv.filter { it in "0123456789.+-*/" }
val filteredValue = nv.filter { it in "0123456789,.+-*/" }
if (viewModel.splitMode == SplitMode.PERCENT) {
viewModel.owersPercentSplit[member.id] = filteredValue
} else {
@@ -528,7 +663,10 @@ fun BillAdditionalDetailsSection(
val context = LocalContext.current
var categoryExpanded by remember { mutableStateOf(false) }
val selectedCategory =
categories.find { it.id == viewModel.categoryId } ?: CategoryUtils.getCategoryById(context, viewModel.categoryId)
categories.find { it.id == viewModel.categoryId } ?: CategoryUtils.getCategoryById(
context,
viewModel.categoryId
)
EditableExposedDropdownMenu(
value = selectedCategory?.name ?: "",
@@ -555,7 +693,7 @@ fun BillAdditionalDetailsSection(
Spacer(modifier = Modifier.width(12.dp))
Text(stringResource(R.string.category_none))
}
DropdownMenuItem(onClick = {
viewModel.categoryId = DBBill.CATEGORY_REIMBURSEMENT
categoryExpanded = false
@@ -582,7 +720,10 @@ fun BillAdditionalDetailsSection(
var pmExpanded by remember { mutableStateOf(false) }
val selectedPm =
paymentModes.find { it.id == viewModel.paymentModeId } ?: CategoryUtils.getPaymentModeById(context, viewModel.paymentModeId)
paymentModes.find { it.id == viewModel.paymentModeId } ?: CategoryUtils.getPaymentModeById(
context,
viewModel.paymentModeId
)
EditableExposedDropdownMenu(
value = selectedPm?.name ?: "",
@@ -48,7 +48,7 @@ class EditBillViewModel : ViewModel() {
if (input.isBlank()) return 0.0
val sanitized = input.replace(',', '.')
// If it contains any math operator, try evalMath first
if (sanitized.any { it in "+-*/" }) {
if (sanitized.any { it in "+-*/()" }) {
try {
val result = evalMath(sanitized)
if (result != 0.0) return SupportUtil.round2(result)
@@ -219,6 +219,7 @@ fun CategoryList(
onDelete = { onDelete(category) }
)
}
item { LabelListBottomSpacer() }
}
}
@@ -238,9 +239,19 @@ fun PaymentModeList(
onDelete = { onDelete(pm) }
)
}
item { LabelListBottomSpacer() }
}
}
/**
* Keeps the last row clear of the add button, which otherwise sits on top of its delete icon and
* swallows the tap - with nothing below to scroll to, that row simply cannot be deleted.
*/
@Composable
private fun LabelListBottomSpacer() {
Spacer(modifier = Modifier.height(64.dp).fillMaxWidth())
}
@Composable
fun LabelItem(
name: String,
@@ -3,6 +3,9 @@ package net.helcel.cowspent.android.main
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -69,13 +72,43 @@ fun EmptyProjectsState(onConfigureNextcloud: () -> Unit, onAddManually: () -> Un
}
}
/**
* A full-height, centred state that a pull to refresh can still act on.
*
* PullRefresh reads the gesture from a nested scroll source, and a plain Column offers none - so
* on an empty list, which is exactly when a refresh is wanted most, the pull never fires. A
* single-item LazyColumn has nowhere to scroll but does provide that source.
*/
@Composable
private fun RefreshableFullScreenState(content: @Composable ColumnScope.() -> Unit) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
item {
Column(
modifier = Modifier.fillParentMaxSize().padding(16.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
content = content
)
}
}
}
@Composable
fun LoadingBillsState() {
RefreshableFullScreenState {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.error_loading),
style = MaterialTheme.typography.subtitle1,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
)
}
}
@Composable
fun EmptyMembersState() {
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
RefreshableFullScreenState {
Text(
text = stringResource(R.string.error_no_members).uppercase(),
style = MaterialTheme.typography.subtitle1,
@@ -94,11 +127,7 @@ fun EmptyMembersState() {
@Composable
fun EmptyBillsState() {
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
RefreshableFullScreenState {
Text(
text = stringResource(R.string.error_no_bills).uppercase(),
style = MaterialTheme.typography.subtitle1,
@@ -203,11 +232,7 @@ fun SectionHeader(title: String) {
@Composable
fun EmptyState() {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
RefreshableFullScreenState {
Text(
text = stringResource(R.string.error_no_bills).uppercase(),
style = MaterialTheme.typography.subtitle1,
@@ -105,9 +105,9 @@ fun BillsListScreen(
val context = LocalContext.current
val sharedPreferences = remember { PreferenceManager.getDefaultSharedPreferences(context) }
val showArchived = sharedPreferences.getBoolean(stringResource(R.string.pref_key_show_archived), false)
val keyBetaFeatures = stringResource(R.string.pref_key_beta_features)
var showBetaFeatures by remember(keyBetaFeatures) {
mutableStateOf(sharedPreferences.getBoolean(keyBetaFeatures, false))
val keyExtraFeatures = stringResource(R.string.pref_key_extra_features)
var showExtraFeatures by remember(keyExtraFeatures) {
mutableStateOf(sharedPreferences.getBoolean(keyExtraFeatures, false))
}
StatefulAlertDialog(
@@ -132,6 +132,10 @@ fun BillsListScreen(
onProjectAction(projectOptionsProjectId, 1)
viewModel.showProjectOptionsDialogByProjectId = null
},
onForgetProject = {
onProjectAction(projectOptionsProjectId, 9)
viewModel.showProjectOptionsDialogByProjectId = null
},
onManageMembers = {
onProjectAction(projectOptionsProjectId, 2)
viewModel.showProjectOptionsDialogByProjectId = null
@@ -165,7 +169,7 @@ fun BillsListScreen(
projectType = proj?.type ?: ProjectType.LOCAL,
accessLevel = proj?.myAccessLevel ?: DBProject.ACCESS_LEVEL_ADMIN,
isShareable = proj?.isShareable() ?: true,
showBetaFeatures = showBetaFeatures
showExtraFeatures = showExtraFeatures
)
}
}
@@ -334,7 +338,7 @@ fun BillsListScreen(
},
actions = {
if (!isSearchExpanded) {
if (showBetaFeatures && viewModel.hasUnlabeledBills) {
if (showExtraFeatures && viewModel.hasUnlabeledBills) {
IconButton(onClick = onLabelBillsClick) {
Icon(
Icons.Default.Category,
@@ -408,6 +412,8 @@ fun BillsListScreen(
.pullRefresh(pullRefreshState)) {
when {
viewModel.showNoProjects -> EmptyProjectsState(onAccountSwitcherClick, onAddProjectClick)
(viewModel.isRefreshing || viewModel.isLoadingBills) && viewModel.bills.isEmpty() ->
LoadingBillsState()
viewModel.showNoMembers -> EmptyMembersState()
viewModel.showNoBills -> EmptyBillsState()
viewModel.bills.isEmpty() -> EmptyState()
@@ -94,14 +94,16 @@ class BillsListViewActivity :
private val syncCallBack = object : ICallback {
override fun onFinish() {
mActionMode?.finish()
refreshLists()
viewModel.isRefreshing = false
refreshLists()
}
override fun onFinish(result: String, message: String) {}
override fun onScheduled() {
viewModel.isRefreshing = false
// Being queued is not being done. Clearing the indicator here stops the spinner while a
// large project is still downloading; synchronize() already releases it when nothing
// actually started.
}
}
@@ -268,6 +270,7 @@ class BillsListViewActivity :
8 -> {
startActivity(net.helcel.cowspent.android.label.LabelManagementActivity.createIntent(this@BillsListViewActivity, pid))
}
9 -> onRemoveProjectClick(pid)
}
},
onAccountSwitcherClick = {
@@ -332,6 +335,7 @@ class BillsListViewActivity :
super.onResume()
val preferences = PreferenceManager.getDefaultSharedPreferences(applicationContext)
val selectedProjectId = preferences.getLong("selected_project", 0)
setupDrawerProjects()
if (selectedProjectId != 0L) {
refreshLists()
}
@@ -339,6 +343,10 @@ class BillsListViewActivity :
synchronize(SyncTrigger.APP_OPEN)
lifecycleScope.launch {
syncNewlyDiscoveredProjects(withContext(Dispatchers.IO) { db.projects })
}
registerBroadcastReceiver()
updateAvatarInDrawer(CowspentServerSyncHelper.isNextcloudAccountConfigured(this))
isActivityVisible = true
@@ -375,6 +383,8 @@ class BillsListViewActivity :
fun onProjectClick(projectId: Long) {
if (viewModel.selectedProjectId != projectId) {
viewModel.selectedMemberId = null
viewModel.bills = emptyList()
viewModel.isLoadingBills = true
}
setSelectedProject(projectId)
navigationSelection = Category(null, null)
@@ -398,16 +408,34 @@ class BillsListViewActivity :
private fun onRemoveProjectClick(projectId: Long) {
if (projectId == 0L) return
lifecycleScope.launch {
val proj = withContext(Dispatchers.IO) { db.getProject(projectId) } ?: return@launch
val (proj, unsyncedBills) = withContext(Dispatchers.IO) {
val p = db.getProject(projectId) ?: return@withContext null
p to db.countUnsyncedBills(projectId)
} ?: return@launch
val message = buildString {
if (!proj.isLocal) append(getString(R.string.dialog_confirm_remove_project_msg))
if (unsyncedBills > 0) {
if (isNotEmpty()) append("\n\n")
append(
resources.getQuantityString(
R.plurals.warning_unsynced_bills, unsyncedBills, unsyncedBills
)
)
}
}
viewModel.showDialog(
title = getString(R.string.title_confirm),
message = if (!proj.isLocal) getString(R.string.dialog_confirm_remove_project_msg) else null,
message = message.ifEmpty { null },
positiveText = getString(R.string.simple_yes),
onConfirm = {
lifecycleScope.launch {
withContext(Dispatchers.IO) {
db.deleteProject(projectId)
// Otherwise the next account sync sees a project the account offers
// with no local row, and creates it again.
CowspentServerSyncHelper.forgetAccountProject(applicationContext, proj)
PreferenceManager.getDefaultSharedPreferences(applicationContext)
.edit { remove(lastProjectSyncKey(projectId)) }
val dbProjects = db.projects
@@ -619,76 +647,80 @@ class BillsListViewActivity :
val selectedProjectId = PreferenceManager.getDefaultSharedPreferences(applicationContext).getLong("selected_project", 0)
lifecycleScope.launch {
val (projId, projName) = withContext(Dispatchers.IO) {
if (selectedProjectId != 0L) {
db.getProject(selectedProjectId)?.let {
it.id to (if (it.name == "null" || it.name.isEmpty()) it.remoteId else it.name)
} ?: (0L to "")
} else {
0L to ""
try {
val (projId, projName) = withContext(Dispatchers.IO) {
if (selectedProjectId != 0L) {
db.getProject(selectedProjectId)?.let {
it.id to (if (it.name == "null" || it.name.isEmpty()) it.remoteId else it.name)
} ?: (0L to "")
} else {
0L to ""
}
}
}
val title = if (selectedProjectId != 0L) projName else getString(R.string.app_name)
val title = if (selectedProjectId != 0L) projName else getString(R.string.app_name)
setSelectedProject(selectedProjectId)
viewModel.title = title
val query = viewModel.searchQuery.ifEmpty { null }
setSelectedProject(selectedProjectId)
viewModel.title = title
val query = viewModel.searchQuery.ifEmpty { null }
val (ljItems, memberCount) = withContext(Dispatchers.IO) {
val db = CowspentSQLiteOpenHelper.getInstance(applicationContext)
val billList: List<DBBill> = if (projId != 0L) {
db.searchBills(query, projId)
} else {
ArrayList()
val (ljItems, memberCount) = withContext(Dispatchers.IO) {
val db = CowspentSQLiteOpenHelper.getInstance(applicationContext)
val billList: List<DBBill> = if (projId != 0L) {
db.searchBills(query, projId)
} else {
ArrayList()
}
val bills = billList.filter {
val mid = viewModel.selectedMemberId
mid == null || mid == it.payerId || it.billOwersIds.contains(mid)
}
viewModel.hasUnlabeledBills = bills.any { it.categoryId == 0L && it.state != DBBill.STATE_DELETED }
val projectMembers = db.getMembersOfProject(projId, null)
val memberMap = projectMembers.associateBy { it.id }
val projectPaymentModes = db.getPaymentModes(projId).associateBy { it.id }
val projectCategories = db.getCategories(projId).associateBy { it.id }
BillFormatter.formatBills(
bills,
memberMap,
projectCategories,
projectPaymentModes
)
val sdf = SimpleDateFormat("yyyy-MM-dd", Locale.ROOT)
val itemList = BillsListUtils.groupAndSectionBills(
bills,
memberMap,
sdf,
applicationContext
)
itemList to projectMembers.size
}
val bills = billList.filter {
val mid = viewModel.selectedMemberId
mid == null || mid == it.payerId || it.billOwersIds.contains(mid)
}
viewModel.hasUnlabeledBills = bills.any { it.categoryId == 0L && it.state != DBBill.STATE_DELETED }
val projectMembers = db.getMembersOfProject(projId, null)
val memberMap = projectMembers.associateBy { it.id }
val projectPaymentModes = db.getPaymentModes(projId).associateBy { it.id }
val projectCategories = db.getCategories(projId).associateBy { it.id }
BillFormatter.formatBills(
bills,
memberMap,
projectCategories,
projectPaymentModes
)
val sdf = SimpleDateFormat("yyyy-MM-dd", Locale.ROOT)
val itemList = BillsListUtils.groupAndSectionBills(
bills,
memberMap,
sdf,
applicationContext
)
itemList to projectMembers.size
}
viewModel.showNoProjects = false
viewModel.showNoMembers = false
viewModel.showNoBills = false
when {
memberCount == 0 -> {
viewModel.showNoMembers = true
}
ljItems.isEmpty() -> {
viewModel.showNoBills = true
viewModel.bills = emptyList()
}
else -> {
viewModel.bills = ljItems
viewModel.showNoProjects = false
viewModel.showNoMembers = false
viewModel.showNoBills = false
when {
memberCount == 0 -> {
viewModel.showNoMembers = true
}
ljItems.isEmpty() -> {
viewModel.showNoBills = true
viewModel.bills = emptyList()
}
else -> {
viewModel.bills = ljItems
}
}
} finally {
viewModel.isLoadingBills = false
}
}
}
@@ -778,40 +810,68 @@ class BillsListViewActivity :
val selectedProjectId = preferences.getLong("selected_project", 0)
val now = System.currentTimeMillis()
// The account and all-projects refresh belongs to opening the app, throttled by the
// SyncOnOpen interval so that resuming within the interval does not repeat it.
val intervalMinutes = SyncSettings.intervalMinutes(applicationContext)
// The account tells the app which projects exist at all, so a project joined elsewhere only
// shows up once it has been read. That happens on login, on opening the app - throttled by
// the interval so resuming does not repeat it - and on a manual refresh, which is an
// explicit ask and so is never throttled.
val intervalMinutes = SyncSettings.OPEN_SYNC_INTERVAL_MINUTES
val lastAccountSync = preferences.getLong(getString(R.string.pref_key_last_account_sync_timestamp), 0L)
val accountSyncDue = trigger == SyncTrigger.APP_OPEN &&
val openPass = trigger == SyncTrigger.APP_OPEN &&
now - lastAccountSync > intervalMinutes * 60 * 1000L
val accountSyncDue = openPass || trigger == SyncTrigger.MANUAL
lifecycleScope.launch {
val remoteProjects = withContext(Dispatchers.IO) { db.projects }
.filter { !it.isLocal && !it.isArchived }
.filter { !it.isLocal }
// Only the first one scheduled actually starts - the rest queue behind it - and it
// takes the screen's callback with it. So the selected project goes first: otherwise
// the spinner follows a project the user is not looking at, stops when that one
// finishes, and theirs is reported as empty while it is still waiting its turn.
.sortedByDescending { it.id == selectedProjectId }
viewModel.isRefreshing = true
db.cowspentServerSyncHelper.addCallbackPull(syncCallBack)
val started = if (accountSyncDue) {
if (CowspentServerSyncHelper.isNextcloudAccountConfigured(applicationContext)) {
db.cowspentServerSyncHelper.runAccountProjectsSync()
if (accountSyncDue && CowspentServerSyncHelper.isNextcloudAccountConfigured(applicationContext)) {
db.cowspentServerSyncHelper.runAccountProjectsSync()
}
val started = if (openPass) {
// The pass is throttled by this stamp, so it has to be written here rather than
// left to the account sync: that only runs when an account is configured and only
// records itself on success, so without one the stamp stayed at zero and the pass
// repeated on every single resume, re-scheduling every project each time.
preferences.edit {
putLong(getString(R.string.pref_key_last_account_sync_timestamp), now)
}
remoteProjects.count {
val scheduled = db.cowspentServerSyncHelper.scheduleSync(false, it, false) != null
if (scheduled) markProjectSynced(preferences, it.id, now)
remoteProjects.filter { !it.isArchived }.count {
val full = neverSynced(it)
val scheduled = db.cowspentServerSyncHelper.scheduleSync(false, it, full) != null
if (scheduled) {
markProjectSynced(preferences, it.id, now)
if (full) markFullySynced(preferences, it.id, now)
}
scheduled
}
} else {
val selectedProj = remoteProjects.find { it.id == selectedProjectId }
val lastSync = preferences.getLong(lastProjectSyncKey(selectedProjectId), 0L)
val due = trigger == SyncTrigger.MANUAL ||
// The pull writes the project cursor only after it has applied everything, so a cursor
// still at zero means no sync ever finished and nothing is stored locally.
// Throttling that strands the user on an empty list with no spinner - which is what
// the back gesture does to a large project, since it destroys the activity while
// the sync is still running. IHateMoney never advances the cursor, so the interval
// stays in charge there.
val neverCompleted = selectedProj != null && neverSynced(selectedProj)
val due = trigger == SyncTrigger.MANUAL || neverCompleted ||
now - lastSync > SELECTED_PROJECT_SYNC_INTERVAL_MS
val fullSync = neverCompleted || (trigger == SyncTrigger.MANUAL &&
!partialRefreshPreferred(preferences, selectedProjectId, now))
if (selectedProj != null && due &&
db.cowspentServerSyncHelper.scheduleSync(
false, selectedProj, trigger == SyncTrigger.MANUAL
) != null
db.cowspentServerSyncHelper.scheduleSync(false, selectedProj, fullSync) != null
) {
markProjectSynced(preferences, selectedProj.id, now)
if (fullSync) markFullySynced(preferences, selectedProj.id, now)
1
} else 0
}
@@ -822,7 +882,52 @@ class BillsListViewActivity :
}
}
private fun neverSynced(project: DBProject) =
project.type != ProjectType.IHATEMONEY && (project.lastSyncedTimestamp ?: 0L) == 0L
@VisibleForTesting
internal fun syncNewlyDiscoveredProjects(projects: List<DBProject>) {
if (!db.cowspentServerSyncHelper.isSyncPossible) return
val preferences = PreferenceManager.getDefaultSharedPreferences(applicationContext)
val now = System.currentTimeMillis()
projects
.filter { !it.isLocal && !it.isArchived && neverSynced(it) }
.forEach {
if (db.cowspentServerSyncHelper.scheduleSync(false, it, true) != null) {
markProjectSynced(preferences, it.id, now)
markFullySynced(preferences, it.id, now)
}
}
}
private fun lastProjectSyncKey(projectId: Long) = "lastProjectSyncTimestamp_$projectId"
private fun lastFullSyncKey(projectId: Long) = "lastFullSyncTimestamp_$projectId"
private fun markFullySynced(preferences: SharedPreferences, projectId: Long, at: Long) {
preferences.edit { putLong(lastFullSyncKey(projectId), at) }
}
/**
* Whether a pull to refresh should settle for the cheaper paged walk.
*
* A refresh by hand normally re-downloads the whole project, which is what makes it a usable
* repair when the local copy looks wrong. On a large project that is expensive to repeat, so
* with beta features on a refresh that follows a recent full one walks the recent pages
* instead - the common case of pulling twice in a row costs a page rather than the project.
*/
private fun partialRefreshPreferred(
preferences: SharedPreferences,
projectId: Long,
now: Long
): Boolean {
if (!preferences.getBoolean(getString(R.string.pref_key_extra_features), false)) return false
// "Always" means what it says: every manual refresh is a full sync.
val delayMinutes = SyncSettings.fullSyncDelayMinutes(applicationContext)
if (delayMinutes == 0) return false
val lastFull = preferences.getLong(lastFullSyncKey(projectId), 0L)
if (lastFull == 0L) return false
return now - lastFull < delayMinutes * 60 * 1000L
}
private fun markProjectSynced(preferences: SharedPreferences, projectId: Long, at: Long) {
preferences.edit { putLong(lastProjectSyncKey(projectId), at) }
}
@@ -897,6 +1002,7 @@ class BillsListViewActivity :
synchronize(SyncTrigger.PROJECT_OPEN)
}
}
syncNewlyDiscoveredProjects(dbProjects)
}
}
}
@@ -20,6 +20,7 @@ class BillsListViewModel : ViewModel() {
var selectedMemberId by mutableStateOf<Long?>(null)
var bills by mutableStateOf<List<Item>>(emptyList())
var isRefreshing by mutableStateOf(false)
var isLoadingBills by mutableStateOf(false)
var searchQuery by mutableStateOf("")
var title by mutableStateOf("")
var accountName by mutableStateOf("")
@@ -11,11 +11,30 @@ import net.helcel.cowspent.R
import net.helcel.cowspent.model.*
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import java.io.InputStreamReader
import java.net.URLDecoder
import java.text.SimpleDateFormat
import java.util.*
object ProjectImportHelper {
private fun optional(line: Array<String>, columns: Map<String, Int>, name: String): String =
columns[name]?.takeIf { it < line.size }?.let { line[it] } ?: ""
/** Parses the `#rrggbb` member colour Cospend writes in its members section. */
private fun parseHexColor(value: String): Triple<Int, Int, Int>? {
val hex = value.trim().removePrefix("#")
if (hex.length != 6) return null
return try {
Triple(
hex.substring(0, 2).toInt(16),
hex.substring(2, 4).toInt(16),
hex.substring(4, 6).toInt(16)
)
} catch (_: NumberFormatException) {
null
}
}
@SuppressLint("Range")
fun getFileName(contentResolver: ContentResolver, uri: Uri): String {
var result: String? = null
@@ -59,6 +78,7 @@ object ProjectImportHelper {
val bills = mutableListOf<DBBill>()
val membersActive = mutableMapOf<String, Boolean>()
val membersWeight = mutableMapOf<String, Double>()
val membersColor = mutableMapOf<String, Triple<Int, Int, Int>>()
val billRemoteIdToPayerName = mutableMapOf<Long, String>()
val billRemoteIdToOwerStr = mutableMapOf<Long, String>()
@@ -76,7 +96,10 @@ object ProjectImportHelper {
currentSection = when {
columns.containsKey("what") && columns.containsKey("amount") -> "bills"
columns.containsKey("name") && columns.containsKey("weight") &&
columns.containsKey("active") -> "members"
columns.containsKey("categoryid") && columns.containsKey("categoryname") -> "categories"
columns.containsKey("paymentmodeid") && columns.containsKey("paymentmodename") -> "paymentmodes"
columns.containsKey("exchange_rate") && columns.containsKey("currencyname") -> "currencies"
else -> {
onError(context.getString(R.string.import_error_header, row))
@@ -86,11 +109,19 @@ object ProjectImportHelper {
} else {
previousLineEmpty = false
when (currentSection) {
"members" -> {
val name = line[columns["name"]!!].trim()
if (name.isNotEmpty()) {
membersWeight[name] = optional(line, columns, "weight").toDoubleOrNull() ?: 1.0
membersActive[name] = optional(line, columns, "active") != "0"
parseHexColor(optional(line, columns, "color"))?.let { membersColor[name] = it }
}
}
"categories" -> {
categories.add(DBCategory(0, line[columns["categoryid"]!!].toLong(), 0, line[columns["categoryname"]!!], line[columns["icon"]!!], line[columns["color"]!!]))
categories.add(DBCategory(0, line[columns["categoryid"]!!].toLong(), 0, line[columns["categoryname"]!!], optional(line, columns, "icon"), optional(line, columns, "color")))
}
"paymentmodes" -> {
paymentModes.add(DBPaymentMode(0, line[columns["categoryid"]!!].toLong(), 0, line[columns["categoryname"]!!], line[columns["icon"]!!], line[columns["color"]!!]))
paymentModes.add(DBPaymentMode(0, line[columns["paymentmodeid"]!!].toLong(), 0, line[columns["paymentmodename"]!!], optional(line, columns, "icon"), optional(line, columns, "color")))
}
"currencies" -> {
val name = line[columns["currencyname"]!!]
@@ -100,7 +131,22 @@ object ProjectImportHelper {
}
"bills" -> {
val what = if (columns.containsKey("what")) line[columns["what"]!!] else ""
val comment = if (columns.containsKey("comment")) line[columns["comment"]!!] else ""
// Cospend url-encodes bill comments on export and marks trashed
// bills with a "deleted" column no other dialect has, so that column
// doubles as the marker for which comment encoding to expect.
val cospendDialect = columns.containsKey("deleted")
val comment = if (columns.containsKey("comment")) {
val raw = line[columns["comment"]!!]
if (cospendDialect) {
try {
URLDecoder.decode(raw, "UTF-8")
} catch (_: Exception) {
raw
}
} else raw
} else ""
val deleted = cospendDialect &&
line[columns["deleted"]!!].trim().let { it.isNotEmpty() && it != "0" }
val amount = if (columns.containsKey("amount")) line[columns["amount"]!!].toDouble() else 0.0
val timestamp: Long = when {
columns.containsKey("timestamp") -> line[columns["timestamp"]!!].toLong()
@@ -121,7 +167,12 @@ object ProjectImportHelper {
val catId = if (columns.containsKey("categoryid") && line[columns["categoryid"]!!].isNotEmpty()) line[columns["categoryid"]!!].toLong() else 0L
val pmId = if (columns.containsKey("paymentmodeid") && line[columns["paymentmodeid"]!!].isNotEmpty()) line[columns["paymentmodeid"]!!].toLong() else 0L
val pm = if (columns.containsKey("paymentmode")) line[columns["paymentmode"]!!] else null
// MoneyBuster's export only carries the legacy one-letter payment
// mode. Everything downstream keys off paymentModeId, so translate
// it back the same way the sync parser does.
val effectivePmId =
if (pmId != 0L) pmId else DBBill.oldPmIdToNew[pm] ?: DBBill.PAYMODE_ID_NONE
if (payerName.isNotEmpty()) {
membersActive[payerName] = payerActive
membersWeight[payerName] = payerWeight
@@ -132,7 +183,7 @@ object ProjectImportHelper {
return
}
if (what != "deleteMeIfYouWant") {
if (what != "deleteMeIfYouWant" && !deleted) {
billRemoteIdToOwerStr[row.toLong()] = owersStr
val owersArray = owersStr.split(",").map { it.trim() }.filter { it.isNotEmpty() }
for (ower in owersArray) {
@@ -140,7 +191,7 @@ object ProjectImportHelper {
membersWeight[ower] = 1.0
}
}
bills.add(DBBill(0, row.toLong(), 0, 0, amount, timestamp, what, DBBill.STATE_OK, "n", pm, catId, comment, pmId))
bills.add(DBBill(0, row.toLong(), 0, 0, amount, timestamp, what, DBBill.STATE_OK, "n", pm, catId, comment, effectivePmId))
billRemoteIdToPayerName[row.toLong()] = payerName
}
}
@@ -168,13 +219,20 @@ object ProjectImportHelper {
currencies.forEach { db.addCurrency(DBCurrency(0, 0, pid, it.name, it.exchangeRate, DBBill.STATE_OK)) }
membersWeight.keys.forEach { mName ->
memberNameToId[mName] = db.addMember(DBMember(0, 0, pid, mName, membersActive[mName] ?: true, membersWeight[mName] ?: 1.0, DBBill.STATE_OK, null, null, null, null, null))
val c = membersColor[mName]
memberNameToId[mName] = db.addMember(DBMember(0, 0, pid, mName, membersActive[mName] ?: true, membersWeight[mName] ?: 1.0, DBBill.STATE_OK, c?.first, c?.second, c?.third, null, null))
}
bills.forEach { b ->
val payerId = memberNameToId[billRemoteIdToPayerName[b.remoteId]] ?: 0L
val localCatId = catRemoteToLocal[b.categoryId] ?: 0L
val localPmId = pmRemoteToLocal[b.paymentModeId] ?: 0L
// Only custom labels are listed in the categories/paymentmodes sections. Built-in
// ones are referenced by their (negative) constant, which the UI resolves on its
// own, so those have to be kept rather than reset to "none". Unmapped positive
// ids are dropped instead, as they would collide with local ids.
val localCatId = catRemoteToLocal[b.categoryId]
?: b.categoryId.takeIf { it < 0 } ?: 0L
val localPmId = pmRemoteToLocal[b.paymentModeId]
?: b.paymentModeId.takeIf { it < 0 } ?: 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(",")?.map { it.trim() }?.filter { it.isNotEmpty() }?.forEach { ower ->
memberNameToId[ower]?.let { owerId -> db.addBillower(billId, owerId) }
@@ -24,6 +24,7 @@ import net.helcel.cowspent.model.ProjectType
fun ProjectOptionsDialogContent(
onEditProject: () -> Unit,
onRemoveProject: () -> Unit,
onForgetProject: () -> Unit = {},
onManageMembers: () -> Unit,
onManageCurrencies: () -> Unit,
onManageLabels: () -> Unit,
@@ -36,7 +37,7 @@ fun ProjectOptionsDialogContent(
projectType: ProjectType = ProjectType.LOCAL,
accessLevel: Int = DBProject.ACCESS_LEVEL_ADMIN,
isShareable: Boolean = true,
showBetaFeatures: Boolean = false
showExtraFeatures: Boolean = false
) {
Surface(
shape = MaterialTheme.shapes.large,
@@ -73,6 +74,15 @@ fun ProjectOptionsDialogContent(
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))
if (isArchived) {
row1.add(
ProjectOption(
stringResource(R.string.action_forget),
Icons.Default.Delete,
onForgetProject
)
)
}
} else {
row1.add(ProjectOption(stringResource(R.string.action_delete), Icons.Default.Delete, onRemoveProject))
}
@@ -80,7 +90,7 @@ fun ProjectOptionsDialogContent(
// Row 2: Manage Member, Manage Labels, Manage Currencies
if (!isArchived && isMaintainer) {
row2.add(ProjectOption(stringResource(R.string.action_members), Icons.Default.Group, onManageMembers))
if (showBetaFeatures && (projectType == ProjectType.LOCAL || projectType == ProjectType.COSPEND)) {
if (showExtraFeatures && (projectType == ProjectType.LOCAL || projectType == ProjectType.COSPEND)) {
row2.add(ProjectOption(stringResource(R.string.action_labels), Icons.AutoMirrored.Filled.Label, onManageLabels))
}
row2.add(ProjectOption(stringResource(R.string.action_currencies), Icons.Default.MonetizationOn, onManageCurrencies))
@@ -183,7 +193,7 @@ fun ProjectOptionsDialogPreview() {
projectType = ProjectType.COSPEND,
accessLevel = DBProject.ACCESS_LEVEL_ADMIN,
isShareable = true,
showBetaFeatures = true
showExtraFeatures = true
)
}
}
@@ -208,7 +218,7 @@ fun ProjectOptionsDialogPreview2() {
projectType = ProjectType.COSPEND,
accessLevel = DBProject.ACCESS_LEVEL_ADMIN,
isShareable = true,
showBetaFeatures = true
showExtraFeatures = true
)
}
}
@@ -232,7 +242,7 @@ fun ProjectOptionsDialogPreview3() {
projectType = ProjectType.LOCAL,
accessLevel = DBProject.ACCESS_LEVEL_ADMIN,
isShareable = true,
showBetaFeatures = true
showExtraFeatures = true
)
}
}
@@ -48,8 +48,10 @@ import net.helcel.cowspent.model.Transaction
import net.helcel.cowspent.model.UserItem
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.util.SupportUtil
import net.helcel.cowspent.util.SupportUtil.SETTLE_DIRECT
import net.helcel.cowspent.util.SupportUtil.SETTLE_OPTIMAL
import net.helcel.cowspent.util.SupportUtil.settleBills
import net.helcel.cowspent.util.SupportUtil.settleBillsDirect
@Composable
fun ProjectSettlementDialogContent(
@@ -60,10 +62,12 @@ fun ProjectSettlementDialogContent(
onDismiss: () -> Unit
) {
val centerNoneStr = stringResource(R.string.center_none)
val centerDirectStr = stringResource(R.string.center_none_direct)
val memberList = remember(proj.id) { db.getMembersOfProject(proj.id, null) }
val userList = remember(memberList, centerNoneStr) {
val userList = remember(memberList, centerNoneStr, centerDirectStr) {
buildList {
add(UserItem(SETTLE_OPTIMAL, centerNoneStr))
add(UserItem(SETTLE_DIRECT, centerDirectStr))
addAll(memberList.map { UserItem(it.id, it.name) })
}
}
@@ -91,7 +95,11 @@ fun ProjectSettlementDialogContent(
}
val transactions = remember(selectedMemberId, membersBalance, membersSortedByName) {
settleBills(membersSortedByName, membersBalance, selectedMemberId)
if (selectedMemberId == SETTLE_DIRECT) {
settleBillsDirect(membersSortedByName, db.getBillsOfProject(proj.id))
} else {
settleBills(membersSortedByName, membersBalance, selectedMemberId)
}
}
ProjectSettlementUI(
@@ -5,6 +5,7 @@ import android.os.Bundle
import androidx.activity.OnBackPressedCallback
import androidx.activity.enableEdgeToEdge
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
@@ -15,8 +16,13 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.core.app.NavUtils
import androidx.preference.PreferenceManager
import net.helcel.cowspent.R
import net.helcel.cowspent.android.about.AboutActivity
import net.helcel.cowspent.android.account.AccountActivity
import net.helcel.cowspent.android.helper.showToast
import net.helcel.cowspent.model.DBAccountProject
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.persistence.CowspentServerSyncHelper
import net.helcel.cowspent.theme.ThemeUtils
import net.helcel.cowspent.util.ColorUtils
@@ -25,6 +31,25 @@ import net.helcel.cowspent.util.ColorUtils
*/
class PreferencesActivity : AppCompatActivity() {
private val accountLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == RESULT_OK) {
setResult(RESULT_OK, result.data)
}
}
private fun restoreDeletedProjects(projects: List<DBAccountProject>) {
if (projects.isEmpty()) return
val syncHelper = CowspentSQLiteOpenHelper.getInstance(applicationContext).cowspentServerSyncHelper
syncHelper.restoreAccountProjects(projects)
if (CowspentServerSyncHelper.isNextcloudAccountConfigured(applicationContext)) {
syncHelper.runAccountProjectsSync()
}
setResult(RESULT_OK)
showToast(this, getString(R.string.settings_restore_deleted_projects_done))
}
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
@@ -59,13 +84,14 @@ class PreferencesActivity : AppCompatActivity() {
SettingsScreen(
onBack = { NavUtils.navigateUpFromSameTask(this) },
onAccountSettingsClick = {
startActivity(Intent(this, AccountActivity::class.java))
accountLauncher.launch(Intent(this, AccountActivity::class.java))
},
onAboutClick = {
startActivity(Intent(this, AboutActivity::class.java))
},
onColorSelected = { appColor = it },
onNightModeChanged = { nightMode = it }
onNightModeChanged = { nightMode = it },
onRestoreDeletedProjects = { restoreDeletedProjects(it) }
)
}
}
@@ -21,6 +21,7 @@ import androidx.compose.material.MaterialTheme
import androidx.compose.material.RadioButton
import androidx.compose.material.Scaffold
import androidx.compose.material.Slider
import androidx.compose.material.Checkbox
import androidx.compose.material.Switch
import androidx.compose.material.SwitchDefaults
import androidx.compose.material.Text
@@ -34,11 +35,14 @@ import androidx.compose.material.icons.filled.Brightness2
import androidx.compose.material.icons.filled.Group
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.Restore
import androidx.compose.material.icons.filled.Sync
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -47,6 +51,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
@@ -56,6 +61,9 @@ import androidx.core.content.edit
import androidx.preference.PreferenceManager
import net.helcel.cowspent.R
import net.helcel.cowspent.android.helper.ColorPicker
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import net.helcel.cowspent.model.DBAccountProject
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.persistence.CowspentServerSyncHelper
import net.helcel.cowspent.util.ColorUtils
@@ -68,7 +76,8 @@ fun SettingsScreen(
onAccountSettingsClick: () -> Unit,
onAboutClick: () -> Unit,
onColorSelected: (Int) -> Unit,
onNightModeChanged: (String) -> Unit = {}
onNightModeChanged: (String) -> Unit = {},
onRestoreDeletedProjects: (List<DBAccountProject>) -> Unit = {}
) {
val context = LocalContext.current
val sharedPreferences = remember { PreferenceManager.getDefaultSharedPreferences(context) }
@@ -84,14 +93,24 @@ fun SettingsScreen(
val keyColor = stringResource(R.string.pref_key_color)
val keyOfflineMode = stringResource(R.string.pref_key_offline_mode)
val keyShowArchived = stringResource(R.string.pref_key_show_archived)
val keyBetaFeatures = stringResource(R.string.pref_key_beta_features)
val keyExtraFeatures = stringResource(R.string.pref_key_extra_features)
val keyStatsIncludeDeactivated = stringResource(R.string.pref_key_stats_include_deactivated)
val keyAutoSyncOnOpen = stringResource(R.string.pref_key_auto_sync_on_open)
val keyFullSyncDelay = stringResource(R.string.pref_key_full_sync_delay)
val keyFillNewBillFromLast = stringResource(R.string.pref_key_fill_new_bill_from_last)
val keyLastAccountSync = stringResource(R.string.pref_key_last_account_sync_timestamp)
val isNextcloudConfigured = CowspentServerSyncHelper.isNextcloudAccountConfigured(context)
var deletedProjects by remember { mutableStateOf(emptyList<DBAccountProject>()) }
var showRestoreDialog by remember { mutableStateOf(false) }
// Off the main thread: this reads the account projects table.
LaunchedEffect(Unit) {
deletedProjects = withContext(Dispatchers.IO) {
CowspentSQLiteOpenHelper.getInstance(context).cowspentServerSyncHelper.deletedAccountProjects()
}
}
// States for preferences
var nightMode by remember(keyNightMode) {
mutableStateOf(sharedPreferences.getString(keyNightMode, "-1") ?: "-1")
@@ -128,8 +147,8 @@ fun SettingsScreen(
var showArchived by remember(keyShowArchived) {
mutableStateOf(sharedPreferences.getBoolean(keyShowArchived, false))
}
var betaFeatures by remember(keyBetaFeatures) {
mutableStateOf(sharedPreferences.getBoolean(keyBetaFeatures, false))
var extraFeatures by remember(keyExtraFeatures) {
mutableStateOf(sharedPreferences.getBoolean(keyExtraFeatures, false))
}
var fillNewBillFromLast by remember(keyFillNewBillFromLast) {
mutableStateOf(sharedPreferences.getBoolean(keyFillNewBillFromLast, false))
@@ -138,16 +157,16 @@ fun SettingsScreen(
mutableStateOf(sharedPreferences.getBoolean(keyStatsIncludeDeactivated, false))
}
val syncIntervals = SyncSettings.INTERVAL_CHOICES_MINUTES
val syncIntervalLabels = listOf(
stringResource(R.string.pref_value_sync_1m),
stringResource(R.string.pref_value_sync_10m),
val fullSyncDelays = SyncSettings.FULL_SYNC_DELAY_CHOICES_MINUTES
val fullSyncDelayLabels = listOf(
stringResource(R.string.pref_value_sync_always),
stringResource(R.string.pref_value_sync_1h),
stringResource(R.string.pref_value_sync_1d)
stringResource(R.string.pref_value_sync_1d),
stringResource(R.string.pref_value_sync_1w)
)
var syncInterval by remember(keyAutoSyncOnOpen) {
mutableIntStateOf(SyncSettings.intervalMinutes(context))
var fullSyncDelay by remember(keyFullSyncDelay) {
mutableIntStateOf(SyncSettings.fullSyncDelayMinutes(context))
}
Scaffold(
@@ -173,28 +192,6 @@ fun SettingsScreen(
// Appearance
SettingsCategory(stringResource(R.string.settings_appearance))
SettingsSwitchPreference(
title = stringResource(R.string.settings_show_archived),
icon = Icons.Default.Archive,
checked = showArchived,
onCheckedChange = {
showArchived = it
sharedPreferences.edit {
putBoolean(keyShowArchived, it)
if (!it) {
val selectedProjectId = sharedPreferences.getLong("selected_project", 0)
if (selectedProjectId != 0L) {
val db = CowspentSQLiteOpenHelper.getInstance(context)
val project = db.getProject(selectedProjectId)
if (project?.isArchived == true) {
putLong("selected_project", 0)
}
}
}
}
}
)
SettingsListPreference(
title = stringResource(R.string.settings_night_mode),
icon = Icons.Default.Brightness2,
@@ -279,23 +276,71 @@ fun SettingsScreen(
onClick = onAccountSettingsClick
)
// Nothing held back means nothing to undo, so the row is not offered at all.
if (deletedProjects.isNotEmpty()) {
SettingsPreference(
title = stringResource(R.string.settings_restore_deleted_projects),
summary = pluralStringResource(
R.plurals.settings_restore_deleted_projects_summary,
deletedProjects.size,
deletedProjects.size
),
icon = Icons.Default.Restore,
onClick = { showRestoreDialog = true }
)
}
if (showRestoreDialog) {
RestoreDeletedProjectsDialog(
projects = deletedProjects,
onDismiss = { showRestoreDialog = false },
onConfirm = { chosen ->
showRestoreDialog = false
onRestoreDeletedProjects(chosen)
deletedProjects = deletedProjects - chosen.toSet()
}
)
}
// Other
SettingsCategory(stringResource(R.string.settings_other))
SettingsSwitchPreference(
title = stringResource(R.string.settings_beta_features),
summary = stringResource(R.string.settings_beta_features_summary),
title = stringResource(R.string.settings_extra_features),
summary = stringResource(R.string.settings_extra_features_summary),
icon = Icons.Default.Info,
checked = betaFeatures,
checked = extraFeatures,
onCheckedChange = {
betaFeatures = it
extraFeatures = it
sharedPreferences.edit {
putBoolean(keyBetaFeatures, it)
putBoolean(keyExtraFeatures, it)
}
}
)
if (betaFeatures) {
if (extraFeatures) {
SettingsSwitchPreference(
title = stringResource(R.string.settings_show_archived),
icon = Icons.Default.Archive,
checked = showArchived,
onCheckedChange = {
showArchived = it
sharedPreferences.edit {
putBoolean(keyShowArchived, it)
if (!it) {
val selectedProjectId = sharedPreferences.getLong("selected_project", 0)
if (selectedProjectId != 0L) {
val db = CowspentSQLiteOpenHelper.getInstance(context)
val project = db.getProject(selectedProjectId)
if (project?.isArchived == true) {
putLong("selected_project", 0)
}
}
}
}
}
)
SettingsSwitchPreference(
title = stringResource(R.string.settings_fill_new_bill_from_last),
summary = stringResource(R.string.settings_fill_new_bill_from_last_summary),
@@ -322,18 +367,18 @@ fun SettingsScreen(
)
SettingsSliderPreference(
title = stringResource(R.string.settings_auto_sync_on_open),
summary = stringResource(R.string.settings_auto_sync_on_open_summary),
title = stringResource(R.string.settings_full_sync_delay),
summary = stringResource(R.string.settings_full_sync_delay_summary),
icon = Icons.Default.Sync,
value = syncInterval,
values = syncIntervals,
labels = syncIntervalLabels,
onValueChange = { newInterval ->
value = fullSyncDelay,
values = fullSyncDelays,
labels = fullSyncDelayLabels,
onValueChange = { newDelay ->
// Slider reports every drag delta, not just the snapped steps.
if (newInterval != syncInterval) {
syncInterval = newInterval
if (newDelay != fullSyncDelay) {
fullSyncDelay = newDelay
sharedPreferences.edit {
putInt(keyAutoSyncOnOpen, newInterval)
putInt(keyFullSyncDelay, newDelay)
}
}
}
@@ -349,6 +394,60 @@ fun SettingsScreen(
}
}
@Composable
fun RestoreDeletedProjectsDialog(
projects: List<DBAccountProject>,
onDismiss: () -> Unit,
onConfirm: (List<DBAccountProject>) -> Unit
) {
val selected = remember(projects) { mutableStateListOf<DBAccountProject>().apply { addAll(projects) } }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.settings_restore_deleted_projects)) },
text = {
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
projects.forEach { project ->
val isSelected = project in selected
Row(
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = isSelected,
onClick = {
if (isSelected) selected.remove(project) else selected.add(project)
}
)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(checked = isSelected, onCheckedChange = null)
Spacer(Modifier.width(16.dp))
Column {
Text(
text = project.name.ifEmpty { project.remoteId },
style = MaterialTheme.typography.subtitle1
)
Text(text = project.ncUrl, style = MaterialTheme.typography.caption)
}
}
}
}
},
confirmButton = {
TextButton(
onClick = { onConfirm(selected.toList()) },
enabled = selected.isNotEmpty()
) {
Text(stringResource(R.string.settings_restore_deleted_projects_confirm))
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.simple_cancel)) }
}
)
}
@Composable
fun SettingsCategory(title: String) {
Text(
@@ -4,6 +4,7 @@ import android.annotation.SuppressLint
import android.content.ContentValues
import android.content.Context
import android.database.Cursor
import android.database.DatabaseUtils
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
import android.text.TextUtils
@@ -238,13 +239,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
@WorkerThread
private fun getProjectsCustom(selection: String, selectionArgs: Array<String>, orderBy: String?, db: SQLiteDatabase): List<DBProject> {
val cursor = db.query(table_projects, columnsProjects, selection, selectionArgs, null, null, orderBy)
val projects: MutableList<DBProject> = ArrayList()
while (cursor.moveToNext()) {
projects.add(getProjectFromCursor(cursor))
}
cursor.close()
return projects
return queryAll(db, table_projects, columnsProjects, selection, selectionArgs, orderBy, ::getProjectFromCursor)
}
@SuppressLint("Range")
@@ -305,11 +300,17 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
fun deleteProject(id: Long) {
val db = writableDatabase
for (b in getBillsOfProject(id)) {
deleteBill(b.id)
val projectId = arrayOf(id.toString())
inTransaction {
db.delete(
table_billowers,
"$key_billId IN (SELECT $key_id FROM $table_bills WHERE $key_projectid = ?)",
projectId
)
db.delete(table_bills, "$key_projectid = ?", projectId)
db.delete(table_members, "$key_projectid = ?", projectId)
db.delete(table_projects, "$key_id = ?", projectId)
}
db.delete(table_members, "$key_projectid = ?", arrayOf(id.toString()))
db.delete(table_projects, "$key_id = ?", arrayOf(id.toString()))
SecureStorage.removePasswordSync(context, "ProjectPassword_$id")
}
@@ -413,14 +414,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
@WorkerThread
private fun getMembersCustom(selection: String, selectionArgs: Array<String>, orderBy: String?): List<DBMember> {
val db = readableDatabase
val cursor = db.query(table_members, columnsMembers, selection, selectionArgs, null, null, orderBy)
val members: MutableList<DBMember> = ArrayList()
while (cursor.moveToNext()) {
members.add(getMemberFromCursor(cursor))
}
cursor.close()
return members
return queryAll(readableDatabase, table_members, columnsMembers, selection, selectionArgs, orderBy, ::getMemberFromCursor)
}
@SuppressLint("Range")
@@ -448,6 +442,26 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
// --- Bills logic ---
/**
* Runs [block] as one database transaction.
*
* Every write here commits on its own otherwise, which for a sync means a committed
* transaction per bill and another per ower - the dominant cost of a large project's first
* sync. Callers batch in chunks rather than wrapping everything: a sync interrupted halfway
* then keeps the chunks it already committed instead of rolling the lot back.
*/
fun <T> inTransaction(block: () -> T): T {
val db = writableDatabase
db.beginTransaction()
try {
val result = block()
db.setTransactionSuccessful()
return result
} finally {
db.endTransaction()
}
}
fun addBill(b: DBBill): Long {
val db = writableDatabase
val values = ContentValues()
@@ -540,6 +554,14 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
fun getBillsOfProject(projId: Long): List<DBBill> {
return getBillsCustom("$key_projectid = ?", arrayOf(projId.toString()), "$key_timestamp ASC")
}
@WorkerThread
fun countUnsyncedBills(projId: Long): Int = DatabaseUtils.queryNumEntries(
readableDatabase,
table_bills,
"$key_projectid = ? AND $key_state != ?",
arrayOf(projId.toString(), DBBill.STATE_OK.toString())
).toInt()
fun getBillsOfProjectWithState(projId: Long, state: Int): List<DBBill> {
return getBillsCustom(
@@ -624,15 +646,20 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
@WorkerThread
private fun getBillsCustom(selection: String, selectionArgs: Array<String>, orderBy: String?): List<DBBill> {
val db = readableDatabase
val cursor = db.query(table_bills, columnsBills, selection, selectionArgs, null, null, orderBy)
val bills: MutableList<DBBill> = ArrayList()
while (cursor.moveToNext()) {
val bill = getBillFromCursor(cursor)
bill.billOwers = getBillowersOfBill(bill.id)
bills.add(bill)
return db.transactionally {
val bills = queryAll(db, table_bills, columnsBills, selection, selectionArgs, orderBy, ::getBillFromCursor)
if (bills.isNotEmpty()) {
val owersByBill = queryAll(
db, table_billowers, columnsBillowers,
"$key_billId IN (SELECT $key_id FROM $table_bills WHERE $selection)",
selectionArgs, null, ::getBillOwerFromCursor
).groupBy { it.billId }
for (bill in bills) {
bill.billOwers = owersByBill[bill.id].orEmpty()
}
}
bills
}
cursor.close()
return bills
}
@SuppressLint("Range")
@@ -680,14 +707,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
@WorkerThread
private fun getBillOwersCustom(selection: String, selectionArgs: Array<String>, orderBy: String?): List<DBBillOwer> {
val db = readableDatabase
val cursor = db.query(table_billowers, columnsBillowers, selection, selectionArgs, null, null, orderBy)
val billOwers: MutableList<DBBillOwer> = ArrayList()
while (cursor.moveToNext()) {
billOwers.add(getBillOwerFromCursor(cursor))
}
cursor.close()
return billOwers
return queryAll(readableDatabase, table_billowers, columnsBillowers, selection, selectionArgs, orderBy, ::getBillOwerFromCursor)
}
@SuppressLint("Range")
@@ -754,14 +774,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
@WorkerThread
private fun getCategoriesCustom(selection: String, selectionArgs: Array<String>, orderBy: String?): List<DBCategory> {
val db = readableDatabase
val cursor = db.query(table_categories, columnsCategories, selection, selectionArgs, null, null, orderBy)
val categories: MutableList<DBCategory> = ArrayList()
while (cursor.moveToNext()) {
categories.add(getCategoryFromCursor(cursor))
}
cursor.close()
return categories
return queryAll(readableDatabase, table_categories, columnsCategories, selection, selectionArgs, orderBy, ::getCategoryFromCursor)
}
@SuppressLint("Range")
@@ -870,13 +883,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
@WorkerThread
private fun getPaymentModesCustom(selection: String, selectionArgs: Array<String>, orderBy: String?, db: SQLiteDatabase): List<DBPaymentMode> {
val cursor = db.query(table_payment_modes, columnsPaymentModes, selection, selectionArgs, null, null, orderBy)
val paymentModes: MutableList<DBPaymentMode> = ArrayList()
while (cursor.moveToNext()) {
paymentModes.add(getPaymentModeFromCursor(cursor))
}
cursor.close()
return paymentModes
return queryAll(db, table_payment_modes, columnsPaymentModes, selection, selectionArgs, orderBy, ::getPaymentModeFromCursor)
}
@SuppressLint("Range")
@@ -986,13 +993,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
@WorkerThread
private fun getCurrenciesCustom(selection: String, selectionArgs: Array<String>, orderBy: String?, db: SQLiteDatabase): List<DBCurrency> {
val cursor = db.query(table_currencies, columnsCurrencies, selection, selectionArgs, null, null, orderBy)
val currencies: MutableList<DBCurrency> = ArrayList()
while (cursor.moveToNext()) {
currencies.add(getCurrencyFromCursor(cursor))
}
cursor.close()
return currencies
return queryAll(db, table_currencies, columnsCurrencies, selection, selectionArgs, orderBy, ::getCurrencyFromCursor)
}
@SuppressLint("Range")
@@ -1040,6 +1041,36 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
// --- Common Helpers ---
@WorkerThread
private fun <T> queryAll(
db: SQLiteDatabase,
table: String,
columns: Array<String>,
selection: String?,
selectionArgs: Array<String>?,
orderBy: String?,
fromCursor: (Cursor) -> T
): List<T> = db.transactionally {
val items: MutableList<T> = ArrayList()
db.query(table, columns, selection, selectionArgs, null, null, orderBy).use { cursor ->
while (cursor.moveToNext()) {
items.add(fromCursor(cursor))
}
}
items
}
private fun <T> SQLiteDatabase.transactionally(block: () -> T): T {
beginTransaction()
try {
val result = block()
setTransactionSuccessful()
return result
} finally {
endTransaction()
}
}
fun syncIfRemote(proj: DBProject) {
if (!proj.isLocal) {
val preferences = PreferenceManager.getDefaultSharedPreferences(context)
@@ -1112,13 +1143,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
@WorkerThread
private fun getAccountProjectsCustom(selection: String, selectionArgs: Array<String>, orderBy: String?, db: SQLiteDatabase): List<DBAccountProject> {
val cursor = db.query(table_account_projects, columnsAccountProjects, selection, selectionArgs, null, null, orderBy)
val accountProjects: MutableList<DBAccountProject> = ArrayList()
while (cursor.moveToNext()) {
accountProjects.add(getAccountProjectFromCursor(cursor))
}
cursor.close()
return accountProjects
return queryAll(db, table_account_projects, columnsAccountProjects, selection, selectionArgs, orderBy, ::getAccountProjectFromCursor)
}
@SuppressLint("Range")
@@ -6,6 +6,7 @@ import android.content.SharedPreferences
import android.net.ConnectivityManager
import android.util.Log
import androidx.annotation.VisibleForTesting
import androidx.annotation.WorkerThread
import androidx.core.content.edit
import androidx.core.graphics.toColorInt
import androidx.preference.PreferenceManager
@@ -14,6 +15,7 @@ import com.nextcloud.android.sso.api.NextcloudAPI
import com.nextcloud.android.sso.exceptions.NextcloudHttpRequestFailedException
import com.nextcloud.android.sso.exceptions.TokenMismatchException
import com.nextcloud.android.sso.helper.SingleAccountHelper
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
@@ -26,6 +28,7 @@ import net.helcel.cowspent.R
import net.helcel.cowspent.android.account.AccountActivity
import net.helcel.cowspent.android.main.BillsListViewActivity
import net.helcel.cowspent.android.main.MainConstants
import net.helcel.cowspent.model.DBAccountProject
import net.helcel.cowspent.model.DBBill
import net.helcel.cowspent.model.DBMember
import net.helcel.cowspent.model.DBProject
@@ -50,7 +53,6 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
private var syncActive = false
private var syncAccountProjectsActive = false
private var callbacksPush: MutableList<ICallback> = ArrayList()
private var callbacksPull: MutableList<ICallback> = ArrayList()
init {
@@ -101,8 +103,6 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
if (project != null) {
Log.d(TAG, "... starting now")
val syncTask = SyncTask(onlyLocalChanges, project, forceFullSync)
syncTask.addCallbacks(callbacksPush)
callbacksPush = ArrayList()
if (!onlyLocalChanges) {
syncTask.addCallbacks(callbacksPull)
callbacksPull = ArrayList()
@@ -114,14 +114,8 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
} else if (!onlyLocalChanges) {
Log.d(TAG, "... scheduled")
projectIdsToSync.add(projId)
for (callback in callbacksPush) {
callback.onScheduled()
}
} else {
Log.d(TAG, "... do nothing")
for (callback in callbacksPush) {
callback.onScheduled()
}
}
return null
}
@@ -153,7 +147,12 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
* walk it is empty, and nothing may be deleted locally on the strength of it.
*/
val allIds: List<Long>,
val syncTimestamp: Long?
val syncTimestamp: Long?,
/**
* A bill referenced a member, category or payment mode this device does not know. A
* partial sync skips re-reading the project, so this is what tells it to go back for it.
*/
val hasUnresolvedReferences: Boolean = false
)
inner class SyncTask(private val onlyLocalChanges: Boolean, private val project: DBProject, private val forceFullSync: Boolean = false) {
@@ -180,14 +179,22 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
}
fun execute(): SyncTask {
syncActive = true
deferred = scope.async {
syncActive = true
val status = withContext(Dispatchers.IO) {
doWork()
try {
val status = withContext(Dispatchers.IO) {
doWork()
}
onPostExecute(status)
status
} catch (e: CancellationException) {
syncActive = false
throw e
} catch (e: Exception) {
Log.e(TAG, "Sync failed for ${project.remoteId}", e)
syncActive = false
LoginStatus.CONNECTION_FAILED
}
onPostExecute(status)
syncActive = false
status
}
return this
}
@@ -197,11 +204,18 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
if (project.type == ProjectType.COSPEND) {
nextcloudClient = createNextcloudClient()
if (nextcloudClient != null) {
try {
val response = nextcloudClient!!.getCapabilities(project)
version = response.cospendVersion
} catch (e: Exception) {
Log.i(TAG, "Failed to get cospend version when syncing: $e")
// The server's version only moves when the server itself is upgraded, and
// asking costs a whole round trip before any project data moves. Remember it
// per server, and re-read it whenever a full sync is asked for.
version = if (forceFullSync) null else cachedCospendVersion()
if (version == null) {
try {
val response = nextcloudClient!!.getCapabilities(project)
version = response.cospendVersion
rememberCospendVersion(version)
} catch (e: Exception) {
Log.i(TAG, "Failed to get cospend version when syncing: $e")
}
}
} else if (preferences.getBoolean(AccountActivity.SETTINGS_USE_SSO, false)) {
return LoginStatus.SSO_TOKEN_MISMATCH
@@ -227,35 +241,53 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
deferred?.await() ?: LoginStatus.CONNECTION_FAILED
}
/** Keyed by server rather than by project, since several projects can share one. */
private fun versionKey() = "cospend_version_" + (project.serverUrl ?: "")
private fun cachedCospendVersion(): String? =
preferences.getString(versionKey(), null)
private fun rememberCospendVersion(version: String?) {
// A failed lookup is not an answer, and caching it would pin the wrong API dialect
// until the next forced sync.
if (version.isNullOrEmpty()) return
preferences.edit { putString(versionKey(), version) }
}
private fun pushLocalChanges(): LoginStatus {
Log.d(TAG, "PUSH LOCAL CHANGES")
return try {
val membersResponse = client!!.getMembers(project)
val remoteMembers = membersResponse.getMembers(project.id)
val remoteMembersNames = remoteMembers.map { it.name }
// The remote member list is only needed to match members waiting to be created,
// and pullRemoteChanges fetches them again as part of the project anyway, so it
// is not worth a round trip of its own when there is nothing to add.
val membersToAdd = dbHelper.getMembersOfProjectWithState(project.id, DBBill.STATE_ADDED)
for (mToAdd in membersToAdd) {
val searchIndex = remoteMembersNames.indexOf(mToAdd.name)
if (searchIndex != -1) {
val remoteMember = remoteMembers[searchIndex]
dbHelper.updateMember(
mToAdd.id, null,
remoteMember.weight, remoteMember.isActivated,
DBBill.STATE_OK, remoteMember.remoteId, remoteMember.r,
remoteMember.g, remoteMember.b,
remoteMember.ncUserId, ""
)
} else {
val createRemoteMemberResponse = client!!.createRemoteMember(project, mToAdd)
val newRemoteId = createRemoteMemberResponse.remoteMemberId
if (newRemoteId > 0) {
if (membersToAdd.isNotEmpty()) {
val membersResponse = client!!.getMembers(project)
val remoteMembers = membersResponse.getMembers(project.id)
val remoteMembersNames = remoteMembers.map { it.name }
for (mToAdd in membersToAdd) {
val searchIndex = remoteMembersNames.indexOf(mToAdd.name)
if (searchIndex != -1) {
val remoteMember = remoteMembers[searchIndex]
dbHelper.updateMember(
mToAdd.id, null,
null, null, DBBill.STATE_OK, newRemoteId,
null, null, null, null, null
remoteMember.weight, remoteMember.isActivated,
DBBill.STATE_OK, remoteMember.remoteId, remoteMember.r,
remoteMember.g, remoteMember.b,
remoteMember.ncUserId, ""
)
} else {
val createRemoteMemberResponse = client!!.createRemoteMember(project, mToAdd)
val newRemoteId = createRemoteMemberResponse.remoteMemberId
if (newRemoteId > 0) {
dbHelper.updateMember(
mToAdd.id, null,
null, null, DBBill.STATE_OK, newRemoteId,
null, null, null, null, null
)
}
}
}
}
@@ -272,12 +304,8 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
null, null, null, null, null
)
}
} catch (e: IOException) {
if (e.message == "{\"message\": \"Internal Server Error\"}") {
Log.d(TAG, "EDIT MEMBER FAILED : it does not exist remotely")
} else {
throw e
}
} catch (e: Exception) {
Log.e(TAG, "EDIT MEMBER FAILED for ${mToEdit.name}", e)
}
}
@@ -548,33 +576,54 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
}
}
/**
* Reads the project's own row and each of its collections. One request, and everything it
* carries is applied as it goes, so a failure part way through leaves the earlier steps
* written. Returns the members it saw, keyed by remote id.
*/
private fun pullProject(): Map<Long, DBMember> {
val projResponse = client!!.getProject(project, 0, null)
updateLocalProject(projResponse)
syncPaymentModes(projResponse)
syncCategories(projResponse)
syncCurrencies(projResponse)
return syncMembers(projResponse)
}
/**
* Brings the local copy of the project in line with the server: its own row, then each of
* its collections, then its bills. Each step is applied as it goes, so a failure part way
* through leaves the earlier steps written - the next sync picks up where this one stopped.
*
* A partial sync is meant to cost one request. A project's members and labels change far
* less often than its bills, so it skips reading them and goes straight for the bills; it
* only goes back for the project when a bill turns out to name something this device has
* never seen. What that trades away is noticing a rename or a deletion that no bill
* refers to - those land on the next full sync.
*/
private fun pullRemoteChanges(): LoginStatus {
Log.d(TAG, "pullRemoteChanges($project)")
return try {
val projResponse = client!!.getProject(project, 0, null)
val localBills = dbHelper.getBillsOfProject(project.id)
val localBillsByRemoteId = localBills.associateBy { it.remoteId }
val partial = !forceFullSync && localBillsByRemoteId.isNotEmpty()
updateLocalProject(projResponse)
syncPaymentModes(projResponse)
syncCategories(projResponse)
syncCurrencies(projResponse)
val remoteMembersByRemoteId = syncMembers(projResponse)
var remoteMembersByRemoteId: Map<Long, DBMember>? = null
if (!partial) remoteMembersByRemoteId = pullProject()
// Bills arrive with the server's ids for their member, category and payment mode,
// so the maps have to be built after those collections are in place.
val idMaps = buildRemoteIdMaps()
var pulled = fetchRemoteBills(buildRemoteIdMaps(), localBillsByRemoteId)
val localBills = dbHelper.getBillsOfProject(project.id)
val localBillsByRemoteId = localBills.associateBy { it.remoteId }
val pulled = fetchRemoteBills(idMaps, localBillsByRemoteId)
if (pulled.hasUnresolvedReferences && remoteMembersByRemoteId == null) {
Log.d(TAG, "Bill referenced an unknown member or label; re-reading the project")
remoteMembersByRemoteId = pullProject()
pulled = fetchRemoteBills(buildRemoteIdMaps(), localBillsByRemoteId)
}
applyRemoteBills(pulled.bills, localBillsByRemoteId)
deleteVanishedBills(pulled, localBills)
deleteVanishedMembers(remoteMembersByRemoteId)
remoteMembersByRemoteId?.let { deleteVanishedMembers(it) }
dbHelper.updateProject(
projId = project.id,
@@ -808,7 +857,9 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
if (usePagedWalk) {
walkBillPages(idMaps, localBillsByRemoteId)?.let { return it }
}
return fetchAllBills(idMaps)
// A project with nothing stored has no cursor worth sending, so it asks for everything.
val since = if (forceFullSync || localBillsByRemoteId.isEmpty()) 0L else null
return fetchAllBills(idMaps, since)
}
/**
@@ -832,6 +883,7 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
var syncTimestamp = project.lastSyncedTimestamp
var offset = 0
var previousPageIds: List<Long>? = null
var unresolved = false
// Counted across pages, not restarted at each one: a run that begins near the end of
// a page still finishes on the next.
var unchangedRun = 0
@@ -841,6 +893,7 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
val page = response.getBillsCospend(
project.id, idMaps.members, idMaps.categories, idMaps.paymentModes
)
if (response.hasUnresolvedReferences) unresolved = true
if (page.isEmpty()) break
if (page.first().timestamp < page.last().timestamp) {
@@ -880,24 +933,31 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
}
// A walk reports no allIds, so it never causes a local deletion.
return RemoteBills(bills, emptyList(), syncTimestamp)
return RemoteBills(bills, emptyList(), syncTimestamp, unresolved)
}
private fun fetchAllBills(idMaps: RemoteIdMaps): RemoteBills {
Log.d(TAG, "Starting full sync for project ${project.remoteId}")
val response = client!!.getBills(project)
/**
* One request for the whole collection. [since] 0 asks for everything; the project cursor
* asks only for what changed. Either way the response carries the full id list, which is
* what lets vanished bills be removed locally.
*/
private fun fetchAllBills(idMaps: RemoteIdMaps, since: Long?): RemoteBills {
Log.d(TAG, "Fetching bills for ${project.remoteId} (since=${since ?: project.lastSyncedTimestamp})")
val response = client!!.getBills(project, since = since)
return if (project.type == ProjectType.IHATEMONEY) {
val bills = response.getBillsIHM(
project.id, idMaps.members, idMaps.categories, idMaps.paymentModes
)
RemoteBills(bills, bills.map { it.remoteId }, 0L)
RemoteBills(bills, bills.map { it.remoteId }, 0L, response.hasUnresolvedReferences)
} else {
val bills = response.getBillsCospend(
project.id, idMaps.members, idMaps.categories, idMaps.paymentModes
)
RemoteBills(
response.getBillsCospend(
project.id, idMaps.members, idMaps.categories, idMaps.paymentModes
),
bills,
response.allBillIds,
response.syncTimestamp
response.syncTimestamp,
response.hasUnresolvedReferences
)
}
}
@@ -905,6 +965,18 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
private fun applyRemoteBills(
remoteBills: List<DBBill>,
localBillsByRemoteId: Map<Long, DBBill>
) {
// Committed in chunks: one transaction per chunk turns a per-row commit into one write
// for the whole batch, while still leaving finished chunks on disk if the sync is cut
// short - a project part-way through is far better than one that rolled back.
remoteBills.chunked(BILL_APPLY_CHUNK).forEach { chunk ->
dbHelper.inTransaction { applyBillChunk(chunk, localBillsByRemoteId) }
}
}
private fun applyBillChunk(
remoteBills: List<DBBill>,
localBillsByRemoteId: Map<Long, DBBill>
) {
for (remoteBill in remoteBills) {
val localBill = localBillsByRemoteId[remoteBill.remoteId]
@@ -966,6 +1038,10 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
}
for (localBill in localBills) {
// A bill still waiting to be pushed has no remote id yet, so it is absent from the
// server list for the ordinary reason that the server has never seen it. Deleting
// it here would throw away a bill the user added while the sync was running.
if (localBill.state != DBBill.STATE_OK) continue
if (localBill.remoteId !in stillRemote) {
dbHelper.deleteBill(localBill.id)
nbPulledDeletedBills++
@@ -979,6 +1055,8 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
for (localMember in dbHelper.getMembersOfProject(project.id, null)) {
if (remoteMembersByRemoteId.containsKey(localMember.remoteId)) continue
if (localMember.state != DBBill.STATE_OK) continue
// A member still named by a bill cannot be removed without orphaning it.
if (dbHelper.getBillsOfMember(localMember.id).isEmpty() &&
dbHelper.getBillowersOfMember(localMember.id).isEmpty()
@@ -1399,6 +1477,21 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
}
}
@WorkerThread
fun deletedAccountProjects(): List<DBAccountProject> {
val forgotten = forgottenAccountProjects(preferences)
if (forgotten.isEmpty()) return emptyList()
return dbHelper.accountProjects.filter { accountProjectKey(it.remoteId, it.ncUrl) in forgotten }
}
fun restoreAccountProjects(projects: List<DBAccountProject>) {
if (projects.isEmpty()) return
val restored = projects.map { accountProjectKey(it.remoteId, it.ncUrl) }.toSet()
preferences.edit {
putStringSet(FORGOTTEN_ACCOUNT_PROJECTS, forgottenAccountProjects(preferences) - restored)
}
}
fun runAccountProjectsSync() {
Log.d(TAG, "Account projects sync requested; ${if (syncAccountProjectsActive) "sync active" else "sync NOT active"}) ...")
updateNetworkStatus()
@@ -1445,11 +1538,18 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
fun execute(): SyncAccountProjectsTask {
scope.launch {
syncAccountProjectsActive = true
val status = withContext(Dispatchers.IO) {
doWork()
try {
val status = withContext(Dispatchers.IO) {
doWork()
}
onPostExecute(status)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.e(TAG, "Account projects sync failed", e)
} finally {
syncAccountProjectsActive = false
}
onPostExecute(status)
syncAccountProjectsActive = false
}
return this
}
@@ -1478,20 +1578,25 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
val response = client.getAccountProjects(useOcsApi)
val remoteAccountProjects = response.getAccountProjects(url)
val forgotten = forgottenAccountProjects(preferences)
dbHelper.clearAccountProjects()
for (remoteAccountProject in remoteAccountProjects) {
dbHelper.addAccountProject(remoteAccountProject)
Log.v(TAG, "received account project $remoteAccountProject")
val existingProj = localProjects.find {
it.remoteId == remoteAccountProject.remoteId &&
it.serverUrl?.replace("/+$".toRegex(), "") == remoteAccountProject.ncUrl.replace("/+$".toRegex(), "") + "/index.php/apps/cospend"
val existingProj = localProjects.find {
it.remoteId == remoteAccountProject.remoteId &&
it.serverUrl?.replace("/+$".toRegex(), "") == remoteAccountProject.ncUrl.replace("/+$".toRegex(), "") + COSPEND_PATH
}
if (existingProj == null) {
if (accountProjectKey(remoteAccountProject.remoteId, remoteAccountProject.ncUrl) in forgotten) {
Log.d(TAG, "skipping ${remoteAccountProject.remoteId}, deleted on this device")
continue
}
val newProj = DBProject(0,
remoteAccountProject.remoteId,
"",
remoteAccountProject.name,
remoteAccountProject.ncUrl.replace("/+$".toRegex(), "") + "/index.php/apps/cospend",
remoteAccountProject.ncUrl.replace("/+$".toRegex(), "") + COSPEND_PATH,
"",
null,
ProjectType.COSPEND,
@@ -1801,6 +1906,9 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
*/
private const val UNCHANGED_RUN_TO_SETTLE = 25
/** Bills written per transaction while applying a pull. */
private const val BILL_APPLY_CHUNK = 500
private var instance: CowspentServerSyncHelper? = null
private val projectIdsToSync: MutableList<Long> = ArrayList()
@@ -1824,6 +1932,34 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
preferences.getBoolean(AccountActivity.SETTINGS_USE_SSO, false)
}
/** The path a Cospend project's URL carries on top of its Nextcloud server URL. */
const val COSPEND_PATH = "/index.php/apps/cospend"
private const val FORGOTTEN_ACCOUNT_PROJECTS = "forgottenAccountProjects"
private fun trimSlashes(url: String) = url.replace("/+$".toRegex(), "")
/** Names one project an account offers, by server and remote id, with no local row needed. */
private fun accountProjectKey(remoteId: String, ncUrl: String) = "${trimSlashes(ncUrl)}|$remoteId"
/** The same key for a stored project, or null when it is not one an account can offer. */
private fun accountProjectKey(project: DBProject): String? {
val url = trimSlashes(project.serverUrl.orEmpty())
if (!url.endsWith(COSPEND_PATH)) return null
return accountProjectKey(project.remoteId, url.removeSuffix(COSPEND_PATH))
}
private fun forgottenAccountProjects(preferences: SharedPreferences): Set<String> =
preferences.getStringSet(FORGOTTEN_ACCOUNT_PROJECTS, emptySet()).orEmpty()
fun forgetAccountProject(context: Context, project: DBProject) {
val key = accountProjectKey(project) ?: return
val preferences = PreferenceManager.getDefaultSharedPreferences(context)
preferences.edit {
putStringSet(FORGOTTEN_ACCOUNT_PROJECTS, forgottenAccountProjects(preferences) + key)
}
}
fun getNextcloudAccountServerUrl(context: Context): String {
val preferences = PreferenceManager.getDefaultSharedPreferences(context)
return if (preferences.getBoolean(AccountActivity.SETTINGS_USE_SSO, false)) {
@@ -5,6 +5,9 @@ import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
object ExportUtil {
/** Quotes a field the way RFC4180 (and opencsv, and Cospend) expect. */
private fun q(value: String?): String = "\"" + (value ?: "").replace("\"", "\"\"") + "\""
@JvmStatic
fun createExportContent(db: CowspentSQLiteOpenHelper, projectId: Long): String {
var fileContent = ""
@@ -18,8 +21,8 @@ object ExportUtil {
}
val bills = db.getBillsOfProject(projectId).toMutableList()
// write header
fileContent += "what,amount,date,timestamp,payer_name,payer_weight,payer_active,owers,repeat,categoryid,paymentmode\n"
// write header.
fileContent += "what,amount,date,timestamp,payer_name,payer_weight,payer_active,owers,repeat,categoryid,paymentmode,paymentmodeid,comment\n"
// write members
for (m in members) {
@@ -41,15 +44,11 @@ object ExportUtil {
val payerName = payer.name
val payerWeight = payer.weight
val payerActive = if (payer.isActivated) 1 else 0
val billOwers = b.billOwers
var owersTxt = ""
for (bo in billOwers) {
owersTxt += membersById[bo.memberId]?.name + ","
}
owersTxt = owersTxt.replace(",$".toRegex(), "")
fileContent += "\"${b.what}\",${b.amount},${b.date},${b.timestamp},\"$payerName\"," +
"$payerWeight,$payerActive,\"$owersTxt\",${b.repeat},${b.categoryId}," +
"${b.paymentMode}\n"
val owersTxt = b.billOwers.mapNotNull { membersById[it.memberId]?.name }.joinToString(",")
fileContent += "${q(b.what)},${b.amount},${b.date},${b.timestamp},${q(payerName)}," +
"$payerWeight,$payerActive,${q(owersTxt)},${b.repeat ?: DBBill.NON_REPEATED}," +
"${b.categoryId},${b.paymentMode ?: DBBill.PAYMODE_NONE},${b.paymentModeId}," +
"${q(b.comment)}\n"
}
// write categories
@@ -57,7 +56,16 @@ object ExportUtil {
if (cats.isNotEmpty()) {
fileContent += "\ncategoryname,categoryid,icon,color\n"
for (cat in cats) {
fileContent += "\"${cat.name}\",${cat.id},\"${cat.icon}\",\"${cat.color}\"\n"
fileContent += "${q(cat.name)},${cat.id},${q(cat.icon)},${q(cat.color)}\n"
}
}
// write payment modes
val pms = db.getPaymentModes(projectId)
if (pms.isNotEmpty()) {
fileContent += "\npaymentmodename,paymentmodeid,icon,color\n"
for (pm in pms) {
fileContent += "${q(pm.name)},${pm.id},${q(pm.icon)},${q(pm.color)}\n"
}
}
@@ -67,9 +75,9 @@ object ExportUtil {
project.currencyName!!.isNotEmpty() && project.currencyName != "null"
) {
fileContent += "\ncurrencyname,exchange_rate\n"
fileContent += "\"${project.currencyName}\",1\n"
fileContent += "${q(project.currencyName)},1\n"
for (cur in curs) {
fileContent += "\"${cur.name}\",${cur.exchangeRate}\n"
fileContent += "${q(cur.name)},${cur.exchangeRate}\n"
}
}
@@ -28,6 +28,33 @@ open class ServerResponse(
) {
private val sdf = SimpleDateFormat("yyyy-MM-dd", Locale.ROOT)
/**
* Set while parsing bills when one referenced a member, category or payment mode that none of
* the supplied maps knew about. A partial sync does not re-read the project, so this is how it
* learns that its copy of those collections has gone stale and has to be refreshed.
*/
var hasUnresolvedReferences = false
private set
/** Members are only ever addressed by the server's own id, so an unknown one is stale state. */
private fun resolveMember(map: Map<Long, Long>, remoteId: Long): Long {
map[remoteId]?.let { return it }
if (remoteId != 0L) hasUnresolvedReferences = true
return 0L
}
/**
* Built-in labels are negative constants with no row of their own on a remote project, so they
* never appear in these maps and are carried through as-is. An unknown *positive* id is a real
* gap - it is dropped rather than kept, as it would collide with a local id.
*/
private fun resolveLabel(map: Map<Long, Long>, remoteId: Long): Long {
map[remoteId]?.let { return it }
if (remoteId < 0) return remoteId
if (remoteId > 0) hasUnresolvedReferences = true
return 0L
}
class NotModifiedException : IOException()
protected val content: String
@@ -744,10 +771,10 @@ open class ServerResponse(
}
if (!json.isNull("payer_id")) {
payerRemoteId = json.getLong("payer_id")
payerId = memberRemoteIdToId[payerRemoteId] ?: 0
payerId = resolveMember(memberRemoteIdToId, payerRemoteId)
} else if (!json.isNull("payer")) {
payerRemoteId = json.getLong("payer")
payerId = memberRemoteIdToId[payerRemoteId] ?: 0
payerId = resolveMember(memberRemoteIdToId, payerRemoteId)
}
if (!json.isNull("amount")) {
amount = json.getDouble("amount")
@@ -794,8 +821,8 @@ open class ServerResponse(
paymentModeRemoteId = DBBill.oldPmIdToNew[paymentMode] ?: DBBill.PAYMODE_ID_NONE
}
val categoryId = catRemoteIdToId[categoryRemoteId] ?: 0L
val paymentModeId = pmRemoteIdToId[paymentModeRemoteId] ?: 0L
val categoryId = resolveLabel(catRemoteIdToId, categoryRemoteId)
val paymentModeId = resolveLabel(pmRemoteIdToId, paymentModeRemoteId)
val bill = DBBill(
0, remoteId, projId, payerId, amount, timestamp, what,
@@ -822,7 +849,7 @@ open class ServerResponse(
} else {
jsonOs.getLong(i)
}
val memberLocalId = memberRemoteIdToId[memberRemoteId] ?: 0
val memberLocalId = resolveMember(memberRemoteIdToId, memberRemoteId)
billOwers.add(DBBillOwer(0, 0, memberLocalId))
}
}
@@ -141,7 +141,7 @@ object SupportUtil {
membersBalance[b.payerId] = balPayer + amount
val paid = membersPaid[b.payerId] ?: 0.0
membersPaid[b.payerId] = paid + amount
var nbOwerShares = 0.0
for (bo in b.billOwers) {
nbOwerShares += membersWeight[bo.memberId] ?: 0.0
@@ -167,6 +167,39 @@ object SupportUtil {
}
const val SETTLE_OPTIMAL: Long = 0
const val SETTLE_DIRECT: Long = -1
@JvmStatic
fun settleBillsDirect(members: List<DBMember>, bills: List<DBBill>): List<Transaction> {
val membersWeight = members.associate { it.id to it.weight }
// owed[debtor][creditor] = total the debtor owes the creditor over all bills
val owed = HashMap<Long, HashMap<Long, Double>>()
for (b in bills) {
if (b.state == DBBill.STATE_DELETED) continue
val nbOwerShares = b.billOwers.sumOf { membersWeight[it.memberId] ?: 0.0 }
if (nbOwerShares <= 0.0) continue
for (bo in b.billOwers) {
if (bo.memberId == b.payerId) continue
val share = b.amount / nbOwerShares * (membersWeight[bo.memberId] ?: 0.0)
val row = owed.getOrPut(bo.memberId) { HashMap() }
row[b.payerId] = (row[b.payerId] ?: 0.0) + share
}
}
val results: MutableList<Transaction> = ArrayList()
for (debtor in members) {
for (creditor in members) {
if (debtor.id == creditor.id) continue
val net = (owed[debtor.id]?.get(creditor.id) ?: 0.0) -
(owed[creditor.id]?.get(debtor.id) ?: 0.0)
if (round2(net) > 0.0) {
results.add(Transaction(debtor.id, creditor.id, round2(net)))
}
}
}
return results
}
@JvmStatic
fun settleBills(
@@ -233,11 +266,11 @@ object SupportUtil {
if (cd1.balance < cd2.balance) 1 else -1
}
}
for (c in crediters) {
Log.e(SupportUtil::class.java.simpleName, "* " + c.memberId + " : " + c.balance)
}
debiters.sortWith { cd2, cd1 ->
if (cd1.balance == cd2.balance) {
0
@@ -5,29 +5,45 @@ import androidx.preference.PreferenceManager
import net.helcel.cowspent.R
/**
* The SyncOnOpen preference: how often opening the app refreshes the account and every project.
* The two independent delays that govern syncing.
*
* The choices and the default live here rather than in the settings screen so that the screen and
* the sync trigger cannot disagree about what is in effect.
* They used to be one setting, which meant the app-open throttle and the full-sync delay could
* not be tuned apart even though they answer different questions. The choices and defaults live
* here rather than in the settings screen so the screen and the sync triggers cannot disagree
* about what is in effect.
*/
object SyncSettings {
/** Steps offered by the slider, in minutes. */
val INTERVAL_CHOICES_MINUTES = listOf(1, 10, 60, 1440)
const val DEFAULT_INTERVAL_MINUTES = 10
/**
* How long opening the app waits before refreshing the account and every project again, so
* that resuming within the interval does not repeat the work. Not user-facing: it governs
* background catch-up, not anything the user asked for.
*/
const val OPEN_SYNC_INTERVAL_MINUTES = 10
/**
* The configured interval in minutes. A stored value that is not one of the offered steps —
* from a restored backup, or a build that changed the steps — falls back to the default
* rather than being displayed as the first step while a different value drives the sync.
* Steps offered for the full sync delay, in minutes. 0 means every manual refresh is a full
* sync; anything else lets a refresh inside the window settle for fetching just the changes.
*/
fun intervalMinutes(context: Context): Int {
val FULL_SYNC_DELAY_CHOICES_MINUTES = listOf(0, 60, 1440, 10080)
/**
* Only consulted once Extra Features is on - with it off every pull-to-refresh is a full
* sync, whatever is stored here.
*/
const val DEFAULT_FULL_SYNC_DELAY_MINUTES = 1440
/**
* The configured delay in minutes. A stored value that is not one of the offered steps - from
* a restored backup, or a build that changed the steps - falls back to the default rather
* than being displayed as one step while a different value drives the sync.
*/
fun fullSyncDelayMinutes(context: Context): Int {
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
val stored = prefs.getInt(
context.getString(R.string.pref_key_auto_sync_on_open),
DEFAULT_INTERVAL_MINUTES
context.getString(R.string.pref_key_full_sync_delay),
DEFAULT_FULL_SYNC_DELAY_MINUTES
)
return if (stored in INTERVAL_CHOICES_MINUTES) stored else DEFAULT_INTERVAL_MINUTES
return if (stored in FULL_SYNC_DELAY_CHOICES_MINUTES) stored else DEFAULT_FULL_SYNC_DELAY_MINUTES
}
}
@@ -689,12 +689,18 @@ class VersatileProjectSyncClient(
}
@Throws(JSONException::class, IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class)
/**
* [since] overrides the project cursor sent as lastChanged. Passing 0 asks the server for the
* whole project rather than the changes since the last sync; omitting it keeps the cursor, so
* the server returns only what has moved.
*/
fun getBills(
project: DBProject,
offset: Int? = null,
limit: Int? = null,
reverse: Boolean? = null,
deleted: Int? = null
deleted: Int? = null,
since: Long? = null
): ServerResponse.BillsResponse {
var target: String
var username: String?
@@ -706,7 +712,7 @@ class VersatileProjectSyncClient(
val paramValues: MutableList<String> = ArrayList()
if (ProjectType.COSPEND == project.type) {
val tsLastSync = project.lastSyncedTimestamp
val tsLastSync = since ?: project.lastSyncedTimestamp
if (offset == null) {
if (cospendVersionGT161) {
paramKeys.add("lastChanged")
@@ -144,14 +144,8 @@
<string name="settings_color_custom">Custom color</string>
<string name="settings_color_mode">Color Selection</string>
<string name="settings_show_archived">Show archived projects</string>
<string name="settings_beta_features">Beta Features</string>
<string name="settings_beta_features_summary">Enable experimental features. Use at your own risk.</string>
<string name="settings_fill_new_bill_from_last">Auto-fill from last bill</string>
<string name="settings_fill_new_bill_from_last_summary">Pre-fill payer, category, mode and owers from the last bill created in the project.</string>
<string name="settings_auto_sync_on_open">Sync interval</string>
<string name="settings_auto_sync_on_open_summary">How often to refresh the account and all projects when opening the app.</string>
<string name="pref_value_sync_1m">1 minute</string>
<string name="pref_value_sync_10m">10 minutes</string>
<string name="pref_value_sync_1h">1 hour</string>
<string name="pref_value_sync_1d">1 day</string>
<string name="settings_url_warn_http">WARNING: "http" is unsafe. Use "https".</string>
@@ -144,14 +144,8 @@
<string name="settings_color_custom">Custom color</string>
<string name="settings_color_mode">Color Selection</string>
<string name="settings_show_archived">Show archived projects</string>
<string name="settings_beta_features">Beta Features</string>
<string name="settings_beta_features_summary">Enable experimental features. Use at your own risk.</string>
<string name="settings_fill_new_bill_from_last">Auto-fill from last bill</string>
<string name="settings_fill_new_bill_from_last_summary">Pre-fill payer, category, mode and owers from the last bill created in the project.</string>
<string name="settings_auto_sync_on_open">Sync interval</string>
<string name="settings_auto_sync_on_open_summary">How often to refresh the account and all projects when opening the app.</string>
<string name="pref_value_sync_1m">1 minute</string>
<string name="pref_value_sync_10m">10 minutes</string>
<string name="pref_value_sync_1h">1 hour</string>
<string name="pref_value_sync_1d">1 day</string>
<string name="settings_url_warn_http">WARNING: "http" is unsafe. Use "https".</string>
@@ -144,14 +144,8 @@
<string name="settings_color_custom">Custom color</string>
<string name="settings_color_mode">Color Selection</string>
<string name="settings_show_archived">Show archived projects</string>
<string name="settings_beta_features">Beta Features</string>
<string name="settings_beta_features_summary">Enable experimental features. Use at your own risk.</string>
<string name="settings_fill_new_bill_from_last">Auto-fill from last bill</string>
<string name="settings_fill_new_bill_from_last_summary">Pre-fill payer, category, mode and owers from the last bill created in the project.</string>
<string name="settings_auto_sync_on_open">Sync interval</string>
<string name="settings_auto_sync_on_open_summary">How often to refresh the account and all projects when opening the app.</string>
<string name="pref_value_sync_1m">1 minute</string>
<string name="pref_value_sync_10m">10 minutes</string>
<string name="pref_value_sync_1h">1 hour</string>
<string name="pref_value_sync_1d">1 day</string>
<string name="settings_url_warn_http">WARNING: "http" is unsafe. Use "https".</string>
@@ -144,14 +144,8 @@
<string name="settings_color_custom">Custom color</string>
<string name="settings_color_mode">Color Selection</string>
<string name="settings_show_archived">Show archived projects</string>
<string name="settings_beta_features">Beta Features</string>
<string name="settings_beta_features_summary">Enable experimental features. Use at your own risk.</string>
<string name="settings_fill_new_bill_from_last">Auto-fill from last bill</string>
<string name="settings_fill_new_bill_from_last_summary">Pre-fill payer, category, mode and owers from the last bill created in the project.</string>
<string name="settings_auto_sync_on_open">Sync interval</string>
<string name="settings_auto_sync_on_open_summary">How often to refresh the account and all projects when opening the app.</string>
<string name="pref_value_sync_1m">1 minute</string>
<string name="pref_value_sync_10m">10 minutes</string>
<string name="pref_value_sync_1h">1 hour</string>
<string name="pref_value_sync_1d">1 day</string>
<string name="settings_url_warn_http">WARNING: "http" is unsafe. Use "https".</string>
@@ -144,14 +144,8 @@
<string name="settings_color_custom">Custom color</string>
<string name="settings_color_mode">Color Selection</string>
<string name="settings_show_archived">Show archived projects</string>
<string name="settings_beta_features">Beta Features</string>
<string name="settings_beta_features_summary">Enable experimental features. Use at your own risk.</string>
<string name="settings_fill_new_bill_from_last">Auto-fill from last bill</string>
<string name="settings_fill_new_bill_from_last_summary">Pre-fill payer, category, mode and owers from the last bill created in the project.</string>
<string name="settings_auto_sync_on_open">Sync interval</string>
<string name="settings_auto_sync_on_open_summary">How often to refresh the account and all projects when opening the app.</string>
<string name="pref_value_sync_1m">1 minute</string>
<string name="pref_value_sync_10m">10 minutes</string>
<string name="pref_value_sync_1h">1 hour</string>
<string name="pref_value_sync_1d">1 day</string>
<string name="settings_url_warn_http">WARNING: "http" is unsafe. Use "https".</string>
+1 -6
View File
@@ -138,14 +138,8 @@
<string name="settings_color_custom">Eigene Farbe</string>
<string name="settings_color_mode">Farbauswahl</string>
<string name="settings_show_archived">Archivierte Projekte anzeigen</string>
<string name="settings_beta_features">Beta-Funktionen</string>
<string name="settings_beta_features_summary">Experimentelle Funktionen aktivieren. Benutzung auf eigene Gefahr.</string>
<string name="settings_fill_new_bill_from_last">Aus letzter Rechnung vorausfüllen</string>
<string name="settings_fill_new_bill_from_last_summary">Zahler, Kategorie, Zahlungsart und Beteiligte aus der zuletzt erstellten Rechnung des Projekts übernehmen.</string>
<string name="settings_auto_sync_on_open">Synchronisierungsintervall</string>
<string name="settings_auto_sync_on_open_summary">Wie oft Konto und alle Projekte beim Öffnen der App aktualisiert werden.</string>
<string name="pref_value_sync_1m">1 Minute</string>
<string name="pref_value_sync_10m">10 Minuten</string>
<string name="pref_value_sync_1h">1 Stunde</string>
<string name="pref_value_sync_1d">1 Tag</string>
<string name="settings_url_warn_http">WARNUNG: \"http\" ist unsicher. Verwende \"https\".</string>
@@ -215,6 +209,7 @@
<!-- Settlement -->
<string name="center_none">Keine (optimal)</string>
<string name="center_none_direct">Keine (direkt)</string>
<string name="settle_who">Wer zahlt</string>
<string name="settle_to_whom">An wen</string>
<string name="settle_how_much">Betrag</string>
@@ -144,14 +144,8 @@
<string name="settings_color_custom">Custom color</string>
<string name="settings_color_mode">Color Selection</string>
<string name="settings_show_archived">Show archived projects</string>
<string name="settings_beta_features">Beta Features</string>
<string name="settings_beta_features_summary">Enable experimental features. Use at your own risk.</string>
<string name="settings_fill_new_bill_from_last">Auto-fill from last bill</string>
<string name="settings_fill_new_bill_from_last_summary">Pre-fill payer, category, mode and owers from the last bill created in the project.</string>
<string name="settings_auto_sync_on_open">Sync interval</string>
<string name="settings_auto_sync_on_open_summary">How often to refresh the account and all projects when opening the app.</string>
<string name="pref_value_sync_1m">1 minute</string>
<string name="pref_value_sync_10m">10 minutes</string>
<string name="pref_value_sync_1h">1 hour</string>
<string name="pref_value_sync_1d">1 day</string>
<string name="settings_url_warn_http">WARNING: "http" is unsafe. Use "https".</string>
+1 -6
View File
@@ -137,14 +137,8 @@
<string name="settings_color_custom">Color personalizado</string>
<string name="settings_color_mode">Selección de color</string>
<string name="settings_show_archived">Mostrar los proyectos archivados</string>
<string name="settings_beta_features">Funciones beta</string>
<string name="settings_beta_features_summary">Activar las funciones experimentales. Úsalas bajo tu propia responsabilidad.</string>
<string name="settings_fill_new_bill_from_last">Rellenar desde la última factura</string>
<string name="settings_fill_new_bill_from_last_summary">Reutilizar el pagador, la categoría, el modo y los participantes de la última factura creada en el proyecto.</string>
<string name="settings_auto_sync_on_open">Intervalo de sincronización</string>
<string name="settings_auto_sync_on_open_summary">Con qué frecuencia se actualizan la cuenta y todos los proyectos al abrir la aplicación.</string>
<string name="pref_value_sync_1m">1 minuto</string>
<string name="pref_value_sync_10m">10 minutos</string>
<string name="pref_value_sync_1h">1 hora</string>
<string name="pref_value_sync_1d">1 día</string>
<string name="settings_url_warn_http">ADVERTENCIA: \"http\" no es seguro. Usa \"https\".</string>
@@ -216,6 +210,7 @@
<!-- Settlement -->
<string name="center_none">Ninguno (óptimo)</string>
<string name="center_none_direct">Ninguno (directo)</string>
<string name="settle_who">Quién paga</string>
<string name="settle_to_whom">A quién</string>
<string name="settle_how_much">Importe</string>
@@ -144,14 +144,8 @@
<string name="settings_color_custom">Custom color</string>
<string name="settings_color_mode">Color Selection</string>
<string name="settings_show_archived">Show archived projects</string>
<string name="settings_beta_features">Beta Features</string>
<string name="settings_beta_features_summary">Enable experimental features. Use at your own risk.</string>
<string name="settings_fill_new_bill_from_last">Auto-fill from last bill</string>
<string name="settings_fill_new_bill_from_last_summary">Pre-fill payer, category, mode and owers from the last bill created in the project.</string>
<string name="settings_auto_sync_on_open">Sync interval</string>
<string name="settings_auto_sync_on_open_summary">How often to refresh the account and all projects when opening the app.</string>
<string name="pref_value_sync_1m">1 minute</string>
<string name="pref_value_sync_10m">10 minutes</string>
<string name="pref_value_sync_1h">1 hour</string>
<string name="pref_value_sync_1d">1 day</string>
<string name="settings_url_warn_http">WARNING: "http" is unsafe. Use "https".</string>
+1 -6
View File
@@ -138,14 +138,8 @@
<string name="settings_color_custom">Couleur personnalisée</string>
<string name="settings_color_mode">Sélection de la couleur</string>
<string name="settings_show_archived">Afficher les projets archivés</string>
<string name="settings_beta_features">Fonctionnalités bêta</string>
<string name="settings_beta_features_summary">Activer les fonctionnalités expérimentales. À utiliser à vos risques et périls.</string>
<string name="settings_fill_new_bill_from_last">Pré-remplir depuis la dernière facture</string>
<string name="settings_fill_new_bill_from_last_summary">Reprendre le payeur, la catégorie, le mode et les participants de la dernière facture créée dans le projet.</string>
<string name="settings_auto_sync_on_open">Intervalle de synchronisation</string>
<string name="settings_auto_sync_on_open_summary">Fréquence de rafraîchissement du compte et de tous les projets à l\'ouverture de l\'application.</string>
<string name="pref_value_sync_1m">1 minute</string>
<string name="pref_value_sync_10m">10 minutes</string>
<string name="pref_value_sync_1h">1 heure</string>
<string name="pref_value_sync_1d">1 jour</string>
<string name="settings_url_warn_http">AVERTISSEMENT : \"http\" n\'est pas sûr. Utilisez \"https\".</string>
@@ -219,6 +213,7 @@
<!-- Settlement -->
<string name="center_none">Aucun (optimal)</string>
<string name="center_none_direct">Aucun (direct)</string>
<string name="settle_who">Qui paie</string>
<string name="settle_to_whom">À qui</string>
<string name="settle_how_much">Montant</string>
@@ -144,14 +144,8 @@
<string name="settings_color_custom">Custom color</string>
<string name="settings_color_mode">Color Selection</string>
<string name="settings_show_archived">Show archived projects</string>
<string name="settings_beta_features">Beta Features</string>
<string name="settings_beta_features_summary">Enable experimental features. Use at your own risk.</string>
<string name="settings_fill_new_bill_from_last">Auto-fill from last bill</string>
<string name="settings_fill_new_bill_from_last_summary">Pre-fill payer, category, mode and owers from the last bill created in the project.</string>
<string name="settings_auto_sync_on_open">Sync interval</string>
<string name="settings_auto_sync_on_open_summary">How often to refresh the account and all projects when opening the app.</string>
<string name="pref_value_sync_1m">1 minute</string>
<string name="pref_value_sync_10m">10 minutes</string>
<string name="pref_value_sync_1h">1 hour</string>
<string name="pref_value_sync_1d">1 day</string>
<string name="settings_url_warn_http">WARNING: "http" is unsafe. Use "https".</string>
@@ -144,14 +144,8 @@
<string name="settings_color_custom">Custom color</string>
<string name="settings_color_mode">Color Selection</string>
<string name="settings_show_archived">Show archived projects</string>
<string name="settings_beta_features">Beta Features</string>
<string name="settings_beta_features_summary">Enable experimental features. Use at your own risk.</string>
<string name="settings_fill_new_bill_from_last">Auto-fill from last bill</string>
<string name="settings_fill_new_bill_from_last_summary">Pre-fill payer, category, mode and owers from the last bill created in the project.</string>
<string name="settings_auto_sync_on_open">Sync interval</string>
<string name="settings_auto_sync_on_open_summary">How often to refresh the account and all projects when opening the app.</string>
<string name="pref_value_sync_1m">1 minute</string>
<string name="pref_value_sync_10m">10 minutes</string>
<string name="pref_value_sync_1h">1 hour</string>
<string name="pref_value_sync_1d">1 day</string>
<string name="settings_url_warn_http">WARNING: "http" is unsafe. Use "https".</string>
@@ -144,14 +144,8 @@
<string name="settings_color_custom">Custom color</string>
<string name="settings_color_mode">Color Selection</string>
<string name="settings_show_archived">Show archived projects</string>
<string name="settings_beta_features">Beta Features</string>
<string name="settings_beta_features_summary">Enable experimental features. Use at your own risk.</string>
<string name="settings_fill_new_bill_from_last">Auto-fill from last bill</string>
<string name="settings_fill_new_bill_from_last_summary">Pre-fill payer, category, mode and owers from the last bill created in the project.</string>
<string name="settings_auto_sync_on_open">Sync interval</string>
<string name="settings_auto_sync_on_open_summary">How often to refresh the account and all projects when opening the app.</string>
<string name="pref_value_sync_1m">1 minute</string>
<string name="pref_value_sync_10m">10 minutes</string>
<string name="pref_value_sync_1h">1 hour</string>
<string name="pref_value_sync_1d">1 day</string>
<string name="settings_url_warn_http">WARNING: "http" is unsafe. Use "https".</string>
@@ -144,14 +144,8 @@
<string name="settings_color_custom">Custom color</string>
<string name="settings_color_mode">Color Selection</string>
<string name="settings_show_archived">Show archived projects</string>
<string name="settings_beta_features">Beta Features</string>
<string name="settings_beta_features_summary">Enable experimental features. Use at your own risk.</string>
<string name="settings_fill_new_bill_from_last">Auto-fill from last bill</string>
<string name="settings_fill_new_bill_from_last_summary">Pre-fill payer, category, mode and owers from the last bill created in the project.</string>
<string name="settings_auto_sync_on_open">Sync interval</string>
<string name="settings_auto_sync_on_open_summary">How often to refresh the account and all projects when opening the app.</string>
<string name="pref_value_sync_1m">1 minute</string>
<string name="pref_value_sync_10m">10 minutes</string>
<string name="pref_value_sync_1h">1 hour</string>
<string name="pref_value_sync_1d">1 day</string>
<string name="settings_url_warn_http">WARNING: "http" is unsafe. Use "https".</string>
@@ -144,14 +144,8 @@
<string name="settings_color_custom">Custom color</string>
<string name="settings_color_mode">Color Selection</string>
<string name="settings_show_archived">Show archived projects</string>
<string name="settings_beta_features">Beta Features</string>
<string name="settings_beta_features_summary">Enable experimental features. Use at your own risk.</string>
<string name="settings_fill_new_bill_from_last">Auto-fill from last bill</string>
<string name="settings_fill_new_bill_from_last_summary">Pre-fill payer, category, mode and owers from the last bill created in the project.</string>
<string name="settings_auto_sync_on_open">Sync interval</string>
<string name="settings_auto_sync_on_open_summary">How often to refresh the account and all projects when opening the app.</string>
<string name="pref_value_sync_1m">1 minute</string>
<string name="pref_value_sync_10m">10 minutes</string>
<string name="pref_value_sync_1h">1 hour</string>
<string name="pref_value_sync_1d">1 day</string>
<string name="settings_url_warn_http">WARNING: "http" is unsafe. Use "https".</string>
@@ -144,14 +144,8 @@
<string name="settings_color_custom">Custom color</string>
<string name="settings_color_mode">Color Selection</string>
<string name="settings_show_archived">Show archived projects</string>
<string name="settings_beta_features">Beta Features</string>
<string name="settings_beta_features_summary">Enable experimental features. Use at your own risk.</string>
<string name="settings_fill_new_bill_from_last">Auto-fill from last bill</string>
<string name="settings_fill_new_bill_from_last_summary">Pre-fill payer, category, mode and owers from the last bill created in the project.</string>
<string name="settings_auto_sync_on_open">Sync interval</string>
<string name="settings_auto_sync_on_open_summary">How often to refresh the account and all projects when opening the app.</string>
<string name="pref_value_sync_1m">1 minute</string>
<string name="pref_value_sync_10m">10 minutes</string>
<string name="pref_value_sync_1h">1 hour</string>
<string name="pref_value_sync_1d">1 day</string>
<string name="settings_url_warn_http">WARNING: "http" is unsafe. Use "https".</string>
@@ -144,14 +144,8 @@
<string name="settings_color_custom">Custom color</string>
<string name="settings_color_mode">Color Selection</string>
<string name="settings_show_archived">Show archived projects</string>
<string name="settings_beta_features">Beta Features</string>
<string name="settings_beta_features_summary">Enable experimental features. Use at your own risk.</string>
<string name="settings_fill_new_bill_from_last">Auto-fill from last bill</string>
<string name="settings_fill_new_bill_from_last_summary">Pre-fill payer, category, mode and owers from the last bill created in the project.</string>
<string name="settings_auto_sync_on_open">Sync interval</string>
<string name="settings_auto_sync_on_open_summary">How often to refresh the account and all projects when opening the app.</string>
<string name="pref_value_sync_1m">1 minute</string>
<string name="pref_value_sync_10m">10 minutes</string>
<string name="pref_value_sync_1h">1 hour</string>
<string name="pref_value_sync_1d">1 day</string>
<string name="settings_url_warn_http">WARNING: "http" is unsafe. Use "https".</string>
@@ -144,14 +144,8 @@
<string name="settings_color_custom">Custom color</string>
<string name="settings_color_mode">Color Selection</string>
<string name="settings_show_archived">Show archived projects</string>
<string name="settings_beta_features">Beta Features</string>
<string name="settings_beta_features_summary">Enable experimental features. Use at your own risk.</string>
<string name="settings_fill_new_bill_from_last">Auto-fill from last bill</string>
<string name="settings_fill_new_bill_from_last_summary">Pre-fill payer, category, mode and owers from the last bill created in the project.</string>
<string name="settings_auto_sync_on_open">Sync interval</string>
<string name="settings_auto_sync_on_open_summary">How often to refresh the account and all projects when opening the app.</string>
<string name="pref_value_sync_1m">1 minute</string>
<string name="pref_value_sync_10m">10 minutes</string>
<string name="pref_value_sync_1h">1 hour</string>
<string name="pref_value_sync_1d">1 day</string>
<string name="settings_url_warn_http">WARNING: "http" is unsafe. Use "https".</string>
@@ -144,14 +144,8 @@
<string name="settings_color_custom">Custom color</string>
<string name="settings_color_mode">Color Selection</string>
<string name="settings_show_archived">Show archived projects</string>
<string name="settings_beta_features">Beta Features</string>
<string name="settings_beta_features_summary">Enable experimental features. Use at your own risk.</string>
<string name="settings_fill_new_bill_from_last">Auto-fill from last bill</string>
<string name="settings_fill_new_bill_from_last_summary">Pre-fill payer, category, mode and owers from the last bill created in the project.</string>
<string name="settings_auto_sync_on_open">Sync interval</string>
<string name="settings_auto_sync_on_open_summary">How often to refresh the account and all projects when opening the app.</string>
<string name="pref_value_sync_1m">1 minute</string>
<string name="pref_value_sync_10m">10 minutes</string>
<string name="pref_value_sync_1h">1 hour</string>
<string name="pref_value_sync_1d">1 day</string>
<string name="settings_url_warn_http">WARNING: "http" is unsafe. Use "https".</string>
@@ -144,14 +144,8 @@
<string name="settings_color_custom">Custom color</string>
<string name="settings_color_mode">Color Selection</string>
<string name="settings_show_archived">Show archived projects</string>
<string name="settings_beta_features">Beta Features</string>
<string name="settings_beta_features_summary">Enable experimental features. Use at your own risk.</string>
<string name="settings_fill_new_bill_from_last">Auto-fill from last bill</string>
<string name="settings_fill_new_bill_from_last_summary">Pre-fill payer, category, mode and owers from the last bill created in the project.</string>
<string name="settings_auto_sync_on_open">Sync interval</string>
<string name="settings_auto_sync_on_open_summary">How often to refresh the account and all projects when opening the app.</string>
<string name="pref_value_sync_1m">1 minute</string>
<string name="pref_value_sync_10m">10 minutes</string>
<string name="pref_value_sync_1h">1 hour</string>
<string name="pref_value_sync_1d">1 day</string>
<string name="settings_url_warn_http">WARNING: "http" is unsafe. Use "https".</string>
@@ -144,14 +144,8 @@
<string name="settings_color_custom">Custom color</string>
<string name="settings_color_mode">Color Selection</string>
<string name="settings_show_archived">Show archived projects</string>
<string name="settings_beta_features">Beta Features</string>
<string name="settings_beta_features_summary">Enable experimental features. Use at your own risk.</string>
<string name="settings_fill_new_bill_from_last">Auto-fill from last bill</string>
<string name="settings_fill_new_bill_from_last_summary">Pre-fill payer, category, mode and owers from the last bill created in the project.</string>
<string name="settings_auto_sync_on_open">Sync interval</string>
<string name="settings_auto_sync_on_open_summary">How often to refresh the account and all projects when opening the app.</string>
<string name="pref_value_sync_1m">1 minute</string>
<string name="pref_value_sync_10m">10 minutes</string>
<string name="pref_value_sync_1h">1 hour</string>
<string name="pref_value_sync_1d">1 day</string>
<string name="settings_url_warn_http">WARNING: "http" is unsafe. Use "https".</string>
+223 -236
View File
@@ -1,272 +1,259 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<!--
Untranslated: these are the English strings, kept here so a translation can be
contributed by simply replacing the values. Strings marked translatable="false" in
values/strings.xml (app and product names, preference keys) are deliberately absent
and must not be added.
-->
<!-- Actions -->
<string name="action_new_bill">New bill</string>
<string name="action_add_project">Add project</string>
<string name="action_save">Save</string>
<string name="action_edit">Edit</string>
<string name="action_share">Share</string>
<string name="action_search">Search</string>
<string name="action_open_menu">Open menu</string>
<string name="action_close_search">Close search</string>
<string name="action_clear_search">Clear search</string>
<string name="action_delete">Delete</string>
<string name="simple_back">Back</string>
<string name="action_archive">Archive</string>
<string name="action_unarchive">Unarchive</string>
<string name="action_export">Export</string>
<string name="action_stats">Stats</string>
<string name="action_settle">Settle</string>
<string name="action_scan_qrcode">Scan QR Code</string>
<string name="action_settings">Settings</string>
<string name="action_label_bills">Label missing categories</string>
<string name="action_logout">Logout</string>
<string name="action_connect">Connect</string>
<string name="action_discard">Discard</string>
<string name="action_members">Members</string>
<string name="action_labels">Labels</string>
<string name="action_currencies">Currencies</string>
<string name="action_new_bill">Новый расход</string>
<string name="action_add_project">Добавить проект</string>
<string name="action_save">Сохранить</string>
<string name="action_edit">Редактировать</string>
<string name="action_share">Поделиться</string>
<string name="action_search">Поиск</string>
<string name="action_open_menu">Открыть меню</string>
<string name="action_close_search">Закрыть поиск</string>
<string name="action_clear_search">Очистить поиск</string>
<string name="action_delete">Удалить</string>
<string name="simple_back">Назад</string>
<string name="action_archive">Архивировать</string>
<string name="action_unarchive">Разархивировать</string>
<string name="action_export">Экспорт</string>
<string name="action_stats">Статистика</string>
<string name="action_settle">Свести баланс</string>
<string name="action_scan_qrcode">Отсканировать QR-код</string>
<string name="action_settings">Настройки</string>
<string name="action_label_bills">Указать отсутствующие категории</string>
<string name="action_logout">Выйти</string>
<string name="action_connect">Подключиться</string>
<string name="action_discard">Отменить</string>
<string name="action_members">Участники</string>
<string name="action_labels">Метки</string>
<string name="action_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>
<string name="title_stats">Статистика</string>
<string name="title_edit_project">Редактировать проект</string>
<string name="title_label_bills">Указать категории</string>
<string name="title_labels">Управление метками</string>
<string name="title_about">О программе</string>
<string name="title_settle">Сведение баланса</string>
<string name="title_share">Поделиться проектом</string>
<string name="title_add_project">Добавить проект</string>
<string name="title_add_category">Добавить категорию</string>
<string name="title_add_payment_mode">Добавить способ оплаты</string>
<string name="title_account">Учётная запись Nextcloud</string>
<string name="title_share_web">Веб-ссылка</string>
<string name="title_share_qr">Ссылка Cowspent</string>
<string name="title_confirm">Вы уверены?</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>
<string name="label_all_bills">Все расходы</string>
<string name="label_categories">Категории</string>
<string name="label_payment_modes">Способы оплаты</string>
<string name="label_name">Название</string>
<string name="label_icon">Значок / Смайлик</string>
<string name="label_color">Цвет</string>
<string name="label_weight">Вес</string>
<string name="label_activated">Активировано</string>
<string name="label_password">Пароль</string>
<string name="label_email">Электронная почта</string>
<string name="label_url">Адрес сервера</string>
<string name="label_username">Имя пользователя</string>
<string name="label_comment">Комментарий</string>
<string name="label_what">Что?</string>
<string name="label_payer">Кто оплатил?</string>
<string name="label_owers">Для кого?</string>
<string name="label_repeat">Повторять каждые</string>
<string name="label_mode">Режим</string>
<string name="label_category">Категория</string>
<string name="label_project_id">Идентификатор/название проекта</string>
<string name="label_project_title">Название проекта</string>
<string name="label_use_sso">Использовать учётную запись приложения Nextcloud</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>
<string name="dialog_unsaved_changes_title">Несохраненные изменения</string>
<string name="dialog_unsaved_changes_msg">Сохранить изменения перед выходом?</string>
<string name="dialog_confirm_remove_project_msg">Проект на сервере не будет удален.</string>
<string name="dialog_sync_error_title">Ошибка синхронизации</string>
<string name="dialog_sync_error_msg">Не удалось синхронизировать %1$s.\n\n%2$s</string>
<string name="dialog_balanced_msg">Расходы уже сбалансированы.</string>
<string name="msg_project_added">Проект %1$s добавлен</string>
<string name="msg_bill_labeled_done">Все расходы помечены</string>
<string name="msg_no_suggestions">Нет предложений</string>
<string name="msg_auth_warning">Требуется Cospend v0.3.4+.</string>
<string name="msg_link_copied">Ссылка скопирована в буфер обмена</string>
<string name="msg_share_qr">Отсканируйте QR-код или поделитесь ссылкой, чтобы присоединиться.</string>
<string name="msg_share_web">Ссылка для доступа через веб-браузер.</string>
<string name="msg_share_qr_warn">Поделитесь этой ссылкой с пользователем Cowspent.</string>
<string name="msg_settle_intro">Расчет по %1$s:</string>
<string name="msg_settle_sentence">%1$s должен %2$s %3$.2f</string>
<string name="msg_stats_intro">Статистика для %1$s:</string>
<string name="msg_stats_header">Участник (Оплачено | Потрачено | Баланс)</string>
<string name="msg_logged_in_as">Вошёл под именем %1$s</string>
<!-- Errors -->
<string name="error_generic">Error</string>
<string name="error_loading">Loading</string>
<string name="error_no_projects">No projects found</string>
<string name="error_no_members">No members found</string>
<string name="error_no_bills">No bills found</string>
<string name="error_no_member">At least one member required</string>
<string name="error_maintenance_mode">Server is in maintenance mode</string>
<string name="error_400">400 Bad request</string>
<string name="error_401">401 Unauthorized</string>
<string name="error_403">403 Forbidden</string>
<string name="error_404">404 Not Found</string>
<string name="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>
<string name="error_generic">Ошибка</string>
<string name="error_loading">Загрузка</string>
<string name="error_no_projects">Проекты не найдены</string>
<string name="error_no_members">Участники не найдены</string>
<string name="error_no_bills">Расходы не найдены</string>
<string name="error_no_member">Требуется как минимум один участник</string>
<string name="error_maintenance_mode">Сервер находится в режиме технического обслуживания</string>
<string name="error_400">400 Неверный запрос</string>
<string name="error_401">401 Неавторизованный доступ</string>
<string name="error_403">403 Доступ запрещен</string>
<string name="error_404">404 Не найдено</string>
<string name="error_sync">Сбой синхронизации: %1$s</string>
<string name="error_invalid_login">Неверный логин: %1$s</string>
<string name="error_auth">Неверное имя пользователя или пароль</string>
<string name="error_json">Неверный ответ сервера</string>
<string name="error_req_failed">Ошибка запроса</string>
<string name="error_invalid_email">Неверный адрес электронной почты</string>
<string name="error_invalid_project_id">Неверный идентификатор проекта</string>
<string name="error_invalid_project_name">Неверное название проекта</string>
<string name="error_invalid_bill_name">Неверное название расхода</string>
<string name="error_invalid_bill_date">Неверная дата расхода</string>
<string name="error_invalid_bill_payer">Требуется указать плательщика</string>
<string name="error_invalid_bill_owers">Требуется указать участников</string>
<string name="error_no_network">Отсутствует подключение к сети</string>
<string name="error_server">Ошибка сервера</string>
<string name="error_io">Сбой соединения с сервером</string>
<string name="error_share_impossible">Невозможно поделиться этим проектом</string>
<!-- Drawer / Common UI -->
<string name="drawer_no_account">Connect to Nextcloud account</string>
<string name="drawer_last_sync">Last sync: %1$02d:%2$02d</string>
<string name="simple_cancel">Cancel</string>
<string name="simple_ok" tools:ignore="ButtonCase,Typos">Ok</string>
<string name="simple_yes">Yes</string>
<string name="simple_no">No</string>
<string name="simple_close">Close</string>
<string name="drawer_no_account">Подключиться к учетной записи Nextcloud</string>
<string name="drawer_last_sync">Последняя синхронизация: %1$02d:%2$02d</string>
<string name="simple_cancel">Отменить</string>
<string name="simple_ok" tools:ignore="ButtonCase,Typos">ОК</string>
<string name="simple_yes">Да</string>
<string name="simple_no">Нет</string>
<string name="simple_close">Закрыть</string>
<!-- Settings -->
<string name="settings_appearance">Appearance</string>
<string name="settings_network">Network</string>
<string name="settings_other">Other</string>
<string name="settings_night_mode">Theme</string>
<string name="settings_offline_mode">Offline mode</string>
<string name="settings_offline_mode_summary">Only sync manually.</string>
<string name="settings_color_custom">Custom color</string>
<string name="settings_color_mode">Color Selection</string>
<string name="settings_show_archived">Show archived projects</string>
<string name="settings_beta_features">Beta Features</string>
<string name="settings_beta_features_summary">Enable experimental features. Use at your own risk.</string>
<string name="settings_fill_new_bill_from_last">Auto-fill from last bill</string>
<string name="settings_fill_new_bill_from_last_summary">Pre-fill payer, category, mode and owers from the last bill created in the project.</string>
<string name="settings_auto_sync_on_open">Sync interval</string>
<string name="settings_auto_sync_on_open_summary">How often to refresh the account and all projects when opening the app.</string>
<string name="pref_value_sync_1m">1 minute</string>
<string name="pref_value_sync_10m">10 minutes</string>
<string name="pref_value_sync_1h">1 hour</string>
<string name="pref_value_sync_1d">1 day</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_manual">Manual</string>
<string name="pref_value_theme_light">Light</string>
<string name="pref_value_theme_dark">Dark</string>
<string name="pref_value_theme_system">Follow system</string>
<string name="settings_appearance">Внешний вид</string>
<string name="settings_network">Сеть</string>
<string name="settings_other">Прочее</string>
<string name="settings_night_mode">Тема</string>
<string name="settings_offline_mode">Офлайн-режим</string>
<string name="settings_offline_mode_summary">Синхронизация только вручную</string>
<string name="settings_color_custom">Пользовательский цвет</string>
<string name="settings_color_mode">Выбор цвета</string>
<string name="settings_show_archived">Показывать заархивированные проекты</string>
<string name="settings_fill_new_bill_from_last">Автозаполнение на основе последнего расхода</string>
<string name="settings_fill_new_bill_from_last_summary">Предварительное заполнение полей "Плательщик", "Категория", "Способ оплаты" и "Для кого" на основе последнего расхода, созданного в проекте.</string>
<string name="pref_value_sync_1h">1 час</string>
<string name="pref_value_sync_1d">1 день</string>
<string name="settings_url_warn_http">ПРЕДУПРЕЖДЕНИЕ: "http" небезопасен. Используйте "https".</string>
<string name="settings_colorpicker_title">Выбрать цвет</string>
<string name="pref_value_color_system">Системная</string>
<string name="pref_value_color_manual">Вручную</string>
<string name="pref_value_theme_light">Светлая</string>
<string name="pref_value_theme_dark">Тёмная</string>
<string name="pref_value_theme_system">По системе</string>
<!-- Enums and Lists -->
<string name="repeat_no">No repeat</string>
<string name="repeat_day">Daily</string>
<string name="repeat_week">Weekly</string>
<string name="repeat_fortnight">Fortnightly</string>
<string name="repeat_month">Monthly</string>
<string name="repeat_year">Yearly</string>
<string name="repeat_no">Без повторения</string>
<string name="repeat_day">Ежедневно</string>
<string name="repeat_week">Еженедельно</string>
<string name="repeat_fortnight">Раз в две недели</string>
<string name="repeat_month">Ежемесячно</string>
<string name="repeat_year">Ежегодно</string>
<string name="payment_mode_none">None</string>
<string name="payment_mode_all">All</string>
<string name="payment_mode_credit_card">Credit card</string>
<string name="payment_mode_cash">Cash</string>
<string name="payment_mode_check">Check</string>
<string name="payment_mode_online">Online</string>
<string name="payment_mode_transfer">Transfer</string>
<string name="payment_mode_none">Нет</string>
<string name="payment_mode_all">Все</string>
<string name="payment_mode_credit_card">Кредитная карта</string>
<string name="payment_mode_cash">Наличные</string>
<string name="payment_mode_check">Чек</string>
<string name="payment_mode_online">Онлайн</string>
<string name="payment_mode_transfer">Перевод</string>
<string name="category_none">None</string>
<string name="category_all">All</string>
<string name="category_all_except_reimbursement">All except reimbursement</string>
<string name="category_groceries">Grocery</string>
<string name="category_leisure">Bar/Party</string>
<string name="category_rent">Rent</string>
<string name="category_bills">Bill</string>
<string name="category_excursion">Excursion/Culture</string>
<string name="category_health">Health</string>
<string name="category_shopping">Shopping</string>
<string name="category_reimbursement">Reimbursement</string>
<string name="category_restaurant">Restaurant</string>
<string name="category_accomodation">Accommodation</string>
<string name="category_transport">Transport</string>
<string name="category_sport">Sport</string>
<string name="category_none">Нет</string>
<string name="category_all">Все</string>
<string name="category_all_except_reimbursement">Все, кроме возмещения</string>
<string name="category_groceries">Продукты</string>
<string name="category_leisure">Бар/Вечеринка</string>
<string name="category_rent">Аренда</string>
<string name="category_bills">Счета</string>
<string name="category_excursion">Экскурсии/Культура</string>
<string name="category_health">Здоровье</string>
<string name="category_shopping">Покупки</string>
<string name="category_reimbursement">Возмещение расходов</string>
<string name="category_restaurant">Ресторан</string>
<string name="category_accomodation">Проживание</string>
<string name="category_transport">Транспорт</string>
<string name="category_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="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_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>
<string name="new_project_action">Что</string>
<string name="new_project_where">Где</string>
<string name="where_local">Только локально</string>
<string name="todo_join">Присоединиться к существующему проекту</string>
<string name="todo_create">Создать новый проект</string>
<string name="import_tooltip">Импортировать из файла</string>
<string name="choose_project_management_action">Проект</string>
<string name="project_added_success">Проект добавлен успешно.</string>
<string name="no_projects_text">У вас пока нет проектов.</string>
<string name="configure_account_choice">Настроить учётную запись Nextcloud</string>
<string name="add_project_choice">Добавить проект вручную</string>
<string name="no_members_text">В этом проекте нет участников.</string>
<string name="no_bills_text">Расходов не найдено.</string>
<string name="member_already_exists">Участник уже существует.</string>
<string name="activity_dialog_title">Проект: %1$s</string>
<string name="remove_project_confirmation">Проект %1$s удален.</string>
<string name="file_saved_success">Файл сохранен: %1$s</string>
<string name="import_error_header">Ошибка импорта в строке %d</string>
<string name="import_error_date">Недопустимый формат даты в строке %d</string>
<string name="import_error_owers">Недопустимые участники в строке %d</string>
<string name="add_member_dialog_title">Добавить участника</string>
<string name="edit_member_dialog_title">Редактировать участника</string>
<string name="member_edit_delete">Удалить</string>
<string name="project_edition_no_change">Изменений для сохранения нет.</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>
<string name="center_none">Нет (оптимально)</string>
<string name="center_none_direct">Нет (напрямую)</string>
<string name="settle_who">Кто платит</string>
<string name="settle_to_whom">Кому</string>
<string name="settle_how_much">Сумма</string>
<string name="simple_settle_share">Поделиться</string>
<string name="simple_create_bills">Создать расходы</string>
<string name="settle_bill_what">Расход</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>
<string name="currency_dialog_title">Выбрать валюту (%s)</string>
<string name="setting_none">Нет</string>
<string name="setting_all">Все</string>
<string name="currency_saved_success">Настройки валюты сохранены.</string>
<string name="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>
<string name="label_bills_suggested">Рекомендуемые категории</string>
<string name="label_bills_skip">Пропустить</string>
<string name="stats_date_min">С</string>
<string name="stats_date_max">По</string>
<string name="stats_who">Участник</string>
<string name="stats_paid">Оплачено</string>
<string name="stats_spent">Потрачено</string>
<string name="stats_balance">Баланс</string>
<string name="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>
<string name="error_project_connect_check">Ошибка подключения: %1$s</string>
<string name="error_create_remote_project_helper">Ошибка создания: %1$s</string>
<string name="error_edit_remote_project_helper">Ошибка обновления удаленного проекта: %1$s</string>
<string name="remote_project_operation_no_network">Отсутствует подключение к сети для операций на сервере.</string>
<string name="error_scanning_bill_qr_code">Не удалось распознать QR-код.</string>
<string name="error_token_mismatch">Ошибка токена аутентификации. Пожалуйста, войдите заново.</string>
<string name="insufficient_access_level">У вас нет прав на выполнение этого действия.</string>
<string name="delete_label_confirmation_title">Удалить метку</string>
<string name="delete_label_confirmation_message">Вы действительно хотите удалить эту метку?</string>
<!-- About -->
<string name="about_version">Version %1$s</string>
<string name="about_maintainer_title">Maintainer</string>
<string name="about_license_title">License</string>
<string name="about_source_title">Source code</string>
<string name="about_version">Версия %1$s</string>
<string name="about_maintainer_title">Сопровождающий</string>
<string name="about_license_title">Лицензия</string>
<string name="about_source_title">Исходный код</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>
<string name="share_intent_title">Проект %1$s</string>
<string name="share_chooser_title">Поделиться %1$s</string>
</resources>
@@ -144,14 +144,8 @@
<string name="settings_color_custom">Custom color</string>
<string name="settings_color_mode">Color Selection</string>
<string name="settings_show_archived">Show archived projects</string>
<string name="settings_beta_features">Beta Features</string>
<string name="settings_beta_features_summary">Enable experimental features. Use at your own risk.</string>
<string name="settings_fill_new_bill_from_last">Auto-fill from last bill</string>
<string name="settings_fill_new_bill_from_last_summary">Pre-fill payer, category, mode and owers from the last bill created in the project.</string>
<string name="settings_auto_sync_on_open">Sync interval</string>
<string name="settings_auto_sync_on_open_summary">How often to refresh the account and all projects when opening the app.</string>
<string name="pref_value_sync_1m">1 minute</string>
<string name="pref_value_sync_10m">10 minutes</string>
<string name="pref_value_sync_1h">1 hour</string>
<string name="pref_value_sync_1d">1 day</string>
<string name="settings_url_warn_http">WARNING: "http" is unsafe. Use "https".</string>
@@ -144,14 +144,8 @@
<string name="settings_color_custom">Custom color</string>
<string name="settings_color_mode">Color Selection</string>
<string name="settings_show_archived">Show archived projects</string>
<string name="settings_beta_features">Beta Features</string>
<string name="settings_beta_features_summary">Enable experimental features. Use at your own risk.</string>
<string name="settings_fill_new_bill_from_last">Auto-fill from last bill</string>
<string name="settings_fill_new_bill_from_last_summary">Pre-fill payer, category, mode and owers from the last bill created in the project.</string>
<string name="settings_auto_sync_on_open">Sync interval</string>
<string name="settings_auto_sync_on_open_summary">How often to refresh the account and all projects when opening the app.</string>
<string name="pref_value_sync_1m">1 minute</string>
<string name="pref_value_sync_10m">10 minutes</string>
<string name="pref_value_sync_1h">1 hour</string>
<string name="pref_value_sync_1d">1 day</string>
<string name="settings_url_warn_http">WARNING: "http" is unsafe. Use "https".</string>
@@ -144,14 +144,8 @@
<string name="settings_color_custom">Custom color</string>
<string name="settings_color_mode">Color Selection</string>
<string name="settings_show_archived">Show archived projects</string>
<string name="settings_beta_features">Beta Features</string>
<string name="settings_beta_features_summary">Enable experimental features. Use at your own risk.</string>
<string name="settings_fill_new_bill_from_last">Auto-fill from last bill</string>
<string name="settings_fill_new_bill_from_last_summary">Pre-fill payer, category, mode and owers from the last bill created in the project.</string>
<string name="settings_auto_sync_on_open">Sync interval</string>
<string name="settings_auto_sync_on_open_summary">How often to refresh the account and all projects when opening the app.</string>
<string name="pref_value_sync_1m">1 minute</string>
<string name="pref_value_sync_10m">10 minutes</string>
<string name="pref_value_sync_1h">1 hour</string>
<string name="pref_value_sync_1d">1 day</string>
<string name="settings_url_warn_http">WARNING: "http" is unsafe. Use "https".</string>
@@ -144,14 +144,8 @@
<string name="settings_color_custom">Custom color</string>
<string name="settings_color_mode">Color Selection</string>
<string name="settings_show_archived">Show archived projects</string>
<string name="settings_beta_features">Beta Features</string>
<string name="settings_beta_features_summary">Enable experimental features. Use at your own risk.</string>
<string name="settings_fill_new_bill_from_last">Auto-fill from last bill</string>
<string name="settings_fill_new_bill_from_last_summary">Pre-fill payer, category, mode and owers from the last bill created in the project.</string>
<string name="settings_auto_sync_on_open">Sync interval</string>
<string name="settings_auto_sync_on_open_summary">How often to refresh the account and all projects when opening the app.</string>
<string name="pref_value_sync_1m">1 minute</string>
<string name="pref_value_sync_10m">10 minutes</string>
<string name="pref_value_sync_1h">1 hour</string>
<string name="pref_value_sync_1d">1 day</string>
<string name="settings_url_warn_http">WARNING: "http" is unsafe. Use "https".</string>
@@ -144,14 +144,8 @@
<string name="settings_color_custom">Custom color</string>
<string name="settings_color_mode">Color Selection</string>
<string name="settings_show_archived">Show archived projects</string>
<string name="settings_beta_features">Beta Features</string>
<string name="settings_beta_features_summary">Enable experimental features. Use at your own risk.</string>
<string name="settings_fill_new_bill_from_last">Auto-fill from last bill</string>
<string name="settings_fill_new_bill_from_last_summary">Pre-fill payer, category, mode and owers from the last bill created in the project.</string>
<string name="settings_auto_sync_on_open">Sync interval</string>
<string name="settings_auto_sync_on_open_summary">How often to refresh the account and all projects when opening the app.</string>
<string name="pref_value_sync_1m">1 minute</string>
<string name="pref_value_sync_10m">10 minutes</string>
<string name="pref_value_sync_1h">1 hour</string>
<string name="pref_value_sync_1d">1 day</string>
<string name="settings_url_warn_http">WARNING: "http" is unsafe. Use "https".</string>
@@ -144,14 +144,8 @@
<string name="settings_color_custom">Custom color</string>
<string name="settings_color_mode">Color Selection</string>
<string name="settings_show_archived">Show archived projects</string>
<string name="settings_beta_features">Beta Features</string>
<string name="settings_beta_features_summary">Enable experimental features. Use at your own risk.</string>
<string name="settings_fill_new_bill_from_last">Auto-fill from last bill</string>
<string name="settings_fill_new_bill_from_last_summary">Pre-fill payer, category, mode and owers from the last bill created in the project.</string>
<string name="settings_auto_sync_on_open">Sync interval</string>
<string name="settings_auto_sync_on_open_summary">How often to refresh the account and all projects when opening the app.</string>
<string name="pref_value_sync_1m">1 minute</string>
<string name="pref_value_sync_10m">10 minutes</string>
<string name="pref_value_sync_1h">1 hour</string>
<string name="pref_value_sync_1d">1 day</string>
<string name="settings_url_warn_http">WARNING: "http" is unsafe. Use "https".</string>
@@ -144,14 +144,8 @@
<string name="settings_color_custom">Custom color</string>
<string name="settings_color_mode">Color Selection</string>
<string name="settings_show_archived">Show archived projects</string>
<string name="settings_beta_features">Beta Features</string>
<string name="settings_beta_features_summary">Enable experimental features. Use at your own risk.</string>
<string name="settings_fill_new_bill_from_last">Auto-fill from last bill</string>
<string name="settings_fill_new_bill_from_last_summary">Pre-fill payer, category, mode and owers from the last bill created in the project.</string>
<string name="settings_auto_sync_on_open">Sync interval</string>
<string name="settings_auto_sync_on_open_summary">How often to refresh the account and all projects when opening the app.</string>
<string name="pref_value_sync_1m">1 minute</string>
<string name="pref_value_sync_10m">10 minutes</string>
<string name="pref_value_sync_1h">1 hour</string>
<string name="pref_value_sync_1d">1 day</string>
<string name="settings_url_warn_http">WARNING: "http" is unsafe. Use "https".</string>
+30 -8
View File
@@ -17,6 +17,7 @@
<string name="simple_back">Back</string>
<string name="action_archive">Archive</string>
<string name="action_unarchive">Unarchive</string>
<string name="action_forget">Delete locally</string>
<string name="action_export">Export</string>
<string name="action_stats">Stats</string>
<string name="action_settle">Settle</string>
@@ -24,6 +25,8 @@
<string name="action_settings">Settings</string>
<string name="action_label_bills">Label missing categories</string>
<string name="action_logout">Logout</string>
<string name="logout_confirm_title">Log out?</string>
<string name="logout_confirm_no_projects">You can sign back in at any time.</string>
<string name="action_connect">Connect</string>
<string name="action_discard">Discard</string>
<string name="action_members">Members</string>
@@ -136,20 +139,23 @@
<string name="settings_night_mode">Theme</string>
<string name="settings_offline_mode">Offline mode</string>
<string name="settings_offline_mode_summary">Only sync manually.</string>
<string name="settings_restore_deleted_projects">Restore deleted projects</string>
<string name="settings_restore_deleted_projects_done">Restoring from the account</string>
<string name="settings_restore_deleted_projects_confirm">Restore</string>
<string name="settings_color_custom">Custom color</string>
<string name="settings_color_mode">Color Selection</string>
<string name="settings_show_archived">Show archived projects</string>
<string name="settings_beta_features">Beta Features</string>
<string name="settings_beta_features_summary">Enable experimental features. Use at your own risk.</string>
<string name="settings_extra_features">Extra Features</string>
<string name="settings_extra_features_summary">Experimental and advanced settings. Use at your own risk.</string>
<string name="settings_fill_new_bill_from_last">Auto-fill from last bill</string>
<string name="settings_fill_new_bill_from_last_summary">Pre-fill payer, category, mode and owers from the last bill created in the project.</string>
<string name="settings_stats_include_deactivated">Include deactivated members in stats</string>
<string name="settings_auto_sync_on_open">Sync interval</string>
<string name="settings_auto_sync_on_open_summary">How often to refresh the account and all projects when opening the app.</string>
<string name="pref_value_sync_1m">1 minute</string>
<string name="pref_value_sync_10m">10 minutes</string>
<string name="settings_full_sync_delay">Full sync delay</string>
<string name="settings_full_sync_delay_summary">How long a pull-to-refresh waits between two full syncs. In between, it only fetches what changed, which is much faster.</string>
<string name="pref_value_sync_always">Always</string>
<string name="pref_value_sync_1h">1 hour</string>
<string name="pref_value_sync_1d">1 day</string>
<string name="pref_value_sync_1w">1 week</string>
<string name="settings_url_warn_http">WARNING: "http" is unsafe. Use "https".</string>
<string name="settings_colorpicker_title">Choose Color</string>
@@ -170,9 +176,9 @@
<string name="pref_key_color_mode" translatable="false">colorMode</string>
<string name="pref_key_offline_mode" translatable="false">offlineMode</string>
<string name="pref_key_show_archived" translatable="false">showArchived</string>
<string name="pref_key_beta_features" translatable="false">betaFeatures</string>
<string name="pref_key_extra_features" translatable="false">betaFeatures</string>
<string name="pref_key_stats_include_deactivated" translatable="false">statsIncludeDeactivated</string>
<string name="pref_key_auto_sync_on_open" translatable="false">autoSyncOnOpen</string>
<string name="pref_key_full_sync_delay" translatable="false">fullSyncDelayMinutes</string>
<string name="pref_key_last_account_sync_timestamp" translatable="false">lastAccountSyncTimestamp</string>
<string name="pref_key_fill_new_bill_from_last" translatable="false">fillNewBillFromLast</string>
<string name="pref_value_night_mode_no" translatable="false">1</string>
@@ -241,6 +247,7 @@
<!-- Settlement -->
<string name="center_none">None (Optimal)</string>
<string name="center_none_direct">None (Direct)</string>
<string name="settle_who">Who pays</string>
<string name="settle_to_whom">To whom</string>
<string name="settle_how_much">Amount</string>
@@ -290,4 +297,19 @@
<string name="share_intent_title">Project %1$s</string>
<string name="share_chooser_title">Share %1$s</string>
<plurals name="settings_restore_deleted_projects_summary">
<item quantity="one">%d project the account offers is kept off this device.</item>
<item quantity="other">%d projects the account offers are kept off this device.</item>
</plurals>
<plurals name="logout_confirm_projects">
<item quantity="one">%d project from this account will be removed from this device. It comes back when you sign in again.</item>
<item quantity="other">%d projects from this account will be removed from this device. They come back when you sign in again.</item>
</plurals>
<plurals name="warning_unsynced_bills">
<item quantity="one">%d bill has not reached the server yet and will be lost.</item>
<item quantity="other">%d bills have not reached the server yet and will be lost.</item>
</plurals>
</resources>