Compare commits
2 Commits
c2f59fa1df
...
1.0b
| Author | SHA1 | Date | |
|---|---|---|---|
| 65799496f9 | |||
|
2967dbd001
|
+3
-3
@@ -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.0b"
|
||||
versionCode project.hasProperty('VERSION_CODE') ? project.property('VERSION_CODE').toInteger() : 1
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+299
-366
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user