This commit is contained in:
soraefir
2026-06-08 22:21:22 +02:00
parent 5c43bedc77
commit 2967dbd001
15 changed files with 338 additions and 376 deletions
+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.3.21'
id 'org.jetbrains.kotlin.plugin.compose' version '2.3.21'
id 'org.jetbrains.kotlin.plugin.serialization' version '2.4.0'
id 'org.jetbrains.kotlin.plugin.compose' version '2.4.0'
}
android {
@@ -14,7 +14,7 @@ android {
applicationId "net.helcel.cowspent"
minSdk = 26
targetSdk = 37
versionName "1.0"
versionName "1.0a"
versionCode project.hasProperty('VERSION_CODE') ? project.property('VERSION_CODE').toInteger() : 1
}
+8 -5
View File
@@ -20,11 +20,14 @@
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity android:name="net.helcel.cowspent.android.currencies.ManageCurrenciesActivity" />
<activity
android:name="net.helcel.cowspent.android.currencies.ManageCurrenciesActivity"
android:windowSoftInputMode="adjustResize" />
<activity
android:name="net.helcel.cowspent.android.main.BillsListViewActivity"
android:theme="@style/AppTheme.NoActionBar"
android:windowSoftInputMode="adjustResize"
android:exported="true">
<intent-filter>
@@ -45,7 +48,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="stateVisible"
android:windowSoftInputMode="adjustResize|stateVisible"
android:launchMode="standard"
/>
@@ -66,7 +69,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="stateHidden"
android:windowSoftInputMode="adjustResize"
android:launchMode="standard"
android:exported="true">
<intent-filter>
@@ -93,7 +96,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="stateHidden"
android:windowSoftInputMode="adjustResize"
android:launchMode="standard">
</activity>
@@ -101,7 +104,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="stateHidden"
android:windowSoftInputMode="adjustResize"
android:launchMode="standard">
</activity>
@@ -1,6 +1,7 @@
package net.helcel.cowspent.android.about
import android.os.Bundle
import androidx.activity.enableEdgeToEdge
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import net.helcel.cowspent.theme.ThemeUtils
@@ -8,6 +9,7 @@ import net.helcel.cowspent.theme.ThemeUtils
class AboutActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContent {
@@ -13,6 +13,7 @@ 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
@@ -73,6 +74,7 @@ class AccountActivity : AppCompatActivity() {
private var showLoginDialog by mutableStateOf(false)
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
preferences = PreferenceManager.getDefaultSharedPreferences(applicationContext)
@@ -5,6 +5,7 @@ import android.app.TimePickerDialog
import android.content.Intent
import android.os.Bundle
import androidx.activity.OnBackPressedCallback
import androidx.activity.enableEdgeToEdge
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
@@ -39,6 +40,7 @@ class EditBillActivity : AppCompatActivity() {
private val calendar = Calendar.getInstance()
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
db = CowspentSQLiteOpenHelper.getInstance(this)
@@ -3,6 +3,7 @@ package net.helcel.cowspent.android.bill_label
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.enableEdgeToEdge
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
@@ -20,6 +21,7 @@ class LabelBillsActivity : AppCompatActivity() {
private val viewModel: LabelBillsViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
val projectId = intent.getLongExtra(EXTRA_PROJECT_ID, -1L)
@@ -3,6 +3,7 @@ package net.helcel.cowspent.android.currencies
import android.os.Bundle
import android.util.Log
import android.widget.Toast
import androidx.activity.enableEdgeToEdge
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
@@ -40,6 +41,7 @@ class ManageCurrenciesActivity : AppCompatActivity() {
}
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
intent.extras?.let {
@@ -7,6 +7,7 @@ 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
@@ -42,6 +43,7 @@ class QrCodeScannerActivity : AppCompatActivity() {
private var isScanning = true
override fun onCreate(state: Bundle?) {
enableEdgeToEdge()
super.onCreate(state)
cameraExecutor = Executors.newSingleThreadExecutor()
@@ -17,6 +17,7 @@ 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
@@ -193,6 +194,7 @@ class BillsListViewActivity :
}
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
isActivityVisible = true
if (savedInstanceState != null) {
@@ -5,6 +5,7 @@ import android.net.Uri
import android.os.Bundle
import android.util.Patterns
import android.widget.Toast
import androidx.activity.enableEdgeToEdge
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
@@ -33,6 +34,7 @@ class NewProjectActivity : AppCompatActivity() {
private lateinit var db: CowspentSQLiteOpenHelper
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
db = CowspentSQLiteOpenHelper.getInstance(this)
@@ -3,6 +3,7 @@ package net.helcel.cowspent.android.project.edit
import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import androidx.activity.enableEdgeToEdge
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
@@ -27,6 +28,7 @@ class EditProjectActivity : AppCompatActivity() {
private lateinit var project: DBProject
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
db = CowspentSQLiteOpenHelper.getInstance(this)
@@ -3,6 +3,7 @@ package net.helcel.cowspent.android.settings
import android.content.Intent
import android.os.Bundle
import androidx.activity.OnBackPressedCallback
import androidx.activity.enableEdgeToEdge
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.getValue
@@ -25,6 +26,7 @@ import net.helcel.cowspent.util.ColorUtils
class PreferencesActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
@@ -1,34 +1,86 @@
package net.helcel.cowspent.android.statistics
import android.annotation.SuppressLint
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.LocalContentColor
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Group
import androidx.compose.runtime.*
import androidx.compose.material.icons.filled.ViewColumn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.res.stringResource
import androidx.core.graphics.toColorInt
import kotlinx.coroutines.launch
import net.helcel.cowspent.R
import net.helcel.cowspent.android.helper.*
import net.helcel.cowspent.android.helper.EditableExposedDropdownMenu
import net.helcel.cowspent.android.helper.UserAvatar
import net.helcel.cowspent.android.helper.formatShortValue
import net.helcel.cowspent.model.DBBill
import net.helcel.cowspent.model.DBCategory
import net.helcel.cowspent.model.DBMember
import androidx.core.graphics.toColorInt
import kotlin.math.abs
import kotlin.math.roundToInt
private object SankeyDimens {
val NodeHeight = 76.dp
val TotalNodeHeight = 42.dp
val NormalGap = 8.dp
val FocusGap = 16.dp
val ActiveMinWidth = 160.dp
}
@OptIn(ExperimentalMaterialApi::class)
@SuppressLint("UseKtx")
@@ -41,15 +93,11 @@ 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 } }
@@ -62,408 +110,293 @@ fun ProjectSankeyDiagram(
return
}
// Consolidated spending calculation
val spendings = remember(activeBills, selectedMemberId, membersMap) {
val spentMap = mutableMapOf<Long, Double>()
val spentMap = mutableMapOf<Long, Double>().apply { membersMap.keys.forEach { put(it, 0.0) } }
val catMap = mutableMapOf<Int, Double>()
membersMap.keys.forEach { spentMap[it] = 0.0 }
activeBills.forEach { bill ->
val totalWeight = bill.billOwers.sumOf { bo ->
membersMap[bo.memberId]?.weight ?: 1.0
}
val totalWeight = bill.billOwers.sumOf { membersMap[it.memberId]?.weight ?: 1.0 }
if (totalWeight > 0) {
if (selectedMemberId == -1L) {
catMap[bill.categoryRemoteId] = (catMap[bill.categoryRemoteId] ?: 0.0) + bill.amount
bill.billOwers.forEach { bo ->
val weight = membersMap[bo.memberId]?.weight ?: 1.0
val share = (bill.amount / totalWeight) * weight
spentMap[bo.memberId] = (spentMap[bo.memberId] ?: 0.0) + share
spentMap[bo.memberId] = (spentMap[bo.memberId] ?: 0.0) + (bill.amount / totalWeight) * weight
}
} else {
bill.billOwers.find { it.memberId == selectedMemberId }?.let { bo ->
val weight = membersMap[bo.memberId]?.weight ?: 1.0
val share = (bill.amount / totalWeight) * weight
catMap[bill.categoryRemoteId] = (catMap[bill.categoryRemoteId] ?: 0.0) + share
spentMap[selectedMemberId] = (spentMap[selectedMemberId] ?: 0.0) + share
catMap[bill.categoryRemoteId] = (catMap[bill.categoryRemoteId] ?: 0.0) + (bill.amount / totalWeight) * weight
spentMap[selectedMemberId] = (spentMap[selectedMemberId] ?: 0.0) + (bill.amount / totalWeight) * weight
}
}
}
}
val memberList = spentMap.toList().filter { it.second > 0 }.sortedByDescending { it.second }
val categoryList = catMap.toList().sortedByDescending { it.second }
memberList to categoryList
spentMap.toList().filter { it.second > 0 }.sortedByDescending { it.second } to catMap.toList().sortedByDescending { it.second }
}
val displayMemberSpendings = spendings.first
val displayCategorySpendings = spendings.second
val totalAmount = remember(displayMemberSpendings) { displayMemberSpendings.sumOf { it.second } }
var isSpecialMode by remember { mutableStateOf(false) }
LaunchedEffect(displayMemberSpendings, displayCategorySpendings, totalAmount, projectName) {
val statsText = StringBuilder()
statsText.append("// ").append(shareStatsIntro.replace("\n", "\n// ")).append("\n\n")
val middleNode = if (selectedMemberId == -1L) "Total" else "Spent"
displayMemberSpendings.forEach { (memberId, amount) ->
val name = membersMap[memberId]?.name ?: "???"
statsText.append("$name [${formatShortValue(amount)}] $middleNode\n")
}
statsText.append("\n")
displayCategorySpendings.forEach { (catRemoteId, amount) ->
val name = categoriesMap[catRemoteId]?.name ?: "Other"
statsText.append("$middleNode [${formatShortValue(amount)}] $name\n")
}
statsText.append("\n")
displayMemberSpendings.forEach { (memberId, _) ->
val member = membersMap[memberId]
if (member != null) {
val hexColor = String.format("#%02x%02x%02x", member.r ?: 128, member.g ?: 128, member.b ?: 128)
statsText.append(":${member.name} $hexColor\n")
val statsText = StringBuilder().apply {
append("// ").append(shareStatsIntro.replace("\n", "\n// ")).append("\n\n")
val middleNode = if (selectedMemberId == -1L) "Total" else "Spent"
displayMemberSpendings.forEach { (id, amount) -> append("${membersMap[id]?.name ?: "???"} [${formatShortValue(amount)}] $middleNode\n") }
append("\n")
displayCategorySpendings.forEach { (id, amount) -> append("$middleNode [${formatShortValue(amount)}] ${categoriesMap[id]?.name ?: "Other"}\n") }
append("\n")
displayMemberSpendings.forEach { (id, _) ->
membersMap[id]?.let { append(":${it.name} ${String.format("#%02x%02x%02x", it.r ?: 128, it.g ?: 128, it.b ?: 128)}\n") }
}
displayCategorySpendings.forEach { (id, _) -> categoriesMap[id]?.let { append(":${it.name ?: "Other"} ${it.color}\n") } }
}
displayCategorySpendings.forEach { (catRemoteId, _) ->
val category = categoriesMap[catRemoteId]
if (category != null) {
statsText.append(":${category.name ?: "Other"} ${category.color}\n")
}
}
onShareReady(statsText.toString())
}
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
val selectedMember = membersMap[selectedMemberId]
EditableExposedDropdownMenu(
value = selectedMember?.name ?: "All Members",
placeholder = "Filter by member",
expanded = expanded,
onExpandedChange = { expanded = it },
onDismissRequest = { expanded = false },
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
leadingIcon = {
Box(modifier = Modifier.padding(start = 12.dp)) {
if (selectedMember != null) {
UserAvatar(
name = selectedMember.name,
r = selectedMember.r,
g = selectedMember.g,
b = selectedMember.b,
disabled = !selectedMember.isActivated,
size = 24.dp
)
} else {
Icon(Icons.Default.Group, contentDescription = null)
}
}
},
content = {
DropdownMenuItem(onClick = {
selectedMemberId = -1L
expanded = false
}) {
Icon(Icons.Default.Group, contentDescription = null)
Spacer(modifier = Modifier.width(12.dp))
Text("All Members")
}
allMembers.forEach { member ->
DropdownMenuItem(onClick = {
selectedMemberId = member.id
expanded = false
}) {
UserAvatar(
name = member.name,
r = member.r,
g = member.g,
b = member.b,
disabled = !member.isActivated,
size = 24.dp
)
Spacer(modifier = Modifier.width(12.dp))
Text(member.name)
Column(modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.padding(16.dp)) {
val selectedMember = membersMap[selectedMemberId]
Row(verticalAlignment = Alignment.CenterVertically) {
EditableExposedDropdownMenu(
value = selectedMember?.name ?: "All Members",
placeholder = "Filter by member",
expanded = expanded,
onExpandedChange = { expanded = it },
onDismissRequest = { expanded = false },
modifier = Modifier.weight(1f).padding(bottom = 16.dp),
leadingIcon = {
Box(modifier = Modifier.padding(start = 12.dp)) {
if (selectedMember != null) {
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 {
BoxWithConstraints(modifier = Modifier.weight(1f).fillMaxWidth()) {
val nodeHeightMember = 76.dp
val nodeHeightCategory = 76.dp
val nodeHeightTotal = 42.dp
val horizontalGap = 8.dp
val memberCount = displayMemberSpendings.size
val categoryCount = displayCategorySpendings.size
val maxGaps = maxOf(memberCount - 1, categoryCount - 1, 0)
val usableWidth = (maxWidth.value - horizontalGap.value * maxGaps).coerceAtLeast(0f)
val moneyScale = if (totalAmount > 0) usableWidth / totalAmount.toFloat() else 0f
Canvas(modifier = Modifier.fillMaxSize()) {
val nodeHeightMemberPx = nodeHeightMember.toPx()
val nodeHeightCategoryPx = nodeHeightCategory.toPx()
val nodeHeightTotalPx = nodeHeightTotal.toPx()
val gapPx = horizontalGap.toPx()
val totalWidthPx = size.width
val usableWidthPx = totalWidthPx - gapPx * maxGaps
val moneyScalePx = if (totalAmount > 0) usableWidthPx / totalAmount else 0.0
val totalNodeWidthPx = (totalAmount * moneyScalePx).toFloat()
val totalNodeXPx = (totalWidthPx - totalNodeWidthPx) / 2
val topY = 0f
val middleY = (size.height - nodeHeightTotalPx) / 2
val bottomY = size.height - nodeHeightCategoryPx
val totalNodeColor = Color(0xFF333333)
// Flows
var currentXTop = (totalWidthPx - (totalAmount * moneyScalePx + (memberCount - 1).coerceAtLeast(0) * gapPx).toFloat()) / 2
var currentXInTotalTop = totalNodeXPx
displayMemberSpendings.forEach { (memberId, amount) ->
val member = membersMap[memberId]
val boxWidth = (amount * moneyScalePx).toFloat()
val color = member?.let {
Color(it.r ?: 128, it.g ?: 128, it.b ?: 128)
} ?: Color.Gray
if (boxWidth > 0.5f) {
drawSankeyFlow(
startX = currentXTop,
startY = topY + nodeHeightMemberPx * 0.5f,
startWidth = boxWidth,
endX = currentXInTotalTop,
endY = middleY + nodeHeightTotalPx * 0.5f,
endWidth = boxWidth,
startColor = color.copy(alpha = 0.5f),
endColor = totalNodeColor.copy(alpha = 0.35f)
)
}
currentXTop += boxWidth + gapPx
currentXInTotalTop += boxWidth
}
var currentXBottom = (totalWidthPx - (totalAmount * moneyScalePx + (categoryCount - 1).coerceAtLeast(0) * gapPx).toFloat()) / 2
var currentXInTotalBottom = totalNodeXPx
displayCategorySpendings.forEach { (catRemoteId, amount) ->
val category = categoriesMap[catRemoteId]
val boxWidth = (amount * moneyScalePx).toFloat()
val color = category?.color?.let {
try { Color(it.toColorInt()) } catch (_: Exception) { Color(0xFF999999) }
} ?: Color(0xFF999999)
if (boxWidth > 0.5f) {
drawSankeyFlow(
startX = currentXInTotalBottom,
startY = middleY + nodeHeightTotalPx * 0.5f,
startWidth = boxWidth,
endX = currentXBottom,
endY = bottomY + nodeHeightCategoryPx * 0.5f,
endWidth = boxWidth,
startColor = totalNodeColor.copy(alpha = 0.35f),
endColor = color.copy(alpha = 0.5f)
)
}
currentXInTotalBottom += boxWidth
currentXBottom += boxWidth + gapPx
}
// Nodes
currentXTop = (totalWidthPx - (totalAmount * moneyScalePx + (memberCount - 1).coerceAtLeast(0) * gapPx).toFloat()) / 2
displayMemberSpendings.forEach { (memberId, amount) ->
val member = membersMap[memberId]
val width = (amount * moneyScalePx).toFloat()
val color = member?.let {
Color(it.r ?: 128, it.g ?: 128, it.b ?: 128)
} ?: Color.Gray
if (width > 0.5f) {
drawRoundRect(
color = color,
topLeft = Offset(currentXTop, topY),
size = Size(width, nodeHeightMemberPx),
cornerRadius = CornerRadius(10.dp.toPx())
)
}
currentXTop += width + gapPx
}
drawRect(
color = totalNodeColor,
topLeft = Offset(totalNodeXPx, middleY),
size = Size(totalNodeWidthPx, nodeHeightTotalPx)
)
currentXBottom = (totalWidthPx - (totalAmount * moneyScalePx + (categoryCount - 1).coerceAtLeast(0) * gapPx).toFloat()) / 2
displayCategorySpendings.forEach { (catRemoteId, amount) ->
val category = categoriesMap[catRemoteId]
val width = (amount * moneyScalePx).toFloat()
val color = category?.color?.let {
try { Color(it.toColorInt()) } catch (_: Exception) { Color(0xFF999999) }
} ?: Color(0xFF999999)
if (width > 0.5f) {
drawRoundRect(
color = color,
topLeft = Offset(currentXBottom, bottomY),
size = Size(width, nodeHeightCategoryPx),
cornerRadius = CornerRadius(10.dp.toPx())
)
}
currentXBottom += width + gapPx
}
}
// Member labels
var currentXTopLabel = (maxWidth.value - (totalAmount.toFloat() * moneyScale + (memberCount - 1).coerceAtLeast(0) * horizontalGap.value)) / 2
displayMemberSpendings.forEach { (memberId, amount) ->
val member = membersMap[memberId]
val widthValue = amount.toFloat() * moneyScale
val width = widthValue.dp
if (widthValue > 12f) {
Box(
modifier = Modifier
.offset(x = currentXTopLabel.dp, y = 0.dp)
.size(width, nodeHeightMember),
contentAlignment = Alignment.Center
) {
if (width >= 36.dp) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = member?.name ?: "???",
fontSize = 16.sp,
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontWeight = FontWeight.Bold
)
Text(
text = formatShortValue(amount),
fontSize = 14.sp,
color = Color.White.copy(alpha = 0.9f)
)
}
}
}
}
currentXTopLabel += widthValue + horizontalGap.value
}
// Total label
Box(
modifier = Modifier
.align(Alignment.Center)
.fillMaxWidth()
.height(nodeHeightTotal),
contentAlignment = Alignment.Center
) {
Text(
text = if (selectedMemberId == -1L) formatShortValue(totalAmount) else "SPENT: ${formatShortValue(totalAmount)}",
fontSize = 16.sp,
color = Color.White,
textAlign = TextAlign.Center,
fontWeight = FontWeight.ExtraBold
)
}
// Category labels
var currentXBottomLabel = (maxWidth.value - (totalAmount.toFloat() * moneyScale + (categoryCount - 1).coerceAtLeast(0) * horizontalGap.value)) / 2
displayCategorySpendings.forEach { (catRemoteId, amount) ->
val category = categoriesMap[catRemoteId]
val widthValue = amount.toFloat() * moneyScale
val width = widthValue.dp
if (widthValue > 12f) {
Box(
modifier = Modifier
.align(Alignment.BottomStart)
.offset(x = currentXBottomLabel.dp)
.size(width, nodeHeightCategory),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = category?.icon ?: "",
fontSize = 20.sp
)
if (width >= 32.dp) {
Text(
text = formatShortValue(amount),
fontSize = 14.sp,
color = Color.White.copy(alpha = 0.9f)
)
}
}
}
}
currentXBottomLabel += widthValue + horizontalGap.value
}
}
SankeyContent(isSpecialMode, selectedMemberId, totalAmount, displayMemberSpendings, displayCategorySpendings, membersMap, categoriesMap)
}
Spacer(Modifier.fillMaxWidth().height(32.dp))
}
}
private fun DrawScope.drawSankeyFlow(
startX: Float,
startY: Float,
startWidth: Float,
endX: Float,
endY: Float,
endWidth: Float,
startColor: Color,
endColor: Color
private data class NodeLayout(val centerX: Float, val visualWidth: Float)
@SuppressLint("FrequentlyChangingValue")
@Composable
private fun SankeyContent(
isSpecialMode: Boolean,
selectedMemberId: Long,
totalAmount: Double,
displayMemberSpendings: List<Pair<Long, Double>>,
displayCategorySpendings: List<Pair<Int, Double>>,
membersMap: Map<Long, DBMember>,
categoriesMap: Map<Int, DBCategory>
) {
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(
projectName = "Test Project",
allMembers = StatisticsMockData.members,
allBills = StatisticsMockData.bills,
customCategories = emptyList(),
onShareReady = {}
)
}
}
fun ProjectSankeyDiagramPreview() = MaterialTheme { ProjectSankeyDiagram("Test Project", StatisticsMockData.members, StatisticsMockData.bills, emptyList()) {} }
@@ -3,6 +3,7 @@ package net.helcel.cowspent.android.statistics
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.enableEdgeToEdge
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
@@ -15,6 +16,7 @@ import net.helcel.cowspent.theme.ThemeUtils
class ProjectStatisticsActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
val projectId = intent.getLongExtra(EXTRA_PROJECT_ID, -1L)
@@ -100,14 +100,18 @@ object ThemeUtils {
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background
) {
Column {
Column(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colors.primary)
.statusBarsPadding()
)
Box(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.weight(1f)
.navigationBarsPadding()
) {
content()
}
}