Compare commits

..
40 Commits
Author SHA1 Message Date
sora 0bb1ca9682 Fix build issues 2026-11-14 14:13:45 +01:00
sora c21f0c2d7a Undo java bump breaking build 2026-11-14 13:47:24 +01:00
sora d8fd4aaded build with version file 2026-11-14 13:39:31 +01:00
sora a5c825c8e0 better prefill, autocategorize 2026-11-14 13:34:43 +01:00
sora 206223bff5 Merge pull request 'Update plugin com.android.application to v9.5.1' (#7) from renovate/com.android.application-9.x into main
Reviewed-on: #7
2026-11-14 12:24:53 +01:00
sora 3deaff97fb Merge pull request 'Update dependency androidx.datastore:datastore-preferences to v1.3.0' (#25) from renovate/androidx.datastore-datastore-preferences-1.x into main
Reviewed-on: #25
2026-11-14 12:24:14 +01:00
sora 9e6ba6e1b7 Merge pull request 'Update dependency java-jdk to v27' (#26) from renovate/java-jdk-27.x into main
Reviewed-on: #26
2026-11-14 12:23:59 +01:00
sora af72449037 Merge pull request 'Update dependency com.github.nextcloud:Android-SingleSignOn to v1.3.5' (#27) from renovate/com.github.nextcloud-android-singlesignon-1.x into main
Reviewed-on: #27
2026-11-14 12:23:47 +01:00
Renovate Bot 211f751c0e Update plugin com.android.application to v9.5.1 2026-11-06 02:01:21 +00:00
Renovate Bot bad6d59c5c Update dependency com.github.nextcloud:Android-SingleSignOn to v1.3.5 2026-10-29 02:01:11 +00:00
Renovate Bot 7ecd10abf0 Update dependency java-jdk to v27 2026-10-23 02:02:12 +00:00
Renovate Bot 04c7deb066 Update dependency androidx.datastore:datastore-preferences to v1.3.0 2026-10-22 02:01:42 +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
gtcrowd 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
Renovate Bot 206c756ad7 Update dependency androidx.compose.foundation:foundation to v1.12.1 2026-09-10 02:01:21 +00:00
Renovate Bot a66d046b3f Update plugin org.jetbrains.kotlin.plugin.compose to v2.4.20 2026-09-08 02:01:15 +00:00
47 changed files with 834 additions and 168 deletions

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

+34 -10
View File
@@ -8,11 +8,11 @@ env:
on:
push:
branches: [ main ]
branches: [main]
tags:
- '**'
- "**"
pull_request:
branches: [ main ]
branches: [main]
workflow_dispatch:
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
@@ -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,8 @@ jobs:
else
VERSION_NAME="${VERSION_BASE}"
fi
./gradlew assembleSignedRelease -PVERSION_CODE=$VERSION_CODE -PVERSION_NAME=$VERSION_NAME
printf '%s\n%s\n' "$VERSION_CODE" "$VERSION_NAME" > version.txt
./gradlew bundleSignedRelease assembleSignedRelease -PVERSION_CODE=$VERSION_CODE -PVERSION_NAME=$VERSION_NAME
- name: Upload APK
uses: actions/upload-artifact@v7
@@ -71,15 +77,33 @@ jobs:
compression-level: 0
archive: false
- name: Determine prerelease
id: prerelease
if: startsWith(github.ref, 'refs/tags/')
run: |
if [[ "$GITHUB_REF_NAME" =~ ^[0-9]+(\.[0-9]+)+$ ]]; then
echo "value=false" >> "$GITHUB_OUTPUT"
else
echo "value=true" >> "$GITHUB_OUTPUT"
fi
- name: Release
uses: softprops/action-gh-release@v3
if: startsWith(github.ref, 'refs/tags/')
with:
files: |
app/build/outputs/apk/signedRelease/app-signedRelease.apk
prerelease: |
if [[ ${{ github.ref }} =~ ^refs/tags/[0-9]+\.[0-9]+[a-z]+$ ]]; then
echo "false"
else
echo "true"
fi
version.txt
prerelease: ${{ steps.prerelease.outputs.value }}
- name: Upload to Google Play
uses: r0adkll/upload-google-play@v1
if: github.event_name == 'push'
continue-on-error: true
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
```
+11 -11
View File
@@ -1,7 +1,7 @@
plugins {
id 'com.android.application' version '9.3.2'
id 'org.jetbrains.kotlin.plugin.serialization' version '2.4.10'
id 'org.jetbrains.kotlin.plugin.compose' version '2.4.10'
id 'com.android.application' version '9.5.1'
id 'org.jetbrains.kotlin.plugin.serialization' version '2.4.20'
id 'org.jetbrains.kotlin.plugin.compose' version '2.4.20'
id 'jacoco'
}
@@ -118,21 +118,21 @@ 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'
implementation 'androidx.camera:camera-lifecycle:1.6.2'
implementation 'androidx.camera:camera-view:1.6.2'
implementation 'com.github.nextcloud:Android-SingleSignOn:1.3.4'
implementation 'com.github.nextcloud:Android-SingleSignOn:1.3.5'
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'
@@ -142,17 +142,17 @@ dependencies {
implementation 'androidx.activity:activity-ktx:1.13.0'
implementation 'androidx.security:security-crypto:1.1.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.11.0'
implementation "androidx.datastore:datastore-preferences:1.2.1"
implementation "androidx.datastore:datastore-preferences:1.3.0"
implementation "com.google.crypto.tink:tink-android:1.23.0"
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
@@ -9,6 +9,7 @@ import androidx.activity.enableEdgeToEdge
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.annotation.WorkerThread
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.remember
import androidx.lifecycle.lifecycleScope
@@ -27,6 +28,10 @@ import net.helcel.cowspent.model.ProjectType
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.theme.ThemeUtils
import net.helcel.cowspent.util.BillParser
import net.helcel.cowspent.util.BillPrefill
import net.helcel.cowspent.util.BillTemplate
import net.helcel.cowspent.util.CategorySuggester
import net.helcel.cowspent.util.PrefillMode
import net.helcel.cowspent.util.SupportUtil
import java.text.ParseException
import java.time.ZoneId
@@ -40,6 +45,9 @@ class EditBillActivity : AppCompatActivity() {
private var projectType: ProjectType = ProjectType.LOCAL
private val calendar = Calendar.getInstance()
// The pre-fill mode the form was opened with; null when the bill is not a fresh one.
private var prefillMode: PrefillMode? = null
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
@@ -95,8 +103,14 @@ class EditBillActivity : AppCompatActivity() {
val createIntent = Intent(this@EditBillActivity, QrCodeScannerActivity::class.java)
scanQRCodeLauncher.launch(createIntent)
},
onDuplicate = if (!viewModel.isNewBill) { { duplicateCurrentBill() } } else null,
onDelete = if (!viewModel.isNewBill) { { deleteBillAsked() } } else null,
onDuplicate = if (!viewModel.isNewBill) {
{ duplicateCurrentBill() }
} else null,
onDelete = if (!viewModel.isNewBill) {
{ deleteBillAsked() }
} else null,
onPrefillModeSelected = { onPrefillModeSelected(it) },
onSavePrefillPreset = { onSavePrefillPreset() },
accessLevel = db.getProject(bill.projectId)?.myAccessLevel ?: DBProject.ACCESS_LEVEL_ADMIN
)
}
@@ -152,25 +166,13 @@ class EditBillActivity : AppCompatActivity() {
val billIdToDuplicate = intent.getLongExtra(PARAM_BILL_ID_TO_DUPLICATE, 0)
val timeNowSeconds = System.currentTimeMillis() / 1000
if (billIdToDuplicate == 0L) {
val preferences = PreferenceManager.getDefaultSharedPreferences(applicationContext)
val fillFromLast = preferences.getBoolean(getString(R.string.pref_key_fill_new_bill_from_last), false)
val lastBill = if (fillFromLast) db.getLastBillOfProject(projectId) else null
if (lastBill != null) {
bill = DBBill(
0, 0, projectId, lastBill.payerId, 0.0, timeNowSeconds,
"", DBBill.STATE_ADDED, lastBill.repeat ?: DBBill.NON_REPEATED,
lastBill.paymentMode, lastBill.categoryId, "", lastBill.paymentModeId
val mode = BillPrefill.mode(applicationContext, projectId)
bill = newBill(
projectId,
timeNowSeconds,
BillPrefill.templateFor(applicationContext, db, projectId, mode)
)
bill.billOwers = lastBill.billOwers.map { DBBillOwer(0, 0, it.memberId) }
} else {
val project = db.getProject(projectId)
bill = DBBill(
0, 0, projectId, project?.lastPayerId ?: 0, 0.0, timeNowSeconds,
"", DBBill.STATE_ADDED, DBBill.NON_REPEATED,
DBBill.PAYMODE_NONE, DBBill.CATEGORY_NONE, "", DBBill.PAYMODE_ID_NONE
)
}
prefillMode = mode
} else {
val btd = db.getBill(billIdToDuplicate)!!
bill = DBBill(
@@ -201,15 +203,99 @@ class EditBillActivity : AppCompatActivity() {
db.ensureDefaultLabels(bill.projectId, project.type)
}
val hasPreset = BillPrefill.preset(applicationContext, bill.projectId) != null
val categoryMatcher = if (bill.id == 0L) autoCategoryMatcher(bill.projectId) else null
withContext(Dispatchers.Main) {
viewModel.currencies = currencies
viewModel.mainCurrencyName = project?.currencyName ?: ""
viewModel.isNewBill = (bill.id == 0L)
viewModel.prefillMode = prefillMode
viewModel.hasPrefillPreset = hasPreset
viewModel.initFromBill(bill, members, customSplits)
categoryMatcher?.let { viewModel.enableAutoCategorize(it) }
}
}
}
@WorkerThread
private fun autoCategoryMatcher(projectId: Long): ((String) -> Long?)? {
val preferences = PreferenceManager.getDefaultSharedPreferences(applicationContext)
// The switch lives under Extra Features; with those off it is hidden, so it must not act.
if (!preferences.getBoolean(getString(R.string.pref_key_extra_features), false)) return null
if (!preferences.getBoolean(getString(R.string.pref_key_auto_categorize), false)) return null
val categorized = db.getBillsOfProject(projectId)
.filter { it.categoryId != DBBill.CATEGORY_NONE && it.state != DBBill.STATE_DELETED }
// Only categories the picker offers: the project's own, plus the built-in reimbursement.
val allowed = db.getCategories(projectId).map { it.id }.toSet() + DBBill.CATEGORY_REIMBURSEMENT
return { what -> CategorySuggester.confidentMatch(what, categorized, allowed) }
}
@WorkerThread
private fun newBill(projectId: Long, timestamp: Long, template: BillTemplate?): DBBill {
val project = db.getProject(projectId)
val lastPayerId = project?.lastPayerId ?: 0
if (template == null) {
return DBBill(
0, 0, projectId, lastPayerId, 0.0, timestamp,
"", DBBill.STATE_ADDED, DBBill.NON_REPEATED,
DBBill.PAYMODE_NONE, DBBill.CATEGORY_NONE, "", DBBill.PAYMODE_ID_NONE
)
}
val activeIds = db.getMembersOfProject(projectId, null).filter { it.isActivated }.map { it.id }.toSet()
return DBBill(
0, 0, projectId, if (template.payerId in activeIds) template.payerId else lastPayerId,
0.0, timestamp, "", DBBill.STATE_ADDED, template.repeat,
template.paymentMode, template.categoryId, "", template.paymentModeId
).apply {
billOwers = template.owerIds.filter { it in activeIds }.map { DBBillOwer(0, 0, it) }
}
}
private fun onPrefillModeSelected(mode: PrefillMode) {
val projectId = bill.projectId
lifecycleScope.launch {
val template = withContext(Dispatchers.IO) {
BillPrefill.setMode(applicationContext, projectId, mode)
newBill(projectId, bill.timestamp, BillPrefill.templateFor(applicationContext, db, projectId, mode))
}
// Keep the reference bill in step, so the unsaved-changes check compares against
// what pre-filling put there rather than flagging it as the user's edit.
bill.payerId = template.payerId
bill.billOwers = template.billOwers
bill.categoryId = template.categoryId
bill.paymentMode = template.paymentMode
bill.paymentModeId = template.paymentModeId
bill.repeat = template.repeat
viewModel.prefillMode = mode
viewModel.applyPrefill(template)
}
}
private fun currentTemplate() = BillTemplate(
payerId = viewModel.payerId,
owerIds = viewModel.getOwersIds(),
categoryId = viewModel.categoryId,
paymentModeId = viewModel.paymentModeId,
paymentMode = bill.paymentMode,
repeat = viewModel.repeat
)
/** Saves the form's current values as the project's preset and switches to using it. */
private fun onSavePrefillPreset() {
val projectId = bill.projectId
val template = currentTemplate()
lifecycleScope.launch {
withContext(Dispatchers.IO) {
BillPrefill.savePreset(applicationContext, projectId, template)
BillPrefill.setMode(applicationContext, projectId, PrefillMode.PRESET)
}
viewModel.hasPrefillPreset = true
viewModel.prefillMode = PrefillMode.PRESET
showToast(this@EditBillActivity, getString(R.string.prefill_preset_saved))
}
}
private val scanQRCodeLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK && result.data != null) {
@@ -282,8 +368,18 @@ class EditBillActivity : AppCompatActivity() {
if (validationError != null) {
showToast(this, validationError)
} else {
// Taken before saving: saving can split the bill into one per ower, and the template
// should hold the whole selection rather than whichever split was written last.
val createdHere = bill.id == 0L
val template = currentTemplate()
lifecycleScope.launch {
val savedBillId = withContext(Dispatchers.IO) { saveBill() }
val savedBillId = withContext(Dispatchers.IO) {
saveBill().also { id ->
if (createdHere && id != 0L) {
BillPrefill.recordCreatedOnDevice(applicationContext, bill.projectId, template)
}
}
}
val data = Intent()
data.putExtra(MainConstants.SAVED_BILL_ID, savedBillId)
setResult(RESULT_OK, data)
@@ -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
@@ -40,6 +49,7 @@ import net.helcel.cowspent.R
import net.helcel.cowspent.android.helper.*
import net.helcel.cowspent.model.*
import net.helcel.cowspent.util.CategoryUtils
import net.helcel.cowspent.util.PrefillMode
import net.helcel.cowspent.util.SupportUtil
import java.util.Date
import kotlin.math.abs
@@ -57,11 +67,15 @@ fun EditBillScreen(
onScan: () -> Unit,
onDuplicate: (() -> Unit)? = null,
onDelete: (() -> Unit)? = null,
onPrefillModeSelected: (PrefillMode) -> Unit = {},
onSavePrefillPreset: () -> Unit = {},
accessLevel: Int = DBProject.ACCESS_LEVEL_ADMIN
) {
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)) }
// 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
@@ -91,6 +105,14 @@ fun EditBillScreen(
},
actions = {
if (canEdit) {
viewModel.prefillMode?.let { mode ->
PrefillMenu(
mode = mode,
hasPreset = viewModel.hasPrefillPreset,
onModeSelected = onPrefillModeSelected,
onSavePreset = onSavePrefillPreset
)
}
IconButton(onClick = onScan) {
Icon(Icons.Default.QrCodeScanner, contentDescription = null)
}
@@ -118,7 +140,12 @@ fun EditBillScreen(
val errorOwers = stringResource(R.string.error_invalid_bill_owers)
val errorInvalidForm = stringResource(R.string.error_generic)
FloatingActionButton(onClick = {
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
)
@@ -141,8 +168,12 @@ fun EditBillScreen(
modifier = Modifier
.padding(padding)
.imePadding()
.padding(16.dp)
.fillMaxSize()
) {
Column(
modifier = Modifier
.weight(1f)
.padding(16.dp)
.verticalScroll(scrollState)
) {
BillBasicInfoSection(
@@ -150,7 +181,10 @@ fun EditBillScreen(
canEdit = canEdit,
onDateClick = onDateClick,
onTimeClick = onTimeClick,
amountFocusRequester = focusRequester
amountFocusRequester = focusRequester,
amountSelection = amountSelection,
onAmountSelectionChange = { amountSelection = it },
onAmountFocusChange = { amountFocused = it }
)
Spacer(modifier = Modifier.height(8.dp))
@@ -178,6 +212,154 @@ fun EditBillScreen(
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
/**
* Chooses where this project's new bills take their payer, owers, category and payment mode
* from, and saves the form's current values as the project's preset.
*/
@Composable
fun PrefillMenu(
mode: PrefillMode,
hasPreset: Boolean,
onModeSelected: (PrefillMode) -> Unit,
onSavePreset: () -> Unit
) {
var expanded by remember { mutableStateOf(false) }
Box {
IconButton(onClick = { expanded = true }) {
Icon(Icons.Default.AutoFixHigh, contentDescription = stringResource(R.string.prefill_button))
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
val options = listOf(
PrefillMode.OFF to R.string.prefill_mode_off,
PrefillMode.LATEST_IN_PROJECT to R.string.prefill_mode_latest_project,
PrefillMode.LATEST_ON_DEVICE to R.string.prefill_mode_latest_device,
PrefillMode.PRESET to R.string.prefill_mode_preset
)
for ((option, label) in options) {
// The preset has nothing to fill from until one is saved.
val enabled = option != PrefillMode.PRESET || hasPreset
DropdownMenuItem(
enabled = enabled,
onClick = {
expanded = false
onModeSelected(option)
}
) {
RadioButton(selected = option == mode, onClick = null, enabled = enabled)
Spacer(Modifier.width(12.dp))
Column {
Text(stringResource(label))
if (!enabled) {
Text(
text = stringResource(R.string.prefill_mode_preset_empty),
style = MaterialTheme.typography.caption
)
}
}
}
}
Divider(modifier = Modifier.padding(vertical = 4.dp))
DropdownMenuItem(onClick = {
expanded = false
onSavePreset()
}) {
Icon(Icons.Default.Save, contentDescription = null)
Spacer(Modifier.width(12.dp))
Text(stringResource(R.string.prefill_save_preset))
}
}
}
}
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)
}
}
}
}
}
@@ -187,7 +369,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",
@@ -203,15 +388,29 @@ fun BillBasicInfoSection(
stringResource(R.string.currency_dialog_title, viewModel.mainCurrencyName)
OutlinedTextField(
value = viewModel.amount,
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.filter { it in "0123456789.+-*/" }
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 { "$" }
@@ -245,7 +444,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) })
)
@@ -253,7 +452,7 @@ fun BillBasicInfoSection(
OutlinedTextField(
value = viewModel.what,
onValueChange = { viewModel.what = it },
onValueChange = { viewModel.updateWhat(it) },
enabled = canEdit,
placeholder = { Text(stringResource(R.string.label_what)) },
modifier = Modifier.fillMaxWidth(),
@@ -473,7 +672,7 @@ fun OwerSelectionSection(
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 {
@@ -557,7 +756,7 @@ fun BillAdditionalDetailsSection(
},
content = {
DropdownMenuItem(onClick = {
viewModel.categoryId = 0
viewModel.selectCategory(DBBill.CATEGORY_NONE)
categoryExpanded = false
}) {
Icon(Icons.Default.Close, tint = Color.Red, contentDescription = null)
@@ -566,7 +765,7 @@ fun BillAdditionalDetailsSection(
}
DropdownMenuItem(onClick = {
viewModel.categoryId = DBBill.CATEGORY_REIMBURSEMENT
viewModel.selectCategory(DBBill.CATEGORY_REIMBURSEMENT)
categoryExpanded = false
}) {
Text(text = "\uD83D\uDCB0", fontSize = 20.sp)
@@ -576,7 +775,7 @@ fun BillAdditionalDetailsSection(
categories.filter { it.remoteId != DBBill.CATEGORY_REIMBURSEMENT }.forEach { category ->
DropdownMenuItem(onClick = {
viewModel.categoryId = category.id
viewModel.selectCategory(category.id)
categoryExpanded = false
}) {
Text(text = category.icon, fontSize = 20.sp)
@@ -13,6 +13,7 @@ import net.helcel.cowspent.android.helper.parseAmount
import net.helcel.cowspent.model.DBBill
import net.helcel.cowspent.model.DBCurrency
import net.helcel.cowspent.model.DBMember
import net.helcel.cowspent.util.PrefillMode
import net.helcel.cowspent.util.SupportUtil
import net.helcel.cowspent.util.evalMath
@@ -31,6 +32,20 @@ class EditBillViewModel : ViewModel() {
var categoryId by mutableLongStateOf(0L)
var isNewBill by mutableStateOf(false)
// Null hides the pre-fill menu: it only applies to a bill started from scratch.
var prefillMode by mutableStateOf<PrefillMode?>(null)
var hasPrefillPreset by mutableStateOf(false)
// Auto-categorization: null while it is off, or for a bill that is not new.
private var autoCategorizer: ((String) -> Long?)? = null
// Once the user picks a category themselves, the name no longer gets a say.
private var categoryChosenByUser = false
// The category in place before an automatic one replaced it, to restore when the name stops
// matching; null while no automatic category is applied.
private var categoryBeforeAuto: Long? = null
var currencies by mutableStateOf<List<DBCurrency>>(emptyList())
var mainCurrencyName by mutableStateOf("")
var selectedCurrencyName by mutableStateOf("")
@@ -48,11 +63,12 @@ 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)
} catch (_: Exception) {}
} catch (_: Exception) {
}
}
val parsed = parseAmount(input) ?: 0.0
return SupportUtil.round2(parsed)
@@ -279,6 +295,48 @@ class EditBillViewModel : ViewModel() {
}
}
fun applyPrefill(bill: DBBill) {
payerId = bill.payerId
categoryId = bill.categoryId
paymentModeId = bill.paymentModeId
repeat = bill.repeat ?: DBBill.NON_REPEATED
val owerIds = bill.billOwersIds.toSet()
for (member in members) {
toggleMember(member.id, member.id in owerIds)
}
// The pre-filled category is the new fallback; a name match still wins over it.
categoryBeforeAuto = null
autoCategorize()
}
fun enableAutoCategorize(match: (String) -> Long?) {
autoCategorizer = match
}
fun updateWhat(value: String) {
what = value
autoCategorize()
}
fun selectCategory(id: Long) {
categoryId = id
categoryChosenByUser = true
categoryBeforeAuto = null
}
private fun autoCategorize() {
val match = autoCategorizer ?: return
if (!isNewBill || categoryChosenByUser) return
val categoryForName = match(what)
if (categoryForName != null) {
if (categoryBeforeAuto == null) categoryBeforeAuto = categoryId
categoryId = categoryForName
} else {
categoryBeforeAuto?.let { categoryId = it }
categoryBeforeAuto = null
}
}
fun isFormValid(): Boolean {
return what.isNotEmpty() &&
timestamp != 0L &&
@@ -8,6 +8,7 @@ import androidx.lifecycle.ViewModel
import net.helcel.cowspent.model.DBBill
import net.helcel.cowspent.model.DBCategory
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.util.CategorySuggester
class LabelBillsViewModel : ViewModel() {
var billsToLabel by mutableStateOf<List<DBBill>>(emptyList())
@@ -32,26 +33,9 @@ class LabelBillsViewModel : ViewModel() {
suggestedCategories = emptyList()
return
}
val name = bill.what.lowercase().trim()
if (name.isEmpty()) {
suggestedCategories = emptyList()
return
}
val matches = allCategorizedBills.filter {
val otherName = it.what.lowercase().trim()
otherName == name || (name.length > 3 && otherName.contains(name)) || (otherName.length > 3 && name.contains(otherName))
}
val counts = matches.groupBy { it.categoryId }
.mapValues { it.value.size }
.toList()
.sortedByDescending { it.second }
suggestedCategories = CategorySuggester.rank(bill.what, allCategorizedBills)
.take(2)
suggestedCategories = counts.mapNotNull { (catId, _) ->
categoriesMap[catId]
}
.mapNotNull { (catId, _) -> categoriesMap[catId] }
}
fun labelCurrentBill(db: CowspentSQLiteOpenHelper, categoryId: Long) {
@@ -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,8 +95,12 @@ fun ProjectSettlementDialogContent(
}
val transactions = remember(selectedMemberId, membersBalance, membersSortedByName) {
if (selectedMemberId == SETTLE_DIRECT) {
settleBillsDirect(membersSortedByName, db.getBillsOfProject(proj.id))
} else {
settleBills(membersSortedByName, membersBalance, selectedMemberId)
}
}
ProjectSettlementUI(
transactions = transactions,
@@ -32,6 +32,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Archive
import androidx.compose.material.icons.filled.Brightness2
import androidx.compose.material.icons.filled.Category
import androidx.compose.material.icons.filled.Group
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Palette
@@ -95,8 +96,8 @@ fun SettingsScreen(
val keyShowArchived = stringResource(R.string.pref_key_show_archived)
val keyExtraFeatures = stringResource(R.string.pref_key_extra_features)
val keyStatsIncludeDeactivated = stringResource(R.string.pref_key_stats_include_deactivated)
val keyAutoCategorize = stringResource(R.string.pref_key_auto_categorize)
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)
@@ -150,12 +151,12 @@ fun SettingsScreen(
var extraFeatures by remember(keyExtraFeatures) {
mutableStateOf(sharedPreferences.getBoolean(keyExtraFeatures, false))
}
var fillNewBillFromLast by remember(keyFillNewBillFromLast) {
mutableStateOf(sharedPreferences.getBoolean(keyFillNewBillFromLast, false))
}
var statsIncludeDeactivated by remember(keyStatsIncludeDeactivated) {
mutableStateOf(sharedPreferences.getBoolean(keyStatsIncludeDeactivated, false))
}
var autoCategorize by remember(keyAutoCategorize) {
mutableStateOf(sharedPreferences.getBoolean(keyAutoCategorize, false))
}
val fullSyncDelays = SyncSettings.FULL_SYNC_DELAY_CHOICES_MINUTES
val fullSyncDelayLabels = listOf(
@@ -341,19 +342,6 @@ fun SettingsScreen(
}
)
SettingsSwitchPreference(
title = stringResource(R.string.settings_fill_new_bill_from_last),
summary = stringResource(R.string.settings_fill_new_bill_from_last_summary),
icon = Icons.Default.Info,
checked = fillNewBillFromLast,
onCheckedChange = {
fillNewBillFromLast = it
sharedPreferences.edit {
putBoolean(keyFillNewBillFromLast, it)
}
}
)
SettingsSwitchPreference(
title = stringResource(R.string.settings_stats_include_deactivated),
icon = Icons.Default.Group,
@@ -366,6 +354,19 @@ fun SettingsScreen(
}
)
SettingsSwitchPreference(
title = stringResource(R.string.settings_auto_categorize),
summary = stringResource(R.string.settings_auto_categorize_summary),
icon = Icons.Default.Category,
checked = autoCategorize,
onCheckedChange = {
autoCategorize = it
sharedPreferences.edit {
putBoolean(keyAutoCategorize, it)
}
}
)
SettingsSliderPreference(
title = stringResource(R.string.settings_full_sync_delay),
summary = stringResource(R.string.settings_full_sync_delay_summary),
@@ -21,6 +21,7 @@ import net.helcel.cowspent.model.DBMember
import net.helcel.cowspent.model.DBPaymentMode
import net.helcel.cowspent.model.DBProject
import net.helcel.cowspent.model.ProjectType
import net.helcel.cowspent.util.BillPrefill
import net.helcel.cowspent.util.CategoryUtils
import net.helcel.cowspent.util.SecureStorage
import net.helcel.cowspent.util.SupportUtil
@@ -312,6 +313,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
db.delete(table_projects, "$key_id = ?", projectId)
}
SecureStorage.removePasswordSync(context, "ProjectPassword_$id")
BillPrefill.clear(context, id)
}
// --- Members logic ---
@@ -0,0 +1,163 @@
package net.helcel.cowspent.util
import android.content.Context
import androidx.annotation.WorkerThread
import androidx.core.content.edit
import androidx.preference.PreferenceManager
import net.helcel.cowspent.R
import net.helcel.cowspent.model.DBBill
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
/** Where a new bill takes its payer, owers, category, payment mode and repeat from. */
enum class PrefillMode(val id: String) {
/** Start from an empty form (the project's last payer is still suggested). */
OFF("off"),
/** The most recent bill of the project, whoever created it and on whichever device. */
LATEST_IN_PROJECT("project"),
/**
* The last bill created on this device. When entering a batch of older bills, the project's
* newest bill keeps changing under you as other members sync theirs, while this one only
* follows what you typed.
*/
LATEST_ON_DEVICE("device"),
/** Values the user saved once for the project and wants every time. */
PRESET("preset");
companion object {
fun fromId(id: String?): PrefillMode? = entries.firstOrNull { it.id == id }
}
}
/**
* The part of a bill that pre-filling carries over. Amount, description, date and comment are
* deliberately absent: those describe the one expense, not the habit.
*/
data class BillTemplate(
val payerId: Long,
val owerIds: List<Long>,
val categoryId: Long,
val paymentModeId: Long,
val paymentMode: String?,
val repeat: String
) {
fun toJson(): String = JSONObject()
.put(KEY_PAYER, payerId)
.put(KEY_OWERS, JSONArray(owerIds))
.put(KEY_CATEGORY, categoryId)
.put(KEY_PAYMENT_MODE_ID, paymentModeId)
.put(KEY_PAYMENT_MODE, paymentMode ?: JSONObject.NULL)
.put(KEY_REPEAT, repeat)
.toString()
companion object {
private const val KEY_PAYER = "payer"
private const val KEY_OWERS = "owers"
private const val KEY_CATEGORY = "category"
private const val KEY_PAYMENT_MODE_ID = "paymentModeId"
private const val KEY_PAYMENT_MODE = "paymentMode"
private const val KEY_REPEAT = "repeat"
fun of(bill: DBBill) = BillTemplate(
payerId = bill.payerId,
owerIds = bill.billOwersIds,
categoryId = bill.categoryId,
paymentModeId = bill.paymentModeId,
paymentMode = bill.paymentMode,
repeat = bill.repeat ?: DBBill.NON_REPEATED
)
/** Null for a missing or unreadable value, so a corrupt entry acts as "nothing saved". */
fun fromJson(json: String?): BillTemplate? {
if (json.isNullOrEmpty()) return null
return try {
val o = JSONObject(json)
val owers = o.getJSONArray(KEY_OWERS)
BillTemplate(
payerId = o.getLong(KEY_PAYER),
owerIds = List(owers.length()) { owers.getLong(it) },
categoryId = o.optLong(KEY_CATEGORY, DBBill.CATEGORY_NONE),
paymentModeId = o.optLong(KEY_PAYMENT_MODE_ID, DBBill.PAYMODE_ID_NONE),
paymentMode = if (o.isNull(KEY_PAYMENT_MODE)) null else o.getString(KEY_PAYMENT_MODE),
repeat = o.optString(KEY_REPEAT, DBBill.NON_REPEATED)
)
} catch (_: JSONException) {
null
}
}
}
}
/**
* Per-project pre-fill choice and the templates behind it. Everything lives in this device's
* preferences: "latest on this device" is device-local by definition, and the mode and preset
* describe how this user enters bills, not something the other members should get.
*/
object BillPrefill {
private fun modeKey(projectId: Long) = "prefill_mode_$projectId"
private fun deviceKey(projectId: Long) = "prefill_device_$projectId"
private fun presetKey(projectId: Long) = "prefill_preset_$projectId"
private fun prefs(context: Context) = PreferenceManager.getDefaultSharedPreferences(context)
fun mode(context: Context, projectId: Long): PrefillMode {
val prefs = prefs(context)
PrefillMode.fromId(prefs.getString(modeKey(projectId), null))?.let { return it }
// This used to be one global on/off switch. Projects that never picked a mode keep
// behaving the way that switch said.
val legacyOn = prefs.getBoolean(context.getString(R.string.pref_key_fill_new_bill_from_last), false)
return if (legacyOn) PrefillMode.LATEST_IN_PROJECT else PrefillMode.OFF
}
fun setMode(context: Context, projectId: Long, mode: PrefillMode) {
prefs(context).edit { putString(modeKey(projectId), mode.id) }
}
fun deviceTemplate(context: Context, projectId: Long): BillTemplate? =
BillTemplate.fromJson(prefs(context).getString(deviceKey(projectId), null))
/** Called for every bill created here, whatever the mode, so switching to it later works. */
fun recordCreatedOnDevice(context: Context, projectId: Long, template: BillTemplate) {
prefs(context).edit { putString(deviceKey(projectId), template.toJson()) }
}
fun preset(context: Context, projectId: Long): BillTemplate? =
BillTemplate.fromJson(prefs(context).getString(presetKey(projectId), null))
fun savePreset(context: Context, projectId: Long, template: BillTemplate) {
prefs(context).edit { putString(presetKey(projectId), template.toJson()) }
}
/** Forget everything stored for a project, e.g. once it is removed from the device. */
fun clear(context: Context, projectId: Long) {
prefs(context).edit {
remove(modeKey(projectId))
remove(deviceKey(projectId))
remove(presetKey(projectId))
}
}
/**
* The template [mode] points at, or null when there is nothing to fill from: the mode is
* off, the project has no bill yet, nothing was created on this device, or no preset was
* saved. Callers then fall back to an empty form.
*/
@WorkerThread
fun templateFor(
context: Context,
db: CowspentSQLiteOpenHelper,
projectId: Long,
mode: PrefillMode = mode(context, projectId)
): BillTemplate? = when (mode) {
PrefillMode.OFF -> null
PrefillMode.LATEST_IN_PROJECT -> db.getLastBillOfProject(projectId)?.let(BillTemplate::of)
PrefillMode.LATEST_ON_DEVICE -> deviceTemplate(context, projectId)
PrefillMode.PRESET -> preset(context, projectId)
}
}
@@ -0,0 +1,64 @@
package net.helcel.cowspent.util
import net.helcel.cowspent.model.DBBill
/**
* Guesses a bill's category from the names of bills that already have one.
*
* The bill-labelling screen shows the top guesses and lets the user pick; the bill editor can
* apply one by itself, so it asks [confidentMatch] instead, which only answers when the history
* clearly agrees.
*/
object CategorySuggester {
/** Below this length a name matches too many others by containment ("a", "bus", ...). */
private const val MIN_CONTAINMENT_LENGTH = 4
/** Share of the matching bills the leading category needs before it is applied unasked. */
private const val CONFIDENT_SHARE = 0.75
/** Matches by containment are looser, so one of them alone is not enough. */
private const val MIN_LOOSE_MATCHES = 2
private fun normalize(name: String) = name.lowercase().trim()
private fun isExactMatch(name: String, other: String) = other == name
private fun isLooseMatch(name: String, other: String) =
(name.length >= MIN_CONTAINMENT_LENGTH && other.contains(name)) ||
(other.length >= MIN_CONTAINMENT_LENGTH && name.contains(other))
fun rank(what: String, categorizedBills: List<DBBill>): List<Pair<Long, Int>> {
val name = normalize(what)
if (name.isEmpty()) return emptyList()
return categorizedBills
.filter { val other = normalize(it.what); isExactMatch(name, other) || isLooseMatch(name, other) }
.groupingBy { it.categoryId }
.eachCount()
.toList()
.sortedByDescending { it.second }
}
fun confidentMatch(
what: String,
categorizedBills: List<DBBill>,
allowedCategoryIds: Set<Long>
): Long? {
val name = normalize(what)
if (name.isEmpty()) return null
val candidates = categorizedBills.filter { it.categoryId in allowedCategoryIds }
val exact = candidates.filter { isExactMatch(name, normalize(it.what)) }
if (exact.isNotEmpty()) return dominant(exact, minCount = 1)
val loose = candidates.filter { isLooseMatch(name, normalize(it.what)) }
return dominant(loose, minCount = MIN_LOOSE_MATCHES)
}
private fun dominant(bills: List<DBBill>, minCount: Int): Long? {
if (bills.size < minCount) return null
val (categoryId, count) = bills.groupingBy { it.categoryId }.eachCount().maxByOrNull { it.value }
?: return null
return if (count >= minCount && count >= bills.size * CONFIDENT_SHARE) categoryId else null
}
}
@@ -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(
@@ -144,8 +144,6 @@
<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_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="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,8 +144,6 @@
<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_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="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,8 +144,6 @@
<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_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="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,8 +144,6 @@
<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_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="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,8 +144,6 @@
<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_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="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 -2
View File
@@ -138,8 +138,6 @@
<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_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="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>
@@ -209,6 +207,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,8 +144,6 @@
<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_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="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 -2
View File
@@ -137,8 +137,6 @@
<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_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="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>
@@ -210,6 +208,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,8 +144,6 @@
<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_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="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 -2
View File
@@ -138,8 +138,6 @@
<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_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="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>
@@ -213,6 +211,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,8 +144,6 @@
<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_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="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,8 +144,6 @@
<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_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="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,8 +144,6 @@
<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_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="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,8 +144,6 @@
<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_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="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,8 +144,6 @@
<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_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="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,8 +144,6 @@
<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_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="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,8 +144,6 @@
<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_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="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,8 +144,6 @@
<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_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="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,8 +144,6 @@
<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_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="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,8 +144,6 @@
<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_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="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,8 +144,6 @@
<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_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="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 -2
View File
@@ -137,8 +137,6 @@
<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>
@@ -209,6 +207,7 @@
<!-- Settlement -->
<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>
@@ -144,8 +144,6 @@
<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_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="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,8 +144,6 @@
<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_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="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,8 +144,6 @@
<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_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="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,8 +144,6 @@
<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_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="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,8 +144,6 @@
<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_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="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,8 +144,6 @@
<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_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="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,8 +144,6 @@
<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_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="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>
+14 -4
View File
@@ -146,12 +146,20 @@
<string name="settings_color_mode">Color Selection</string>
<string name="settings_show_archived">Show archived projects</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_extra_features_summary">Experimental and advanced options.</string>
<string name="prefill_button">Pre-fill</string>
<string name="prefill_mode_off">Nothing</string>
<string name="prefill_mode_latest_project">Last from project</string>
<string name="prefill_mode_latest_device">Last from device</string>
<string name="prefill_mode_preset">Saved preset</string>
<string name="prefill_mode_preset_empty">None saved yet</string>
<string name="prefill_save_preset">Save current as preset</string>
<string name="prefill_preset_saved">Preset saved for this project</string>
<string name="settings_stats_include_deactivated">Include deactivated members in stats</string>
<string name="settings_auto_categorize">Auto-categorize new bills</string>
<string name="settings_auto_categorize_summary">Pick the category from similar past bills.</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="settings_full_sync_delay_summary">Time between full syncs. Other refreshes only fetch changes.</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>
@@ -178,6 +186,7 @@
<string name="pref_key_show_archived" translatable="false">showArchived</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_categorize" translatable="false">autoCategorizeNewBills</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>
@@ -247,6 +256,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>