1 Commits

Author SHA1 Message Date
Renovate Bot c2f59fa1df Add renovate.json 2026-05-30 02:02:13 +00:00
37 changed files with 602 additions and 943 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

-1
View File
@@ -4,7 +4,6 @@
<img width="100px" src="./metadata/en-US/images/icon.webp" alt="Logo">
<p>
Shared budget manager able to sync with [Nextcloud Cospend](https://github.com/julien-nc/cospend-nc).
This work is based upon [MoneyBuster](https://gitlab.com/eneiluj/moneybuster).
+3 -3
View File
@@ -1,7 +1,7 @@
plugins {
id 'com.android.application' version '9.2.1'
id 'org.jetbrains.kotlin.plugin.serialization' version '2.4.0'
id 'org.jetbrains.kotlin.plugin.compose' version '2.4.0'
id 'org.jetbrains.kotlin.plugin.serialization' version '2.3.21'
id 'org.jetbrains.kotlin.plugin.compose' version '2.3.21'
}
android {
@@ -14,7 +14,7 @@ android {
applicationId "net.helcel.cowspent"
minSdk = 26
targetSdk = 37
versionName "1.0b"
versionName "1.0"
versionCode project.hasProperty('VERSION_CODE') ? project.property('VERSION_CODE').toInteger() : 1
}
+5 -8
View File
@@ -20,14 +20,11 @@
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity
android:name="net.helcel.cowspent.android.currencies.ManageCurrenciesActivity"
android:windowSoftInputMode="adjustResize" />
<activity android:name="net.helcel.cowspent.android.currencies.ManageCurrenciesActivity" />
<activity
android:name="net.helcel.cowspent.android.main.BillsListViewActivity"
android:theme="@style/AppTheme.NoActionBar"
android:windowSoftInputMode="adjustResize"
android:exported="true">
<intent-filter>
@@ -48,7 +45,7 @@
android:name="net.helcel.cowspent.android.account.AccountActivity"
android:label="@string/settings_server_settings"
android:parentActivityName="net.helcel.cowspent.android.settings.PreferencesActivity"
android:windowSoftInputMode="adjustResize|stateVisible"
android:windowSoftInputMode="stateVisible"
android:launchMode="standard"
/>
@@ -69,7 +66,7 @@
android:name="net.helcel.cowspent.android.project.create.NewProjectActivity"
android:label="@string/simple_add_project"
android:parentActivityName="net.helcel.cowspent.android.main.BillsListViewActivity"
android:windowSoftInputMode="adjustResize"
android:windowSoftInputMode="stateHidden"
android:launchMode="standard"
android:exported="true">
<intent-filter>
@@ -96,7 +93,7 @@
android:name="net.helcel.cowspent.android.project.edit.EditProjectActivity"
android:label="@string/simple_edit_project"
android:parentActivityName="net.helcel.cowspent.android.main.BillsListViewActivity"
android:windowSoftInputMode="adjustResize"
android:windowSoftInputMode="stateHidden"
android:launchMode="standard">
</activity>
@@ -104,7 +101,7 @@
android:name="net.helcel.cowspent.android.bill_edit.EditBillActivity"
android:label="@string/simple_edit_bill"
android:parentActivityName="net.helcel.cowspent.android.main.BillsListViewActivity"
android:windowSoftInputMode="adjustResize"
android:windowSoftInputMode="stateHidden"
android:launchMode="standard">
</activity>
@@ -1,7 +1,6 @@
package net.helcel.cowspent.android.about
import android.os.Bundle
import androidx.activity.enableEdgeToEdge
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import net.helcel.cowspent.theme.ThemeUtils
@@ -9,7 +8,6 @@ import net.helcel.cowspent.theme.ThemeUtils
class AboutActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContent {
@@ -13,7 +13,6 @@ import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Toast
import androidx.activity.OnBackPressedCallback
import androidx.activity.enableEdgeToEdge
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
@@ -74,7 +73,6 @@ class AccountActivity : AppCompatActivity() {
private var showLoginDialog by mutableStateOf(false)
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
preferences = PreferenceManager.getDefaultSharedPreferences(applicationContext)
@@ -5,7 +5,6 @@ import android.app.TimePickerDialog
import android.content.Intent
import android.os.Bundle
import androidx.activity.OnBackPressedCallback
import androidx.activity.enableEdgeToEdge
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
@@ -27,7 +26,6 @@ import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.theme.ThemeUtils
import net.helcel.cowspent.util.BillParser
import net.helcel.cowspent.util.CategoryUtils
import net.helcel.cowspent.util.SupportUtil
import java.text.ParseException
import java.time.ZoneId
import java.util.Calendar
@@ -41,7 +39,6 @@ class EditBillActivity : AppCompatActivity() {
private val calendar = Calendar.getInstance()
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
db = CowspentSQLiteOpenHelper.getInstance(this)
@@ -108,8 +105,7 @@ 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,
onDelete = if (bill.id > 0) { { deleteBillAsked() } } else null,
accessLevel = db.getProject(bill.projectId)?.myAccessLevel ?: DBProject.ACCESS_LEVEL_ADMIN
)
}
@@ -193,7 +189,6 @@ class EditBillActivity : AppCompatActivity() {
withContext(Dispatchers.Main) {
viewModel.currencies = currencies
viewModel.mainCurrencyName = project?.currencyName ?: ""
viewModel.isNewBill = (bill.id == 0L)
viewModel.initFromBill(bill, members, customSplits)
}
}
@@ -209,7 +204,6 @@ class EditBillActivity : AppCompatActivity() {
calendar.timeInMillis = austrianBill.date.time
viewModel.timestamp = calendar.timeInMillis / 1000
viewModel.amount = austrianBill.amount.toString()
viewModel.updateSplits()
return@registerForActivityResult
} catch (_: ParseException) {
}
@@ -221,7 +215,6 @@ class EditBillActivity : AppCompatActivity() {
viewModel.timestamp = calendar.timeInMillis / 1000
}
viewModel.amount = croatianBill.amount.toString()
viewModel.updateSplits()
return@registerForActivityResult
} catch (_: ParseException) {
}
@@ -230,20 +223,6 @@ class EditBillActivity : AppCompatActivity() {
}
}
private fun duplicateCurrentBill() {
bill = DBBill(
0, 0, bill.projectId, viewModel.payerId, viewModel.amountAsDouble,
System.currentTimeMillis() / 1000, viewModel.what, DBBill.STATE_ADDED,
viewModel.repeat, bill.paymentMode, viewModel.categoryRemoteId,
viewModel.getFinalComment(), viewModel.paymentModeRemoteId
)
calendar.timeInMillis = System.currentTimeMillis()
viewModel.timestamp = calendar.timeInMillis / 1000
viewModel.isNewBill = true
showToast(this, "Duplicating bill...")
}
private fun onBack() {
if (!valuesHaveChanged()) {
finish()
@@ -317,9 +296,9 @@ class EditBillActivity : AppCompatActivity() {
return !(bill.what == viewModel.what &&
bill.timestamp == viewModel.timestamp &&
bill.amount == viewModel.getFinalAmount() &&
bill.amount == viewModel.amountAsDouble &&
bill.payerId == viewModel.payerId &&
bill.comment == viewModel.getFinalComment() &&
bill.comment == viewModel.comment &&
bill.repeat == viewModel.repeat &&
bill.categoryRemoteId == viewModel.categoryRemoteId &&
bill.paymentModeRemoteId == viewModel.paymentModeRemoteId &&
@@ -331,17 +310,13 @@ class EditBillActivity : AppCompatActivity() {
val isCustomSplit = viewModel.isCustomSplit
if (isCustomSplit) {
val splits: Map<Long, Double> = viewModel.owersCustomSplit.filter { (id, amountStr) ->
viewModel.owersSelection[id] == true && (amountStr.replace(',', '.').toDoubleOrNull()
val splits = viewModel.owersCustomSplit.filter { (id, amount) ->
viewModel.owersSelection[id] == true && (amount.replace(',', '.').toDoubleOrNull()
?: 0.0) > 0
}.mapValues {
val uiAmount = it.value.replace(',', '.').toDoubleOrNull() ?: 0.0
SupportUtil.round2(uiAmount / viewModel.selectedCurrencyRate)
}
}.mapValues { it.value.replace(',', '.').toDoubleOrNull() ?: 0.0 }
if (splits.isEmpty()) return@withContext 0L
val finalComment = viewModel.getFinalComment()
val splitEntries = splits.entries.toList()
// Pool of existing bills in this group that we can potentially reuse
@@ -383,7 +358,7 @@ class EditBillActivity : AppCompatActivity() {
existingBill.paymentMode,
viewModel.paymentModeRemoteId,
viewModel.categoryRemoteId,
finalComment
viewModel.comment
)
if (firstSavedId == 0L) firstSavedId = billToUseId
} else {
@@ -391,7 +366,7 @@ class EditBillActivity : AppCompatActivity() {
val newBill = DBBill(
0, 0, bill.projectId, viewModel.payerId, amount,
viewModel.timestamp, viewModel.what, DBBill.STATE_ADDED, viewModel.repeat,
bill.paymentMode, viewModel.categoryRemoteId, finalComment, viewModel.paymentModeRemoteId
bill.paymentMode, viewModel.categoryRemoteId, viewModel.comment, viewModel.paymentModeRemoteId
)
newBill.billOwers = listOf(DBBillOwer(0, 0, memberId))
val newId = db.addBill(newBill)
@@ -421,8 +396,7 @@ class EditBillActivity : AppCompatActivity() {
return@withContext firstSavedId
} else {
val newAmount = viewModel.getFinalAmount()
val finalComment = viewModel.getFinalComment()
val newAmount = viewModel.amountAsDouble
val newOwersIds = viewModel.getOwersIds()
if (bill.id != 0L) {
@@ -438,7 +412,7 @@ class EditBillActivity : AppCompatActivity() {
bill.paymentMode,
viewModel.paymentModeRemoteId,
viewModel.categoryRemoteId,
finalComment
viewModel.comment
)
if (groupedBillIds != null) {
for (id in groupedBillIds) {
@@ -455,7 +429,7 @@ class EditBillActivity : AppCompatActivity() {
val newBill = DBBill(
0, 0, bill.projectId, viewModel.payerId, newAmount,
viewModel.timestamp, viewModel.what, DBBill.STATE_ADDED, viewModel.repeat,
bill.paymentMode, viewModel.categoryRemoteId, finalComment, viewModel.paymentModeRemoteId
bill.paymentMode, viewModel.categoryRemoteId, viewModel.comment, viewModel.paymentModeRemoteId
)
newOwersIds.forEach { newBill.billOwers += DBBillOwer(0, 0, it) }
val newBillId = db.addBill(newBill)
@@ -45,7 +45,6 @@ fun EditBillScreen(
onDateClick: () -> Unit,
onTimeClick: () -> Unit,
onScan: () -> Unit,
onDuplicate: (() -> Unit)? = null,
onDelete: (() -> Unit)? = null,
accessLevel: Int = DBProject.ACCESS_LEVEL_ADMIN
) {
@@ -60,7 +59,7 @@ fun EditBillScreen(
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(if (viewModel.isNewBill) R.string.simple_new_bill else R.string.simple_edit_bill)) },
title = { Text(stringResource(if (viewModel.what.isEmpty()) R.string.simple_new_bill else R.string.simple_edit_bill)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
@@ -71,11 +70,6 @@ fun EditBillScreen(
IconButton(onClick = onScan) {
Icon(Icons.Default.QrCodeScanner, contentDescription = null)
}
if (onDuplicate != null) {
IconButton(onClick = onDuplicate) {
Icon(Icons.Default.ContentCopy, contentDescription = "Duplicate")
}
}
if (onDelete != null) {
IconButton(onClick = onDelete) {
Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.action_delete))
@@ -166,6 +160,7 @@ fun BillBasicInfoSection(
val context = LocalContext.current
val currencyDialogTitle =
stringResource(R.string.currency_dialog_title, viewModel.mainCurrencyName)
val noCurrencyError = stringResource(R.string.no_currency_error)
OutlinedTextField(
value = viewModel.what,
@@ -187,35 +182,25 @@ fun BillBasicInfoSection(
enabled = canEdit,
placeholder = { Text("0") },
modifier = Modifier.fillMaxWidth(),
leadingIcon = {
val currencyToShow = viewModel.selectedCurrencyName.ifEmpty { viewModel.mainCurrencyName }
TextIconDisplay(
textIcon = TextIcon.Symbol(currencyToShow),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
)
},
leadingIcon = { Icon(Icons.Default.AttachMoney, contentDescription = null) },
trailingIcon = {
IconButton(
enabled = canEdit,
onClick = {
val mainLabel = viewModel.mainCurrencyName.ifEmpty { "Main" }
val options = listOf("$mainLabel | Base") + viewModel.currencies.map {
"${it.name} | 1 $mainLabel = ${it.exchangeRate} ${it.name}"
}
viewModel.showDialog(
title = currencyDialogTitle,
items = options,
onItemSelected = { index ->
if (index == 0) {
viewModel.resetCurrency()
} else {
viewModel.convertCurrency(viewModel.currencies[index - 1])
if (viewModel.currencies.isNotEmpty()) {
viewModel.showDialog(
title = currencyDialogTitle,
items = viewModel.currencies.map { "${it.name} (${it.exchangeRate})" },
onItemSelected = { index ->
viewModel.convertCurrency(viewModel.currencies[index])
}
}
)
)
} else {
showToast(context, noCurrencyError)
}
}
) {
Icon(Icons.Default.SwapHoriz, contentDescription = "Change Currency")
Icon(Icons.Default.CurrencyExchange, contentDescription = null)
}
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
@@ -236,7 +221,7 @@ fun BillBasicInfoSection(
onClick = onDateClick,
modifier = Modifier.weight(1f),
enabled = canEdit,
leadingIcon = { Icon(Icons.Default.Event, contentDescription = "Select Date") }
leadingIcon = { Icon(Icons.Default.Event, contentDescription = null) }
)
Spacer(modifier = Modifier.width(8.dp))
ClickableOutlinedTextField(
@@ -244,7 +229,7 @@ fun BillBasicInfoSection(
onClick = onTimeClick,
modifier = Modifier.weight(1f),
enabled = canEdit,
leadingIcon = { Icon(Icons.Default.AccessTime, contentDescription = "Select Time") }
leadingIcon = { Icon(Icons.Default.AccessTime, contentDescription = null) }
)
}
}
@@ -267,8 +252,12 @@ fun PayerSection(
leadingIcon = {
Box(modifier = Modifier.padding(start = 12.dp)) {
if (selectedPayer != null) {
MemberAvatar(
member = selectedPayer,
UserAvatar(
name = selectedPayer.name,
r = selectedPayer.r,
g = selectedPayer.g,
b = selectedPayer.b,
disabled = !selectedPayer.isActivated,
size = 24.dp
)
} else {
@@ -282,8 +271,12 @@ fun PayerSection(
viewModel.payerId = member.id
payerExpanded = false
}) {
MemberAvatar(
member = member,
UserAvatar(
name = member.name,
r = member.r,
g = member.g,
b = member.b,
disabled = !member.isActivated,
size = 24.dp
)
Spacer(modifier = Modifier.width(8.dp))
@@ -388,8 +381,12 @@ fun OwerSelectionSection(
enabled = canEdit,
onCheckedChange = { viewModel.toggleMember(member.id, it) }
)
MemberAvatar(
member = member,
UserAvatar(
name = member.name,
r = member.r,
g = member.g,
b = member.b,
disabled = !member.isActivated,
size = 32.dp
)
Spacer(modifier = Modifier.width(8.dp))
@@ -590,7 +587,6 @@ fun EditBillScreenPreview() {
viewModel = EditBillViewModel().apply {
what = "Pizza"
amount = "12.50"
mainCurrencyName = "EUR"
members = listOf(
DBMember(1, 0, 0, "Alice", true, 1.0, 0, null, null, null, null, null),
DBMember(2, 0, 0, "Bob", true, 1.0, 0, null, null, null, null, null)
@@ -25,12 +25,9 @@ class EditBillViewModel : ViewModel() {
var repeat by mutableStateOf(DBBill.NON_REPEATED)
var paymentModeRemoteId by mutableIntStateOf(0)
var categoryRemoteId by mutableIntStateOf(0)
var isNewBill by mutableStateOf(false)
var currencies by mutableStateOf<List<DBCurrency>>(emptyList())
var mainCurrencyName by mutableStateOf("")
var selectedCurrencyName by mutableStateOf("")
var selectedCurrencyRate by mutableStateOf(1.0)
var members by mutableStateOf<List<DBMember>>(emptyList())
var owersSelection = mutableStateMapOf<Long, Boolean>()
@@ -131,81 +128,46 @@ class EditBillViewModel : ViewModel() {
}
fun convertCurrency(currency: DBCurrency) {
selectedCurrencyName = currency.name ?: ""
selectedCurrencyRate = currency.exchangeRate
// We don't change 'amount' here anymore as per request.
// It stays as the original amount.
updateSplits()
}
val originalAmountStr = amount
val originalAmount = amountAsDouble
if (originalAmount == 0.0) return
fun resetCurrency() {
selectedCurrencyName = ""
selectedCurrencyRate = 1.0
updateSplits()
}
val newAmount = originalAmount / currency.exchangeRate
amount = SupportUtil.round2(newAmount).toString()
fun getFinalAmount(): Double {
return SupportUtil.round2(amountAsDouble / selectedCurrencyRate)
}
fun getFinalComment(): String {
var baseComment = comment
val regex = "\\s*#[^:]+:[\\d.,]+@[\\d.,]+".toRegex()
baseComment = baseComment.replace(regex, "").trim()
return if (selectedCurrencyName.isNotEmpty() && selectedCurrencyRate != 1.0) {
val metadata = "#$selectedCurrencyName:$amount@$selectedCurrencyRate"
if (baseComment.isEmpty()) metadata else "$baseComment $metadata"
} else {
baseComment
val currencyLabel = currency.name ?: ""
val conversionNote = "($originalAmountStr $currencyLabel)"
if (!comment.contains(conversionNote)) {
if (comment.isNotEmpty() && !comment.endsWith(" ")) {
comment += " "
}
comment += conversionNote
}
if (isCustomSplit) {
owersCustomSplit.keys.toList().forEach { id ->
val value = owersCustomSplit[id] ?: ""
val partAmount = value.replace(',', '.').toDoubleOrNull() ?: 0.0
if (partAmount != 0.0) {
val newPartAmount = partAmount / currency.exchangeRate
owersCustomSplit[id] = SupportUtil.round2(newPartAmount).toString()
}
}
}
updateSplits()
}
fun initFromBill(bill: DBBill, members: List<DBMember>, customSplits: Map<Long, Double>? = null) {
this.members = members
what = bill.what
amount = if (bill.amount == 0.0) "" else bill.amount.toString()
comment = bill.comment ?: ""
timestamp = bill.timestamp
payerId = bill.payerId
repeat = bill.repeat ?: DBBill.NON_REPEATED
paymentModeRemoteId = bill.paymentModeRemoteId
categoryRemoteId = bill.categoryRemoteId
val rawComment = bill.comment ?: ""
// Try to parse existing conversion metadata #CURR:ORIG@RATE
// Using [^:]+ for currency name to support symbols and names with spaces
val regex = "#([^:]+):([\\d.,]+)@([\\d.,]+)".toRegex()
val match = regex.find(rawComment)
if (match != null) {
val (currName, origAmount, rate) = match.destructured
selectedCurrencyName = currName
selectedCurrencyRate = rate.replace(',', '.').toDoubleOrNull() ?: 1.0
amount = origAmount
comment = rawComment.replace(match.value, "").trim()
// Check if latest rate is different
val latestCurrency = currencies.find { it.name == selectedCurrencyName }
if (latestCurrency != null && latestCurrency.exchangeRate != selectedCurrencyRate) {
showDialog(
title = "Update Exchange Rate?",
message = "The exchange rate for $selectedCurrencyName has changed from $selectedCurrencyRate to ${latestCurrency.exchangeRate}. Do you want to update the conversion for the saved total?",
positiveText = "Update Rate",
negativeText = "Keep Old",
onConfirm = {
selectedCurrencyRate = latestCurrency.exchangeRate
updateSplits()
}
)
}
} else {
selectedCurrencyName = ""
selectedCurrencyRate = 1.0
amount = if (bill.amount == 0.0) "" else bill.amount.toString()
comment = rawComment
}
owersSelection.clear()
owersCustomSplit.clear()
@@ -216,23 +178,13 @@ class EditBillViewModel : ViewModel() {
val selected = customSplits.containsKey(member.id)
owersSelection[member.id] = selected
if (selected) {
// If we have metadata, the custom splits from DB are also converted.
// We should show them as "Original" if possible?
// Actually, if we use metadata, we should probably store original splits too,
// but for now let's just reverse the rate for display.
val dbPart = customSplits[member.id]!!
val uiPart = if (selectedCurrencyRate != 1.0) dbPart * selectedCurrencyRate else dbPart
owersCustomSplit[member.id] = SupportUtil.round2(uiPart).toString()
owersCustomSplit[member.id] = SupportUtil.round2(customSplits[member.id]!!).toString()
}
}
} else {
// Even split logic
val billOwerIds = bill.billOwersIds
val selectedCount = billOwerIds.size
// Use UI amount for even split calculation
val uiAmount = amountAsDouble
val evenSplit = if (selectedCount > 0) uiAmount / selectedCount else 0.0
val evenSplit = if (selectedCount > 0) bill.amount / selectedCount else 0.0
val evenSplitStr = if (evenSplit == 0.0) "" else SupportUtil.round2(evenSplit).toString()
for (member in members) {
@@ -3,7 +3,6 @@ package net.helcel.cowspent.android.bill_label
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.enableEdgeToEdge
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
@@ -21,7 +20,6 @@ class LabelBillsActivity : AppCompatActivity() {
private val viewModel: LabelBillsViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
val projectId = intent.getLongExtra(EXTRA_PROJECT_ID, -1L)
@@ -3,7 +3,6 @@ package net.helcel.cowspent.android.currencies
import android.os.Bundle
import android.util.Log
import android.widget.Toast
import androidx.activity.enableEdgeToEdge
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
@@ -15,7 +14,6 @@ import net.helcel.cowspent.R
import net.helcel.cowspent.android.helper.showToast
import net.helcel.cowspent.model.DBBill
import net.helcel.cowspent.model.DBCurrency
import net.helcel.cowspent.model.ProjectType
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.theme.ThemeUtils
import net.helcel.cowspent.util.ICallback
@@ -42,7 +40,6 @@ class ManageCurrenciesActivity : AppCompatActivity() {
}
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
intent.extras?.let {
@@ -87,17 +84,11 @@ class ManageCurrenciesActivity : AppCompatActivity() {
val project = db!!.getProject(selectedProjectID)
if (project != null) {
db!!.syncIfRemote(project)
if (project.type == ProjectType.COSPEND) {
withContext(Dispatchers.Main) {
if (!db!!.cowspentServerSyncHelper
.editRemoteProject(selectedProjectID, project.name, null, null, newMainCurrencyName, editMainCurrencyCallBack)
) {
showToast(this@ManageCurrenciesActivity, getString(R.string.remote_project_operation_no_network), Toast.LENGTH_LONG)
}
}
} else {
withContext(Dispatchers.Main) {
showToast(this@ManageCurrenciesActivity, getString(R.string.currency_saved_success), Toast.LENGTH_LONG)
withContext(Dispatchers.Main) {
if (!db!!.cowspentServerSyncHelper
.editRemoteProject(selectedProjectID, project.name, null, null, newMainCurrencyName, editMainCurrencyCallBack)
) {
showToast(this@ManageCurrenciesActivity, getString(R.string.remote_project_operation_no_network), Toast.LENGTH_LONG)
}
}
}
@@ -4,7 +4,6 @@ import android.annotation.SuppressLint
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
@@ -13,7 +12,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import net.helcel.cowspent.R
@@ -95,42 +93,19 @@ fun ManageCurrenciesScreen(
Spacer(modifier = Modifier.height(24.dp))
Text(stringResource(R.string.add_currency_title), style = MaterialTheme.typography.h6)
// Visual relationship indicator
Surface(
color = MaterialTheme.colors.primary.copy(alpha = 0.05f),
shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Text("1 ", style = MaterialTheme.typography.body1)
Text(viewModel.mainCurrencyName.ifEmpty { "Base" }, fontWeight = FontWeight.Bold)
Text(" = ", style = MaterialTheme.typography.h6)
Text(viewModel.newCurrencyRate.ifEmpty { "0.0" }, fontWeight = FontWeight.Bold, color = MaterialTheme.colors.primary)
Text(" ")
Text(viewModel.newCurrencyName.ifEmpty { "Currency" }, fontWeight = FontWeight.Bold)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = viewModel.newCurrencyName,
onValueChange = { viewModel.newCurrencyName = it },
label = { Text(stringResource(R.string.currency_edit_name)) },
modifier = Modifier.weight(1f),
placeholder = { Text("e.g. USD") }
modifier = Modifier.weight(1f)
)
Spacer(modifier = Modifier.width(8.dp))
OutlinedTextField(
value = viewModel.newCurrencyRate,
onValueChange = { viewModel.newCurrencyRate = it },
label = { Text(stringResource(R.string.currency_rate)) },
modifier = Modifier.weight(1f),
placeholder = { Text("1.0") }
modifier = Modifier.weight(1f)
)
}
Spacer(modifier = Modifier.height(8.dp))
@@ -146,7 +121,7 @@ fun ManageCurrenciesScreen(
LazyColumn(modifier = Modifier.weight(1f)) {
items(viewModel.currencies) { currency ->
CurrencyRow(currency, viewModel.mainCurrencyName, onDelete = { onDelete(currency) })
CurrencyRow(currency, onDelete = { onDelete(currency) })
}
}
}
@@ -154,47 +129,15 @@ fun ManageCurrenciesScreen(
}
@Composable
fun CurrencyRow(currency: DBCurrency, mainCurrencyName: String, onDelete: () -> Unit) {
fun CurrencyRow(currency: DBCurrency, onDelete: () -> Unit) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Table-like layout
Row(
modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "1 ",
style = MaterialTheme.typography.body1,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
)
Text(
text = mainCurrencyName,
style = MaterialTheme.typography.body1,
fontWeight = FontWeight.Bold
)
Text(
text = " = ",
style = MaterialTheme.typography.h6,
color = MaterialTheme.colors.primary,
modifier = Modifier.padding(horizontal = 8.dp)
)
Column {
Text(
text = currency.exchangeRate.toString(),
style = MaterialTheme.typography.body1,
fontWeight = FontWeight.ExtraBold
)
Text(
text = currency.name ?: "",
style = MaterialTheme.typography.caption,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
)
}
Column(modifier = Modifier.weight(1f)) {
Text(currency.name ?: "", style = MaterialTheme.typography.subtitle1)
Text("Rate: ${currency.exchangeRate}", style = MaterialTheme.typography.caption)
}
IconButton(onClick = onDelete) {
Icon(Icons.Default.Delete, contentDescription = null, tint = MaterialTheme.colors.error)
}
@@ -208,7 +151,6 @@ fun CurrencyRowPreview() {
MaterialTheme {
CurrencyRow(
currency = DBCurrency(1, 0, 0, "USD", 1.0, 0),
mainCurrencyName = "EUR",
onDelete = {}
)
}
@@ -11,7 +11,6 @@ import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
@@ -24,11 +23,9 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import net.helcel.cowspent.R
import net.helcel.cowspent.android.helper.MemberAvatar
import net.helcel.cowspent.android.helper.UserAvatar
import net.helcel.cowspent.android.helper.formatBalance
import net.helcel.cowspent.android.helper.lazyVerticalScrollbar
import net.helcel.cowspent.android.helper.TextIcon
import net.helcel.cowspent.android.helper.TextIconDisplay
import net.helcel.cowspent.model.DBMember
import net.helcel.cowspent.model.DBProject
import net.helcel.cowspent.model.ProjectType
@@ -41,7 +38,6 @@ fun Drawer(
selectedProjectId: Long,
selectedMemberId: Long?,
lastSyncText: String,
mainCurrency: String? = null,
showArchived: Boolean = false,
onProjectClick: (Long) -> Unit,
onProjectOptionsClick: (Long) -> Unit,
@@ -100,37 +96,22 @@ fun Drawer(
// Members Section
val membersState = rememberLazyListState()
val sortedMembers = remember(members, memberBalances) {
members.sortedWith(compareBy<DBMember> {
val balance = memberBalances[it.id] ?: 0.0
when {
balance > 0.01 -> 0
balance < -0.01 -> 1
else -> 2
}
}.thenBy {
val balance = memberBalances[it.id] ?: 0.0
if (balance > 0.01) -balance else balance
}.thenBy { it.name })
}
LazyColumn(
state = membersState,
modifier = Modifier
.heightIn(max = maxBottomHeight)
.lazyVerticalScrollbar(membersState)
) {
if (sortedMembers.isNotEmpty()) {
if (members.isNotEmpty()) {
item {
DrawerItem(
icon = Icons.Default.Receipt,
text = stringResource(R.string.label_all_bills),
secondaryText = mainCurrency,
selected = selectedMemberId == null,
onClick = { onMemberClick(null) }
)
}
items(sortedMembers) { member ->
items(members) { member ->
val balance = memberBalances[member.id] ?: 0.0
MemberDrawerItem(
member = member,
@@ -260,7 +241,6 @@ fun DrawerItem(
member: DBMember? = null,
text: String? = null,
balanceText: String? = null,
secondaryText: String? = null,
balanceColor: Color = Color.Unspecified,
selected: Boolean = false,
alpha: Float = 1f,
@@ -279,8 +259,13 @@ fun DrawerItem(
verticalAlignment = Alignment.CenterVertically
) {
if (member != null) {
MemberAvatar(
member = member,
UserAvatar(
name = member.name,
r = member.r,
g = member.g,
b = member.b,
avatar = member.avatar,
disabled = !member.isActivated,
size = 24.dp,
alpha = alpha
)
@@ -309,17 +294,6 @@ fun DrawerItem(
modifier = Modifier.padding(end = 8.dp)
)
}
if (secondaryText != null) {
Box(modifier = Modifier
.padding(end = 8.dp)
) {
TextIconDisplay(
textIcon = TextIcon.Symbol(secondaryText),
tint = contentColor.copy(alpha = 0.6f)
)
}
}
if (onSecondaryClick != null) {
IconButton(onClick = onSecondaryClick) {
@@ -366,7 +340,6 @@ fun DrawerPreview() {
selectedProjectId = 1,
selectedMemberId = null,
lastSyncText = "Last sync: 5 mins ago",
mainCurrency = "EUR",
onProjectClick = {},
onProjectOptionsClick = {},
onMemberClick = {},
@@ -11,7 +11,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -111,55 +110,28 @@ fun AlertDialog(
if (message != null) Spacer(Modifier.height(8.dp))
LazyColumn {
itemsIndexed(items) { index, item ->
Column {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
onItemSelected?.invoke(index)
}
.padding(vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (itemIcons != null && index < itemIcons.size) {
Icon(
itemIcons[index],
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colors.primary.copy(alpha = 0.7f)
)
Spacer(Modifier.width(16.dp))
}
val text = item.toString()
if (text.contains("|")) {
val parts = text.split("|")
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = parts[0].trim(),
style = MaterialTheme.typography.body1,
fontWeight = FontWeight.Bold
)
Text(
text = parts[1].trim(),
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
)
}
} else {
Text(
text = text,
style = MaterialTheme.typography.body1
)
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
onItemSelected?.invoke(index)
}
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (itemIcons != null && index < itemIcons.size) {
Icon(
itemIcons[index],
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colors.primary.copy(alpha = 0.7f)
)
Spacer(Modifier.width(16.dp))
}
if (index < items.size - 1) {
Divider(color = MaterialTheme.colors.onSurface.copy(alpha = 0.08f))
}
Text(
text = item.toString(),
style = MaterialTheme.typography.body1
)
}
}
}
@@ -244,55 +216,28 @@ fun AlertDialogContent(
if (message != null) Spacer(Modifier.height(8.dp))
LazyColumn {
itemsIndexed(items) { index, item ->
Column {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
onItemSelected?.invoke(index)
}
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (itemIcons != null && index < itemIcons.size) {
Icon(
itemIcons[index],
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colors.primary.copy(alpha = 0.7f)
)
Spacer(Modifier.width(16.dp))
}
val text = item.toString()
if (text.contains("|")) {
val parts = text.split("|")
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = parts[0].trim(),
style = MaterialTheme.typography.body1,
fontWeight = FontWeight.Bold
)
Text(
text = parts[1].trim(),
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
)
}
} else {
Text(
text = text,
style = MaterialTheme.typography.body1
)
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
onItemSelected?.invoke(index)
}
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (itemIcons != null && index < itemIcons.size) {
Icon(
itemIcons[index],
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colors.primary.copy(alpha = 0.7f)
)
Spacer(Modifier.width(16.dp))
}
if (index < items.size - 1) {
Divider(color = MaterialTheme.colors.onSurface.copy(alpha = 0.08f))
}
Text(
text = item.toString(),
style = MaterialTheme.typography.body1
)
}
}
}
@@ -1,33 +0,0 @@
package net.helcel.cowspent.android.helper
import androidx.compose.material.LocalContentColor
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
sealed class TextIcon {
data class Symbol(val text: String) : TextIcon()
}
/**
* A helper Composable to display a [TextIcon].
*/
@Composable
fun TextIconDisplay(
textIcon: TextIcon,
modifier: Modifier = Modifier,
tint: Color = LocalContentColor.current
) {
when (textIcon) {
is TextIcon.Symbol -> Text(
text = textIcon.text,
modifier = modifier,
color = tint,
style = MaterialTheme.typography.body1,
fontWeight = FontWeight.Bold
)
}
}
@@ -7,7 +7,6 @@ import android.os.Bundle
import android.os.VibrationEffect
import android.os.Vibrator
import android.util.Log
import androidx.activity.enableEdgeToEdge
import androidx.activity.compose.setContent
import androidx.annotation.OptIn
import androidx.appcompat.app.AppCompatActivity
@@ -43,7 +42,6 @@ class QrCodeScannerActivity : AppCompatActivity() {
private var isScanning = true
override fun onCreate(state: Bundle?) {
enableEdgeToEdge()
super.onCreate(state)
cameraExecutor = Executors.newSingleThreadExecutor()
@@ -21,7 +21,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import android.graphics.BitmapFactory
import android.util.Base64
import net.helcel.cowspent.model.DBMember
@Composable
fun UserAvatar(
@@ -92,26 +91,6 @@ fun UserAvatar(
}
}
@Composable
fun MemberAvatar(
member: DBMember,
modifier: Modifier = Modifier,
size: Dp = 40.dp,
alpha: Float = 1f
) {
UserAvatar(
name = member.name,
modifier = modifier,
size = size,
r = member.r,
g = member.g,
b = member.b,
avatar = member.avatar,
disabled = !member.isActivated,
alpha = alpha
)
}
@Preview
@Composable
fun UserAvatarPreview() {
@@ -16,7 +16,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import net.helcel.cowspent.R
import net.helcel.cowspent.android.helper.MemberAvatar
import net.helcel.cowspent.android.helper.UserAvatar
import net.helcel.cowspent.model.DBBill
import net.helcel.cowspent.model.DBMember
import net.helcel.cowspent.util.SupportUtil
@@ -83,8 +83,13 @@ fun BillItemRow(bill: DBBill, payer: DBMember?, onClick: () -> Unit) {
) {
if (payer != null) {
Box {
MemberAvatar(
member = payer,
UserAvatar(
name = payer.name,
r = payer.r,
g = payer.g,
b = payer.b,
avatar = payer.avatar,
disabled = !payer.isActivated,
size = 40.dp
)
if (bill.repeat != null && bill.repeat != DBBill.NON_REPEATED) {
@@ -430,7 +430,6 @@ fun BillsListScreen(
}
},
drawerContent = {
val selectedProject = viewModel.projects.find { it.id == viewModel.selectedProjectId }
Drawer(
projects = viewModel.projects,
members = viewModel.members,
@@ -438,7 +437,6 @@ fun BillsListScreen(
selectedProjectId = viewModel.selectedProjectId,
selectedMemberId = viewModel.selectedMemberId,
lastSyncText = viewModel.lastSyncText,
mainCurrency = selectedProject?.currencyName,
showArchived = showArchived,
onProjectClick = {
viewModel.selectedMemberId = null
@@ -17,7 +17,6 @@ import android.util.Log
import android.widget.Toast
import androidx.activity.OnBackPressedCallback
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
@@ -194,7 +193,6 @@ class BillsListViewActivity :
}
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
isActivityVisible = true
if (savedInstanceState != null) {
@@ -444,11 +442,11 @@ class BillsListViewActivity :
fun onManageCurrenciesClick(projectId: Long) {
lifecycleScope.launch {
val proj = withContext(Dispatchers.IO) { db.getProject(projectId) }
if (proj != null) {
if (proj != null && proj.type == ProjectType.COSPEND) {
startActivity(Intent(applicationContext, ManageCurrenciesActivity::class.java).apply {
putExtra(ManageCurrenciesActivity.EXTRA_PROJECT_ID, projectId)
})
}
} else showToast(this@BillsListViewActivity, getString(R.string.currency_management_unavailable))
}
}
@@ -5,7 +5,6 @@ import android.net.Uri
import android.os.Bundle
import android.util.Patterns
import android.widget.Toast
import androidx.activity.enableEdgeToEdge
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
@@ -21,7 +20,6 @@ import net.helcel.cowspent.model.*
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.theme.ThemeUtils
import net.helcel.cowspent.util.*
import java.util.Locale
import androidx.core.net.toUri
import androidx.core.content.edit
import net.helcel.cowspent.model.ProjectType
@@ -35,7 +33,6 @@ class NewProjectActivity : AppCompatActivity() {
private lateinit var db: CowspentSQLiteOpenHelper
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
db = CowspentSQLiteOpenHelper.getInstance(this)
@@ -61,8 +58,7 @@ class NewProjectActivity : AppCompatActivity() {
chooseFromNextcloud()
},
onOkPressed = { onPressOk() },
onBack = { finish() },
onFieldsChanged = { updateAuthStatus() }
onBack = { finish() }
)
}
}
@@ -87,8 +83,6 @@ class NewProjectActivity : AppCompatActivity() {
viewModel.projectUrl = defaultNcUrl
}
}
updateAuthStatus()
val defaultProjectId = intent.getStringExtra(PARAM_DEFAULT_PROJECT_ID)
if (defaultProjectId != null) {
@@ -143,17 +137,6 @@ class NewProjectActivity : AppCompatActivity() {
}
}
private fun updateAuthStatus() {
val url = getFormattedUrl()
val fakeProj = DBProject(
0, "", "", "", url,
"", 0L, viewModel.projectType, 0L,
null, false, DBProject.ACCESS_LEVEL_UNKNOWN,
""
)
viewModel.isAuthenticatedAccount = db.cowspentServerSyncHelper.canCreateAuthenticatedProject(fakeProj)
}
private fun onPressOk() {
val type = viewModel.projectType
val todoCreate = viewModel.whatTodoIsCreate
@@ -201,16 +184,7 @@ class NewProjectActivity : AppCompatActivity() {
}
private fun createProject() {
val url = getFormattedUrl()
val isCospendScheme = isCospendSchemeLink(url)
if (viewModel.whatTodoIsCreate && viewModel.projectId.isEmpty() && viewModel.projectName.isNotEmpty()) {
viewModel.projectId = viewModel.projectName.lowercase(Locale.ROOT)
.replace("[^a-z0-9]".toRegex(), "-")
.replace("-+".toRegex(), "-")
.trim('-')
}
val isCospendScheme = isCospendSchemeLink(getFormattedUrl())
val rid = viewModel.projectId
if (!isCospendScheme && (rid == "" || rid.contains(",") || rid.contains("/"))) {
showToast(getString(R.string.error_invalid_project_remote_id), Toast.LENGTH_LONG)
@@ -218,7 +192,7 @@ class NewProjectActivity : AppCompatActivity() {
}
if (viewModel.projectType != ProjectType.LOCAL && !isCospendScheme) {
if (!isValidUrl(url)) {
if (!isValidUrl(getFormattedUrl())) {
showToast("Invalid URL", Toast.LENGTH_SHORT)
return
}
@@ -241,16 +215,7 @@ class NewProjectActivity : AppCompatActivity() {
showToast("Invalid project name", Toast.LENGTH_SHORT)
return
}
val fakeProj = DBProject(
0, rid, "", viewModel.projectName, url,
viewModel.projectEmail, 0L, viewModel.projectType, 0L,
null, false, DBProject.ACCESS_LEVEL_UNKNOWN,
""
)
val isAuthenticated = db.cowspentServerSyncHelper.canCreateAuthenticatedProject(fakeProj)
if (!isAuthenticated && !SupportUtil.isValidEmail(viewModel.projectEmail)) {
if (!SupportUtil.isValidEmail(viewModel.projectEmail)) {
showToast("Invalid email", Toast.LENGTH_SHORT)
return
}
@@ -259,7 +224,7 @@ class NewProjectActivity : AppCompatActivity() {
if (!db.cowspentServerSyncHelper.createRemoteProject(
viewModel.projectId, viewModel.projectName,
viewModel.projectEmail, viewModel.projectPassword, url, viewModel.projectType, createRemoteCallBack
viewModel.projectEmail, viewModel.projectPassword, getFormattedUrl(), viewModel.projectType, createRemoteCallBack
)
) {
viewModel.isCreatingRemoteProject = false
@@ -32,13 +32,8 @@ fun NewProjectScreen(
onImportFile: () -> Unit,
onChooseFromNextcloud: () -> Unit,
onOkPressed: () -> Unit,
onBack: () -> Unit,
onFieldsChanged: () -> Unit
onBack: () -> Unit
) {
LaunchedEffect(viewModel.projectUrl, viewModel.projectType) {
onFieldsChanged()
}
Scaffold(
topBar = {
TopAppBar(
@@ -167,18 +162,16 @@ fun NewProjectScreen(
Spacer(modifier = Modifier.height(8.dp))
}
if (!viewModel.whatTodoIsCreate || !viewModel.isAuthenticatedAccount || viewModel.projectType != ProjectType.COSPEND) {
OutlinedTextField(
value = viewModel.projectId,
onValueChange = { viewModel.projectId = it },
label = { Text(stringResource(R.string.setting_project_id)) },
modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.AutoMirrored.Filled.LibraryBooks, contentDescription = null) }
)
Spacer(modifier = Modifier.height(8.dp))
}
OutlinedTextField(
value = viewModel.projectId,
onValueChange = { viewModel.projectId = it },
label = { Text(stringResource(R.string.setting_project_id)) },
modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.AutoMirrored.Filled.LibraryBooks, contentDescription = null) }
)
if (viewModel.projectType != ProjectType.LOCAL && (!viewModel.whatTodoIsCreate || viewModel.projectType == ProjectType.IHATEMONEY)) {
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = viewModel.projectPassword,
onValueChange = { viewModel.projectPassword = it },
@@ -186,10 +179,10 @@ fun NewProjectScreen(
modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) }
)
Spacer(modifier = Modifier.height(8.dp))
}
if (viewModel.whatTodoIsCreate && viewModel.projectType != ProjectType.LOCAL) {
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = viewModel.projectName,
onValueChange = { viewModel.projectName = it },
@@ -197,17 +190,14 @@ fun NewProjectScreen(
modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.Default.Title, contentDescription = null) }
)
if (!viewModel.isAuthenticatedAccount || viewModel.projectType == ProjectType.IHATEMONEY) {
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = viewModel.projectEmail,
onValueChange = { viewModel.projectEmail = it },
label = { Text(stringResource(R.string.setting_new_project_email)) },
modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.Default.Email, contentDescription = null) }
)
}
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = viewModel.projectEmail,
onValueChange = { viewModel.projectEmail = it },
label = { Text(stringResource(R.string.setting_new_project_email)) },
modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.Default.Email, contentDescription = null) }
)
}
}
}
@@ -344,7 +334,6 @@ fun NewProjectScreenPreview() {
onImportFile = {},
onChooseFromNextcloud = {},
onOkPressed = {},
onBack = {},
onFieldsChanged = {}
onBack = {}
)
}
@@ -30,8 +30,6 @@ class NewProjectViewModel : ViewModel() {
var projectName by mutableStateOf("")
var projectEmail by mutableStateOf("")
var isAuthenticatedAccount by mutableStateOf(false)
var showAuthWarningDialog by mutableStateOf(false)
var showNextcloudProjectDialog by mutableStateOf(false)
var nextcloudProjects by mutableStateOf<List<DBAccountProject>>(emptyList())
@@ -41,23 +39,12 @@ class NewProjectViewModel : ViewModel() {
fun isFormValid(): Boolean {
if (whatTodoIsCreate) {
if (projectType == ProjectType.LOCAL) {
return projectId.isNotEmpty()
} else {
if (projectId.isEmpty()) return false
if (projectType != ProjectType.LOCAL) {
if (projectUrl.isEmpty()) return false
if (projectName.isEmpty()) return false
if (projectType == ProjectType.COSPEND && isAuthenticatedAccount) {
return true
}
if (projectId.isEmpty()) return false
if (projectType == ProjectType.IHATEMONEY) {
if (projectEmail.isEmpty()) return false
if (projectPassword.isEmpty()) return false
} else {
if (projectEmail.isEmpty()) return false
}
if (projectEmail.isEmpty()) return false
if (projectType == ProjectType.IHATEMONEY && projectPassword.isEmpty()) return false
}
} else {
// Join
@@ -3,7 +3,6 @@ package net.helcel.cowspent.android.project.edit
import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import androidx.activity.enableEdgeToEdge
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
@@ -28,7 +27,6 @@ class EditProjectActivity : AppCompatActivity() {
private lateinit var project: DBProject
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
db = CowspentSQLiteOpenHelper.getInstance(this)
@@ -30,7 +30,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import net.helcel.cowspent.R
import net.helcel.cowspent.android.helper.MemberAvatar
import net.helcel.cowspent.android.helper.UserAvatar
import net.helcel.cowspent.android.helper.lazyVerticalScrollbar
import net.helcel.cowspent.model.DBMember
@@ -84,8 +84,13 @@ fun MemberManagementDialogContent(
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
MemberAvatar(
member = member,
UserAvatar(
name = member.name,
r = member.r,
g = member.g,
b = member.b,
avatar = member.avatar,
disabled = !member.isActivated,
size = 40.dp
)
Spacer(modifier = Modifier.width(16.dp))
@@ -3,7 +3,6 @@ package net.helcel.cowspent.android.settings
import android.content.Intent
import android.os.Bundle
import androidx.activity.OnBackPressedCallback
import androidx.activity.enableEdgeToEdge
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.getValue
@@ -26,7 +25,6 @@ import net.helcel.cowspent.util.ColorUtils
class PreferencesActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
@@ -1,86 +1,34 @@
package net.helcel.cowspent.android.statistics
import android.annotation.SuppressLint
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.LocalContentColor
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Group
import androidx.compose.material.icons.filled.ViewColumn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.graphics.toColorInt
import kotlinx.coroutines.launch
import androidx.compose.ui.res.stringResource
import net.helcel.cowspent.R
import net.helcel.cowspent.android.helper.EditableExposedDropdownMenu
import net.helcel.cowspent.android.helper.MemberAvatar
import net.helcel.cowspent.android.helper.formatShortValue
import net.helcel.cowspent.android.helper.*
import net.helcel.cowspent.model.DBBill
import net.helcel.cowspent.model.DBCategory
import net.helcel.cowspent.model.DBMember
import kotlin.math.abs
import kotlin.math.roundToInt
private object SankeyDimens {
val NodeHeight = 76.dp
val TotalNodeHeight = 42.dp
val NormalGap = 8.dp
val FocusGap = 16.dp
val ActiveMinWidth = 160.dp
}
import androidx.core.graphics.toColorInt
@OptIn(ExperimentalMaterialApi::class)
@SuppressLint("UseKtx")
@@ -93,11 +41,15 @@ fun ProjectSankeyDiagram(
onShareReady: (String) -> Unit
) {
val shareStatsIntro = stringResource(R.string.share_stats_intro, projectName)
var selectedMemberId by remember { mutableLongStateOf(-1L) }
var expanded by remember { mutableStateOf(false) }
val activeBills = remember(allBills) {
allBills.filter { it.state != DBBill.STATE_DELETED && it.categoryRemoteId != DBBill.CATEGORY_REIMBURSEMENT }
allBills.filter {
it.state != DBBill.STATE_DELETED &&
it.categoryRemoteId != DBBill.CATEGORY_REIMBURSEMENT
}
}
val membersMap = remember(allMembers) { allMembers.associateBy { it.id } }
@@ -110,293 +62,408 @@ fun ProjectSankeyDiagram(
return
}
// Consolidated spending calculation
val spendings = remember(activeBills, selectedMemberId, membersMap) {
val spentMap = mutableMapOf<Long, Double>().apply { membersMap.keys.forEach { put(it, 0.0) } }
val spentMap = mutableMapOf<Long, Double>()
val catMap = mutableMapOf<Int, Double>()
membersMap.keys.forEach { spentMap[it] = 0.0 }
activeBills.forEach { bill ->
val totalWeight = bill.billOwers.sumOf { membersMap[it.memberId]?.weight ?: 1.0 }
val totalWeight = bill.billOwers.sumOf { bo ->
membersMap[bo.memberId]?.weight ?: 1.0
}
if (totalWeight > 0) {
if (selectedMemberId == -1L) {
catMap[bill.categoryRemoteId] = (catMap[bill.categoryRemoteId] ?: 0.0) + bill.amount
bill.billOwers.forEach { bo ->
val weight = membersMap[bo.memberId]?.weight ?: 1.0
spentMap[bo.memberId] = (spentMap[bo.memberId] ?: 0.0) + (bill.amount / totalWeight) * weight
val share = (bill.amount / totalWeight) * weight
spentMap[bo.memberId] = (spentMap[bo.memberId] ?: 0.0) + share
}
} else {
bill.billOwers.find { it.memberId == selectedMemberId }?.let { bo ->
val weight = membersMap[bo.memberId]?.weight ?: 1.0
catMap[bill.categoryRemoteId] = (catMap[bill.categoryRemoteId] ?: 0.0) + (bill.amount / totalWeight) * weight
spentMap[selectedMemberId] = (spentMap[selectedMemberId] ?: 0.0) + (bill.amount / totalWeight) * weight
val share = (bill.amount / totalWeight) * weight
catMap[bill.categoryRemoteId] = (catMap[bill.categoryRemoteId] ?: 0.0) + share
spentMap[selectedMemberId] = (spentMap[selectedMemberId] ?: 0.0) + share
}
}
}
}
spentMap.toList().filter { it.second > 0 }.sortedByDescending { it.second } to catMap.toList().sortedByDescending { it.second }
val memberList = spentMap.toList().filter { it.second > 0 }.sortedByDescending { it.second }
val categoryList = catMap.toList().sortedByDescending { it.second }
memberList to categoryList
}
val displayMemberSpendings = spendings.first
val displayCategorySpendings = spendings.second
val totalAmount = remember(displayMemberSpendings) { displayMemberSpendings.sumOf { it.second } }
var isSpecialMode by remember { mutableStateOf(false) }
LaunchedEffect(displayMemberSpendings, displayCategorySpendings, totalAmount, projectName) {
val statsText = StringBuilder().apply {
append("// ").append(shareStatsIntro.replace("\n", "\n// ")).append("\n\n")
val middleNode = if (selectedMemberId == -1L) "Total" else "Spent"
displayMemberSpendings.forEach { (id, amount) -> append("${membersMap[id]?.name ?: "???"} [${formatShortValue(amount)}] $middleNode\n") }
append("\n")
displayCategorySpendings.forEach { (id, amount) -> append("$middleNode [${formatShortValue(amount)}] ${categoriesMap[id]?.name ?: "Other"}\n") }
append("\n")
displayMemberSpendings.forEach { (id, _) ->
membersMap[id]?.let { append(":${it.name} ${String.format("#%02x%02x%02x", it.r ?: 128, it.g ?: 128, it.b ?: 128)}\n") }
}
displayCategorySpendings.forEach { (id, _) -> categoriesMap[id]?.let { append(":${it.name ?: "Other"} ${it.color}\n") } }
val statsText = StringBuilder()
statsText.append("// ").append(shareStatsIntro.replace("\n", "\n// ")).append("\n\n")
val middleNode = if (selectedMemberId == -1L) "Total" else "Spent"
displayMemberSpendings.forEach { (memberId, amount) ->
val name = membersMap[memberId]?.name ?: "???"
statsText.append("$name [${formatShortValue(amount)}] $middleNode\n")
}
statsText.append("\n")
displayCategorySpendings.forEach { (catRemoteId, amount) ->
val name = categoriesMap[catRemoteId]?.name ?: "Other"
statsText.append("$middleNode [${formatShortValue(amount)}] $name\n")
}
statsText.append("\n")
displayMemberSpendings.forEach { (memberId, _) ->
val member = membersMap[memberId]
if (member != null) {
val hexColor = String.format("#%02x%02x%02x", member.r ?: 128, member.g ?: 128, member.b ?: 128)
statsText.append(":${member.name} $hexColor\n")
}
}
displayCategorySpendings.forEach { (catRemoteId, _) ->
val category = categoriesMap[catRemoteId]
if (category != null) {
statsText.append(":${category.name ?: "Other"} ${category.color}\n")
}
}
onShareReady(statsText.toString())
}
Column(modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.padding(16.dp)) {
val selectedMember = membersMap[selectedMemberId]
Row(verticalAlignment = Alignment.CenterVertically) {
EditableExposedDropdownMenu(
value = selectedMember?.name ?: "All Members",
placeholder = "Filter by member",
expanded = expanded,
onExpandedChange = { expanded = it },
onDismissRequest = { expanded = false },
modifier = Modifier.weight(1f).padding(bottom = 16.dp),
leadingIcon = {
Box(modifier = Modifier.padding(start = 12.dp)) {
if (selectedMember != null) {
MemberAvatar(member = selectedMember, size = 24.dp)
} else Icon(Icons.Default.Group, contentDescription = null)
}
},
content = {
DropdownMenuItem(onClick = { selectedMemberId = -1L; expanded = false }) {
Icon(Icons.Default.Group, contentDescription = null)
Spacer(modifier = Modifier.width(12.dp))
Text("All Members")
}
allMembers.forEach { member ->
DropdownMenuItem(onClick = { selectedMemberId = member.id; expanded = false }) {
MemberAvatar(member = member, size = 24.dp)
Spacer(modifier = Modifier.width(12.dp))
Text(member.name)
}
}
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
val selectedMember = membersMap[selectedMemberId]
EditableExposedDropdownMenu(
value = selectedMember?.name ?: "All Members",
placeholder = "Filter by member",
expanded = expanded,
onExpandedChange = { expanded = it },
onDismissRequest = { expanded = false },
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
leadingIcon = {
Box(modifier = Modifier.padding(start = 12.dp)) {
if (selectedMember != null) {
UserAvatar(
name = selectedMember.name,
r = selectedMember.r,
g = selectedMember.g,
b = selectedMember.b,
disabled = !selectedMember.isActivated,
size = 24.dp
)
} else {
Icon(Icons.Default.Group, contentDescription = null)
}
}
},
content = {
DropdownMenuItem(onClick = {
selectedMemberId = -1L
expanded = false
}) {
Icon(Icons.Default.Group, contentDescription = null)
Spacer(modifier = Modifier.width(12.dp))
Text("All Members")
}
allMembers.forEach { member ->
DropdownMenuItem(onClick = {
selectedMemberId = member.id
expanded = false
}) {
UserAvatar(
name = member.name,
r = member.r,
g = member.g,
b = member.b,
disabled = !member.isActivated,
size = 24.dp
)
Spacer(modifier = Modifier.width(12.dp))
Text(member.name)
}
)
IconButton(onClick = { isSpecialMode = !isSpecialMode }, modifier = Modifier.padding(bottom = 16.dp)) {
Icon(Icons.Default.ViewColumn, contentDescription = "Focus Mode", tint = if (isSpecialMode) MaterialTheme.colors.primary else LocalContentColor.current)
}
}
}
)
if (totalAmount <= 0) {
Box(modifier = Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
Text("No spending data for selected filter", style = MaterialTheme.typography.body1)
}
} else {
SankeyContent(isSpecialMode, selectedMemberId, totalAmount, displayMemberSpendings, displayCategorySpendings, membersMap, categoriesMap)
BoxWithConstraints(modifier = Modifier.weight(1f).fillMaxWidth()) {
val nodeHeightMember = 76.dp
val nodeHeightCategory = 76.dp
val nodeHeightTotal = 42.dp
val horizontalGap = 8.dp
val memberCount = displayMemberSpendings.size
val categoryCount = displayCategorySpendings.size
val maxGaps = maxOf(memberCount - 1, categoryCount - 1, 0)
val usableWidth = (maxWidth.value - horizontalGap.value * maxGaps).coerceAtLeast(0f)
val moneyScale = if (totalAmount > 0) usableWidth / totalAmount.toFloat() else 0f
Canvas(modifier = Modifier.fillMaxSize()) {
val nodeHeightMemberPx = nodeHeightMember.toPx()
val nodeHeightCategoryPx = nodeHeightCategory.toPx()
val nodeHeightTotalPx = nodeHeightTotal.toPx()
val gapPx = horizontalGap.toPx()
val totalWidthPx = size.width
val usableWidthPx = totalWidthPx - gapPx * maxGaps
val moneyScalePx = if (totalAmount > 0) usableWidthPx / totalAmount else 0.0
val totalNodeWidthPx = (totalAmount * moneyScalePx).toFloat()
val totalNodeXPx = (totalWidthPx - totalNodeWidthPx) / 2
val topY = 0f
val middleY = (size.height - nodeHeightTotalPx) / 2
val bottomY = size.height - nodeHeightCategoryPx
val totalNodeColor = Color(0xFF333333)
// Flows
var currentXTop = (totalWidthPx - (totalAmount * moneyScalePx + (memberCount - 1).coerceAtLeast(0) * gapPx).toFloat()) / 2
var currentXInTotalTop = totalNodeXPx
displayMemberSpendings.forEach { (memberId, amount) ->
val member = membersMap[memberId]
val boxWidth = (amount * moneyScalePx).toFloat()
val color = member?.let {
Color(it.r ?: 128, it.g ?: 128, it.b ?: 128)
} ?: Color.Gray
if (boxWidth > 0.5f) {
drawSankeyFlow(
startX = currentXTop,
startY = topY + nodeHeightMemberPx * 0.5f,
startWidth = boxWidth,
endX = currentXInTotalTop,
endY = middleY + nodeHeightTotalPx * 0.5f,
endWidth = boxWidth,
startColor = color.copy(alpha = 0.5f),
endColor = totalNodeColor.copy(alpha = 0.35f)
)
}
currentXTop += boxWidth + gapPx
currentXInTotalTop += boxWidth
}
var currentXBottom = (totalWidthPx - (totalAmount * moneyScalePx + (categoryCount - 1).coerceAtLeast(0) * gapPx).toFloat()) / 2
var currentXInTotalBottom = totalNodeXPx
displayCategorySpendings.forEach { (catRemoteId, amount) ->
val category = categoriesMap[catRemoteId]
val boxWidth = (amount * moneyScalePx).toFloat()
val color = category?.color?.let {
try { Color(it.toColorInt()) } catch (_: Exception) { Color(0xFF999999) }
} ?: Color(0xFF999999)
if (boxWidth > 0.5f) {
drawSankeyFlow(
startX = currentXInTotalBottom,
startY = middleY + nodeHeightTotalPx * 0.5f,
startWidth = boxWidth,
endX = currentXBottom,
endY = bottomY + nodeHeightCategoryPx * 0.5f,
endWidth = boxWidth,
startColor = totalNodeColor.copy(alpha = 0.35f),
endColor = color.copy(alpha = 0.5f)
)
}
currentXInTotalBottom += boxWidth
currentXBottom += boxWidth + gapPx
}
// Nodes
currentXTop = (totalWidthPx - (totalAmount * moneyScalePx + (memberCount - 1).coerceAtLeast(0) * gapPx).toFloat()) / 2
displayMemberSpendings.forEach { (memberId, amount) ->
val member = membersMap[memberId]
val width = (amount * moneyScalePx).toFloat()
val color = member?.let {
Color(it.r ?: 128, it.g ?: 128, it.b ?: 128)
} ?: Color.Gray
if (width > 0.5f) {
drawRoundRect(
color = color,
topLeft = Offset(currentXTop, topY),
size = Size(width, nodeHeightMemberPx),
cornerRadius = CornerRadius(10.dp.toPx())
)
}
currentXTop += width + gapPx
}
drawRect(
color = totalNodeColor,
topLeft = Offset(totalNodeXPx, middleY),
size = Size(totalNodeWidthPx, nodeHeightTotalPx)
)
currentXBottom = (totalWidthPx - (totalAmount * moneyScalePx + (categoryCount - 1).coerceAtLeast(0) * gapPx).toFloat()) / 2
displayCategorySpendings.forEach { (catRemoteId, amount) ->
val category = categoriesMap[catRemoteId]
val width = (amount * moneyScalePx).toFloat()
val color = category?.color?.let {
try { Color(it.toColorInt()) } catch (_: Exception) { Color(0xFF999999) }
} ?: Color(0xFF999999)
if (width > 0.5f) {
drawRoundRect(
color = color,
topLeft = Offset(currentXBottom, bottomY),
size = Size(width, nodeHeightCategoryPx),
cornerRadius = CornerRadius(10.dp.toPx())
)
}
currentXBottom += width + gapPx
}
}
// Member labels
var currentXTopLabel = (maxWidth.value - (totalAmount.toFloat() * moneyScale + (memberCount - 1).coerceAtLeast(0) * horizontalGap.value)) / 2
displayMemberSpendings.forEach { (memberId, amount) ->
val member = membersMap[memberId]
val widthValue = amount.toFloat() * moneyScale
val width = widthValue.dp
if (widthValue > 12f) {
Box(
modifier = Modifier
.offset(x = currentXTopLabel.dp, y = 0.dp)
.size(width, nodeHeightMember),
contentAlignment = Alignment.Center
) {
if (width >= 36.dp) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = member?.name ?: "???",
fontSize = 16.sp,
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontWeight = FontWeight.Bold
)
Text(
text = formatShortValue(amount),
fontSize = 14.sp,
color = Color.White.copy(alpha = 0.9f)
)
}
}
}
}
currentXTopLabel += widthValue + horizontalGap.value
}
// Total label
Box(
modifier = Modifier
.align(Alignment.Center)
.fillMaxWidth()
.height(nodeHeightTotal),
contentAlignment = Alignment.Center
) {
Text(
text = if (selectedMemberId == -1L) formatShortValue(totalAmount) else "SPENT: ${formatShortValue(totalAmount)}",
fontSize = 16.sp,
color = Color.White,
textAlign = TextAlign.Center,
fontWeight = FontWeight.ExtraBold
)
}
// Category labels
var currentXBottomLabel = (maxWidth.value - (totalAmount.toFloat() * moneyScale + (categoryCount - 1).coerceAtLeast(0) * horizontalGap.value)) / 2
displayCategorySpendings.forEach { (catRemoteId, amount) ->
val category = categoriesMap[catRemoteId]
val widthValue = amount.toFloat() * moneyScale
val width = widthValue.dp
if (widthValue > 12f) {
Box(
modifier = Modifier
.align(Alignment.BottomStart)
.offset(x = currentXBottomLabel.dp)
.size(width, nodeHeightCategory),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = category?.icon ?: "",
fontSize = 20.sp
)
if (width >= 32.dp) {
Text(
text = formatShortValue(amount),
fontSize = 14.sp,
color = Color.White.copy(alpha = 0.9f)
)
}
}
}
}
currentXBottomLabel += widthValue + horizontalGap.value
}
}
}
Spacer(Modifier.fillMaxWidth().height(32.dp))
}
}
private data class NodeLayout(val centerX: Float, val visualWidth: Float)
@SuppressLint("FrequentlyChangingValue")
@Composable
private fun SankeyContent(
isSpecialMode: Boolean,
selectedMemberId: Long,
totalAmount: Double,
displayMemberSpendings: List<Pair<Long, Double>>,
displayCategorySpendings: List<Pair<Int, Double>>,
membersMap: Map<Long, DBMember>,
categoriesMap: Map<Int, DBCategory>
private fun DrawScope.drawSankeyFlow(
startX: Float,
startY: Float,
startWidth: Float,
endX: Float,
endY: Float,
endWidth: Float,
startColor: Color,
endColor: Color
) {
val density = LocalDensity.current
val scope = rememberCoroutineScope()
val topFocalIndex = remember { Animatable(0f) }
val bottomFocalIndex = remember { Animatable(0f) }
var diagramAreaCoordinates by remember { mutableStateOf<LayoutCoordinates?>(null) }
var totalNodeCoordinates by remember { mutableStateOf<LayoutCoordinates?>(null) }
LaunchedEffect(isSpecialMode) {
if (!isSpecialMode) {
topFocalIndex.snapTo(0f)
bottomFocalIndex.snapTo(0f)
}
}
BoxWithConstraints(modifier = Modifier.fillMaxSize().onGloballyPositioned { diagramAreaCoordinates = it }) {
val totalMaxWidthPx = with(density) { maxWidth.toPx() }
val normalGapPx = with(density) { SankeyDimens.NormalGap.toPx() }
val focusGapPx = with(density) { SankeyDimens.FocusGap.toPx() }
val activeMinPx = with(density) { SankeyDimens.ActiveMinWidth.toPx() }
val moneyScalePx = if (totalAmount > 0) (totalMaxWidthPx - (maxOf(displayMemberSpendings.size, displayCategorySpendings.size) - 1) * normalGapPx).coerceAtLeast(0f) / totalAmount else 0.0
fun calculateRowLayout(spendings: List<Pair<*, Double>>, focalIndex: Float): List<NodeLayout> {
val visualWidths = spendings.mapIndexed { i, (_, amount) ->
val propWidthPx = (amount * moneyScalePx).toFloat()
if (!isSpecialMode) propWidthPx
else {
val focusAmount = (1f - abs(i - focalIndex)).coerceIn(0f, 1f)
maxOf(propWidthPx, activeMinPx * focusAmount)
}
}
return if (!isSpecialMode) {
val totalWidthPx = visualWidths.sum() + (spendings.size - 1).coerceAtLeast(0) * normalGapPx
var currentX = (totalMaxWidthPx - totalWidthPx) / 2
visualWidths.map { w ->
NodeLayout(currentX + w / 2, w).also { currentX += w + normalGapPx }
}
} else {
val centers = mutableListOf<Float>()
var currentX = 0f
visualWidths.forEachIndexed { i, w ->
if (i > 0) currentX += visualWidths[i - 1] / 2 + focusGapPx + w / 2
else currentX = w / 2
centers.add(currentX)
}
val focalPointX = if (spendings.isEmpty()) 0f else {
val idx = focalIndex.coerceIn(0f, (spendings.size - 1).toFloat())
val low = idx.toInt()
val high = (low + 1).coerceAtMost(spendings.size - 1)
val fract = idx - low
centers[low] * (1 - fract) + centers[high] * fract
}
centers.mapIndexed { i, c ->
NodeLayout(totalMaxWidthPx / 2 + (c - focalPointX), visualWidths[i])
}
}
}
val topLayout = calculateRowLayout(displayMemberSpendings, topFocalIndex.value)
val bottomLayout = calculateRowLayout(displayCategorySpendings, bottomFocalIndex.value)
Canvas(modifier = Modifier.fillMaxSize()) {
val root = diagramAreaCoordinates ?: return@Canvas
val totalRect = totalNodeCoordinates?.let {
val pos = it.positionInRoot() - root.positionInRoot()
Rect(pos.x, pos.y, pos.x + it.size.width, pos.y + it.size.height)
} ?: return@Canvas
val flowMoneyScalePx = totalRect.width / totalAmount
val totalNodeColor = Color(0xFF333333)
displayMemberSpendings.forEachIndexed { index, (_, amount) ->
val flowWidth = (amount * flowMoneyScalePx).toFloat()
val layout = topLayout[index]
if (flowWidth > 0.5f) {
val color = membersMap[displayMemberSpendings[index].first]?.let { Color(it.r ?: 128, it.g ?: 128, it.b ?: 128) } ?: Color.Gray
drawSankeyFlow(layout.centerX - layout.visualWidth / 2, SankeyDimens.NodeHeight.toPx() / 2, layout.visualWidth, totalRect.left + displayMemberSpendings.take(index).sumOf { it.second * flowMoneyScalePx }.toFloat(), totalRect.top + totalRect.height / 2, flowWidth, color.copy(alpha = 0.5f), totalNodeColor.copy(alpha = 0.35f))
}
}
displayCategorySpendings.forEachIndexed { index, (_, amount) ->
val flowWidth = (amount * flowMoneyScalePx).toFloat()
val layout = bottomLayout[index]
if (flowWidth > 0.5f) {
val color = categoriesMap[displayCategorySpendings[index].first]?.color?.let { try { Color(it.toColorInt()) } catch (_: Exception) { Color(0xFF999999) } } ?: Color(0xFF999999)
drawSankeyFlow(totalRect.left + displayCategorySpendings.take(index).sumOf { it.second * flowMoneyScalePx }.toFloat(), totalRect.top + totalRect.height / 2, flowWidth, layout.centerX - layout.visualWidth / 2, size.height - SankeyDimens.NodeHeight.toPx() / 2, layout.visualWidth, totalNodeColor.copy(alpha = 0.35f), color.copy(alpha = 0.5f))
}
}
}
Column(modifier = Modifier.fillMaxSize()) {
// TOP Row
Box(modifier = Modifier.fillMaxWidth().height(SankeyDimens.NodeHeight).then(if (isSpecialMode) Modifier.draggable(
orientation = Orientation.Horizontal,
state = rememberDraggableState { delta ->
scope.launch {
val currentIdx = topFocalIndex.value.roundToInt().coerceIn(displayMemberSpendings.indices)
val step = topLayout[currentIdx].visualWidth + focusGapPx
topFocalIndex.snapTo((topFocalIndex.value - delta / step).coerceIn(0f, (displayMemberSpendings.size - 1).toFloat()))
}
},
onDragStopped = {
topFocalIndex.animateTo(topFocalIndex.value.roundToInt().toFloat(), spring(Spring.DampingRatioLowBouncy, Spring.StiffnessLow))
}
) else Modifier)) {
displayMemberSpendings.forEachIndexed { index, (id, amount) ->
val member = membersMap[id]
val layout = topLayout[index]
val wDp = with(density) { layout.visualWidth.toDp() }
val xDp = with(density) { (layout.centerX - layout.visualWidth / 2).toDp() }
Box(modifier = Modifier.offset(x = xDp).width(wDp).fillMaxHeight()
.then(if (isSpecialMode) Modifier.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {
scope.launch { topFocalIndex.animateTo(index.toFloat(), spring(Spring.DampingRatioLowBouncy, Spring.StiffnessLow)) }
} else Modifier), contentAlignment = Alignment.Center) {
Column(modifier = Modifier.fillMaxSize().background(color = member?.let { Color(it.r ?: 128, it.g ?: 128, it.b ?: 128) } ?: Color.Gray, shape = RoundedCornerShape(10.dp)), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
Text(text = member?.name ?: "???", color = Color.White, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center, modifier = Modifier.wrapContentWidth(unbounded = true))
if (wDp >= 40.dp) Text(text = formatShortValue(amount), fontSize = 12.sp, color = Color.White.copy(alpha = 0.9f), textAlign = TextAlign.Center)
}
}
}
}
Box(modifier = Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
Box(modifier = Modifier.width(with(density) { (totalAmount * moneyScalePx).toFloat().toDp() }).height(SankeyDimens.TotalNodeHeight).background(Color(0xFF333333)).onGloballyPositioned { totalNodeCoordinates = it }, contentAlignment = Alignment.Center) {
Text(text = if (selectedMemberId == -1L) formatShortValue(totalAmount) else "SPENT: ${formatShortValue(totalAmount)}", color = Color.White, fontWeight = FontWeight.ExtraBold, fontSize = 16.sp, modifier = Modifier.padding(horizontal = 8.dp), maxLines = 1)
}
}
// BOTTOM Row
Box(modifier = Modifier.fillMaxWidth().height(SankeyDimens.NodeHeight).then(if (isSpecialMode) Modifier.draggable(
orientation = Orientation.Horizontal,
state = rememberDraggableState { delta ->
scope.launch {
val currentIdx = bottomFocalIndex.value.roundToInt().coerceIn(displayCategorySpendings.indices)
val step = bottomLayout[currentIdx].visualWidth + focusGapPx
bottomFocalIndex.snapTo((bottomFocalIndex.value - delta / step).coerceIn(0f, (displayCategorySpendings.size - 1).toFloat()))
}
},
onDragStopped = {
bottomFocalIndex.animateTo(bottomFocalIndex.value.roundToInt().toFloat(), spring(Spring.DampingRatioLowBouncy, Spring.StiffnessLow))
}
) else Modifier)) {
displayCategorySpendings.forEachIndexed { index, (id, amount) ->
val category = categoriesMap[id]
val layout = bottomLayout[index]
val wDp = with(density) { layout.visualWidth.toDp() }
val xDp = with(density) { (layout.centerX - layout.visualWidth / 2).toDp() }
Box(modifier = Modifier.offset(x = xDp).width(wDp).fillMaxHeight()
.then(if (isSpecialMode) Modifier.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {
scope.launch { bottomFocalIndex.animateTo(index.toFloat(), spring(Spring.DampingRatioLowBouncy, Spring.StiffnessLow)) }
} else Modifier), contentAlignment = Alignment.Center) {
Column(modifier = Modifier.fillMaxSize().background(color = category?.color?.let { try { Color(it.toColorInt()) } catch (_: Exception) { Color(0xFF999999) } } ?: Color(0xFF999999), shape = RoundedCornerShape(10.dp)), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
Text(text = category?.icon ?: "", fontSize = 20.sp, textAlign = TextAlign.Center, modifier = Modifier.wrapContentWidth(unbounded = true))
if (wDp >= 40.dp) Text(text = formatShortValue(amount), fontSize = 12.sp, color = Color.White.copy(alpha = 0.9f), textAlign = TextAlign.Center)
}
}
}
}
}
}
}
private fun DrawScope.drawSankeyFlow(startX: Float, startY: Float, startWidth: Float, endX: Float, endY: Float, endWidth: Float, startColor: Color, endColor: Color) {
val path = Path().apply {
moveTo(startX, startY)
cubicTo(startX, startY + (endY - startY) * 0.5f, endX, endY - (endY - startY) * 0.5f, endX, endY)
cubicTo(
startX, startY + (endY - startY) * 0.5f,
endX, endY - (endY - startY) * 0.5f,
endX, endY
)
lineTo(endX + endWidth, endY)
cubicTo(endX + endWidth, endY - (endY - startY) * 0.5f, startX + startWidth, startY + (endY - startY) * 0.5f, startX + startWidth, startY)
cubicTo(
endX + endWidth, endY - (endY - startY) * 0.5f,
startX + startWidth, startY + (endY - startY) * 0.5f,
startX + startWidth, startY
)
close()
}
drawPath(path = path, brush = Brush.verticalGradient(colors = listOf(startColor, endColor), startY = startY, endY = endY))
drawPath(
path = path,
brush = Brush.verticalGradient(
colors = listOf(startColor, endColor),
startY = startY,
endY = endY
)
)
}
@Preview(showBackground = true, widthDp = 360, heightDp = 640)
@Composable
fun ProjectSankeyDiagramPreview() = MaterialTheme { ProjectSankeyDiagram("Test Project", StatisticsMockData.members, StatisticsMockData.bills, emptyList()) {} }
fun ProjectSankeyDiagramPreview() {
MaterialTheme {
ProjectSankeyDiagram(
projectName = "Test Project",
allMembers = StatisticsMockData.members,
allBills = StatisticsMockData.bills,
customCategories = emptyList(),
onShareReady = {}
)
}
}
@@ -3,7 +3,6 @@ package net.helcel.cowspent.android.statistics
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.enableEdgeToEdge
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
@@ -16,7 +15,6 @@ import net.helcel.cowspent.theme.ThemeUtils
class ProjectStatisticsActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
val projectId = intent.getLongExtra(EXTRA_PROJECT_ID, -1L)
@@ -553,11 +553,7 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
}
fun getCurrencies(projectId: Long): List<DBCurrency> {
return getCurrenciesCustom(
"$key_projectid = ? AND $key_state != ?",
arrayOf(projectId.toString(), DBBill.STATE_DELETED.toString()),
null
)
return getCurrenciesCustom("$key_projectid = ?", arrayOf(projectId.toString()), null)
}
@WorkerThread
@@ -320,69 +320,48 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
}
}
if (project.type == ProjectType.COSPEND) {
val currenciesToDelete = dbHelper.getCurrenciesOfProjectWithState(project.id, DBBill.STATE_DELETED)
for (cToDel in currenciesToDelete) {
try {
val deleteRemoteCurrencyResponse = client!!.deleteRemoteCurrency(project, cToDel.remoteId)
if (deleteRemoteCurrencyResponse.stringContent == "OK") {
Log.d(TAG, "successfully deleted currency on remote project : delete it locally")
dbHelper.deleteCurrency(cToDel.id)
}
} catch (e: IOException) {
if (e.message == "\"Not Found\"") {
Log.d(TAG, "failed to delete currency on remote project : delete it locally anyway")
dbHelper.deleteCurrency(cToDel.id)
} else {
throw e
}
val currenciesToDelete = dbHelper.getCurrenciesOfProjectWithState(project.id, DBBill.STATE_DELETED)
for (cToDel in currenciesToDelete) {
try {
val deleteRemoteCurrencyResponse = client!!.deleteRemoteCurrency(project, cToDel.remoteId)
if (deleteRemoteCurrencyResponse.stringContent == "OK") {
Log.d(TAG, "successfully deleted currency on remote project : delete it locally")
dbHelper.deleteCurrency(cToDel.id)
}
} catch (e: IOException) {
if (e.message == "\"Not Found\"") {
Log.d(TAG, "failed to delete currency on remote project : delete it locally anyway")
dbHelper.deleteCurrency(cToDel.id)
} else {
throw e
}
}
} else {
val currenciesToDelete = dbHelper.getCurrenciesOfProjectWithState(project.id, DBBill.STATE_DELETED)
for (cToDel in currenciesToDelete) {
dbHelper.deleteCurrency(cToDel.id)
}
}
if (project.type == ProjectType.COSPEND) {
val currenciesToEdit = dbHelper.getCurrenciesOfProjectWithState(project.id, DBBill.STATE_EDITED)
for (cToEdit in currenciesToEdit) {
try {
val editRemoteCurrencyResponse = client!!.editRemoteCurrency(project, cToEdit)
if (editRemoteCurrencyResponse.stringContent == cToEdit.remoteId.toString()) {
dbHelper.setCurrencyState(cToEdit.id, DBBill.STATE_OK)
Log.d(TAG, "SUCCESSFUL remote currency edition (${editRemoteCurrencyResponse.stringContent})")
} else {
Log.d(TAG, "FAILED to edit remote currency (${editRemoteCurrencyResponse.stringContent})")
}
} catch (e: IOException) {
if (e.message == "{\"message\": \"Internal Server Error\"}") {
Log.d(TAG, "FAILED to edit remote currency : it does not exist remotely")
} else {
throw e
}
val currenciesToEdit = dbHelper.getCurrenciesOfProjectWithState(project.id, DBBill.STATE_EDITED)
for (cToEdit in currenciesToEdit) {
try {
val editRemoteCurrencyResponse = client!!.editRemoteCurrency(project, cToEdit)
if (editRemoteCurrencyResponse.stringContent == cToEdit.remoteId.toString()) {
dbHelper.setCurrencyState(cToEdit.id, DBBill.STATE_OK)
Log.d(TAG, "SUCCESSFUL remote currency edition (${editRemoteCurrencyResponse.stringContent})")
} else {
Log.d(TAG, "FAILED to edit remote currency (${editRemoteCurrencyResponse.stringContent})")
}
} catch (e: IOException) {
if (e.message == "{\"message\": \"Internal Server Error\"}") {
Log.d(TAG, "FAILED to edit remote currency : it does not exist remotely")
} else {
throw e
}
}
} else {
val currenciesToEdit = dbHelper.getCurrenciesOfProjectWithState(project.id, DBBill.STATE_EDITED)
for (cToEdit in currenciesToEdit) {
dbHelper.setCurrencyState(cToEdit.id, DBBill.STATE_OK)
}
}
if (project.type == ProjectType.COSPEND) {
val currencyToAdd = dbHelper.getCurrenciesOfProjectWithState(project.id, DBBill.STATE_ADDED)
for (cToAdd in currencyToAdd) {
val createRemoteCurrencyResponse = client!!.createRemoteCurrency(project, cToAdd)
val newRemoteId = createRemoteCurrencyResponse.stringContent.toLong()
if (newRemoteId > 0) {
dbHelper.setCurrencyState(cToAdd.id, DBBill.STATE_OK)
}
}
} else {
val currencyToAdd = dbHelper.getCurrenciesOfProjectWithState(project.id, DBBill.STATE_ADDED)
for (cToAdd in currencyToAdd) {
val currencyToAdd = dbHelper.getCurrenciesOfProjectWithState(project.id, DBBill.STATE_ADDED)
for (cToAdd in currencyToAdd) {
val createRemoteCurrencyResponse = client!!.createRemoteCurrency(project, cToAdd)
val newRemoteId = createRemoteCurrencyResponse.stringContent.toLong()
if (newRemoteId > 0) {
dbHelper.setCurrencyState(cToAdd.id, DBBill.STATE_OK)
}
}
@@ -100,18 +100,14 @@ object ThemeUtils {
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background
) {
Column(modifier = Modifier.fillMaxSize()) {
Column {
Box(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colors.primary)
.statusBarsPadding()
)
Box(
modifier = Modifier
.weight(1f)
.navigationBarsPadding()
) {
Box(modifier = Modifier.fillMaxSize()) {
content()
}
}
@@ -29,6 +29,7 @@
<path android:fillColor="#a57939" android:pathData="m65.1818,16.5746l-3.5658.2422c-.1304.0089-.2591.0347-.3828.077l-7.081,2.42c-.043.0147-.0854.0314-.1269.0499l-2.6409,1.1807c-.1171.0524-.2341.0883-.3607.1091-1.3737.2264-10.1731,1.8874-10.0194,7.2658,0,0-.4646,3.6003,1.951,6.1473,1.8803,1.9826,2.9889,4.6478,2.5192,7.3395-.3101,1.7768-1.2515,3.5306-3.4702,4.594h0l2.9104,1.0217c.4295.1508.767.4887.9174.9183l1.4763,4.2192,1.6616-1.7712c1.1136-1.1945,1.9689-5.8742,1.9689-5.8742.4855-3.6065.5032-7.264.0031-10.8685-.0148-.1064-.0287-.2031-.0417-.2889h0s1.2606-.965,1.2606-.965c.1608-.1231.3449-.2123.5411-.2622l5.5633-1.4149c.1716-.0436.3341-.1174.4799-.2177l4.3017-2.9608c.1811-.1247.3326-.2877.4436-.4775l2.6005-4.4465c.1016-.1737.1671-.3661.1927-.5658l.4849-3.7874c.1198-.9359-.6453-1.748-1.5867-1.6841Z"/>
<path android:fillColor="#a57939" android:pathData="m6.867,16.5746l3.5658.2422c.1304.0089.2591.0347.3828.077l7.081,2.42c.043.0147.0854.0314.1269.0499l2.6409,1.1807c.1171.0524.2341.0883.3607.1091,1.3737.2264,10.1731,1.8874,10.0194,7.2658,0,0,.4645,3.6003-1.951,6.1473-1.8803,1.9826-2.9889,4.6478-2.5192,7.3395.3101,1.7768,1.2515,3.5306,3.4702,4.594h0l-2.9104,1.0217c-.4295.1508-.767.4887-.9174.9183l-1.4763,4.2192-1.6616-1.7712c-1.1136-1.1945-2.0412-6.9083-2.0412-6.9083-.3413-3.1673-.1698-6.3689.5077-9.4817h0c.1234-.5672-.092-1.1546-.553-1.5075l-.4665-.3571c-.1858-.1422-.4024-.2389-.6323-.2824l-6.0587-1.1441c-.2049-.0387-.3994-.1197-.5711-.2379l-4.2616-2.9332c-.1811-.1247-.3326-.2877-.4436-.4775l-2.6005-4.4465c-.1016-.1737-.1671-.3661-.1927-.5657l-.485-3.7875c-.1198-.9359.6453-1.7481,1.5867-1.6841Z"/>
<path android:fillColor="#f4aa41" android:pathData="m27.3197,47.9945l-.6598,1.1318c-.7217,2.1691-.9617,4.4695-.7033,6.7408h0c.1194,1.0495.9592,1.871,2.0111,1.9674l.0811.0074,6.4963-.5146c1.249-.0989,2.5045-.0796,3.7499.0577l5.9262.6536c.7598.0838,1.5171-.1772,2.064-.7112h0c.4669-.4559.7436-1.0718.7746-1.7237l.0484-1.0203c.1133-2.3893-.4008-4.7667-1.4912-6.8958h0c-.4317-.843-1.2874-1.3848-2.234-1.4148l-8.6286-.2728h-3.9632c-1.4296,0-2.7515.7595-3.4715,1.9945Z"/>
</group>
<group android:name="line">
<path android:fillColor="#000" android:pathData="m26.1616,31.0595s-3.0474-.219-3.8284-1-.781-2.0474,0-2.8284,2.0474-.781,2.8284,0c.7811.781,1,3.8284,1,3.8284Z"/>
@@ -1,31 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="72"
android:viewportHeight="72">
<group
android:scaleX="0.63461536"
android:scaleY="0.63461536"
android:translateX="13.153846"
android:translateY="13.153846">
<!-- Circle Outline -->
<path
android:strokeColor="#000"
android:strokeWidth="2.5"
android:pathData="M36,2 C17.22,2 2,17.22 2,36 C2,54.78 17.22,70 36,70 C54.78,70 70,54.78 70,36 C70,17.22 54.78,2 36,2 Z" />
<path
android:strokeColor="#000"
android:strokeWidth="2.5"
android:pathData="M36,8 C20.54,8 8,20.54 8,36 C8,51.46 20.54,64 36,64 C51.46,64 64,51.46 64,36 C64,20.54 51.46,8 36,8 Z" />
<!-- Cow Outlines -->
<group android:name="color">
<path android:fillColor="#000" android:pathData="m15,12.0211h0c0,.6116.3056,1.1827.8145,1.5219l4.2449,2.83c.5764.3843,1.774.8721,1.688,1.5596l-1.7051,6.7284c-.028.2241-.0143.4514.0405.6705l1.8343,7.3371c.0548.2191.0685.4464.0405.6705l-.9152,7.322c-.028.2241-.0143.4514.0405.6705l1.7456,6.9825c.1104.4414.3813.8259.7599,1.0783l1.7312,1.1541c.433.2887.7225.7481.7961,1.2632l-.2598,3.7979s-.2916,3.1088,2.3012,6.051c1.6332,1.8533,5.7148,3.8846,8.2656,3.8847,3.3409,0,5.9547-1.9378,7.3949-3.6465.8914-1.0576,2.5398-2.4978,3.2821-5.8244.2014-.9023-.0996-3.6967-.0996-3.6967,0-.2477.4009-1.311.4985-1.5386l1.3063-2.5077c.0158-.0369.0264-.0595.0389-.0795.5394-.8642.7885-1.8786.8851-2.8927,0,0,.9043-4.9747,1.1585-6.6406.005-.0325-.4996-5.5158-.4953-5.5485l1.5496-6.8172c.0386-.2315.0323-.4683-.0187-.6975l-1.5364-6.9139c-.2151-.9678.3801-1.9308,1.3419-2.1712l1.6416-.4104c.4075-.1019.7676-.341,1.0196-.677l2.2445-2.9927c.2375-.3166.3658-.7017.3658-1.0974v-.1102c0-1.1701-1.1152-1.1434-2.2575-.8896l-6.191.4868c-.3593.0798-.7343.0494-1.0761-.0873l-4.3602-1.7441c-.0767-.0307-.1555-.0562-.2357-.0762l-7.3628-1.8407c-.3375-.0844-.692-.0708-1.022.0393l-5.3714,1.7905c-.0821.0274-.1622.0605-.2396.0992l-3.4104,1.7052c-.3092.1546-.656.2184-1,.184l-7.4623-.7462c-1.0768-.1077-2.0111.7379-2.0111,1.82Z"/>
<path android:fillColor="#000" android:pathData="m65.1818,16.5746l-3.5658.2422c-.1304.0089-.2591.0347-.3828.077l-7.081,2.42c-.043.0147-.0854.0314-.1269.0499l-2.6409,1.1807c-.1171.0524-.2341.0883-.3607.1091-1.3737.2264-10.1731,1.8874-10.0194,7.2658,0,0-.4646,3.6003,1.951,6.1473,1.8803,1.9826,2.9889,4.6478,2.5192,7.3395-.3101,1.7768-1.2515,3.5306-3.4702,4.594h0l2.9104,1.0217c.4295.1508.767.4887.9174.9183l1.4763,4.2192,1.6616-1.7712c1.1136-1.1945,1.9689-5.8742,1.9689-5.8742.4855-3.6065.5032-7.264.0031-10.8685-.0148-.1064-.0287-.2031-.0417-.2889h0s1.2606-.965,1.2606-.965c.1608-.1231.3449-.2123.5411-.2622l5.5633-1.4149c.1716-.0436.3341-.1174.4799-.2177l4.3017-2.9608c.1811-.1247.3326-.2877.4436-.4775l2.6005-4.4465c.1016-.1737.1671-.3661.1927-.5658l.4849-3.7874c.1198-.9359-.6453-1.748-1.5867-1.6841Z"/>
<path android:fillColor="#000" android:pathData="m6.867,16.5746l3.5658.2422c.1304.0089.2591.0347.3828.077l7.081,2.42c.043.0147.0854.0314.1269.0499l2.6409,1.1807c.1171.0524.2341.0883.3607.1091,1.3737.2264,10.1731,1.8874,10.0194,7.2658,0,0,.4645,3.6003-1.951,6.1473-1.8803,1.9826-2.9889,4.6478-2.5192,7.3395.3101,1.7768,1.2515,3.5306,3.4702,4.594h0l-2.9104,1.0217c-.4295.1508-.767.4887-.9174.9183l-1.4763,4.2192-1.6616-1.7712c-1.1136-1.1945-2.0412-6.9083-2.0412-6.9083-.3413-3.1673-.1698-6.3689.5077-9.4817h0c.1234-.5672-.092-1.1546-.553-1.5075l-.4665-.3571c-.1858-.1422-.4024-.2389-.6323-.2824l-6.0587-1.1441c-.2049-.0387-.3994-.1197-.5711-.2379l-4.2616-2.9332c-.1811-.1247-.3326-.2877-.4436-.4775l-2.6005-4.4465c-.1016-.1737-.1671-.3661-.1927-.5657l-.485-3.7875c-.1198-.9359.6453-1.7481,1.5867-1.6841Z"/>
<path android:fillColor="#000" android:pathData="m27.3197,47.9945l-.6598,1.1318c-.7217,2.1691-.9617,4.4695-.7033,6.7408h0c.1194,1.0495.9592,1.871,2.0111,1.9674l.0811.0074,6.4963-.5146c1.249-.0989,2.5045-.0796,3.7499.0577l5.9262.6536c.7598.0838,1.5171-.1772,2.064-.7112h0c.4669-.4559.7436-1.0718.7746-1.7237l.0484-1.0203c.1133-2.3893-.4008-4.7667-1.4912-6.8958h0c-.4317-.843-1.2874-1.3848-2.234-1.4148l-8.6286-.2728h-3.9632c-1.4296,0-2.7515.7595-3.4715,1.9945Z"/>
</group>
</group>
</vector>
@@ -2,5 +2,5 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_monochrome"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
@@ -2,5 +2,5 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_monochrome"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
+3
View File
@@ -0,0 +1,3 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
}