Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 65799496f9 | |||
|
2967dbd001
|
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id 'com.android.application' version '9.2.1'
|
id 'com.android.application' version '9.2.1'
|
||||||
id 'org.jetbrains.kotlin.plugin.serialization' version '2.3.21'
|
id 'org.jetbrains.kotlin.plugin.serialization' version '2.4.0'
|
||||||
id 'org.jetbrains.kotlin.plugin.compose' version '2.3.21'
|
id 'org.jetbrains.kotlin.plugin.compose' version '2.4.0'
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
@@ -14,7 +14,7 @@ android {
|
|||||||
applicationId "net.helcel.cowspent"
|
applicationId "net.helcel.cowspent"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 37
|
targetSdk = 37
|
||||||
versionName "1.0"
|
versionName "1.0b"
|
||||||
versionCode project.hasProperty('VERSION_CODE') ? project.property('VERSION_CODE').toInteger() : 1
|
versionCode project.hasProperty('VERSION_CODE') ? project.property('VERSION_CODE').toInteger() : 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,11 +20,14 @@
|
|||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/AppTheme">
|
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
|
<activity
|
||||||
android:name="net.helcel.cowspent.android.main.BillsListViewActivity"
|
android:name="net.helcel.cowspent.android.main.BillsListViewActivity"
|
||||||
android:theme="@style/AppTheme.NoActionBar"
|
android:theme="@style/AppTheme.NoActionBar"
|
||||||
|
android:windowSoftInputMode="adjustResize"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
|
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
@@ -45,7 +48,7 @@
|
|||||||
android:name="net.helcel.cowspent.android.account.AccountActivity"
|
android:name="net.helcel.cowspent.android.account.AccountActivity"
|
||||||
android:label="@string/settings_server_settings"
|
android:label="@string/settings_server_settings"
|
||||||
android:parentActivityName="net.helcel.cowspent.android.settings.PreferencesActivity"
|
android:parentActivityName="net.helcel.cowspent.android.settings.PreferencesActivity"
|
||||||
android:windowSoftInputMode="stateVisible"
|
android:windowSoftInputMode="adjustResize|stateVisible"
|
||||||
android:launchMode="standard"
|
android:launchMode="standard"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -66,7 +69,7 @@
|
|||||||
android:name="net.helcel.cowspent.android.project.create.NewProjectActivity"
|
android:name="net.helcel.cowspent.android.project.create.NewProjectActivity"
|
||||||
android:label="@string/simple_add_project"
|
android:label="@string/simple_add_project"
|
||||||
android:parentActivityName="net.helcel.cowspent.android.main.BillsListViewActivity"
|
android:parentActivityName="net.helcel.cowspent.android.main.BillsListViewActivity"
|
||||||
android:windowSoftInputMode="stateHidden"
|
android:windowSoftInputMode="adjustResize"
|
||||||
android:launchMode="standard"
|
android:launchMode="standard"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
@@ -93,7 +96,7 @@
|
|||||||
android:name="net.helcel.cowspent.android.project.edit.EditProjectActivity"
|
android:name="net.helcel.cowspent.android.project.edit.EditProjectActivity"
|
||||||
android:label="@string/simple_edit_project"
|
android:label="@string/simple_edit_project"
|
||||||
android:parentActivityName="net.helcel.cowspent.android.main.BillsListViewActivity"
|
android:parentActivityName="net.helcel.cowspent.android.main.BillsListViewActivity"
|
||||||
android:windowSoftInputMode="stateHidden"
|
android:windowSoftInputMode="adjustResize"
|
||||||
android:launchMode="standard">
|
android:launchMode="standard">
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
@@ -101,7 +104,7 @@
|
|||||||
android:name="net.helcel.cowspent.android.bill_edit.EditBillActivity"
|
android:name="net.helcel.cowspent.android.bill_edit.EditBillActivity"
|
||||||
android:label="@string/simple_edit_bill"
|
android:label="@string/simple_edit_bill"
|
||||||
android:parentActivityName="net.helcel.cowspent.android.main.BillsListViewActivity"
|
android:parentActivityName="net.helcel.cowspent.android.main.BillsListViewActivity"
|
||||||
android:windowSoftInputMode="stateHidden"
|
android:windowSoftInputMode="adjustResize"
|
||||||
android:launchMode="standard">
|
android:launchMode="standard">
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package net.helcel.cowspent.android.about
|
package net.helcel.cowspent.android.about
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import net.helcel.cowspent.theme.ThemeUtils
|
import net.helcel.cowspent.theme.ThemeUtils
|
||||||
@@ -8,6 +9,7 @@ import net.helcel.cowspent.theme.ThemeUtils
|
|||||||
class AboutActivity : AppCompatActivity() {
|
class AboutActivity : AppCompatActivity() {
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
enableEdgeToEdge()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import android.webkit.WebView
|
|||||||
import android.webkit.WebViewClient
|
import android.webkit.WebViewClient
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.OnBackPressedCallback
|
import androidx.activity.OnBackPressedCallback
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
@@ -73,6 +74,7 @@ class AccountActivity : AppCompatActivity() {
|
|||||||
private var showLoginDialog by mutableStateOf(false)
|
private var showLoginDialog by mutableStateOf(false)
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
enableEdgeToEdge()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
preferences = PreferenceManager.getDefaultSharedPreferences(applicationContext)
|
preferences = PreferenceManager.getDefaultSharedPreferences(applicationContext)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import android.app.TimePickerDialog
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.OnBackPressedCallback
|
import androidx.activity.OnBackPressedCallback
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
@@ -39,6 +40,7 @@ class EditBillActivity : AppCompatActivity() {
|
|||||||
private val calendar = Calendar.getInstance()
|
private val calendar = Calendar.getInstance()
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
enableEdgeToEdge()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
db = CowspentSQLiteOpenHelper.getInstance(this)
|
db = CowspentSQLiteOpenHelper.getInstance(this)
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package net.helcel.cowspent.android.bill_label
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
@@ -20,6 +21,7 @@ class LabelBillsActivity : AppCompatActivity() {
|
|||||||
private val viewModel: LabelBillsViewModel by viewModels()
|
private val viewModel: LabelBillsViewModel by viewModels()
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
enableEdgeToEdge()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
val projectId = intent.getLongExtra(EXTRA_PROJECT_ID, -1L)
|
val projectId = intent.getLongExtra(EXTRA_PROJECT_ID, -1L)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package net.helcel.cowspent.android.currencies
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
@@ -40,6 +41,7 @@ class ManageCurrenciesActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
enableEdgeToEdge()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
intent.extras?.let {
|
intent.extras?.let {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import android.os.Bundle
|
|||||||
import android.os.VibrationEffect
|
import android.os.VibrationEffect
|
||||||
import android.os.Vibrator
|
import android.os.Vibrator
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
@@ -42,6 +43,7 @@ class QrCodeScannerActivity : AppCompatActivity() {
|
|||||||
private var isScanning = true
|
private var isScanning = true
|
||||||
|
|
||||||
override fun onCreate(state: Bundle?) {
|
override fun onCreate(state: Bundle?) {
|
||||||
|
enableEdgeToEdge()
|
||||||
super.onCreate(state)
|
super.onCreate(state)
|
||||||
|
|
||||||
cameraExecutor = Executors.newSingleThreadExecutor()
|
cameraExecutor = Executors.newSingleThreadExecutor()
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import android.util.Log
|
|||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.OnBackPressedCallback
|
import androidx.activity.OnBackPressedCallback
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
@@ -193,6 +194,7 @@ class BillsListViewActivity :
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
enableEdgeToEdge()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
isActivityVisible = true
|
isActivityVisible = true
|
||||||
if (savedInstanceState != null) {
|
if (savedInstanceState != null) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import android.net.Uri
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Patterns
|
import android.util.Patterns
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
@@ -33,6 +34,7 @@ class NewProjectActivity : AppCompatActivity() {
|
|||||||
private lateinit var db: CowspentSQLiteOpenHelper
|
private lateinit var db: CowspentSQLiteOpenHelper
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
enableEdgeToEdge()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
db = CowspentSQLiteOpenHelper.getInstance(this)
|
db = CowspentSQLiteOpenHelper.getInstance(this)
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package net.helcel.cowspent.android.project.edit
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
@@ -27,6 +28,7 @@ class EditProjectActivity : AppCompatActivity() {
|
|||||||
private lateinit var project: DBProject
|
private lateinit var project: DBProject
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
enableEdgeToEdge()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
db = CowspentSQLiteOpenHelper.getInstance(this)
|
db = CowspentSQLiteOpenHelper.getInstance(this)
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package net.helcel.cowspent.android.settings
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.OnBackPressedCallback
|
import androidx.activity.OnBackPressedCallback
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -25,6 +26,7 @@ import net.helcel.cowspent.util.ColorUtils
|
|||||||
class PreferencesActivity : AppCompatActivity() {
|
class PreferencesActivity : AppCompatActivity() {
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
enableEdgeToEdge()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
|
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
|
||||||
|
|||||||
+299
-366
@@ -1,34 +1,86 @@
|
|||||||
package net.helcel.cowspent.android.statistics
|
package net.helcel.cowspent.android.statistics
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
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.Canvas
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.material.*
|
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.Icons
|
||||||
import androidx.compose.material.icons.filled.Group
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.geometry.CornerRadius
|
import androidx.compose.ui.geometry.Rect
|
||||||
import androidx.compose.ui.geometry.Offset
|
|
||||||
import androidx.compose.ui.geometry.Size
|
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.Path
|
import androidx.compose.ui.graphics.Path
|
||||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
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.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
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.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.DBBill
|
||||||
import net.helcel.cowspent.model.DBCategory
|
import net.helcel.cowspent.model.DBCategory
|
||||||
import net.helcel.cowspent.model.DBMember
|
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)
|
@OptIn(ExperimentalMaterialApi::class)
|
||||||
@SuppressLint("UseKtx")
|
@SuppressLint("UseKtx")
|
||||||
@@ -41,15 +93,11 @@ fun ProjectSankeyDiagram(
|
|||||||
onShareReady: (String) -> Unit
|
onShareReady: (String) -> Unit
|
||||||
) {
|
) {
|
||||||
val shareStatsIntro = stringResource(R.string.share_stats_intro, projectName)
|
val shareStatsIntro = stringResource(R.string.share_stats_intro, projectName)
|
||||||
|
|
||||||
var selectedMemberId by remember { mutableLongStateOf(-1L) }
|
var selectedMemberId by remember { mutableLongStateOf(-1L) }
|
||||||
var expanded by remember { mutableStateOf(false) }
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val activeBills = remember(allBills) {
|
val activeBills = remember(allBills) {
|
||||||
allBills.filter {
|
allBills.filter { it.state != DBBill.STATE_DELETED && it.categoryRemoteId != DBBill.CATEGORY_REIMBURSEMENT }
|
||||||
it.state != DBBill.STATE_DELETED &&
|
|
||||||
it.categoryRemoteId != DBBill.CATEGORY_REIMBURSEMENT
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val membersMap = remember(allMembers) { allMembers.associateBy { it.id } }
|
val membersMap = remember(allMembers) { allMembers.associateBy { it.id } }
|
||||||
@@ -62,408 +110,293 @@ fun ProjectSankeyDiagram(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Consolidated spending calculation
|
|
||||||
val spendings = remember(activeBills, selectedMemberId, membersMap) {
|
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>()
|
val catMap = mutableMapOf<Int, Double>()
|
||||||
membersMap.keys.forEach { spentMap[it] = 0.0 }
|
|
||||||
|
|
||||||
activeBills.forEach { bill ->
|
activeBills.forEach { bill ->
|
||||||
val totalWeight = bill.billOwers.sumOf { bo ->
|
val totalWeight = bill.billOwers.sumOf { membersMap[it.memberId]?.weight ?: 1.0 }
|
||||||
membersMap[bo.memberId]?.weight ?: 1.0
|
|
||||||
}
|
|
||||||
if (totalWeight > 0) {
|
if (totalWeight > 0) {
|
||||||
if (selectedMemberId == -1L) {
|
if (selectedMemberId == -1L) {
|
||||||
catMap[bill.categoryRemoteId] = (catMap[bill.categoryRemoteId] ?: 0.0) + bill.amount
|
catMap[bill.categoryRemoteId] = (catMap[bill.categoryRemoteId] ?: 0.0) + bill.amount
|
||||||
bill.billOwers.forEach { bo ->
|
bill.billOwers.forEach { bo ->
|
||||||
val weight = membersMap[bo.memberId]?.weight ?: 1.0
|
val weight = membersMap[bo.memberId]?.weight ?: 1.0
|
||||||
val share = (bill.amount / totalWeight) * weight
|
spentMap[bo.memberId] = (spentMap[bo.memberId] ?: 0.0) + (bill.amount / totalWeight) * weight
|
||||||
spentMap[bo.memberId] = (spentMap[bo.memberId] ?: 0.0) + share
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
bill.billOwers.find { it.memberId == selectedMemberId }?.let { bo ->
|
bill.billOwers.find { it.memberId == selectedMemberId }?.let { bo ->
|
||||||
val weight = membersMap[bo.memberId]?.weight ?: 1.0
|
val weight = membersMap[bo.memberId]?.weight ?: 1.0
|
||||||
val share = (bill.amount / totalWeight) * weight
|
catMap[bill.categoryRemoteId] = (catMap[bill.categoryRemoteId] ?: 0.0) + (bill.amount / totalWeight) * weight
|
||||||
catMap[bill.categoryRemoteId] = (catMap[bill.categoryRemoteId] ?: 0.0) + share
|
spentMap[selectedMemberId] = (spentMap[selectedMemberId] ?: 0.0) + (bill.amount / totalWeight) * weight
|
||||||
spentMap[selectedMemberId] = (spentMap[selectedMemberId] ?: 0.0) + share
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
spentMap.toList().filter { it.second > 0 }.sortedByDescending { it.second } to catMap.toList().sortedByDescending { it.second }
|
||||||
val memberList = spentMap.toList().filter { it.second > 0 }.sortedByDescending { it.second }
|
|
||||||
val categoryList = catMap.toList().sortedByDescending { it.second }
|
|
||||||
memberList to categoryList
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val displayMemberSpendings = spendings.first
|
val displayMemberSpendings = spendings.first
|
||||||
val displayCategorySpendings = spendings.second
|
val displayCategorySpendings = spendings.second
|
||||||
val totalAmount = remember(displayMemberSpendings) { displayMemberSpendings.sumOf { it.second } }
|
val totalAmount = remember(displayMemberSpendings) { displayMemberSpendings.sumOf { it.second } }
|
||||||
|
var isSpecialMode by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
LaunchedEffect(displayMemberSpendings, displayCategorySpendings, totalAmount, projectName) {
|
LaunchedEffect(displayMemberSpendings, displayCategorySpendings, totalAmount, projectName) {
|
||||||
val statsText = StringBuilder()
|
val statsText = StringBuilder().apply {
|
||||||
statsText.append("// ").append(shareStatsIntro.replace("\n", "\n// ")).append("\n\n")
|
append("// ").append(shareStatsIntro.replace("\n", "\n// ")).append("\n\n")
|
||||||
|
val middleNode = if (selectedMemberId == -1L) "Total" else "Spent"
|
||||||
val middleNode = if (selectedMemberId == -1L) "Total" else "Spent"
|
displayMemberSpendings.forEach { (id, amount) -> append("${membersMap[id]?.name ?: "???"} [${formatShortValue(amount)}] $middleNode\n") }
|
||||||
|
append("\n")
|
||||||
displayMemberSpendings.forEach { (memberId, amount) ->
|
displayCategorySpendings.forEach { (id, amount) -> append("$middleNode [${formatShortValue(amount)}] ${categoriesMap[id]?.name ?: "Other"}\n") }
|
||||||
val name = membersMap[memberId]?.name ?: "???"
|
append("\n")
|
||||||
statsText.append("$name [${formatShortValue(amount)}] $middleNode\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") }
|
||||||
|
|
||||||
statsText.append("\n")
|
|
||||||
|
|
||||||
displayCategorySpendings.forEach { (catRemoteId, amount) ->
|
|
||||||
val name = categoriesMap[catRemoteId]?.name ?: "Other"
|
|
||||||
statsText.append("$middleNode [${formatShortValue(amount)}] $name\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
statsText.append("\n")
|
|
||||||
|
|
||||||
displayMemberSpendings.forEach { (memberId, _) ->
|
|
||||||
val member = membersMap[memberId]
|
|
||||||
if (member != null) {
|
|
||||||
val hexColor = String.format("#%02x%02x%02x", member.r ?: 128, member.g ?: 128, member.b ?: 128)
|
|
||||||
statsText.append(":${member.name} $hexColor\n")
|
|
||||||
}
|
}
|
||||||
|
displayCategorySpendings.forEach { (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())
|
onShareReady(statsText.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
val selectedMember = membersMap[selectedMemberId]
|
Box(modifier = Modifier.padding(16.dp)) {
|
||||||
EditableExposedDropdownMenu(
|
val selectedMember = membersMap[selectedMemberId]
|
||||||
value = selectedMember?.name ?: "All Members",
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
placeholder = "Filter by member",
|
EditableExposedDropdownMenu(
|
||||||
expanded = expanded,
|
value = selectedMember?.name ?: "All Members",
|
||||||
onExpandedChange = { expanded = it },
|
placeholder = "Filter by member",
|
||||||
onDismissRequest = { expanded = false },
|
expanded = expanded,
|
||||||
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
onExpandedChange = { expanded = it },
|
||||||
leadingIcon = {
|
onDismissRequest = { expanded = false },
|
||||||
Box(modifier = Modifier.padding(start = 12.dp)) {
|
modifier = Modifier.weight(1f).padding(bottom = 16.dp),
|
||||||
if (selectedMember != null) {
|
leadingIcon = {
|
||||||
UserAvatar(
|
Box(modifier = Modifier.padding(start = 12.dp)) {
|
||||||
name = selectedMember.name,
|
if (selectedMember != null) {
|
||||||
r = selectedMember.r,
|
UserAvatar(name = selectedMember.name, r = selectedMember.r, g = selectedMember.g, b = selectedMember.b, disabled = !selectedMember.isActivated, size = 24.dp)
|
||||||
g = selectedMember.g,
|
} else Icon(Icons.Default.Group, contentDescription = null)
|
||||||
b = selectedMember.b,
|
}
|
||||||
disabled = !selectedMember.isActivated,
|
},
|
||||||
size = 24.dp
|
content = {
|
||||||
)
|
DropdownMenuItem(onClick = { selectedMemberId = -1L; expanded = false }) {
|
||||||
} else {
|
Icon(Icons.Default.Group, contentDescription = null)
|
||||||
Icon(Icons.Default.Group, contentDescription = null)
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
}
|
Text("All Members")
|
||||||
}
|
}
|
||||||
},
|
allMembers.forEach { member ->
|
||||||
content = {
|
DropdownMenuItem(onClick = { selectedMemberId = member.id; expanded = false }) {
|
||||||
DropdownMenuItem(onClick = {
|
UserAvatar(name = member.name, r = member.r, g = member.g, b = member.b, disabled = !member.isActivated, size = 24.dp)
|
||||||
selectedMemberId = -1L
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
expanded = false
|
Text(member.name)
|
||||||
}) {
|
}
|
||||||
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) {
|
if (totalAmount <= 0) {
|
||||||
Box(modifier = Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
Box(modifier = Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||||
Text("No spending data for selected filter", style = MaterialTheme.typography.body1)
|
Text("No spending data for selected filter", style = MaterialTheme.typography.body1)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
BoxWithConstraints(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
SankeyContent(isSpecialMode, selectedMemberId, totalAmount, displayMemberSpendings, displayCategorySpendings, membersMap, categoriesMap)
|
||||||
val nodeHeightMember = 76.dp
|
|
||||||
val nodeHeightCategory = 76.dp
|
|
||||||
val nodeHeightTotal = 42.dp
|
|
||||||
val horizontalGap = 8.dp
|
|
||||||
|
|
||||||
val memberCount = displayMemberSpendings.size
|
|
||||||
val categoryCount = displayCategorySpendings.size
|
|
||||||
|
|
||||||
val maxGaps = maxOf(memberCount - 1, categoryCount - 1, 0)
|
|
||||||
val usableWidth = (maxWidth.value - horizontalGap.value * maxGaps).coerceAtLeast(0f)
|
|
||||||
val moneyScale = if (totalAmount > 0) usableWidth / totalAmount.toFloat() else 0f
|
|
||||||
|
|
||||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
|
||||||
val nodeHeightMemberPx = nodeHeightMember.toPx()
|
|
||||||
val nodeHeightCategoryPx = nodeHeightCategory.toPx()
|
|
||||||
val nodeHeightTotalPx = nodeHeightTotal.toPx()
|
|
||||||
val gapPx = horizontalGap.toPx()
|
|
||||||
|
|
||||||
val totalWidthPx = size.width
|
|
||||||
val usableWidthPx = totalWidthPx - gapPx * maxGaps
|
|
||||||
val moneyScalePx = if (totalAmount > 0) usableWidthPx / totalAmount else 0.0
|
|
||||||
|
|
||||||
val totalNodeWidthPx = (totalAmount * moneyScalePx).toFloat()
|
|
||||||
val totalNodeXPx = (totalWidthPx - totalNodeWidthPx) / 2
|
|
||||||
|
|
||||||
val topY = 0f
|
|
||||||
val middleY = (size.height - nodeHeightTotalPx) / 2
|
|
||||||
val bottomY = size.height - nodeHeightCategoryPx
|
|
||||||
|
|
||||||
val totalNodeColor = Color(0xFF333333)
|
|
||||||
|
|
||||||
// Flows
|
|
||||||
var currentXTop = (totalWidthPx - (totalAmount * moneyScalePx + (memberCount - 1).coerceAtLeast(0) * gapPx).toFloat()) / 2
|
|
||||||
var currentXInTotalTop = totalNodeXPx
|
|
||||||
displayMemberSpendings.forEach { (memberId, amount) ->
|
|
||||||
val member = membersMap[memberId]
|
|
||||||
val boxWidth = (amount * moneyScalePx).toFloat()
|
|
||||||
val color = member?.let {
|
|
||||||
Color(it.r ?: 128, it.g ?: 128, it.b ?: 128)
|
|
||||||
} ?: Color.Gray
|
|
||||||
|
|
||||||
if (boxWidth > 0.5f) {
|
|
||||||
drawSankeyFlow(
|
|
||||||
startX = currentXTop,
|
|
||||||
startY = topY + nodeHeightMemberPx * 0.5f,
|
|
||||||
startWidth = boxWidth,
|
|
||||||
endX = currentXInTotalTop,
|
|
||||||
endY = middleY + nodeHeightTotalPx * 0.5f,
|
|
||||||
endWidth = boxWidth,
|
|
||||||
startColor = color.copy(alpha = 0.5f),
|
|
||||||
endColor = totalNodeColor.copy(alpha = 0.35f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
currentXTop += boxWidth + gapPx
|
|
||||||
currentXInTotalTop += boxWidth
|
|
||||||
}
|
|
||||||
|
|
||||||
var currentXBottom = (totalWidthPx - (totalAmount * moneyScalePx + (categoryCount - 1).coerceAtLeast(0) * gapPx).toFloat()) / 2
|
|
||||||
var currentXInTotalBottom = totalNodeXPx
|
|
||||||
displayCategorySpendings.forEach { (catRemoteId, amount) ->
|
|
||||||
val category = categoriesMap[catRemoteId]
|
|
||||||
val boxWidth = (amount * moneyScalePx).toFloat()
|
|
||||||
val color = category?.color?.let {
|
|
||||||
try { Color(it.toColorInt()) } catch (_: Exception) { Color(0xFF999999) }
|
|
||||||
} ?: Color(0xFF999999)
|
|
||||||
|
|
||||||
if (boxWidth > 0.5f) {
|
|
||||||
drawSankeyFlow(
|
|
||||||
startX = currentXInTotalBottom,
|
|
||||||
startY = middleY + nodeHeightTotalPx * 0.5f,
|
|
||||||
startWidth = boxWidth,
|
|
||||||
endX = currentXBottom,
|
|
||||||
endY = bottomY + nodeHeightCategoryPx * 0.5f,
|
|
||||||
endWidth = boxWidth,
|
|
||||||
startColor = totalNodeColor.copy(alpha = 0.35f),
|
|
||||||
endColor = color.copy(alpha = 0.5f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
currentXInTotalBottom += boxWidth
|
|
||||||
currentXBottom += boxWidth + gapPx
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nodes
|
|
||||||
currentXTop = (totalWidthPx - (totalAmount * moneyScalePx + (memberCount - 1).coerceAtLeast(0) * gapPx).toFloat()) / 2
|
|
||||||
displayMemberSpendings.forEach { (memberId, amount) ->
|
|
||||||
val member = membersMap[memberId]
|
|
||||||
val width = (amount * moneyScalePx).toFloat()
|
|
||||||
val color = member?.let {
|
|
||||||
Color(it.r ?: 128, it.g ?: 128, it.b ?: 128)
|
|
||||||
} ?: Color.Gray
|
|
||||||
|
|
||||||
if (width > 0.5f) {
|
|
||||||
drawRoundRect(
|
|
||||||
color = color,
|
|
||||||
topLeft = Offset(currentXTop, topY),
|
|
||||||
size = Size(width, nodeHeightMemberPx),
|
|
||||||
cornerRadius = CornerRadius(10.dp.toPx())
|
|
||||||
)
|
|
||||||
}
|
|
||||||
currentXTop += width + gapPx
|
|
||||||
}
|
|
||||||
|
|
||||||
drawRect(
|
|
||||||
color = totalNodeColor,
|
|
||||||
topLeft = Offset(totalNodeXPx, middleY),
|
|
||||||
size = Size(totalNodeWidthPx, nodeHeightTotalPx)
|
|
||||||
)
|
|
||||||
|
|
||||||
currentXBottom = (totalWidthPx - (totalAmount * moneyScalePx + (categoryCount - 1).coerceAtLeast(0) * gapPx).toFloat()) / 2
|
|
||||||
displayCategorySpendings.forEach { (catRemoteId, amount) ->
|
|
||||||
val category = categoriesMap[catRemoteId]
|
|
||||||
val width = (amount * moneyScalePx).toFloat()
|
|
||||||
val color = category?.color?.let {
|
|
||||||
try { Color(it.toColorInt()) } catch (_: Exception) { Color(0xFF999999) }
|
|
||||||
} ?: Color(0xFF999999)
|
|
||||||
|
|
||||||
if (width > 0.5f) {
|
|
||||||
drawRoundRect(
|
|
||||||
color = color,
|
|
||||||
topLeft = Offset(currentXBottom, bottomY),
|
|
||||||
size = Size(width, nodeHeightCategoryPx),
|
|
||||||
cornerRadius = CornerRadius(10.dp.toPx())
|
|
||||||
)
|
|
||||||
}
|
|
||||||
currentXBottom += width + gapPx
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Member labels
|
|
||||||
var currentXTopLabel = (maxWidth.value - (totalAmount.toFloat() * moneyScale + (memberCount - 1).coerceAtLeast(0) * horizontalGap.value)) / 2
|
|
||||||
displayMemberSpendings.forEach { (memberId, amount) ->
|
|
||||||
val member = membersMap[memberId]
|
|
||||||
val widthValue = amount.toFloat() * moneyScale
|
|
||||||
val width = widthValue.dp
|
|
||||||
if (widthValue > 12f) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.offset(x = currentXTopLabel.dp, y = 0.dp)
|
|
||||||
.size(width, nodeHeightMember),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
if (width >= 36.dp) {
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = member?.name ?: "???",
|
|
||||||
fontSize = 16.sp,
|
|
||||||
color = Color.White,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = formatShortValue(amount),
|
|
||||||
fontSize = 14.sp,
|
|
||||||
color = Color.White.copy(alpha = 0.9f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
currentXTopLabel += widthValue + horizontalGap.value
|
|
||||||
}
|
|
||||||
|
|
||||||
// Total label
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.Center)
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(nodeHeightTotal),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = if (selectedMemberId == -1L) formatShortValue(totalAmount) else "SPENT: ${formatShortValue(totalAmount)}",
|
|
||||||
fontSize = 16.sp,
|
|
||||||
color = Color.White,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
fontWeight = FontWeight.ExtraBold
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Category labels
|
|
||||||
var currentXBottomLabel = (maxWidth.value - (totalAmount.toFloat() * moneyScale + (categoryCount - 1).coerceAtLeast(0) * horizontalGap.value)) / 2
|
|
||||||
displayCategorySpendings.forEach { (catRemoteId, amount) ->
|
|
||||||
val category = categoriesMap[catRemoteId]
|
|
||||||
val widthValue = amount.toFloat() * moneyScale
|
|
||||||
val width = widthValue.dp
|
|
||||||
if (widthValue > 12f) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.BottomStart)
|
|
||||||
.offset(x = currentXBottomLabel.dp)
|
|
||||||
.size(width, nodeHeightCategory),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = category?.icon ?: "❔",
|
|
||||||
fontSize = 20.sp
|
|
||||||
)
|
|
||||||
if (width >= 32.dp) {
|
|
||||||
Text(
|
|
||||||
text = formatShortValue(amount),
|
|
||||||
fontSize = 14.sp,
|
|
||||||
color = Color.White.copy(alpha = 0.9f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
currentXBottomLabel += widthValue + horizontalGap.value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Spacer(Modifier.fillMaxWidth().height(32.dp))
|
Spacer(Modifier.fillMaxWidth().height(32.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun DrawScope.drawSankeyFlow(
|
private data class NodeLayout(val centerX: Float, val visualWidth: Float)
|
||||||
startX: Float,
|
|
||||||
startY: Float,
|
@SuppressLint("FrequentlyChangingValue")
|
||||||
startWidth: Float,
|
@Composable
|
||||||
endX: Float,
|
private fun SankeyContent(
|
||||||
endY: Float,
|
isSpecialMode: Boolean,
|
||||||
endWidth: Float,
|
selectedMemberId: Long,
|
||||||
startColor: Color,
|
totalAmount: Double,
|
||||||
endColor: Color
|
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 {
|
val path = Path().apply {
|
||||||
moveTo(startX, startY)
|
moveTo(startX, startY)
|
||||||
cubicTo(
|
cubicTo(startX, startY + (endY - startY) * 0.5f, endX, endY - (endY - startY) * 0.5f, endX, endY)
|
||||||
startX, startY + (endY - startY) * 0.5f,
|
|
||||||
endX, endY - (endY - startY) * 0.5f,
|
|
||||||
endX, endY
|
|
||||||
)
|
|
||||||
lineTo(endX + endWidth, endY)
|
lineTo(endX + endWidth, endY)
|
||||||
cubicTo(
|
cubicTo(endX + endWidth, endY - (endY - startY) * 0.5f, startX + startWidth, startY + (endY - startY) * 0.5f, startX + startWidth, startY)
|
||||||
endX + endWidth, endY - (endY - startY) * 0.5f,
|
|
||||||
startX + startWidth, startY + (endY - startY) * 0.5f,
|
|
||||||
startX + startWidth, startY
|
|
||||||
)
|
|
||||||
close()
|
close()
|
||||||
}
|
}
|
||||||
drawPath(
|
drawPath(path = path, brush = Brush.verticalGradient(colors = listOf(startColor, endColor), startY = startY, endY = endY))
|
||||||
path = path,
|
|
||||||
brush = Brush.verticalGradient(
|
|
||||||
colors = listOf(startColor, endColor),
|
|
||||||
startY = startY,
|
|
||||||
endY = endY
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview(showBackground = true, widthDp = 360, heightDp = 640)
|
@Preview(showBackground = true, widthDp = 360, heightDp = 640)
|
||||||
@Composable
|
@Composable
|
||||||
fun ProjectSankeyDiagramPreview() {
|
fun ProjectSankeyDiagramPreview() = MaterialTheme { ProjectSankeyDiagram("Test Project", StatisticsMockData.members, StatisticsMockData.bills, emptyList()) {} }
|
||||||
MaterialTheme {
|
|
||||||
ProjectSankeyDiagram(
|
|
||||||
projectName = "Test Project",
|
|
||||||
allMembers = StatisticsMockData.members,
|
|
||||||
allBills = StatisticsMockData.bills,
|
|
||||||
customCategories = emptyList(),
|
|
||||||
onShareReady = {}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package net.helcel.cowspent.android.statistics
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
@@ -15,6 +16,7 @@ import net.helcel.cowspent.theme.ThemeUtils
|
|||||||
class ProjectStatisticsActivity : AppCompatActivity() {
|
class ProjectStatisticsActivity : AppCompatActivity() {
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
enableEdgeToEdge()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
val projectId = intent.getLongExtra(EXTRA_PROJECT_ID, -1L)
|
val projectId = intent.getLongExtra(EXTRA_PROJECT_ID, -1L)
|
||||||
|
|||||||
@@ -100,14 +100,18 @@ object ThemeUtils {
|
|||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
color = MaterialTheme.colors.background
|
color = MaterialTheme.colors.background
|
||||||
) {
|
) {
|
||||||
Column {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(MaterialTheme.colors.primary)
|
.background(MaterialTheme.colors.primary)
|
||||||
.statusBarsPadding()
|
.statusBarsPadding()
|
||||||
)
|
)
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.navigationBarsPadding()
|
||||||
|
) {
|
||||||
content()
|
content()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user