diff --git a/app/build.gradle b/app/build.gradle index 9a1a5aa..2ad6f2d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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 } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 67a0b67..778d94d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -20,11 +20,14 @@ android:supportsRtl="true" android:theme="@style/AppTheme"> - + @@ -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"> @@ -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"> @@ -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"> diff --git a/app/src/main/java/net/helcel/cowspent/android/about/AboutActivity.kt b/app/src/main/java/net/helcel/cowspent/android/about/AboutActivity.kt index a0606b1..a8c09d6 100644 --- a/app/src/main/java/net/helcel/cowspent/android/about/AboutActivity.kt +++ b/app/src/main/java/net/helcel/cowspent/android/about/AboutActivity.kt @@ -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 { diff --git a/app/src/main/java/net/helcel/cowspent/android/account/AccountActivity.kt b/app/src/main/java/net/helcel/cowspent/android/account/AccountActivity.kt index efe64de..3250acf 100644 --- a/app/src/main/java/net/helcel/cowspent/android/account/AccountActivity.kt +++ b/app/src/main/java/net/helcel/cowspent/android/account/AccountActivity.kt @@ -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) diff --git a/app/src/main/java/net/helcel/cowspent/android/bill_edit/EditBillActivity.kt b/app/src/main/java/net/helcel/cowspent/android/bill_edit/EditBillActivity.kt index 4681bfc..8f3898e 100644 --- a/app/src/main/java/net/helcel/cowspent/android/bill_edit/EditBillActivity.kt +++ b/app/src/main/java/net/helcel/cowspent/android/bill_edit/EditBillActivity.kt @@ -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) diff --git a/app/src/main/java/net/helcel/cowspent/android/bill_label/LabelBillsActivity.kt b/app/src/main/java/net/helcel/cowspent/android/bill_label/LabelBillsActivity.kt index 6345092..07545c1 100644 --- a/app/src/main/java/net/helcel/cowspent/android/bill_label/LabelBillsActivity.kt +++ b/app/src/main/java/net/helcel/cowspent/android/bill_label/LabelBillsActivity.kt @@ -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) diff --git a/app/src/main/java/net/helcel/cowspent/android/currencies/ManageCurrenciesActivity.kt b/app/src/main/java/net/helcel/cowspent/android/currencies/ManageCurrenciesActivity.kt index a0c711b..be736c5 100644 --- a/app/src/main/java/net/helcel/cowspent/android/currencies/ManageCurrenciesActivity.kt +++ b/app/src/main/java/net/helcel/cowspent/android/currencies/ManageCurrenciesActivity.kt @@ -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 { diff --git a/app/src/main/java/net/helcel/cowspent/android/helper/QrCodeScannerActivity.kt b/app/src/main/java/net/helcel/cowspent/android/helper/QrCodeScannerActivity.kt index 2c87df7..f5ba6fd 100644 --- a/app/src/main/java/net/helcel/cowspent/android/helper/QrCodeScannerActivity.kt +++ b/app/src/main/java/net/helcel/cowspent/android/helper/QrCodeScannerActivity.kt @@ -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() diff --git a/app/src/main/java/net/helcel/cowspent/android/main/BillsListViewActivity.kt b/app/src/main/java/net/helcel/cowspent/android/main/BillsListViewActivity.kt index 06df8f1..e479865 100644 --- a/app/src/main/java/net/helcel/cowspent/android/main/BillsListViewActivity.kt +++ b/app/src/main/java/net/helcel/cowspent/android/main/BillsListViewActivity.kt @@ -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) { diff --git a/app/src/main/java/net/helcel/cowspent/android/project/create/NewProjectActivity.kt b/app/src/main/java/net/helcel/cowspent/android/project/create/NewProjectActivity.kt index 92f58a6..7bdf5d5 100644 --- a/app/src/main/java/net/helcel/cowspent/android/project/create/NewProjectActivity.kt +++ b/app/src/main/java/net/helcel/cowspent/android/project/create/NewProjectActivity.kt @@ -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) diff --git a/app/src/main/java/net/helcel/cowspent/android/project/edit/EditProjectActivity.kt b/app/src/main/java/net/helcel/cowspent/android/project/edit/EditProjectActivity.kt index a105eb8..4e39ac1 100644 --- a/app/src/main/java/net/helcel/cowspent/android/project/edit/EditProjectActivity.kt +++ b/app/src/main/java/net/helcel/cowspent/android/project/edit/EditProjectActivity.kt @@ -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) diff --git a/app/src/main/java/net/helcel/cowspent/android/settings/PreferencesActivity.kt b/app/src/main/java/net/helcel/cowspent/android/settings/PreferencesActivity.kt index 854f23f..623c2fd 100644 --- a/app/src/main/java/net/helcel/cowspent/android/settings/PreferencesActivity.kt +++ b/app/src/main/java/net/helcel/cowspent/android/settings/PreferencesActivity.kt @@ -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) { diff --git a/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectSankeyDiagram.kt b/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectSankeyDiagram.kt index 19410a1..416a5cc 100644 --- a/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectSankeyDiagram.kt +++ b/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectSankeyDiagram.kt @@ -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() + val spentMap = mutableMapOf().apply { membersMap.keys.forEach { put(it, 0.0) } } val catMap = mutableMapOf() - 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>, + displayCategorySpendings: List>, + membersMap: Map, + categoriesMap: Map ) { + val density = LocalDensity.current + val scope = rememberCoroutineScope() + + val topFocalIndex = remember { Animatable(0f) } + val bottomFocalIndex = remember { Animatable(0f) } + + var diagramAreaCoordinates by remember { mutableStateOf(null) } + var totalNodeCoordinates by remember { mutableStateOf(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>, focalIndex: Float): List { + 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() + 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()) {} } diff --git a/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectStatisticsActivity.kt b/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectStatisticsActivity.kt index 8ff5c6a..5677935 100644 --- a/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectStatisticsActivity.kt +++ b/app/src/main/java/net/helcel/cowspent/android/statistics/ProjectStatisticsActivity.kt @@ -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) diff --git a/app/src/main/java/net/helcel/cowspent/theme/ThemeUtils.kt b/app/src/main/java/net/helcel/cowspent/theme/ThemeUtils.kt index dfdcb6c..2b371fa 100644 --- a/app/src/main/java/net/helcel/cowspent/theme/ThemeUtils.kt +++ b/app/src/main/java/net/helcel/cowspent/theme/ThemeUtils.kt @@ -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() } }