init
Some checks failed
CI-Android APK / build (push) Failing after 4m27s

This commit is contained in:
soraefir
2026-05-23 15:09:28 +02:00
commit 5ee2041769
129 changed files with 19835 additions and 0 deletions

111
app/build.gradle Normal file
View File

@@ -0,0 +1,111 @@
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'
}
android {
namespace = 'net.helcel.cowspent'
compileSdk = 37
defaultConfig {
buildConfigField("String", "APP_NAME", "\"Cowspent\"")
manifestPlaceholders["APP_NAME"] = "Cowspent"
applicationId "net.helcel.cowspent"
minSdk = 26
targetSdk = 37
versionName "1.0"
versionCode project.hasProperty('VERSION_CODE') ? project.property('VERSION_CODE').toInteger() : 1
}
signingConfigs {
register("release") {
try {
def keystorePropertiesFile = rootProject.file("app/keystore.properties")
def keystoreProperties = new Properties()
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
storeFile file(keystoreProperties['storeFile'])
storePassword keystoreProperties['storePassword']
} catch (FileNotFoundException e) {
println("File not found: ${e.message}")
}
}
}
buildTypes {
debug {
debuggable true
initWith(buildTypes.release)
signingConfig = signingConfigs.debug
}
release {
minifyEnabled true
shrinkResources = true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
signedRelease {
minifyEnabled true
shrinkResources = true
initWith(buildTypes.release)
matchingFallbacks = ['release']
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
signingConfig = signingConfigs.getByName("release")
}
}
compileOptions {
coreLibraryDesugaringEnabled = true
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
encoding = 'utf-8'
}
buildFeatures {
viewBinding = true
buildConfig = true
compose = true
}
kotlin {
jvmToolchain(21)
}
lint {
abortOnError = false
disable 'MissingTranslation'
disable 'UsingMaterialAndMaterial3Libraries'
}
androidResources {
generateLocaleConfig = true
}
}
dependencies {
implementation 'androidx.compose.foundation:foundation:1.11.2'
implementation 'androidx.compose.runtime:runtime:1.11.2'
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.5'
implementation 'androidx.preference:preference-ktx:1.2.1'
implementation 'androidx.work:work-runtime-ktx:2.11.2'
implementation 'com.google.zxing:core:3.5.4'
implementation 'androidx.camera:camera-camera2:1.6.1'
implementation 'androidx.camera:camera-lifecycle:1.6.1'
implementation 'androidx.camera:camera-view:1.6.1'
implementation 'com.github.nextcloud:Android-SingleSignOn:1.1.0'
implementation 'com.opencsv:opencsv:5.12.0'
implementation platform('androidx.compose:compose-bom:2026.05.01')
implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.material:material'
implementation 'androidx.compose.material:material-icons-extended'
implementation 'androidx.compose.ui:ui-tooling-preview'
debugImplementation 'androidx.compose.ui:ui-tooling'
implementation 'androidx.activity:activity-compose:1.13.0'
implementation 'androidx.activity:activity-ktx:1.13.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0'
}

17
app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,17 @@
# Add project specific ProGuard rules here.
# By default, the flags in this file are appended to flags specified
# in C:\Users\stnieder\AppData\Local\Android\sdk/tools/proguard/proguard-android.txt
# You can edit the include path and order by changing the proguardFiles
# directive in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# Add any project specific keep options here:
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}

View File

@@ -0,0 +1,126 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
android:installLocation="preferExternal">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature
android:name="android.hardware.camera"
android:required="false"/>
<uses-permission android:name="android.permission.GET_ACCOUNTS"/>
<application
android:name="net.helcel.cowspent.util.Cowspent"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity android:name="net.helcel.cowspent.android.currencies.ManageCurrenciesActivity" />
<activity
android:name="net.helcel.cowspent.android.main.BillsListViewActivity"
android:theme="@style/AppTheme.NoActionBar"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.SEARCH" />
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<meta-data
android:name="android.app.searchable"
android:resource="@xml/searchable" />
<meta-data
android:name="android.app.default_searchable"
android:value="net.helcel.cowspent.android.main.BillsListViewActivity" />
</activity>
<activity
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:launchMode="standard"
/>
<activity
android:name="net.helcel.cowspent.android.settings.PreferencesActivity"
android:label="@string/action_settings"
android:parentActivityName="net.helcel.cowspent.android.main.BillsListViewActivity"
android:windowSoftInputMode="stateHidden" />
<activity
android:name="net.helcel.cowspent.android.helper.QrCodeScannerActivity"
android:label="@string/action_scan_qrcode"
android:parentActivityName="net.helcel.cowspent.android.main.BillsListViewActivity"
android:windowSoftInputMode="stateHidden"
/>
<activity
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:launchMode="standard"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="cospend" />
<data android:scheme="cowspent" />
<data android:host="*" />
<data android:pathPrefix="/" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="cospend+http" />
<data android:scheme="cowspent+http" />
<data android:host="*" />
<data android:pathPrefix="/" />
</intent-filter>
</activity>
<activity
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:launchMode="standard">
</activity>
<activity
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:launchMode="standard">
</activity>
<activity
android:name="net.helcel.cowspent.android.about.AboutActivity"
android:label="@string/simple_about"
android:parentActivityName="net.helcel.cowspent.android.main.BillsListViewActivity" />
<activity
android:name="net.helcel.cowspent.android.statistics.ProjectStatisticsActivity"
android:label="@string/statistic_title"
android:parentActivityName="net.helcel.cowspent.android.main.BillsListViewActivity"
android:theme="@style/AppTheme.NoActionBar" />
<activity
android:name="net.helcel.cowspent.android.bill_label.LabelBillsActivity"
android:label="@string/label_bills_title"
android:parentActivityName="net.helcel.cowspent.android.main.BillsListViewActivity"
android:theme="@style/AppTheme.NoActionBar" />
</application>
</manifest>

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

View File

@@ -0,0 +1,21 @@
package net.helcel.cowspent.android.about
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import net.helcel.cowspent.theme.ThemeUtils
class AboutActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ThemeUtils.CowspentTheme {
AboutScreen(
onBack = { finish() }
)
}
}
}
}

View File

@@ -0,0 +1,153 @@
package net.helcel.cowspent.android.about
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
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.TextDecoration
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import net.helcel.cowspent.BuildConfig
import net.helcel.cowspent.R
import net.helcel.cowspent.theme.ThemeUtils
@Composable
fun AboutScreen(
onBack: () -> Unit
) {
val uriHandler = LocalUriHandler.current
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.simple_about)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
},
backgroundColor = MaterialTheme.colors.primary,
elevation = 0.dp
)
}
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Image(
painter = painterResource(id = R.drawable.ic_launcher_foreground),
contentDescription = null,
modifier = Modifier
.size(156.dp)
.clip(CircleShape)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(id = R.string.app_name),
style = MaterialTheme.typography.h4,
fontWeight = FontWeight.Bold
)
Text(
text = stringResource(R.string.about_version, BuildConfig.VERSION_NAME),
style = MaterialTheme.typography.body2,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(48.dp))
AboutItem(
title = stringResource(R.string.about_maintainer_title),
content = stringResource(R.string.about_maintainer)
)
Spacer(modifier = Modifier.height(24.dp))
AboutItem(
title = stringResource(R.string.about_license_title),
content = stringResource(R.string.about_license)
)
Spacer(modifier = Modifier.height(24.dp))
val sourceUrl = stringResource(R.string.about_source)
AboutItem(
title = stringResource(R.string.about_source_title),
content = sourceUrl,
onClick = { uriHandler.openUri(sourceUrl) }
)
}
}
}
@Composable
fun AboutItem(
title: String,
content: String,
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null
) {
var itemModifier = modifier.fillMaxWidth()
if (onClick != null) {
itemModifier = itemModifier.clickable(onClick = onClick)
}
Column(
modifier = itemModifier,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = title.uppercase(),
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.primary,
fontWeight = FontWeight.Bold
)
Text(
text = content,
style = MaterialTheme.typography.body1,
textAlign = TextAlign.Center,
color = if (onClick != null) MaterialTheme.colors.primary else MaterialTheme.typography.body1.color,
textDecoration = if (onClick != null) TextDecoration.Underline else null
)
}
}
@Preview(showBackground = true)
@Composable
fun AboutScreenPreview() {
ThemeUtils.CowspentTheme {
AboutScreen(onBack = {})
}
}

View File

@@ -0,0 +1,339 @@
package net.helcel.cowspent.android.account
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.webkit.CookieManager
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Toast
import androidx.activity.OnBackPressedCallback
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.edit
import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceManager
import com.nextcloud.android.sso.AccountImporter
import com.nextcloud.android.sso.helper.SingleAccountHelper
import com.nextcloud.android.sso.model.SingleSignOnAccount
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.helcel.cowspent.R
import net.helcel.cowspent.android.main.MainConstants
import net.helcel.cowspent.theme.ThemeUtils
import net.helcel.cowspent.util.CospendClientUtil
import net.helcel.cowspent.util.CospendClientUtil.LoginStatus
import java.net.URLDecoder
import java.util.Locale
class AccountActivity : AppCompatActivity() {
private val viewModel: AccountViewModel by viewModels()
companion object {
private val TAG = AccountActivity::class.java.simpleName
const val SETTINGS_USE_SSO = "settingsUseSSO"
const val SETTINGS_SSO_URL = "settingsSSOUrl"
const val SETTINGS_SSO_USERNAME = "settingsSSOUsername"
const val SETTINGS_URL = "settingsUrl"
const val SETTINGS_USERNAME = "settingsUsername"
const val SETTINGS_PASSWORD = "settingsPassword"
const val SETTINGS_KEY_ETAG = "sessions_last_etag"
const val SETTINGS_KEY_LAST_MODIFIED = "sessions_last_modified"
const val DEFAULT_SETTINGS = ""
const val CREDENTIALS_CHANGED = 3
const val LOGIN_URL_DATA_KEY_VALUE_SEPARATOR = ":"
const val WEBDAV_PATH_4_0_AND_LATER = "/remote.php/webdav"
}
private lateinit var preferences: SharedPreferences
private var oldPassword = ""
private var useWebLogin = true
private var showLoginDialog by mutableStateOf(false)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
preferences = PreferenceManager.getDefaultSharedPreferences(applicationContext)
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
if (viewModel.showWebView) {
viewModel.showWebView = false
} else {
finish()
}
}
})
setContent {
ThemeUtils.CowspentTheme {
if (viewModel.showWebView) {
val serverUrl = CospendClientUtil.formatURL(viewModel.serverUrl)
WebLoginScreen(
url = normalizeUrlSuffix(serverUrl) + "index.php/login/flow",
onLoginUrlDetected = { parseAndLoginFromWebView(it) }
)
} else {
AccountScreen(
viewModel = viewModel,
onBack = { finish() },
onConnect = { login() },
onSsoClick = { isChecked ->
if (isChecked) {
showLoginDialog = true
} else {
viewModel.useSso = false
preferences.edit { putBoolean(SETTINGS_USE_SSO, false) }
}
},
onLogout = { viewModel.logout() }
)
}
LoginDialog(
showDialog = showLoginDialog,
onDismissRequest = { showLoginDialog = false },
onInitiateSsoLogin = {
showLoginDialog = false
try {
AccountImporter.pickNewAccount(this@AccountActivity)
} catch (e: Exception) {
Log.e(TAG, "Failed to initiate SSO login", e)
Toast.makeText(this@AccountActivity, "SSO login failed: ${e.message}", Toast.LENGTH_SHORT).show()
}
}
)
}
}
oldPassword = preferences.getString(SETTINGS_PASSWORD, DEFAULT_SETTINGS) ?: ""
viewModel.validateUrl()
}
@SuppressLint("SetJavaScriptEnabled")
@Composable
private fun WebLoginScreen(url: String, onLoginUrlDetected: (String) -> Unit) {
var isLoading by remember { mutableStateOf(true) }
Box(modifier = Modifier.fillMaxSize()) {
AndroidView(
factory = { context ->
WebView(context).apply {
CookieManager.getInstance().setAcceptCookie(true)
CookieManager.getInstance().setAcceptThirdPartyCookies(this, true)
settings.apply {
allowFileAccess = false
javaScriptEnabled = true
domStorageEnabled = true
userAgentString = getWebLoginUserAgent(context)
}
webViewClient = object : WebViewClient() {
@Deprecated("Deprecated in Java")
override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean {
if (url?.startsWith("nc://login/") == true) {
onLoginUrlDetected(url)
return true
}
return false
}
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
val url = request?.url?.toString()
if (url?.startsWith("nc://login/") == true) {
onLoginUrlDetected(url)
return true
}
return false
}
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
isLoading = false
}
}
val headers = HashMap<String, String>()
headers["OCS-APIREQUEST"] = "true"
loadUrl(url, headers)
}
},
modifier = Modifier.fillMaxSize()
)
if (isLoading) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
}
}
@Deprecated("Deprecated in Java")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
try {
AccountImporter.onActivityResult(requestCode, resultCode, data, this
) { ssoAccount ->
lifecycleScope.launch {
SingleAccountHelper.commitCurrentAccount(applicationContext, ssoAccount.name)
preferences.edit {
putBoolean(SETTINGS_USE_SSO, true)
putString(SETTINGS_SSO_URL, ssoAccount.url)
putString(SETTINGS_SSO_USERNAME, ssoAccount.userId)
}
viewModel.useSso = true
viewModel.serverUrl = ssoAccount.url
viewModel.username = ssoAccount.userId
val resultData = Intent()
resultData.putExtra(MainConstants.CREDENTIALS_CHANGED, CREDENTIALS_CHANGED)
setResult(RESULT_OK, resultData)
finish()
}
}
} catch (e: Exception) {
Log.e(TAG, "SSO account selection failed", e)
}
}
private fun legacyLogin() {
val url = CospendClientUtil.formatURL(viewModel.serverUrl.trim())
val username = viewModel.username
var password = viewModel.password
if (password.isEmpty()) {
password = oldPassword
}
performLogin(url, username, password)
}
private fun login() {
if (useWebLogin) {
viewModel.showWebView = true
} else {
legacyLogin()
}
}
private fun getWebLoginUserAgent(context: Context): String {
val defaultUA = try {
android.webkit.WebSettings.getDefaultUserAgent(context)
} catch (_: Exception) {
Build.MANUFACTURER + " " + Build.MODEL
}
return "$defaultUA Cowspent/Android"
}
private fun parseAndLoginFromWebView(dataString: String) {
try {
val loginUrlInfo = parseLoginDataUrl(dataString)
val url = normalizeUrlSuffix(loginUrlInfo.serverAddress)
performLogin(url, loginUrlInfo.username, loginUrlInfo.password)
} catch (e: IllegalArgumentException) {
Log.e(TAG, "Invalid login URL", e)
}
}
private fun parseLoginDataUrl(dataString: String): LoginUrlInfo {
val prefix = "nc://login/"
if (dataString.length < prefix.length) {
throw IllegalArgumentException("Invalid login URL detected")
}
val loginUrlInfo = LoginUrlInfo()
val data = dataString.substring(prefix.length)
val values = data.split("&")
if (values.size !in 1..3) {
throw IllegalArgumentException("Illegal number of login URL elements detected: ${values.size}")
}
for (value in values) {
when {
value.startsWith("user$LOGIN_URL_DATA_KEY_VALUE_SEPARATOR") -> {
loginUrlInfo.username = URLDecoder.decode(value.substring(("user$LOGIN_URL_DATA_KEY_VALUE_SEPARATOR").length), "UTF-8")
}
value.startsWith("password$LOGIN_URL_DATA_KEY_VALUE_SEPARATOR") -> {
loginUrlInfo.password = URLDecoder.decode(value.substring(("password$LOGIN_URL_DATA_KEY_VALUE_SEPARATOR").length), "UTF-8")
}
value.startsWith("server$LOGIN_URL_DATA_KEY_VALUE_SEPARATOR") -> {
loginUrlInfo.serverAddress = URLDecoder.decode(value.substring(("server$LOGIN_URL_DATA_KEY_VALUE_SEPARATOR").length), "UTF-8")
}
else -> throw IllegalArgumentException("Illegal magic login URL element detected: $value")
}
}
return loginUrlInfo
}
private fun normalizeUrlSuffix(url: String): String {
return when {
url.lowercase(Locale.ROOT).endsWith(WEBDAV_PATH_4_0_AND_LATER) -> {
url.substring(0, url.length - WEBDAV_PATH_4_0_AND_LATER.length)
}
!url.endsWith("/") -> "$url/"
else -> url
}
}
private fun performLogin(url: String, username: String, password: String) {
viewModel.isSubmitting = true
viewModel.showWebView = false
lifecycleScope.launch {
val status = withContext(Dispatchers.IO) {
CospendClientUtil.isValidLogin(url, username, password)
}
if (status == LoginStatus.OK) {
preferences.edit {
putString(SETTINGS_URL, url)
putString(SETTINGS_USERNAME, username)
putString(SETTINGS_PASSWORD, password)
remove(SETTINGS_KEY_ETAG)
remove(SETTINGS_KEY_LAST_MODIFIED)
}
val data = Intent()
data.putExtra(MainConstants.CREDENTIALS_CHANGED, CREDENTIALS_CHANGED)
setResult(RESULT_OK, data)
finish()
} else {
Log.e("Cowspent", "invalid login")
viewModel.isSubmitting = false
Toast.makeText(applicationContext, getString(R.string.error_invalid_login, getString(status.str)), Toast.LENGTH_LONG).show()
}
}
}
class LoginUrlInfo {
var serverAddress: String = ""
var username: String = ""
var password: String = ""
}
}

View File

@@ -0,0 +1,266 @@
@file:Suppress("SameParameterValue", "SameParameterValue", "SameParameterValue",
"SameParameterValue", "SameParameterValue", "SameParameterValue"
)
package net.helcel.cowspent.android.account
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Button
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Scaffold
import androidx.compose.material.Switch
import androidx.compose.material.SwitchDefaults
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Person
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import net.helcel.cowspent.R
import net.helcel.cowspent.theme.ThemeUtils
@Composable
fun AccountScreen(
viewModel: AccountViewModel,
onBack: () -> Unit,
onConnect: () -> Unit,
onSsoClick: (Boolean) -> Unit,
onLogout: () -> Unit
) {
AccountScreenContent(
isLoggedIn = viewModel.isLoggedIn,
isValidatingLogin = viewModel.isValidatingLogin,
useSso = viewModel.useSso,
serverUrl = viewModel.serverUrl,
username = viewModel.username,
password = viewModel.password,
isUrlValid = viewModel.isUrlValid,
showUrlWarning = viewModel.showUrlWarning,
isSubmitting = viewModel.isSubmitting,
isFormValid = viewModel.isFormValid,
onServerUrlChange = {
viewModel.serverUrl = it
viewModel.validateUrl()
},
onUsernameChange = { viewModel.username = it },
onPasswordChange = { viewModel.password = it },
onBack = onBack,
onConnect = onConnect,
onSsoClick = onSsoClick,
onLogout = onLogout
)
}
@Composable
fun AccountScreenContent(
isLoggedIn: Boolean,
isValidatingLogin: Boolean,
useSso: Boolean,
serverUrl: String,
username: String,
password: String,
isUrlValid: Boolean,
showUrlWarning: Boolean,
isSubmitting: Boolean,
isFormValid: Boolean,
onServerUrlChange: (String) -> Unit,
onUsernameChange: (String) -> Unit,
onPasswordChange: (String) -> Unit,
onBack: () -> Unit,
onConnect: () -> Unit,
onSsoClick: (Boolean) -> Unit,
onLogout: () -> Unit
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.settings_server_settings)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
},
backgroundColor = MaterialTheme.colors.primary,
elevation = 0.dp
)
}
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.padding(16.dp)
.verticalScroll(rememberScrollState())
.fillMaxSize()
) {
if (isValidatingLogin) {
Box(modifier = Modifier.fillMaxWidth().padding(vertical = 32.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else if (isLoggedIn) {
Text(
text = stringResource(R.string.account_logged_in_as, username),
style = MaterialTheme.typography.h6
)
Text(
text = serverUrl,
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = onLogout,
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.account_logout))
}
Spacer(modifier = Modifier.height(24.dp))
}
if (!isValidatingLogin) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 8.dp)
) {
Text(stringResource(R.string.use_sso_toggle), modifier = Modifier.weight(1f))
Switch(
checked = useSso,
onCheckedChange = { onSsoClick(it) },
colors = SwitchDefaults.colors(
uncheckedThumbColor = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
uncheckedTrackColor = MaterialTheme.colors.onSurface.copy(alpha = 0.3f)
)
)
}
if (!useSso) {
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = serverUrl,
onValueChange = onServerUrlChange,
label = { Text(stringResource(R.string.settings_url)) },
modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.Default.Link, contentDescription = null) },
trailingIcon = {
if (isUrlValid) {
Icon(
Icons.Default.Check,
contentDescription = null,
tint = Color.Green
)
}
},
singleLine = true
)
if (showUrlWarning) {
Text(
stringResource(R.string.settings_url_warn_http),
color = MaterialTheme.colors.error,
style = MaterialTheme.typography.caption,
modifier = Modifier.padding(start = 16.dp, top = 4.dp)
)
}
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = username,
onValueChange = onUsernameChange,
label = { Text(stringResource(R.string.settings_username)) },
modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.Default.Person, contentDescription = null) },
singleLine = true
)
Spacer(modifier = Modifier.height(8.dp))
@Suppress("DEPRECATION")
OutlinedTextField(
value = password,
onValueChange = onPasswordChange,
label = { Text(stringResource(R.string.settings_password)) },
modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) },
visualTransformation = PasswordVisualTransformation(),
singleLine = true
)
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = onConnect,
modifier = Modifier.fillMaxWidth(),
enabled = isFormValid && !isSubmitting
) {
if (isSubmitting) {
CustomCircularProgressIndicator(size = 24.dp, color = Color.White)
} else {
Text(stringResource(R.string.settings_submit))
}
}
}
}
}
}
}
@Composable
fun CustomCircularProgressIndicator(size: Dp, color: Color) {
CircularProgressIndicator(
modifier = Modifier.size(size),
color = color,
strokeWidth = 2.dp
)
}
@Preview(showBackground = true)
@Composable
fun AccountScreenPreview() {
ThemeUtils.CowspentTheme {
AccountScreenContent(
isLoggedIn = false,
isValidatingLogin = false,
useSso = false,
serverUrl = "https://nextcloud.example.com",
username = "user",
password = "",
isUrlValid = true,
showUrlWarning = false,
isSubmitting = false,
isFormValid = true,
onServerUrlChange = {},
onUsernameChange = {},
onPasswordChange = {},
onBack = {},
onConnect = {},
onSsoClick = {},
onLogout = {}
)
}
}

View File

@@ -0,0 +1,122 @@
package net.helcel.cowspent.android.account
import android.app.Application
import android.content.SharedPreferences
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.core.content.edit
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import androidx.preference.PreferenceManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.helcel.cowspent.util.CospendClientUtil
class AccountViewModel(application: Application) : AndroidViewModel(application) {
private val preferences: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(application)
var useSso by mutableStateOf(preferences.getBoolean(AccountActivity.SETTINGS_USE_SSO, false))
var serverUrl by mutableStateOf(
if (preferences.getBoolean(AccountActivity.SETTINGS_USE_SSO, false)) {
preferences.getString(AccountActivity.SETTINGS_SSO_URL, "") ?: ""
} else {
preferences.getString(AccountActivity.SETTINGS_URL, "") ?: ""
}
)
var username by mutableStateOf(
if (preferences.getBoolean(AccountActivity.SETTINGS_USE_SSO, false)) {
preferences.getString(AccountActivity.SETTINGS_SSO_USERNAME, "") ?: ""
} else {
preferences.getString(AccountActivity.SETTINGS_USERNAME, "") ?: ""
}
)
var password by mutableStateOf("")
var isUrlValid by mutableStateOf(false)
var isSubmitting by mutableStateOf(false)
var showUrlWarning by mutableStateOf(false)
var showWebView by mutableStateOf(false)
var isLoggedIn by mutableStateOf(false)
private set
var isValidatingLogin by mutableStateOf(false)
private set
init {
checkLoginStatus()
}
private fun checkLoginStatus() {
val url = if (useSso) {
preferences.getString(AccountActivity.SETTINGS_SSO_URL, "")
} else {
preferences.getString(AccountActivity.SETTINGS_URL, "")
}
val username = if (useSso) {
preferences.getString(AccountActivity.SETTINGS_SSO_USERNAME, "")
} else {
preferences.getString(AccountActivity.SETTINGS_USERNAME, "")
}
val password = if (useSso) {
""
} else {
preferences.getString(AccountActivity.SETTINGS_PASSWORD, "")
}
if (!url.isNullOrEmpty() && !username.isNullOrEmpty()) {
viewModelScope.launch {
isValidatingLogin = true
isLoggedIn = withContext(Dispatchers.IO) {
if (useSso) {
true
} else {
!password.isNullOrEmpty() &&
CospendClientUtil.isValidLogin(
url,
username,
password
) == CospendClientUtil.LoginStatus.OK
}
}
isValidatingLogin = false
}
}
}
fun logout() {
preferences.edit {
remove(AccountActivity.SETTINGS_USE_SSO)
remove(AccountActivity.SETTINGS_SSO_URL)
remove(AccountActivity.SETTINGS_SSO_USERNAME)
remove(AccountActivity.SETTINGS_URL)
remove(AccountActivity.SETTINGS_USERNAME)
remove(AccountActivity.SETTINGS_PASSWORD)
remove(AccountActivity.SETTINGS_KEY_ETAG)
remove(AccountActivity.SETTINGS_KEY_LAST_MODIFIED)
}
useSso = false
serverUrl = ""
username = ""
password = ""
isLoggedIn = false
}
fun validateUrl() {
val formattedUrl = CospendClientUtil.formatURL(serverUrl)
showUrlWarning = CospendClientUtil.isHttp(formattedUrl) && !useSso
viewModelScope.launch {
val valid = withContext(Dispatchers.IO) {
CospendClientUtil.isValidURL(formattedUrl)
}
isUrlValid = valid
}
}
val isFormValid: Boolean
get() = useSso || (isUrlValid && username.isNotEmpty())
}

View File

@@ -0,0 +1,117 @@
package net.helcel.cowspent.android.account
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.AlertDialog
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Composable
fun LoginDialog(
showDialog: Boolean,
onDismissRequest: () -> Unit,
onInitiateSsoLogin: () -> Unit,
errorMessage: String? = null
) {
if (showDialog) {
AlertDialog(
onDismissRequest = onDismissRequest,
shape = MaterialTheme.shapes.large,
title = { Text("Login") },
text = {
if (errorMessage != null) {
Text(text = errorMessage)
} else {
Text(text = "Please choose a login method.")
}
},
confirmButton = {
TextButton(onClick = onInitiateSsoLogin) {
Text("Login with Nextcloud SSO")
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text("Cancel")
}
},
modifier = Modifier
)
}
}
@Composable
fun LoginDialogContent(
onInitiateSsoLogin: () -> Unit,
onDismissRequest: () -> Unit,
errorMessage: String? = null
) {
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colors.surface,
contentColor = contentColorFor(MaterialTheme.colors.surface)
) {
Column(modifier = Modifier.padding(24.dp)) {
Text(
text = "Login",
style = MaterialTheme.typography.h6,
modifier = Modifier.padding(bottom = 16.dp)
)
if (errorMessage != null) {
Text(text = errorMessage)
} else {
Text(text = "Please choose a login method.")
}
Spacer(modifier = Modifier.height(24.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
TextButton(onClick = onDismissRequest) {
Text("Cancel")
}
TextButton(onClick = onInitiateSsoLogin) {
Text("Login with Nextcloud SSO")
}
}
}
}
}
@Preview(showBackground = true)
@Composable
fun LoginDialogPreview() {
MaterialTheme {
LoginDialogContent(
onDismissRequest = {},
onInitiateSsoLogin = {}
)
}
}
@Preview(showBackground = true)
@Composable
fun LoginDialogErrorPreview() {
MaterialTheme {
LoginDialogContent(
onDismissRequest = {},
onInitiateSsoLogin = {},
errorMessage = "Invalid credentials. Please try again."
)
}
}

View File

@@ -0,0 +1,462 @@
package net.helcel.cowspent.android.bill_edit
import android.app.DatePickerDialog
import android.app.TimePickerDialog
import android.content.Intent
import android.os.Bundle
import androidx.activity.OnBackPressedCallback
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.remember
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.helcel.cowspent.R
import net.helcel.cowspent.android.helper.QrCodeScannerActivity
import net.helcel.cowspent.android.helper.showToast
import net.helcel.cowspent.android.main.MainConstants
import net.helcel.cowspent.model.DBBill
import net.helcel.cowspent.model.DBBillOwer
import net.helcel.cowspent.model.DBProject
import net.helcel.cowspent.model.ProjectType
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.theme.ThemeUtils
import net.helcel.cowspent.util.BillParser
import net.helcel.cowspent.util.CategoryUtils
import java.text.ParseException
import java.time.ZoneId
import java.util.Calendar
class EditBillActivity : AppCompatActivity() {
private val viewModel: EditBillViewModel by viewModels()
private lateinit var db: CowspentSQLiteOpenHelper
private lateinit var bill: DBBill
private var projectType: ProjectType = ProjectType.LOCAL
private val calendar = Calendar.getInstance()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
db = CowspentSQLiteOpenHelper.getInstance(this)
lifecycleScope.launch {
initBill()
setContent {
ThemeUtils.CowspentTheme {
val categories = remember {
val syncedCategories = db.getCategories(bill.projectId)
val defaultCategories = CategoryUtils.getDefaultCategories(this@EditBillActivity, bill.projectId)
val hardcoded = if (projectType == ProjectType.LOCAL) {
defaultCategories
} else {
listOfNotNull(defaultCategories.find { it.remoteId.toInt() == DBBill.CATEGORY_REIMBURSEMENT })
}
syncedCategories + hardcoded
}
val paymentModes = remember {
val syncedPaymentModes = db.getPaymentModes(bill.projectId)
val defaultPaymentModes = CategoryUtils.getDefaultPaymentModes(this@EditBillActivity, bill.projectId)
if (projectType == ProjectType.LOCAL) {
syncedPaymentModes + defaultPaymentModes
} else {
syncedPaymentModes.ifEmpty { defaultPaymentModes }
}
}
EditBillScreen(
viewModel = viewModel,
categories = categories,
paymentModes = paymentModes,
onSave = { saveBillAsked() },
onBack = { onBack() },
onDateClick = {
DatePickerDialog(
this@EditBillActivity,
{ _, year, month, day ->
calendar[Calendar.YEAR] = year
calendar[Calendar.MONTH] = month
calendar[Calendar.DAY_OF_MONTH] = day
viewModel.timestamp = calendar.timeInMillis / 1000
},
calendar[Calendar.YEAR],
calendar[Calendar.MONTH],
calendar[Calendar.DAY_OF_MONTH]
).show()
},
onTimeClick = {
TimePickerDialog(
this@EditBillActivity,
{ _, hour, minute ->
calendar[Calendar.HOUR_OF_DAY] = hour
calendar[Calendar.MINUTE] = minute
viewModel.timestamp = calendar.timeInMillis / 1000
},
calendar[Calendar.HOUR_OF_DAY],
calendar[Calendar.MINUTE],
true
).show()
},
onScan = {
val createIntent = Intent(this@EditBillActivity, QrCodeScannerActivity::class.java)
scanQRCodeLauncher.launch(createIntent)
},
onDelete = if (bill.id > 0) { { deleteBillAsked() } } else null,
accessLevel = db.getProject(bill.projectId)?.myAccessLevel ?: DBProject.ACCESS_LEVEL_ADMIN
)
}
}
}
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
onBack()
}
})
}
private suspend fun initBill() {
val billId = intent.getLongExtra(PARAM_BILL_ID, 0)
val groupedBillIds = intent.getLongArrayExtra(PARAM_GROUPED_BILL_IDS)
val projectId = intent.getLongExtra(PARAM_PROJECT_ID, 0)
val projectTypeStr = intent.getStringExtra(PARAM_PROJECT_TYPE)
projectType = if (!projectTypeStr.isNullOrEmpty()) {
ProjectType.getTypeById(projectTypeStr) ?: ProjectType.LOCAL
} else {
ProjectType.LOCAL
}
withContext(Dispatchers.IO) {
var customSplits: Map<Long, Double>? = null
if (groupedBillIds != null && groupedBillIds.isNotEmpty()) {
val sourceBills: List<DBBill> = groupedBillIds.map { db.getBill(it) }.filterNotNull()
if (sourceBills.isNotEmpty()) {
val first = sourceBills[0]
val totalAmount = sourceBills.sumOf { it.amount }
bill = DBBill(
first.id, 0, first.projectId, first.payerId, totalAmount,
first.timestamp, first.what, first.state, first.repeat,
first.paymentMode, first.categoryRemoteId, first.comment, first.paymentModeRemoteId
)
val splits = mutableMapOf<Long, Double>()
for (b in sourceBills) {
val owersCount = b.billOwers.size
if (owersCount > 0) {
val part = b.amount / owersCount
for (ower in b.billOwers) {
splits[ower.memberId] = (splits[ower.memberId] ?: 0.0) + part
}
}
}
customSplits = splits
}
} else if (billId > 0) {
bill = db.getBill(billId)!!
} else {
val billIdToDuplicate = intent.getLongExtra(PARAM_BILL_ID_TO_DUPLICATE, 0)
val timeNowSeconds = System.currentTimeMillis() / 1000
if (billIdToDuplicate == 0L) {
bill = DBBill(
0, 0, projectId, 0, 0.0, timeNowSeconds,
"", DBBill.STATE_ADDED, DBBill.NON_REPEATED,
DBBill.PAYMODE_NONE, DBBill.CATEGORY_NONE, "", DBBill.PAYMODE_ID_NONE
)
} else {
val btd = db.getBill(billIdToDuplicate)!!
bill = DBBill(
0, 0, projectId, btd.payerId, btd.amount,
timeNowSeconds, btd.what, DBBill.STATE_ADDED,
btd.repeat, btd.paymentMode, btd.categoryRemoteId,
btd.comment, btd.paymentModeRemoteId
)
val btdOwers = btd.billOwers
val newBillOwers = btdOwers.filter {
val m = db.getMember(it.memberId)
m != null && m.isActivated
}
bill.billOwers = newBillOwers
}
}
calendar.timeInMillis = bill.timestamp * 1000
val members = db.getMembersOfProject(bill.projectId, null)
val project = db.getProject(bill.projectId)
val currencies = db.getCurrencies(bill.projectId)
withContext(Dispatchers.Main) {
viewModel.currencies = currencies
viewModel.mainCurrencyName = project?.currencyName ?: ""
viewModel.initFromBill(bill, members, customSplits)
}
}
}
private val scanQRCodeLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK && result.data != null) {
val scannedBill = result.data?.getStringExtra(MainConstants.KEY_QR_CODE)
if (scannedBill != null) {
try {
val austrianBill = BillParser.parseAustrianBillFromQrCode(scannedBill)
calendar.timeInMillis = austrianBill.date.time
viewModel.timestamp = calendar.timeInMillis / 1000
viewModel.amount = austrianBill.amount.toString()
return@registerForActivityResult
} catch (_: ParseException) {
}
try {
val croatianBill = BillParser.parseCroatianBillFromQrCode(scannedBill)
if (croatianBill.date != null) {
calendar.timeInMillis =
croatianBill.date.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()
viewModel.timestamp = calendar.timeInMillis / 1000
}
viewModel.amount = croatianBill.amount.toString()
return@registerForActivityResult
} catch (_: ParseException) {
}
showToast(this, getString(R.string.error_scanning_bill_qr_code))
}
}
}
private fun onBack() {
if (!valuesHaveChanged()) {
finish()
return
}
viewModel.showDialog(
title = getString(R.string.save_or_discard_bill_dialog_title),
message = getString(R.string.save_or_discard_bill_dialog_message),
positiveText = getString(R.string.save_or_discard_bill_dialog_save),
onConfirm = { saveBillAsked() },
negativeText = getString(R.string.save_or_discard_bill_dialog_discard),
onCancel = { finish() }
)
}
private fun saveBillAsked() {
val validationError = viewModel.getValidationError(
getString(R.string.error_invalid_bill_what),
getString(R.string.error_invalid_bill_date),
getString(R.string.error_invalid_bill_payerid),
getString(R.string.error_invalid_bill_owers),
getString(R.string.simple_error)
)
if (validationError != null) {
showToast(this, validationError)
} else {
lifecycleScope.launch {
val savedBillId = withContext(Dispatchers.IO) { saveBill() }
val data = Intent()
data.putExtra(MainConstants.SAVED_BILL_ID, savedBillId)
setResult(RESULT_OK, data)
finish()
}
}
}
private fun deleteBillAsked() {
viewModel.showDialog(
title = getString(R.string.confirm_remove_project_dialog_title),
message = bill.what,
positiveText = getString(R.string.action_delete),
onConfirm = {
lifecycleScope.launch {
withContext(Dispatchers.IO) {
val groupedBillIds = intent.getLongArrayExtra(PARAM_GROUPED_BILL_IDS)
if (groupedBillIds != null && groupedBillIds.isNotEmpty()) {
for (id in groupedBillIds) {
db.setBillState(id, DBBill.STATE_DELETED)
}
} else if (bill.id > 0) {
db.setBillState(bill.id, DBBill.STATE_DELETED)
}
val proj = db.getProject(bill.projectId)
if (proj != null) db.syncIfRemote(proj)
}
val data = Intent()
data.putExtra(MainConstants.DELETED_BILL, bill.id)
setResult(RESULT_OK, data)
finish()
}
},
negativeText = getString(R.string.simple_cancel)
)
}
private fun valuesHaveChanged(): Boolean {
val newOwersIds = viewModel.getOwersIds().toSet()
val billOwersIds = bill.billOwersIds.toSet()
val owersChanged = newOwersIds != billOwersIds
return !(bill.what == viewModel.what &&
bill.timestamp == viewModel.timestamp &&
bill.amount == viewModel.amountAsDouble &&
bill.payerId == viewModel.payerId &&
bill.comment == viewModel.comment &&
bill.repeat == viewModel.repeat &&
bill.categoryRemoteId == viewModel.categoryRemoteId &&
bill.paymentModeRemoteId == viewModel.paymentModeRemoteId &&
!owersChanged)
}
private suspend fun saveBill(): Long = withContext(Dispatchers.IO) {
val groupedBillIds = intent.getLongArrayExtra(PARAM_GROUPED_BILL_IDS)
val isCustomSplit = viewModel.isCustomSplit
if (isCustomSplit) {
val splits = viewModel.owersCustomSplit.filter { (id, amount) ->
viewModel.owersSelection[id] == true && (amount.replace(',', '.').toDoubleOrNull()
?: 0.0) > 0
}.mapValues { it.value.replace(',', '.').toDoubleOrNull() ?: 0.0 }
if (splits.isEmpty()) return@withContext 0L
val splitEntries = splits.entries.toList()
// Pool of existing bills in this group that we can potentially reuse
val billsToPool = mutableListOf<Long>()
if (bill.id != 0L) billsToPool.add(bill.id)
groupedBillIds?.forEach { if (it != bill.id) billsToPool.add(it) }
val processedBillIds = mutableSetOf<Long>()
var firstSavedId = 0L
for (entry in splitEntries) {
val memberId = entry.key
val amount = entry.value
// Try to find a bill in the pool that already exists for this exact member
var billToUseId = billsToPool.find { id ->
val b = db.getBill(id)
b?.billOwers?.size == 1 && b.billOwers[0].memberId == memberId
}
// Fallback: just take any available bill from the pool
if (billToUseId == null) {
billToUseId = billsToPool.firstOrNull()
}
if (billToUseId != null) {
billsToPool.remove(billToUseId)
processedBillIds.add(billToUseId)
val existingBill = db.getBill(billToUseId)!!
db.updateBillAndSync(
existingBill,
viewModel.payerId,
amount,
viewModel.timestamp,
viewModel.what,
listOf(memberId),
viewModel.repeat,
existingBill.paymentMode,
viewModel.paymentModeRemoteId,
viewModel.categoryRemoteId,
viewModel.comment
)
if (firstSavedId == 0L) firstSavedId = billToUseId
} else {
// Create a new bill for this payee
val newBill = DBBill(
0, 0, bill.projectId, viewModel.payerId, amount,
viewModel.timestamp, viewModel.what, DBBill.STATE_ADDED, viewModel.repeat,
bill.paymentMode, viewModel.categoryRemoteId, viewModel.comment, viewModel.paymentModeRemoteId
)
newBill.billOwers = listOf(DBBillOwer(0, 0, memberId))
val newId = db.addBill(newBill)
if (firstSavedId == 0L) firstSavedId = newId
}
}
// Mark any remaining bills in the original group as deleted
for (id in billsToPool) {
db.setBillState(id, DBBill.STATE_DELETED)
}
val proj = db.getProject(bill.projectId)
if (proj != null) db.syncIfRemote(proj)
db.updateProject(
bill.projectId,
null,
null,
null,
viewModel.payerId,
null,
null,
null,
null,
null
)
return@withContext firstSavedId
} else {
val newAmount = viewModel.amountAsDouble
val newOwersIds = viewModel.getOwersIds()
if (bill.id != 0L) {
if (valuesHaveChanged()) {
db.updateBillAndSync(
bill,
viewModel.payerId,
newAmount,
viewModel.timestamp,
viewModel.what,
newOwersIds,
viewModel.repeat,
bill.paymentMode,
viewModel.paymentModeRemoteId,
viewModel.categoryRemoteId,
viewModel.comment
)
if (groupedBillIds != null) {
for (id in groupedBillIds) {
if (id != bill.id) {
db.setBillState(id, DBBill.STATE_DELETED)
}
}
val proj = db.getProject(bill.projectId)
if (proj != null) db.syncIfRemote(proj)
}
}
return@withContext bill.id
} else {
val newBill = DBBill(
0, 0, bill.projectId, viewModel.payerId, newAmount,
viewModel.timestamp, viewModel.what, DBBill.STATE_ADDED, viewModel.repeat,
bill.paymentMode, viewModel.categoryRemoteId, viewModel.comment, viewModel.paymentModeRemoteId
)
newOwersIds.forEach { newBill.billOwers += DBBillOwer(0, 0, it) }
val newBillId = db.addBill(newBill)
db.updateProject(
bill.projectId,
null,
null,
null,
viewModel.payerId,
null,
null,
null,
null,
null
)
val proj = db.getProject(bill.projectId)
if (proj != null) db.syncIfRemote(proj)
return@withContext newBillId
}
}
}
companion object {
const val PARAM_BILL_ID = "billId"
const val PARAM_GROUPED_BILL_IDS = "grouped_bill_ids"
const val PARAM_PROJECT_ID = "projectId"
const val PARAM_PROJECT_TYPE = "projectType"
const val PARAM_BILL_ID_TO_DUPLICATE = "billToDuplicate"
}
}

View File

@@ -0,0 +1,604 @@
package net.helcel.cowspent.android.bill_edit
import android.annotation.SuppressLint
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Comment
import androidx.compose.material.icons.filled.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import net.helcel.cowspent.R
import net.helcel.cowspent.android.helper.*
import net.helcel.cowspent.model.*
import net.helcel.cowspent.util.SupportUtil
import java.util.Date
import kotlin.math.abs
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun EditBillScreen(
viewModel: EditBillViewModel,
categories: List<DBCategory>,
paymentModes: List<DBPaymentMode>,
onSave: () -> Unit,
onBack: () -> Unit,
onDateClick: () -> Unit,
onTimeClick: () -> Unit,
onScan: () -> Unit,
onDelete: (() -> Unit)? = null,
accessLevel: Int = DBProject.ACCESS_LEVEL_ADMIN
) {
val canEdit = accessLevel == DBProject.ACCESS_LEVEL_UNKNOWN || accessLevel >= DBProject.ACCESS_LEVEL_PARTICIPANT
val context = LocalContext.current
StatefulAlertDialog(
state = viewModel.dialogState,
onDismissRequest = { viewModel.dismissDialog() }
)
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(if (viewModel.what.isEmpty()) R.string.simple_new_bill else R.string.simple_edit_bill)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
},
actions = {
if (canEdit) {
IconButton(onClick = onScan) {
Icon(Icons.Default.QrCodeScanner, contentDescription = null)
}
if (onDelete != null) {
IconButton(onClick = onDelete) {
Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.action_delete))
}
}
}
},
backgroundColor = MaterialTheme.colors.primary,
elevation = 0.dp
)
},
floatingActionButton = {
if (canEdit) {
val errorWhat = stringResource(R.string.error_invalid_bill_what)
val errorDate = stringResource(R.string.error_invalid_bill_date)
val errorPayer = stringResource(R.string.error_invalid_bill_payerid)
val errorOwers = stringResource(R.string.error_invalid_bill_owers)
val errorInvalidForm = stringResource(R.string.simple_error)
FloatingActionButton(onClick = {
val validationError = viewModel.getValidationError(
errorWhat, errorDate, errorPayer, errorOwers, errorInvalidForm
)
if (validationError == null) {
onSave()
} else {
showToast(context, validationError)
}
}) {
Icon(
Icons.Default.Done,
contentDescription = stringResource(R.string.action_save_bill)
)
}
}
}
) { padding ->
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.padding(padding)
.padding(16.dp)
.fillMaxSize()
.verticalScroll(scrollState)
) {
BillBasicInfoSection(
viewModel = viewModel,
canEdit = canEdit,
onDateClick = onDateClick,
onTimeClick = onTimeClick
)
Spacer(modifier = Modifier.height(8.dp))
PayerSection(
viewModel = viewModel,
canEdit = canEdit
)
Spacer(modifier = Modifier.height(8.dp))
OwerSelectionSection(
viewModel = viewModel,
canEdit = canEdit
)
Spacer(modifier = Modifier.height(8.dp))
BillAdditionalDetailsSection(
viewModel = viewModel,
categories = categories,
paymentModes = paymentModes,
canEdit = canEdit
)
Spacer(modifier = Modifier.height(8.dp))
}
}
}
@Composable
fun BillBasicInfoSection(
viewModel: EditBillViewModel,
canEdit: Boolean,
onDateClick: () -> Unit,
onTimeClick: () -> Unit
) {
val context = LocalContext.current
val currencyDialogTitle =
stringResource(R.string.currency_dialog_title, viewModel.mainCurrencyName)
val noCurrencyError = stringResource(R.string.no_currency_error)
OutlinedTextField(
value = viewModel.what,
onValueChange = { viewModel.what = it },
enabled = canEdit,
placeholder = { Text(stringResource(R.string.setting_what)) },
modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.Default.Title, contentDescription = null) }
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = viewModel.amount,
onValueChange = {
viewModel.amount = it
viewModel.updateSplits()
},
enabled = canEdit,
placeholder = { Text("0") },
modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.Default.AttachMoney, contentDescription = null) },
trailingIcon = {
IconButton(
enabled = canEdit,
onClick = {
if (viewModel.currencies.isNotEmpty()) {
viewModel.showDialog(
title = currencyDialogTitle,
items = viewModel.currencies.map { "${it.name} (${it.exchangeRate})" },
onItemSelected = { index ->
viewModel.convertCurrency(viewModel.currencies[index])
}
)
} else {
showToast(context, noCurrencyError)
}
}
) {
Icon(Icons.Default.CurrencyExchange, contentDescription = null)
}
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
)
Spacer(modifier = Modifier.height(8.dp))
val dateFormat =
remember(context) { android.text.format.DateFormat.getDateFormat(context) }
val timeFormat =
remember(context) { android.text.format.DateFormat.getTimeFormat(context) }
val dateStr = dateFormat.format(Date(viewModel.timestamp * 1000))
val timeStr = timeFormat.format(Date(viewModel.timestamp * 1000))
Row {
ClickableOutlinedTextField(
value = dateStr,
onClick = onDateClick,
modifier = Modifier.weight(1f),
enabled = canEdit,
leadingIcon = { Icon(Icons.Default.Event, contentDescription = null) }
)
Spacer(modifier = Modifier.width(8.dp))
ClickableOutlinedTextField(
value = timeStr,
onClick = onTimeClick,
modifier = Modifier.weight(1f),
enabled = canEdit,
leadingIcon = { Icon(Icons.Default.AccessTime, contentDescription = null) }
)
}
}
@Composable
fun PayerSection(
viewModel: EditBillViewModel,
canEdit: Boolean
) {
var payerExpanded by remember { mutableStateOf(false) }
val selectedPayer = viewModel.members.find { it.id == viewModel.payerId }
EditableExposedDropdownMenu(
value = selectedPayer?.name ?: "",
placeholder = stringResource(R.string.setting_payer),
expanded = payerExpanded,
onExpandedChange = { payerExpanded = it },
onDismissRequest = { payerExpanded = false },
enabled = canEdit,
leadingIcon = {
Box(modifier = Modifier.padding(start = 12.dp)) {
if (selectedPayer != null) {
UserAvatar(
name = selectedPayer.name,
r = selectedPayer.r,
g = selectedPayer.g,
b = selectedPayer.b,
disabled = !selectedPayer.isActivated,
size = 24.dp
)
} else {
Icon(Icons.Default.Person, contentDescription = null)
}
}
},
content = {
viewModel.members.forEach { member ->
DropdownMenuItem(onClick = {
viewModel.payerId = member.id
payerExpanded = false
}) {
UserAvatar(
name = member.name,
r = member.r,
g = member.g,
b = member.b,
disabled = !member.isActivated,
size = 24.dp
)
Spacer(modifier = Modifier.width(8.dp))
Text(member.name)
}
}
}
)
}
@Composable
fun OwerSelectionSection(
viewModel: EditBillViewModel,
canEdit: Boolean
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Spacer(modifier = Modifier.width(8.dp))
if (viewModel.owersSelection.all { it.value }) {
IconButton(
enabled = canEdit,
onClick = {
viewModel.members.forEach { viewModel.owersSelection[it.id] = false }
viewModel.updateSplits()
},
modifier = Modifier.size(32.dp)
) {
Icon(
Icons.Default.RemoveDone,
contentDescription = stringResource(R.string.setting_none),
modifier = Modifier.scale(0.8f)
)
}
} else {
IconButton(
enabled = canEdit,
onClick = {
viewModel.members.forEach { viewModel.owersSelection[it.id] = true }
viewModel.updateSplits()
},
modifier = Modifier.size(32.dp)
) {
Icon(
Icons.Default.DoneAll,
contentDescription = stringResource(R.string.setting_all),
modifier = Modifier.scale(0.8f)
)
}
}
Spacer(modifier = Modifier.width(8.dp))
Text(
stringResource(R.string.setting_owers), fontSize = 12.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
)
Spacer(Modifier.weight(1f))
if (viewModel.isCustomSplit) {
val diff = viewModel.getDiffSplit()
if (abs(diff) > 0.01) {
val diffText =
if (diff > 0) "Missing: ${SupportUtil.normalNumberFormat.format(diff)}" else "Excess: ${
SupportUtil.normalNumberFormat.format(-diff)
}"
Text(
diffText,
color = MaterialTheme.colors.error,
fontSize = 12.sp,
modifier = Modifier.padding(horizontal = 16.dp)
)
}
} else {
Text("Even Split", fontSize = 12.sp)
}
Switch(
checked = !viewModel.isCustomSplit,
enabled = canEdit,
onCheckedChange = {
viewModel.isCustomSplit = !it
viewModel.updateSplits()
},
colors = SwitchDefaults.colors(
uncheckedThumbColor = MaterialTheme.colors.onSurface,
)
)
}
viewModel.members.forEach { member ->
val isSelected = viewModel.owersSelection[member.id] ?: false
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = canEdit) {
viewModel.toggleMember(member.id, !isSelected)
}
) {
Checkbox(
checked = isSelected,
enabled = canEdit,
onCheckedChange = { viewModel.toggleMember(member.id, it) }
)
UserAvatar(
name = member.name,
r = member.r,
g = member.g,
b = member.b,
disabled = !member.isActivated,
size = 32.dp
)
Spacer(modifier = Modifier.width(8.dp))
Text(member.name, modifier = Modifier.weight(1f))
if (isSelected || viewModel.isCustomSplit) {
val interactionSource = remember { MutableInteractionSource() }
BasicTextField(
value = viewModel.owersCustomSplit[member.id] ?: "",
onValueChange = {
viewModel.owersCustomSplit[member.id] = it
viewModel.owersSelection[member.id] = (it != "")
},
modifier = Modifier
.width(80.dp)
.height(46.dp),
interactionSource = interactionSource,
singleLine = true,
enabled = viewModel.isCustomSplit && canEdit,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
cursorBrush = SolidColor(MaterialTheme.colors.onSurface),
textStyle = LocalTextStyle.current.copy(
textAlign = TextAlign.Right,
color = MaterialTheme.colors.onSurface
),
) { innerTextField ->
TextFieldDefaults.OutlinedTextFieldDecorationBox(
value = viewModel.owersCustomSplit[member.id] ?: "",
visualTransformation = VisualTransformation.None,
innerTextField = innerTextField,
singleLine = true,
enabled = viewModel.isCustomSplit && canEdit,
interactionSource = interactionSource,
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 0.dp),
colors = TextFieldDefaults.textFieldColors(
cursorColor = MaterialTheme.colors.onSurface,
backgroundColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent
)
)
}
}
}
}
}
@Composable
fun BillAdditionalDetailsSection(
viewModel: EditBillViewModel,
categories: List<DBCategory>,
paymentModes: List<DBPaymentMode>,
canEdit: Boolean
) {
var categoryExpanded by remember { mutableStateOf(false) }
val selectedCategory =
categories.find { it.remoteId.toInt() == viewModel.categoryRemoteId }
EditableExposedDropdownMenu(
value = selectedCategory?.name ?: "",
placeholder = stringResource(R.string.setting_category),
expanded = categoryExpanded,
onExpandedChange = { categoryExpanded = it },
onDismissRequest = { categoryExpanded = false },
enabled = canEdit,
leadingIcon = {
Box(modifier = Modifier.padding(start = 12.dp)) {
if (selectedCategory != null) {
Text(text = selectedCategory.icon, fontSize = 20.sp)
} else {
Icon(Icons.Default.Category, contentDescription = null)
}
}
},
content = {
DropdownMenuItem(onClick = {
viewModel.categoryRemoteId = 0
categoryExpanded = false
}) {
Icon(Icons.Default.Close, tint = Color.Red, contentDescription = null)
Spacer(modifier = Modifier.width(12.dp))
Text(stringResource(R.string.category_none))
}
categories.forEach { category ->
DropdownMenuItem(onClick = {
viewModel.categoryRemoteId = category.remoteId.toInt()
categoryExpanded = false
}) {
Text(text = category.icon, fontSize = 20.sp)
Spacer(modifier = Modifier.width(12.dp))
Text(category.name ?: "")
}
}
}
)
Spacer(modifier = Modifier.height(8.dp))
var pmExpanded by remember { mutableStateOf(false) }
val selectedPm =
paymentModes.find { it.remoteId.toInt() == viewModel.paymentModeRemoteId }
EditableExposedDropdownMenu(
value = selectedPm?.name ?: "",
placeholder = stringResource(R.string.setting_payment_mode),
expanded = pmExpanded,
onExpandedChange = { pmExpanded = it },
onDismissRequest = { pmExpanded = false },
enabled = canEdit,
leadingIcon = {
Box(modifier = Modifier.padding(start = 12.dp)) {
if (selectedPm != null) {
Text(text = selectedPm.icon, fontSize = 20.sp)
} else {
Icon(Icons.Default.Payment, contentDescription = null)
}
}
},
content = {
DropdownMenuItem(onClick = {
viewModel.paymentModeRemoteId = 0
pmExpanded = false
}) {
Icon(Icons.Default.Close, tint = Color.Red, contentDescription = null)
Spacer(modifier = Modifier.width(12.dp))
Text(stringResource(R.string.payment_mode_none))
}
paymentModes.forEach { pm ->
DropdownMenuItem(onClick = {
viewModel.paymentModeRemoteId = pm.remoteId.toInt()
pmExpanded = false
}) {
Text(text = pm.icon, fontSize = 20.sp)
Spacer(modifier = Modifier.width(12.dp))
Text(pm.name ?: "")
}
}
}
)
Spacer(modifier = Modifier.height(8.dp))
val repeatOptions = listOf(
DBBill.NON_REPEATED to stringResource(R.string.repeat_no),
DBBill.REPEAT_DAY to stringResource(R.string.repeat_day),
DBBill.REPEAT_WEEK to stringResource(R.string.repeat_week),
DBBill.REPEAT_FORTNIGHT to stringResource(R.string.repeat_fortnight),
DBBill.REPEAT_MONTH to stringResource(R.string.repeat_month),
DBBill.REPEAT_YEAR to stringResource(R.string.repeat_year)
)
var repeatExpanded by remember { mutableStateOf(false) }
val selectedRepeat = repeatOptions.find { it.first == viewModel.repeat }
EditableExposedDropdownMenu(
value = selectedRepeat?.second ?: "",
placeholder = stringResource(R.string.setting_project_repetition),
expanded = repeatExpanded,
onExpandedChange = { repeatExpanded = it },
onDismissRequest = { repeatExpanded = false },
enabled = canEdit,
leadingIcon = { Icon(Icons.Default.Repeat, contentDescription = null) },
content = {
repeatOptions.forEach { (value, label) ->
DropdownMenuItem(onClick = {
viewModel.repeat = value
repeatExpanded = false
}) {
Text(label)
}
}
}
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = viewModel.comment,
onValueChange = { viewModel.comment = it },
enabled = canEdit,
placeholder = { Text(stringResource(R.string.setting_comment)) },
modifier = Modifier.fillMaxWidth(),
singleLine = false,
leadingIcon = {
Icon(
Icons.AutoMirrored.Filled.Comment,
contentDescription = null
)
}
)
}
@SuppressLint("ViewModelConstructorInComposable")
@Preview(showBackground = true)
@Composable
fun EditBillScreenPreview() {
MaterialTheme {
EditBillScreen(
viewModel = EditBillViewModel().apply {
what = "Pizza"
amount = "12.50"
members = listOf(
DBMember(1, 0, 0, "Alice", true, 1.0, 0, null, null, null, null, null),
DBMember(2, 0, 0, "Bob", true, 1.0, 0, null, null, null, null, null)
)
},
categories = emptyList(),
paymentModes = emptyList(),
onSave = {},
onBack = {},
onDateClick = {},
onTimeClick = {},
onScan = {}
)
}
}

View File

@@ -0,0 +1,224 @@
package net.helcel.cowspent.android.bill_edit
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import net.helcel.cowspent.model.DBBill
import net.helcel.cowspent.model.DBMember
import net.helcel.cowspent.android.helper.DialogState
import net.helcel.cowspent.util.SupportUtil
import net.helcel.cowspent.util.evalMath
import net.helcel.cowspent.model.DBCurrency
import androidx.compose.ui.graphics.vector.ImageVector
class EditBillViewModel : ViewModel() {
var what by mutableStateOf("")
var amount by mutableStateOf("")
var comment by mutableStateOf("")
var timestamp by mutableLongStateOf(0L)
var payerId by mutableLongStateOf(0L)
var repeat by mutableStateOf(DBBill.NON_REPEATED)
var paymentModeRemoteId by mutableIntStateOf(0)
var categoryRemoteId by mutableIntStateOf(0)
var currencies by mutableStateOf<List<DBCurrency>>(emptyList())
var mainCurrencyName by mutableStateOf("")
var members by mutableStateOf<List<DBMember>>(emptyList())
var owersSelection = mutableStateMapOf<Long, Boolean>()
var isCustomSplit by mutableStateOf(false)
var owersCustomSplit = mutableStateMapOf<Long, String>()
var dialogState by mutableStateOf<DialogState?>(null)
val amountAsDouble: Double
get() {
val amountStr = amount.replace(',', '.')
return try {
if (amountStr.matches("[0-9.]+".toRegex())) {
amountStr.toDouble()
} else {
evalMath(amountStr)
}
} catch (_: Exception) {
0.0
}
}
fun getEvenSplit(): Double {
val selectedOwersCount = owersSelection.count { it.value }
return if (selectedOwersCount > 0) amountAsDouble / selectedOwersCount else 0.0
}
fun updateSplits() {
if (!isCustomSplit) {
val even = getEvenSplit()
val evenStr = if (even == 0.0) "" else SupportUtil.round2(even).toString()
members.forEach { m ->
if (owersSelection[m.id] == true) {
owersCustomSplit[m.id] = evenStr
} else {
owersCustomSplit.remove(m.id)
}
}
}
}
fun toggleMember(id: Long, selected: Boolean) {
owersSelection[id] = selected
if (isCustomSplit) {
if (selected) {
if (owersCustomSplit[id].isNullOrEmpty()) {
owersCustomSplit[id] = "0"
}
} else {
owersCustomSplit.remove(id)
}
} else {
updateSplits()
}
}
fun getDiffSplit(): Double {
val customTotal = owersCustomSplit.entries
.filter { owersSelection[it.key] == true }
.sumOf { it.value.replace(',', '.').toDoubleOrNull() ?: 0.0 }
return amountAsDouble - customTotal
}
fun getOwersIds(): List<Long> {
return owersSelection.filter { it.value }.keys.toList()
}
fun showDialog(
title: String? = null,
message: String? = null,
icon: ImageVector? = null,
items: List<CharSequence>? = null,
positiveText: String? = null,
negativeText: String? = null,
neutralText: String? = null,
onConfirm: (() -> Unit)? = null,
onCancel: (() -> Unit)? = null,
onNeutral: (() -> Unit)? = null,
onItemSelected: ((Int) -> Unit)? = null
) {
dialogState = DialogState(
title = title,
message = message,
icon = icon,
items = items,
positiveText = positiveText,
negativeText = negativeText,
neutralText = neutralText,
onConfirm = onConfirm,
onCancel = onCancel,
onNeutral = onNeutral,
onItemSelected = onItemSelected
)
}
fun dismissDialog() {
dialogState = null
}
fun convertCurrency(currency: DBCurrency) {
val originalAmountStr = amount
val originalAmount = amountAsDouble
if (originalAmount == 0.0) return
val newAmount = originalAmount / currency.exchangeRate
amount = SupportUtil.round2(newAmount).toString()
val currencyLabel = currency.name ?: ""
val conversionNote = "($originalAmountStr $currencyLabel)"
if (!comment.contains(conversionNote)) {
if (comment.isNotEmpty() && !comment.endsWith(" ")) {
comment += " "
}
comment += conversionNote
}
if (isCustomSplit) {
owersCustomSplit.keys.toList().forEach { id ->
val value = owersCustomSplit[id] ?: ""
val partAmount = value.replace(',', '.').toDoubleOrNull() ?: 0.0
if (partAmount != 0.0) {
val newPartAmount = partAmount / currency.exchangeRate
owersCustomSplit[id] = SupportUtil.round2(newPartAmount).toString()
}
}
}
updateSplits()
}
fun initFromBill(bill: DBBill, members: List<DBMember>, customSplits: Map<Long, Double>? = null) {
this.members = members
what = bill.what
amount = if (bill.amount == 0.0) "" else bill.amount.toString()
comment = bill.comment ?: ""
timestamp = bill.timestamp
payerId = bill.payerId
repeat = bill.repeat ?: DBBill.NON_REPEATED
paymentModeRemoteId = bill.paymentModeRemoteId
categoryRemoteId = bill.categoryRemoteId
owersSelection.clear()
owersCustomSplit.clear()
if (customSplits != null) {
isCustomSplit = true
for (member in members) {
val selected = customSplits.containsKey(member.id)
owersSelection[member.id] = selected
if (selected) {
owersCustomSplit[member.id] = SupportUtil.round2(customSplits[member.id]!!).toString()
}
}
} else {
val billOwerIds = bill.billOwersIds
val selectedCount = billOwerIds.size
val evenSplit = if (selectedCount > 0) bill.amount / selectedCount else 0.0
val evenSplitStr = if (evenSplit == 0.0) "" else SupportUtil.round2(evenSplit).toString()
for (member in members) {
val selected = billOwerIds.contains(member.id)
owersSelection[member.id] = selected
if (selected) {
owersCustomSplit[member.id] = evenSplitStr
}
}
}
}
fun isFormValid(): Boolean {
return what.isNotEmpty() && !what.contains(",") &&
timestamp != 0L &&
payerId != 0L &&
owersSelection.any { it.value }
}
fun getValidationError(
errorWhat: String,
errorDate: String,
errorPayer: String,
errorOwers: String,
errorInvalidForm: String
): String? {
return when {
what.isEmpty() || what.contains(",") -> errorWhat
timestamp == 0L -> errorDate
payerId == 0L -> errorPayer
owersSelection.none { it.value } -> errorOwers
!isFormValid() -> errorInvalidForm
else -> null
}
}
}

View File

@@ -0,0 +1,85 @@
package net.helcel.cowspent.android.bill_label
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.theme.ThemeUtils
import net.helcel.cowspent.util.CategoryUtils
import net.helcel.cowspent.model.DBBill
import net.helcel.cowspent.model.ProjectType
class LabelBillsActivity : AppCompatActivity() {
private val viewModel: LabelBillsViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val projectId = intent.getLongExtra(EXTRA_PROJECT_ID, -1L)
if (projectId == -1L) {
finish()
return
}
val db = CowspentSQLiteOpenHelper.getInstance(this)
lifecycleScope.launch {
val (members, billsToLabel, categories, allCategorized) = withContext(Dispatchers.IO) {
val project = db.getProject(projectId)
val projectType = project?.type ?: ProjectType.LOCAL
val members = db.getMembersOfProject(projectId, null)
val allBills = db.getBillsOfProject(projectId)
val billsToLabel = allBills.filter { it.categoryRemoteId == 0 && it.state != DBBill.STATE_DELETED }
val allCategorized = allBills.filter { it.categoryRemoteId != 0 && it.state != DBBill.STATE_DELETED }
val syncedCategories = db.getCategories(projectId)
val defaultCategories = CategoryUtils.getDefaultCategories(this@LabelBillsActivity, projectId)
val hardcoded = if (projectType == ProjectType.LOCAL) {
defaultCategories
} else {
listOfNotNull(defaultCategories.find { it.remoteId.toInt() == DBBill.CATEGORY_REIMBURSEMENT })
}
val categories = syncedCategories + hardcoded
Quadruple(members, billsToLabel, categories, allCategorized)
}
viewModel.billsToLabel = billsToLabel
viewModel.categories = categories
viewModel.categoriesMap = categories.associateBy { it.remoteId }
viewModel.allCategorizedBills = allCategorized
viewModel.updateSuggestions()
setContent {
ThemeUtils.CowspentTheme {
LabelBillsScreen(
viewModel = viewModel,
members = members,
db = db,
onBack = { finish() }
)
}
}
}
}
private data class Quadruple<A, B, C, D>(val first: A, val second: B, val third: C, val fourth: D)
companion object {
const val EXTRA_PROJECT_ID = "extra_project_id"
fun createIntent(context: Context, projectId: Long): Intent {
return Intent(context, LabelBillsActivity::class.java).apply {
putExtra(EXTRA_PROJECT_ID, projectId)
}
}
}
}

View File

@@ -0,0 +1,269 @@
package net.helcel.cowspent.android.bill_label
import android.annotation.SuppressLint
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
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 net.helcel.cowspent.R
import net.helcel.cowspent.model.DBBill
import net.helcel.cowspent.model.DBCategory
import net.helcel.cowspent.model.DBMember
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.util.SupportUtil
@Composable
fun LabelBillsScreen(
viewModel: LabelBillsViewModel,
members: List<DBMember>,
db: CowspentSQLiteOpenHelper,
onBack: () -> Unit
) {
val currentBill = viewModel.currentBill
val remainingCount = viewModel.billsToLabel.size - viewModel.currentBillIndex
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.label_bills_title)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
},
backgroundColor = MaterialTheme.colors.primary,
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (currentBill != null) {
BillSummaryCard(currentBill, members, remainingCount)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.label_bills_suggested),
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
val suggestions = viewModel.suggestedCategories
Box(modifier = Modifier.fillMaxWidth().height(64.dp), contentAlignment = Alignment.CenterStart) {
if (suggestions.isNotEmpty()) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
suggestions.forEach { category ->
Box(modifier = Modifier.weight(1f)) {
CategoryButton(
icon = category.icon,
name = category.name ?: "",
onClick = { viewModel.labelCurrentBill(db, category.remoteId.toInt()) }
)
}
}
repeat(2 - suggestions.size) {
Spacer(modifier = Modifier.weight(1f))
}
}
} else {
Text(
text = stringResource(R.string.label_bills_no_suggestions),
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.4f),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.setting_category),
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.Bold,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(viewModel.categories) { category ->
CategoryButton(
icon = category.icon,
name = category.name ?: "",
onClick = { viewModel.labelCurrentBill(db, category.remoteId.toInt()) }
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = { viewModel.skipCurrentBill() },
modifier = Modifier.width(128.dp),
colors = ButtonDefaults.buttonColors(backgroundColor = Color.LightGray)
) {
Text(stringResource(R.string.label_bills_skip))
}
Spacer(modifier = Modifier.height(8.dp))
} else {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(stringResource(R.string.label_bills_no_more))
}
}
}
}
}
@Composable
fun BillSummaryCard(bill: DBBill, members: List<DBMember>, remainingCount: Int) {
val payerName = remember(bill.payerId, members) {
members.find { it.id == bill.payerId }?.name ?: bill.payerId.toString()
}
val owersNames = remember(bill.billOwersIds, members) {
bill.billOwersIds.joinToString(", ") { id ->
members.find { it.id == id }?.name ?: id.toString()
}
}
Box {
Card(
elevation = 4.dp,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp, start = 8.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text(bill.what, maxLines = 2, style = MaterialTheme.typography.h5, fontWeight = FontWeight.Bold)
if (!bill.comment.isNullOrEmpty()) {
Text(bill.comment!!, style = MaterialTheme.typography.body2, color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f))
}
}
Text(
SupportUtil.normalNumberFormat.format(bill.amount),
style = MaterialTheme.typography.h5,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.primary
)
}
Spacer(modifier = Modifier.height(8.dp))
Divider()
Spacer(modifier = Modifier.height(8.dp))
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(
text = "$payerName \u2192 $owersNames",
style = MaterialTheme.typography.caption,
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = bill.date,
style = MaterialTheme.typography.caption
)
}
}
}
Surface(
modifier = Modifier
.size(28.dp),
shape = CircleShape,
color = MaterialTheme.colors.primary,
elevation = 6.dp
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = remainingCount.toString(),
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onPrimary,
fontWeight = FontWeight.Bold
)
}
}
}
}
@Composable
fun CategoryButton(icon: String, name: String, onClick: () -> Unit) {
OutlinedButton(
onClick = onClick,
modifier = Modifier.fillMaxWidth().height(60.dp),
contentPadding = PaddingValues(2.dp)
) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
Text(icon, fontSize = 20.sp)
Text(
name,
fontSize = 11.sp,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
@SuppressLint("ViewModelConstructorInComposable")
@Preview(showBackground = true)
@Composable
fun LabelBillsScreenPreview() {
val viewModel = LabelBillsViewModel().apply {
billsToLabel = listOf(
DBBill(1L, 0, 1L, 1L, 120.5, System.currentTimeMillis() / 1000, "Groceries at Aldi", 0, null, null, 0, null, -1)
)
val cats = listOf(
DBCategory(1, 1, 1, "Groceries", "🛒", ""),
DBCategory(2, 2, 1, "Leisure", "🥳", ""),
DBCategory(3, 3, 1, "Rent", "🏠", ""),
DBCategory(4, 4, 1, "Bills", "💸", "")
)
categories = cats
categoriesMap = cats.associateBy { it.remoteId }
}
val members = listOf(
DBMember(1L, 0, 1L, "Alice", true, 1.0, 0, 255, 100, 100, null, null),
DBMember(2L, 0, 1L, "Bob", true, 1.0, 0, 100, 255, 100, null, null)
)
MaterialTheme {
LabelBillsScreen(
viewModel = viewModel,
members = members,
db = CowspentSQLiteOpenHelper.getInstance(LocalContext.current),
onBack = {}
)
}
}

View File

@@ -0,0 +1,101 @@
package net.helcel.cowspent.android.bill_label
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import net.helcel.cowspent.model.DBBill
import net.helcel.cowspent.model.DBCategory
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
class LabelBillsViewModel : ViewModel() {
var billsToLabel by mutableStateOf<List<DBBill>>(emptyList())
internal set
var currentBillIndex by mutableIntStateOf(0)
private set
var categories by mutableStateOf<List<DBCategory>>(emptyList())
internal set
var suggestedCategories by mutableStateOf<List<DBCategory>>(emptyList())
private set
internal var categoriesMap: Map<Long, DBCategory> = emptyMap()
internal var allCategorizedBills: List<DBBill> = emptyList()
var onBillProcessed: ((Long) -> Unit)? = null
val currentBill: DBBill?
get() = if (currentBillIndex < billsToLabel.size) billsToLabel[currentBillIndex] else null
fun updateSuggestions() {
val bill = currentBill
if (bill == null) {
suggestedCategories = emptyList()
return
}
val name = bill.what.lowercase().trim()
if (name.isEmpty()) {
suggestedCategories = emptyList()
return
}
val matches = allCategorizedBills.filter {
val otherName = it.what.lowercase().trim()
otherName == name || (name.length > 3 && otherName.contains(name)) || (otherName.length > 3 && name.contains(otherName))
}
val counts = matches.groupBy { it.categoryRemoteId }
.mapValues { it.value.size }
.toList()
.sortedByDescending { it.second }
.take(2)
suggestedCategories = counts.mapNotNull { (catId, _) ->
categoriesMap[catId.toLong()]
}
}
fun labelCurrentBill(db: CowspentSQLiteOpenHelper, categoryId: Int) {
currentBill?.let { bill ->
db.updateBillAndSync(
bill = bill,
newPayerId = bill.payerId,
newAmount = bill.amount,
newTimestamp = bill.timestamp,
newWhat = bill.what,
newOwersIds = bill.billOwersIds,
newRepeat = bill.repeat,
newPaymentMode = bill.paymentMode,
newPaymentModeRemoteId = bill.paymentModeRemoteId,
newCategoryId = categoryId,
newComment = bill.comment
)
bill.categoryRemoteId = categoryId
onBillProcessed?.invoke(bill.id)
moveToNext()
}
}
fun skipCurrentBill() {
currentBill?.let { bill ->
onBillProcessed?.invoke(bill.id)
}
moveToNext()
}
private fun moveToNext() {
if (billsToLabel.isEmpty()) return
val start = currentBillIndex
var next = (start + 1) % billsToLabel.size
while (next != start && billsToLabel[next].categoryRemoteId != 0) {
next = (next + 1) % billsToLabel.size
}
currentBillIndex = if (billsToLabel[next].categoryRemoteId == 0) {
next
} else {
billsToLabel.size
}
updateSuggestions()
}
}

View File

@@ -0,0 +1,145 @@
package net.helcel.cowspent.android.currencies
import android.os.Bundle
import android.util.Log
import android.widget.Toast
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.helcel.cowspent.R
import net.helcel.cowspent.android.helper.showToast
import net.helcel.cowspent.model.DBBill
import net.helcel.cowspent.model.DBCurrency
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.theme.ThemeUtils
import net.helcel.cowspent.util.ICallback
class ManageCurrenciesActivity : AppCompatActivity() {
private val viewModel: ManageCurrenciesViewModel by viewModels()
private var db: CowspentSQLiteOpenHelper? = null
private var selectedProjectID: Long = -1
private val editMainCurrencyCallBack: ICallback = object : ICallback {
override fun onFinish() {}
override fun onFinish(result: String, message: String) {
if (message.isEmpty()) {
showToast(this@ManageCurrenciesActivity,getString(R.string.currency_saved_success), Toast.LENGTH_LONG)
} else {
viewModel.showDialog(title=getString(R.string.error_edit_remote_project_helper, message),
message=getString(R.string.currency_manager),
positiveText = getString(android.R.string.ok))
}
}
override fun onScheduled() {}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
intent.extras?.let {
selectedProjectID = it.getLong(EXTRA_PROJECT_ID)
}
if (selectedProjectID == -1L) {
Log.e(TAG, "Missing project id")
finish()
return
}
db = CowspentSQLiteOpenHelper.getInstance(this)
lifecycleScope.launch {
val project = withContext(Dispatchers.IO) { db!!.getProject(selectedProjectID) }
viewModel.mainCurrencyName = project?.currencyName?.let { if (it == "null") "" else it } ?: ""
updateCurrenciesList()
setContent {
ThemeUtils.CowspentTheme {
ManageCurrenciesScreen(
viewModel = viewModel,
onBack = { finish() },
onSaveMain = { saveMainCurrency() },
onAdd = { addCurrency() },
onDelete = { deleteCurrency(it) }
)
}
}
}
}
private fun saveMainCurrency() {
val newMainCurrencyName = viewModel.mainCurrencyName
lifecycleScope.launch {
withContext(Dispatchers.IO) {
db!!.updateProject(
selectedProjectID, null, null, null,
null, null, newMainCurrencyName,
null, null, null
)
val project = db!!.getProject(selectedProjectID)
if (project != null) {
db!!.syncIfRemote(project)
withContext(Dispatchers.Main) {
if (!db!!.cowspentServerSyncHelper
.editRemoteProject(selectedProjectID, project.name, null, null, newMainCurrencyName, editMainCurrencyCallBack)
) {
showToast(this@ManageCurrenciesActivity, getString(R.string.remote_project_operation_no_network), Toast.LENGTH_LONG)
}
}
}
}
}
}
private fun addCurrency() {
val exchangeRate = try { viewModel.newCurrencyRate.toDouble() } catch (_: Exception) { 0.0 }
val newCurrency = DBCurrency(
0, 0, selectedProjectID,
viewModel.newCurrencyName, exchangeRate, DBBill.STATE_ADDED
)
lifecycleScope.launch {
withContext(Dispatchers.IO) {
db!!.addCurrencyAndSync(newCurrency)
}
viewModel.newCurrencyName = ""
viewModel.newCurrencyRate = ""
updateCurrenciesList()
}
}
private fun deleteCurrency(currency: DBCurrency) {
lifecycleScope.launch {
withContext(Dispatchers.IO) {
db!!.setCurrencyStateSync(currency.id, DBBill.STATE_DELETED)
}
updateCurrenciesList()
}
}
private suspend fun updateCurrenciesList() {
val currenciesDB = withContext(Dispatchers.IO) {
val list = db!!.getCurrenciesOfProjectWithState(selectedProjectID, DBBill.STATE_ADDED).toMutableList()
list.addAll(db!!.getCurrenciesOfProjectWithState(selectedProjectID, DBBill.STATE_EDITED))
list.addAll(db!!.getCurrenciesOfProjectWithState(selectedProjectID, DBBill.STATE_OK))
list
}
withContext(Dispatchers.Main) {
viewModel.currencies = currenciesDB
}
}
override fun onSupportNavigateUp(): Boolean {
finish()
return true
}
companion object {
private val TAG = ManageCurrenciesActivity::class.java.simpleName
const val EXTRA_PROJECT_ID = "EXTRA_PROJECT_ID"
}
}

View File

@@ -0,0 +1,178 @@
package net.helcel.cowspent.android.currencies
import android.annotation.SuppressLint
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import net.helcel.cowspent.R
import net.helcel.cowspent.android.helper.AlertDialog
import net.helcel.cowspent.model.DBCurrency
@Composable
fun ManageCurrenciesScreen(
viewModel: ManageCurrenciesViewModel,
onBack: () -> Unit,
onSaveMain: () -> Unit,
onAdd: () -> Unit,
onDelete: (DBCurrency) -> Unit
) {
val dialogState = viewModel.dialogState
if (dialogState != null) {
AlertDialog(
showDialog = true,
onDismissRequest = { viewModel.dismissDialog() },
title = dialogState.title,
message = dialogState.message,
icon = dialogState.icon,
items = dialogState.items,
positiveText = dialogState.positiveText,
negativeText = dialogState.negativeText,
neutralText = dialogState.neutralText,
onConfirm = {
dialogState.onConfirm?.invoke()
viewModel.dismissDialog()
},
onCancel = {
dialogState.onCancel?.invoke()
viewModel.dismissDialog()
},
onNeutral = {
dialogState.onNeutral?.invoke()
viewModel.dismissDialog()
}
) {
dialogState.onItemSelected?.invoke(it)
viewModel.dismissDialog()
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.currency_manager)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
},
backgroundColor = MaterialTheme.colors.primary,
elevation = 0.dp
)
}
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.padding(16.dp)
.fillMaxSize()
) {
Text(stringResource(R.string.main_currency), style = MaterialTheme.typography.h6)
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = viewModel.mainCurrencyName,
onValueChange = { viewModel.mainCurrencyName = it },
label = { Text(stringResource(R.string.currency_edit_name)) },
modifier = Modifier.weight(1f)
)
Spacer(modifier = Modifier.width(8.dp))
Button(onClick = onSaveMain, enabled = viewModel.mainCurrencyName.isNotEmpty()) {
Text(stringResource(R.string.save_or_discard_bill_dialog_save))
}
}
Spacer(modifier = Modifier.height(24.dp))
Text(stringResource(R.string.add_currency_title), style = MaterialTheme.typography.h6)
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = viewModel.newCurrencyName,
onValueChange = { viewModel.newCurrencyName = it },
label = { Text(stringResource(R.string.currency_edit_name)) },
modifier = Modifier.weight(1f)
)
Spacer(modifier = Modifier.width(8.dp))
OutlinedTextField(
value = viewModel.newCurrencyRate,
onValueChange = { viewModel.newCurrencyRate = it },
label = { Text(stringResource(R.string.currency_rate)) },
modifier = Modifier.weight(1f)
)
}
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = onAdd,
modifier = Modifier.fillMaxWidth(),
enabled = viewModel.isAddEnabled()
) {
Text(stringResource(R.string.simple_add))
}
Spacer(modifier = Modifier.height(24.dp))
LazyColumn(modifier = Modifier.weight(1f)) {
items(viewModel.currencies) { currency ->
CurrencyRow(currency, onDelete = { onDelete(currency) })
}
}
}
}
}
@Composable
fun CurrencyRow(currency: DBCurrency, onDelete: () -> Unit) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(currency.name ?: "", style = MaterialTheme.typography.subtitle1)
Text("Rate: ${currency.exchangeRate}", style = MaterialTheme.typography.caption)
}
IconButton(onClick = onDelete) {
Icon(Icons.Default.Delete, contentDescription = null, tint = MaterialTheme.colors.error)
}
}
Divider()
}
@Preview(showBackground = true)
@Composable
fun CurrencyRowPreview() {
MaterialTheme {
CurrencyRow(
currency = DBCurrency(1, 0, 0, "USD", 1.0, 0),
onDelete = {}
)
}
}
@SuppressLint("ViewModelConstructorInComposable")
@Preview(showBackground = true)
@Composable
fun ManageCurrenciesScreenPreview() {
MaterialTheme {
ManageCurrenciesScreen(
viewModel = ManageCurrenciesViewModel().apply {
mainCurrencyName = "EUR"
currencies = listOf(
DBCurrency(1, 0, 0, "USD", 1.1, 0),
DBCurrency(2, 0, 0, "GBP", 0.85, 0)
)
},
onBack = {},
onSaveMain = {},
onAdd = {},
onDelete = {}
)
}
}

View File

@@ -0,0 +1,55 @@
package net.helcel.cowspent.android.currencies
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.lifecycle.ViewModel
import net.helcel.cowspent.android.helper.DialogState
import net.helcel.cowspent.model.DBCurrency
class ManageCurrenciesViewModel : ViewModel() {
var mainCurrencyName by mutableStateOf("")
var newCurrencyName by mutableStateOf("")
var newCurrencyRate by mutableStateOf("")
var currencies by mutableStateOf<List<DBCurrency>>(emptyList())
var dialogState by mutableStateOf<DialogState?>(null)
fun showDialog(
title: String? = null,
message: String? = null,
icon: ImageVector? = null,
items: List<CharSequence>? = null,
positiveText: String? = null,
negativeText: String? = null,
neutralText: String? = null,
onConfirm: (() -> Unit)? = null,
onCancel: (() -> Unit)? = null,
onNeutral: (() -> Unit)? = null,
onItemSelected: ((Int) -> Unit)? = null
) {
dialogState = DialogState(
title = title,
message = message,
icon = icon,
items = items,
positiveText = positiveText,
negativeText = negativeText,
neutralText = neutralText,
onConfirm = onConfirm,
onCancel = onCancel,
onNeutral = onNeutral,
onItemSelected = onItemSelected
)
}
fun dismissDialog() {
dialogState = null
}
fun isAddEnabled(): Boolean {
return newCurrencyName.isNotEmpty() && newCurrencyRate.isNotEmpty()
}
}

View File

@@ -0,0 +1,350 @@
package net.helcel.cowspent.android.drawer
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import net.helcel.cowspent.R
import net.helcel.cowspent.android.helper.UserAvatar
import net.helcel.cowspent.android.helper.formatBalance
import net.helcel.cowspent.android.helper.lazyVerticalScrollbar
import net.helcel.cowspent.model.DBMember
import net.helcel.cowspent.model.DBProject
import net.helcel.cowspent.model.ProjectType
@Composable
fun Drawer(
projects: List<DBProject>,
members: List<DBMember>,
memberBalances: Map<Long, Double> = emptyMap(),
selectedProjectId: Long,
selectedMemberId: Long?,
lastSyncText: String,
showArchived: Boolean = false,
onProjectClick: (Long) -> Unit,
onProjectOptionsClick: (Long) -> Unit,
onMemberClick: (Long?) -> Unit,
onAddProjectClick: () -> Unit,
onAppSettingsClick: () -> Unit
) {
Column(
modifier = Modifier.fillMaxSize().background(MaterialTheme.colors.surface),
) {
DrawerHeader(
lastSyncText = lastSyncText,
onAddProjectClick = onAddProjectClick,
onAppSettingsClick = onAppSettingsClick
)
BoxWithConstraints(modifier = Modifier.weight(1f)) {
val maxBottomHeight = maxHeight / 2
Column(modifier = Modifier.fillMaxSize()) {
// Projects Section
val activeProjects = projects.filter { !it.isArchived }
.sortedWith(compareByDescending<DBProject> { it.latestBillTs }.thenByDescending { it.id })
val archivedProjects = projects.filter { it.isArchived }.sortedByDescending { it.archivedTs }
val archivedToDisplay = if (showArchived) archivedProjects else archivedProjects.filter { it.id == selectedProjectId }
val projectsState = rememberLazyListState()
LazyColumn(
state = projectsState,
modifier = Modifier.weight(1f).lazyVerticalScrollbar(projectsState)
) {
items(activeProjects) { project ->
ProjectDrawerItem(
project = project,
isSelected = project.id == selectedProjectId,
onClick = { onProjectClick(project.id) },
onOptionsClick = { onProjectOptionsClick(project.id) }
)
}
if (archivedToDisplay.isNotEmpty()) {
item { Divider(Modifier.height(4.dp)) }
items(archivedToDisplay) { project ->
ProjectDrawerItem(
project = project,
isSelected = project.id == selectedProjectId,
onClick = { onProjectClick(project.id) },
onOptionsClick = { onProjectOptionsClick(project.id) },
alpha = 0.6f,
icon = Icons.Default.Archive
)
}
}
}
Divider(Modifier.height(4.dp))
// Members Section
val membersState = rememberLazyListState()
LazyColumn(
state = membersState,
modifier = Modifier
.heightIn(max = maxBottomHeight)
.lazyVerticalScrollbar(membersState)
) {
if (members.isNotEmpty()) {
item {
DrawerItem(
icon = Icons.Default.Receipt,
text = stringResource(R.string.label_all_bills),
selected = selectedMemberId == null,
onClick = { onMemberClick(null) }
)
}
items(members) { member ->
val balance = memberBalances[member.id] ?: 0.0
MemberDrawerItem(
member = member,
balance = balance,
isSelected = member.id == selectedMemberId,
onClick = { onMemberClick(member.id) }
)
}
}
}
}
}
Spacer(Modifier.height(8.dp))
}
}
@Composable
private fun DrawerHeader(
lastSyncText: String,
onAddProjectClick: () -> Unit,
onAppSettingsClick: () -> Unit
) {
Box(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colors.primary)
.padding(16.dp, 4.dp)
) {
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
Image(
painter = painterResource(id = R.drawable.ic_launcher_foreground),
contentDescription = null,
modifier = Modifier.size(48.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
stringResource(id = R.string.app_name),
color = MaterialTheme.colors.onPrimary,
fontSize = 18.sp,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.weight(1f))
IconButton(
onClick = onAddProjectClick,
modifier = Modifier.scale(0.8f)
) {
Icon(
Icons.Default.Add,
contentDescription = stringResource(id = R.string.action_add_project),
tint = MaterialTheme.colors.onPrimary
)
}
IconButton(
onClick = onAppSettingsClick,
modifier = Modifier.scale(0.8f)
) {
Icon(
Icons.Default.Settings,
contentDescription = stringResource(id = R.string.action_settings),
tint = MaterialTheme.colors.onPrimary
)
}
}
if (lastSyncText.isNotEmpty()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.Sync,
contentDescription = null,
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colors.onPrimary
)
Spacer(modifier = Modifier.width(8.dp))
Text(lastSyncText, color = MaterialTheme.colors.onPrimary, fontSize = 12.sp)
}
}
}
}
}
@Composable
private fun ProjectDrawerItem(
project: DBProject,
isSelected: Boolean,
onClick: () -> Unit,
onOptionsClick: () -> Unit,
alpha: Float = 1f,
icon: ImageVector = Icons.Default.Folder
) {
val projectName = project.name.ifEmpty { project.remoteId }
DrawerItem(
icon = icon,
text = projectName,
selected = isSelected,
onClick = onClick,
onSecondaryClick = onOptionsClick,
alpha = alpha
)
}
@Composable
private fun MemberDrawerItem(
member: DBMember,
balance: Double,
isSelected: Boolean,
onClick: () -> Unit
) {
val balanceText = formatBalance(balance)
val balanceColor = when {
balance > 0.01 -> Color(0xFF4CAF50)
balance < -0.01 -> Color(0xFFF44336)
else -> MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
}
DrawerItem(
member = member,
balanceText = balanceText.ifEmpty { null },
balanceColor = balanceColor,
selected = isSelected,
onClick = onClick
)
}
@Composable
fun DrawerItem(
icon: ImageVector? = null,
member: DBMember? = null,
text: String? = null,
balanceText: String? = null,
balanceColor: Color = Color.Unspecified,
selected: Boolean = false,
alpha: Float = 1f,
onClick: () -> Unit,
onSecondaryClick: (() -> Unit)? = null
) {
val backgroundColor = if (selected) MaterialTheme.colors.primary.copy(alpha = 0.12f) else Color.Transparent
val contentColor = (if (selected) MaterialTheme.colors.primary else MaterialTheme.colors.onSurface).copy(alpha = alpha)
Row(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor)
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = if (member != null) 0.dp else 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (member != null) {
UserAvatar(
name = member.name,
r = member.r,
g = member.g,
b = member.b,
avatar = member.avatar,
disabled = !member.isActivated,
size = 24.dp,
alpha = alpha
)
} else if (icon != null) {
Icon(icon, contentDescription = null, tint = contentColor.copy(alpha = 0.6f * alpha))
}
Spacer(modifier = Modifier.width(8.dp))
val itemText = text ?: member?.name ?: ""
Text(
text = itemText,
modifier = Modifier.weight(1f),
color = contentColor,
fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal,
maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis
)
if (balanceText != null) {
Text(
text = balanceText,
color = balanceColor,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(end = 8.dp)
)
}
if (onSecondaryClick != null) {
IconButton(onClick = onSecondaryClick) {
Icon(
Icons.Default.MoreVert,
contentDescription = null,
tint = contentColor.copy(alpha = 0.6f)
)
}
} else if (member != null) {
// Equalize height with project items that have a secondary button
Spacer(modifier = Modifier.height(48.dp))
}
}
}
@Preview(showBackground = true)
@Composable
fun DrawerItemPreview() {
MaterialTheme {
DrawerItem(
icon = Icons.Default.Folder,
text = "My Project",
selected = true,
onClick = {},
onSecondaryClick = {}
)
}
}
@Preview(showBackground = true)
@Composable
fun DrawerPreview() {
MaterialTheme {
Drawer(
projects = listOf(
DBProject(1, "Vacation", "", "vacation", null, null, null, ProjectType.LOCAL, 0L, null, false, 0, null, null),
DBProject(2, "Home", "", "home", null, null, null, ProjectType.LOCAL, 0L, null, false, 0, null, 123456789L)
),
members = listOf(
DBMember(1, 0, 1, "Alice", true, 1.0, 0, null, null, null, null, null),
DBMember(2, 0, 1, "Bob", true, 1.0, 0, null, null, null, null, null)
),
selectedProjectId = 1,
selectedMemberId = null,
lastSyncText = "Last sync: 5 mins ago",
onProjectClick = {},
onProjectOptionsClick = {},
onMemberClick = {},
onAddProjectClick = {},
onAppSettingsClick = {}
)
}
}

View File

@@ -0,0 +1,312 @@
package net.helcel.cowspent.android.helper
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
data class DialogState(
val title: String? = null,
val message: String? = null,
val icon: ImageVector? = null,
val items: List<CharSequence>? = null,
val itemIcons: List<ImageVector>? = null,
val positiveText: String? = null,
val negativeText: String? = null,
val neutralText: String? = null,
val onConfirm: (() -> Unit)? = null,
val onCancel: (() -> Unit)? = null,
val onNeutral: (() -> Unit)? = null,
val onItemSelected: ((Int) -> Unit)? = null
)
@Composable
fun StatefulAlertDialog(
state: DialogState?,
onDismissRequest: () -> Unit
) {
if (state != null) {
AlertDialog(
showDialog = true,
onDismissRequest = onDismissRequest,
title = state.title,
message = state.message,
icon = state.icon,
items = state.items,
itemIcons = state.itemIcons,
positiveText = state.positiveText,
negativeText = state.negativeText,
neutralText = state.neutralText,
onConfirm = {
state.onConfirm?.invoke()
onDismissRequest()
},
onCancel = {
state.onCancel?.invoke()
onDismissRequest()
},
onNeutral = {
state.onNeutral?.invoke()
onDismissRequest()
}
) {
state.onItemSelected?.invoke(it)
onDismissRequest()
}
}
}
@Composable
fun AlertDialog(
modifier: Modifier = Modifier,
showDialog: Boolean,
onDismissRequest: () -> Unit,
title: String? = null,
message: String? = null,
icon: ImageVector? = null,
items: List<CharSequence>? = null,
itemIcons: List<ImageVector>? = null,
positiveText: String? = null,
negativeText: String? = null,
neutralText: String? = null,
onConfirm: (() -> Unit)? = null,
onCancel: (() -> Unit)? = null,
onNeutral: (() -> Unit)? = null,
onItemSelected: ((Int) -> Unit)? = null
) {
if (showDialog) {
AlertDialog(
onDismissRequest = onDismissRequest,
shape = MaterialTheme.shapes.large,
title = if (title != null || icon != null) {
{
Row(verticalAlignment = Alignment.CenterVertically) {
if (icon != null) {
Icon(icon, contentDescription = null)
Spacer(Modifier.width(8.dp))
}
if (title != null) {
Text(text = title)
}
}
}
} else null,
text = if (message != null || items != null) {
{
Column {
if (message != null) {
Text(text = message)
}
if (items != null) {
if (message != null) Spacer(Modifier.height(8.dp))
LazyColumn {
itemsIndexed(items) { index, item ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
onItemSelected?.invoke(index)
}
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (itemIcons != null && index < itemIcons.size) {
Icon(
itemIcons[index],
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colors.primary.copy(alpha = 0.7f)
)
Spacer(Modifier.width(16.dp))
}
Text(
text = item.toString(),
style = MaterialTheme.typography.body1
)
}
}
}
}
}
}
} else null,
buttons = {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(all = 8.dp),
horizontalArrangement = Arrangement.End
) {
if (neutralText != null) {
TextButton(onClick = { onNeutral?.invoke() }) {
Text(neutralText)
}
Spacer(modifier = Modifier.weight(1f))
}
if (negativeText != null) {
TextButton(onClick = { onCancel?.invoke() }) {
Text(negativeText)
}
}
if (positiveText != null) {
TextButton(onClick = { onConfirm?.invoke() }) {
Text(positiveText)
}
}
}
},
modifier = modifier
)
}
}
@Composable
fun AlertDialogContent(
modifier: Modifier = Modifier,
title: String? = null,
message: String? = null,
icon: ImageVector? = null,
items: Array<out CharSequence>? = null,
itemIcons: Array<out ImageVector>? = null,
positiveText: String? = null,
negativeText: String? = null,
neutralText: String? = null,
onConfirm: (() -> Unit)? = null,
onCancel: (() -> Unit)? = null,
onNeutral: (() -> Unit)? = null,
onItemSelected: ((Int) -> Unit)? = null
) {
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colors.surface,
contentColor = contentColorFor(MaterialTheme.colors.surface),
modifier = modifier
) {
Column(modifier = Modifier.padding(24.dp,16.dp)) {
if (title != null || icon != null) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(bottom = 16.dp)
) {
if (icon != null) {
Icon(icon, contentDescription = null)
Spacer(Modifier.width(8.dp))
}
if (title != null) {
Text(text = title, style = MaterialTheme.typography.h6)
}
}
}
if (message != null || items != null) {
Column {
if (message != null) {
Text(text = message, style = MaterialTheme.typography.body1)
}
if (items != null) {
if (message != null) Spacer(Modifier.height(8.dp))
LazyColumn {
itemsIndexed(items) { index, item ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
onItemSelected?.invoke(index)
}
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (itemIcons != null && index < itemIcons.size) {
Icon(
itemIcons[index],
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colors.primary.copy(alpha = 0.7f)
)
Spacer(Modifier.width(16.dp))
}
Text(
text = item.toString(),
style = MaterialTheme.typography.body1
)
}
}
}
}
}
}
if (positiveText != null || negativeText != null || neutralText != null) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
if (neutralText != null) {
TextButton(onClick = { onNeutral?.invoke() }) {
Text(neutralText)
}
Spacer(modifier = Modifier.weight(1f))
}
if (negativeText != null) {
TextButton(onClick = { onCancel?.invoke() }) {
Text(negativeText)
}
}
if (positiveText != null) {
TextButton(onClick = { onConfirm?.invoke() }) {
Text(positiveText)
}
}
}
}
}
}
}
@Preview(showBackground = true)
@Composable
fun SimpleAlertDialogPreview() {
MaterialTheme {
AlertDialogContent(
title = "Info",
message = "This is a simple alert message.",
positiveText = "OK"
)
}
}
@Preview(showBackground = true)
@Composable
fun ConfirmationDialogPreview() {
MaterialTheme {
AlertDialogContent(
title = "Confirm Action",
message = "Are you sure you want to proceed?",
icon = Icons.Default.Info,
positiveText = "Yes",
negativeText = "No"
)
}
}
@Preview(showBackground = true)
@Composable
fun ListDialogWithIconsPreview() {
MaterialTheme {
AlertDialogContent(
title = "Select Option",
items = arrayOf("Edit", "Delete", "Share"),
itemIcons = arrayOf(Icons.Default.Edit, Icons.Default.Delete, Icons.Default.Share),
negativeText = "Cancel"
)
}
}

View File

@@ -0,0 +1,298 @@
package net.helcel.cowspent.android.helper
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.core.graphics.ColorUtils
import kotlin.math.*
import androidx.core.graphics.toColorInt
private fun mLCHtoRBG(l: Float, c: Float, h: Float) : Int{
val hRad = h * PI / 180
val a = c * cos(hRad)
val b = c * sin(hRad)
return ColorUtils.LABToColor(l.toDouble(), a, b)
}
@Composable
fun ColorPicker(
initialColor: Int,
onColorChanged: (Int) -> Unit
) {
// LCH state
val initialLch = remember(initialColor) {
val lab = DoubleArray(3)
ColorUtils.colorToLAB(initialColor, lab)
val l = lab[0].toFloat()
val c = sqrt(lab[1].pow(2) + lab[2].pow(2)).toFloat()
val h = (atan2(lab[2], lab[1]) * 180 / PI).toFloat().let { if (it < 0) it + 360f else it }
floatArrayOf(l, c, h)
}
var lightness by remember { mutableFloatStateOf(initialLch[0]) }
var chroma by remember { mutableFloatStateOf(initialLch[1]) }
var hue by remember { mutableFloatStateOf(initialLch[2]) }
val currentColorInt = remember(lightness, chroma, hue) {
val color = mLCHtoRBG(lightness,chroma,hue)
onColorChanged(color)
color
}
val currentColor = Color(currentColorInt)
// HEX state
var hexText by remember { mutableStateOf("%06X".format(0xFFFFFF and currentColorInt)) }
var isHexValid by remember { mutableStateOf(true) }
val interactionSource = remember { MutableInteractionSource() }
// Sync HEX with LCH
LaunchedEffect(currentColorInt) {
val newHex = "%06X".format(0xFFFFFF and currentColorInt)
if (hexText.uppercase() != newHex) {
hexText = newHex
isHexValid = true
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Preview Area
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Box(
modifier = Modifier
.size(64.dp)
.shadow(4.dp, CircleShape)
.clip(CircleShape)
.background(currentColor)
.border(2.dp, Color.White, CircleShape)
)
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(
text = "Selected Color",
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
)
BasicTextField(
value = hexText,
onValueChange = { newText ->
val filtered = newText.removePrefix("#").filter { it.isDigit() || it.uppercaseChar() in 'A'..'F' }.take(6)
hexText = filtered
if (filtered.length == 6) {
try {
val parsedColor = "#$filtered".toColorInt()
val lab = DoubleArray(3)
ColorUtils.colorToLAB(parsedColor, lab)
lightness = lab[0].toFloat()
chroma = sqrt(lab[1].pow(2) + lab[2].pow(2)).toFloat()
hue = (atan2(lab[2], lab[1]) * 180 / PI).toFloat().let { if (it < 0) it + 360f else it }
isHexValid = true
} catch (_: Exception) {
isHexValid = false
}
} else {
isHexValid = false
}
},
modifier = Modifier
.width(104.dp)
.heightIn(min = 36.dp), // 1. Bypasses the default 56.dp constraint
textStyle = MaterialTheme.typography.body1.copy(
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colors.onSurface // Essential for BasicTextField visibility
),
visualTransformation = { text ->
TransformedText(
AnnotatedString("#" + text.text),
object : OffsetMapping {
override fun originalToTransformed(offset: Int): Int = offset + 1
override fun transformedToOriginal(offset: Int): Int = if (offset < 1) 0 else offset - 1
}
)
},
singleLine = true,
interactionSource = interactionSource
) { innerTextField ->
@OptIn(ExperimentalMaterialApi::class)
TextFieldDefaults.TextFieldDecorationBox(
value = hexText,
innerTextField = innerTextField,
enabled = true,
singleLine = true,
visualTransformation = { text ->
TransformedText(
AnnotatedString("#" + text.text),
object : OffsetMapping {
override fun originalToTransformed(offset: Int): Int =
offset + 1
override fun transformedToOriginal(offset: Int): Int =
if (offset < 1) 0 else offset - 1
}
)
},
interactionSource = interactionSource,
isError = !isHexValid, // 2. Tells decoration box to draw the red error indicator line
colors = TextFieldDefaults.textFieldColors(
backgroundColor = Color.Transparent,
focusedIndicatorColor = currentColor,
unfocusedIndicatorColor = MaterialTheme.colors.onSurface.copy(alpha = 0.3f)
),
// 3. Reduces the vertical and horizontal internal padding
contentPadding = PaddingValues(horizontal = 6.dp, vertical = 4.dp)
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
val hueBrush = remember {
Brush.horizontalGradient(
listOf(
Color.Red, Color.Yellow, Color.Green, Color.Cyan, Color.Blue, Color.Magenta, Color.Red
)
)
}
LchSlider(
label = "H",
value = hue,
range = 0f..360f,
onValueChange = { hue = it },
brush = hueBrush
)
Spacer(modifier = Modifier.height(16.dp))
LchSlider(
label = "C",
value = chroma,
range = 0f..150f,
onValueChange = { chroma = it },
brush = Brush.horizontalGradient(listOf(
Color(mLCHtoRBG(lightness,0f, hue)),
Color(mLCHtoRBG(lightness,150f,hue))))
)
Spacer(modifier = Modifier.height(16.dp))
// LCH Sliders
LchSlider(
label = "L",
value = lightness,
range = 0f..100f,
onValueChange = { lightness = it },
brush = Brush.horizontalGradient(listOf(Color.Black, Color.White))
)
}
}
@Composable
fun LchSlider(
label: String,
value: Float,
range: ClosedFloatingPointRange<Float>,
onValueChange: (Float) -> Unit,
brush: Brush
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = label, style = MaterialTheme.typography.subtitle2)
Spacer(Modifier.width(Dp(8f)))
Box(
modifier = Modifier
.fillMaxWidth()
.height(28.dp)
.shadow(2.dp, RoundedCornerShape(14.dp))
.clip(RoundedCornerShape(14.dp))
.background(brush)
.pointerInput(Unit) {
detectTapGestures { offset ->
val newValue = (offset.x / size.width).coerceIn(
0f,
1f
) * (range.endInclusive - range.start) + range.start
onValueChange(newValue)
}
}
.pointerInput(Unit) {
detectDragGestures { change, _ ->
val newValue = (change.position.x / size.width).coerceIn(
0f,
1f
) * (range.endInclusive - range.start) + range.start
onValueChange(newValue)
}
}
) {
// Handle
Canvas(modifier = Modifier.fillMaxSize()) {
val fraction = (value - range.start) / (range.endInclusive - range.start)
val x = fraction * size.width
drawCircle(
color = Color.White,
radius = 12.dp.toPx(),
center = Offset(x, size.height / 2),
style = Stroke(width = 3.dp.toPx())
)
drawCircle(
color = Color.Black.copy(alpha = 0.2f),
radius = 13.dp.toPx(),
center = Offset(x, size.height / 2),
style = Stroke(width = 1.dp.toPx())
)
}
}
}
}
@Preview(showBackground = true)
@Composable
fun ColorPickerPreview() {
MaterialTheme {
ColorPicker(initialColor = android.graphics.Color.BLUE, onColorChanged = {})
}
}

View File

@@ -0,0 +1,85 @@
package net.helcel.cowspent.android.helper
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun ClickableOutlinedTextField(
value: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
placeholder: @Composable (() -> Unit)? = null
) {
Box(modifier = modifier) {
OutlinedTextField(
value = value,
onValueChange = {},
readOnly = true,
enabled = enabled,
placeholder = placeholder,
modifier = Modifier.fillMaxWidth(),
leadingIcon = leadingIcon,
trailingIcon = trailingIcon
)
Box(
modifier = Modifier
.matchParentSize()
.clickable(enabled = enabled) { onClick() })
}
}
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun EditableExposedDropdownMenu(
value: String,
placeholder: String,
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
leadingIcon: @Composable (() -> Unit)? = null,
colors: TextFieldColors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(),
content: @Composable ColumnScope.() -> Unit
) {
ExposedDropdownMenuBox(
expanded = expanded && enabled,
onExpandedChange = { if (enabled) onExpandedChange(!expanded) },
modifier = modifier.fillMaxWidth()
) {
OutlinedTextField(
value = value,
onValueChange = {},
readOnly = true,
enabled = enabled,
placeholder = { Text(placeholder) },
modifier = Modifier.fillMaxWidth(),
leadingIcon = leadingIcon,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
colors = colors
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = onDismissRequest
) {
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 280.dp)
.verticalScrollbar(scrollState)
.verticalScroll(scrollState),
content = content
)
}
}
}

View File

@@ -0,0 +1,20 @@
package net.helcel.cowspent.android.helper
import net.helcel.cowspent.util.SupportUtil
import java.util.Locale
import kotlin.math.abs
import kotlin.math.round
fun formatShortValue(value: Double): String {
return when {
value >= 1_000_000 -> String.format(Locale.ROOT, "%.1fM", value / 1_000_000).replace(".0", "")
value >= 1_000 -> String.format(Locale.ROOT, "%.1fk", value / 1_000).replace(".0", "")
else -> String.format(Locale.ROOT, "%.0f", value)
}
}
fun formatBalance(balance: Double): String {
val rbalance = round(abs(balance) * 100.0) / 100.0
val balanceSign = if (balance > 0.01) "+" else if (balance < -0.01) "-" else ""
return if (rbalance == 0.0) "" else "$balanceSign${SupportUtil.normalNumberFormat.format(rbalance)}"
}

View File

@@ -0,0 +1,70 @@
package net.helcel.cowspent.android.helper
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
fun Modifier.verticalScrollbar(
state: ScrollState,
width: Dp = 4.dp,
color: Color = Color.Gray,
alpha: Float = 0.6f
): Modifier = drawWithContent {
drawContent()
val maxValue = state.maxValue.toFloat()
if (maxValue > 0) {
val viewPortHeight = size.height
val totalContentHeight = maxValue + viewPortHeight
val scrollBarHeight = (viewPortHeight * viewPortHeight / totalContentHeight).coerceAtLeast(24.dp.toPx())
val scrollBarOffset = (state.value.toFloat() / maxValue) * (viewPortHeight - scrollBarHeight)
drawRoundRect(
color = color,
topLeft = Offset(size.width - width.toPx() - 2.dp.toPx(), scrollBarOffset),
size = Size(width.toPx(), scrollBarHeight),
cornerRadius = CornerRadius(width.toPx() / 2, width.toPx() / 2),
alpha = alpha
)
}
}
fun Modifier.lazyVerticalScrollbar(
state: LazyListState,
width: Dp = 4.dp,
color: Color = Color.Gray,
alpha: Float = 0.6f
): Modifier = drawWithContent {
drawContent()
val layoutInfo = state.layoutInfo
val visibleItemsInfo = layoutInfo.visibleItemsInfo
if (visibleItemsInfo.isNotEmpty()) {
val totalItemsCount = layoutInfo.totalItemsCount
val firstVisibleItem = visibleItemsInfo.first()
val viewPortHeight = layoutInfo.viewportEndOffset - layoutInfo.viewportStartOffset
val avgItemHeight = visibleItemsInfo.map { it.size }.average().toFloat()
val totalContentHeight = avgItemHeight * totalItemsCount
if (totalContentHeight > viewPortHeight) {
val scrollBarHeight = (viewPortHeight.toFloat() * viewPortHeight / totalContentHeight).coerceAtLeast(24.dp.toPx())
val firstItemOffset = firstVisibleItem.offset
val scrollOffset = firstVisibleItem.index * avgItemHeight - firstItemOffset
val scrollBarOffset = (scrollOffset / (totalContentHeight - viewPortHeight)) * (viewPortHeight - scrollBarHeight)
drawRoundRect(
color = color,
topLeft = Offset(size.width - width.toPx() - 2.dp.toPx(), scrollBarOffset),
size = Size(width.toPx(), scrollBarHeight),
cornerRadius = CornerRadius(width.toPx() / 2, width.toPx() / 2),
alpha = alpha
)
}
}
}

View File

@@ -0,0 +1,226 @@
package net.helcel.cowspent.android.helper
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import android.os.VibrationEffect
import android.os.Vibrator
import android.util.Log
import androidx.activity.compose.setContent
import androidx.annotation.OptIn
import androidx.appcompat.app.AppCompatActivity
import net.helcel.cowspent.theme.ThemeUtils
import androidx.camera.core.*
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import com.google.zxing.*
import com.google.zxing.common.HybridBinarizer
import net.helcel.cowspent.R
import net.helcel.cowspent.android.main.MainConstants
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
class QrCodeScannerActivity : AppCompatActivity() {
private lateinit var cameraExecutor: ExecutorService
private var isScanning = true
override fun onCreate(state: Bundle?) {
super.onCreate(state)
cameraExecutor = Executors.newSingleThreadExecutor()
setContent {
ThemeUtils.CowspentTheme {
QrCodeScannerScreen(
onBack = { finish() },
onResult = { handleResult(it) },
cameraExecutor = cameraExecutor,
isScanning = isScanning,
onRequestPermission = {
ActivityCompat.requestPermissions(
this, REQUIRED_PERMISSIONS, REQUEST_CODE_PERMISSIONS
)
},
hasPermission = allPermissionsGranted()
)
}
}
}
private fun allPermissionsGranted() = REQUIRED_PERMISSIONS.all {
ContextCompat.checkSelfPermission(baseContext, it) == PackageManager.PERMISSION_GRANTED
}
override fun onDestroy() {
super.onDestroy()
cameraExecutor.shutdown()
}
override fun onRequestPermissionsResult(
requestCode: Int, permissions: Array<String>, grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == REQUEST_CODE_PERMISSIONS) {
if (allPermissionsGranted()) {
// Permissions granted, the UI will recompose and start camera
} else {
finish()
}
}
}
private fun handleResult(result: Result) {
if (!isScanning) return
isScanning = false
Log.v(TAG, "QR result " + result.text)
val intent = Intent()
intent.putExtra(MainConstants.KEY_QR_CODE, result.text)
setResult(RESULT_OK, intent)
finish()
}
companion object {
private val TAG = QrCodeScannerActivity::class.java.simpleName
private const val REQUEST_CODE_PERMISSIONS = 10
private val REQUIRED_PERMISSIONS = arrayOf(Manifest.permission.CAMERA)
}
}
@Composable
fun QrCodeScannerScreen(
onBack: () -> Unit,
onResult: (Result) -> Unit,
cameraExecutor: ExecutorService,
isScanning: Boolean,
onRequestPermission: () -> Unit,
hasPermission: Boolean
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.scan_qrcode)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
},
backgroundColor = MaterialTheme.colors.surface,
elevation = 0.dp
)
}
) { padding ->
Box(modifier = Modifier.fillMaxSize().padding(padding)) {
if (hasPermission) {
AndroidView(
factory = { ctx ->
PreviewView(ctx).apply {
scaleType = PreviewView.ScaleType.FILL_CENTER
}
},
modifier = Modifier.fillMaxSize(),
update = { previewView ->
val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
cameraProviderFuture.addListener({
val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()
val preview = androidx.camera.core.Preview.Builder().build().also {
it.surfaceProvider = previewView.surfaceProvider
}
val imageAnalyzer = ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
.also {
it.setAnalyzer(cameraExecutor, QrCodeAnalyzer { result ->
if (isScanning) {
onResult(result)
}
})
}
try {
cameraProvider.unbindAll()
cameraProvider.bindToLifecycle(
lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA,
preview,
imageAnalyzer
)
} catch (exc: Exception) {
Log.e("QrCodeScannerScreen", "Use case binding failed", exc)
}
}, ContextCompat.getMainExecutor(context))
}
)
} else {
LaunchedEffect(Unit) {
onRequestPermission()
}
}
}
}
}
private class QrCodeAnalyzer(private val onQrCodeScanned: (Result) -> Unit) : ImageAnalysis.Analyzer {
private val reader = MultiFormatReader().apply {
setHints(mapOf(DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE)))
}
@OptIn(ExperimentalGetImage::class)
override fun analyze(image: ImageProxy) {
val buffer = image.planes[0].buffer
val data = ByteArray(buffer.remaining())
buffer.get(data)
val source = PlanarYUVLuminanceSource(
data, image.width, image.height, 0, 0, image.width, image.height, false
)
val binarizer = HybridBinarizer(source)
val binaryBitmap = BinaryBitmap(binarizer)
try {
val result = reader.decode(binaryBitmap)
onQrCodeScanned(result)
} catch (_: Exception) {
// No QR code found
} finally {
image.close()
}
}
}
@Preview(showBackground = true)
@Composable
fun QrCodeScannerScreenPreview() {
MaterialTheme {
QrCodeScannerScreen(
onBack = {},
onResult = {},
cameraExecutor = Executors.newSingleThreadExecutor(),
isScanning = true,
onRequestPermission = {},
hasPermission = true
)
}
}

View File

@@ -0,0 +1,219 @@
package net.helcel.cowspent.android.helper
import android.graphics.*
import android.graphics.drawable.Drawable
import java.security.MessageDigest
import java.security.NoSuchAlgorithmException
import java.util.*
import kotlin.math.max
import kotlin.math.pow
import kotlin.math.round
import kotlin.math.sqrt
/**
* A Drawable object that draws text (1 character) on top of a circular/filled background.
*/
class TextDrawable private constructor(
private val mText: String,
r: Int,
g: Int,
b: Int,
private val mRadius: Float,
private val mDisabled: Boolean
) : Drawable() {
private val mTextPaint: Paint = Paint()
private val mBackground: Paint = Paint()
private val mDisabledCircle: Paint = Paint()
init {
mBackground.style = Paint.Style.FILL
mBackground.isAntiAlias = true
mBackground.color = Color.rgb(r, g, b)
if ((r + g + b) / 3 < 220) {
mTextPaint.color = Color.WHITE
} else {
mTextPaint.color = Color.BLACK
}
mTextPaint.textSize = mRadius
mTextPaint.isAntiAlias = true
mTextPaint.textAlign = Paint.Align.CENTER
mDisabledCircle.style = Paint.Style.STROKE
mDisabledCircle.strokeWidth = mRadius * 0.2f
mDisabledCircle.isAntiAlias = true
mDisabledCircle.color = Color.DKGRAY
}
override fun draw(canvas: Canvas) {
canvas.drawCircle(mRadius, mRadius, mRadius, mBackground)
canvas.drawText(
mText,
mRadius,
mRadius - (mTextPaint.descent() + mTextPaint.ascent()) / 2,
mTextPaint
)
if (mDisabled) {
canvas.drawCircle(mRadius, mRadius, mRadius * 0.9f, mDisabledCircle)
canvas.drawLine(
mRadius * 0.4f,
mRadius * 1.6f,
mRadius * 1.6f,
mRadius * 0.4f,
mDisabledCircle
)
}
}
override fun setAlpha(alpha: Int) {
mTextPaint.alpha = alpha
}
override fun setColorFilter(cf: ColorFilter?) {
mTextPaint.colorFilter = cf
}
@Deprecated("Deprecated in Java")
override fun getOpacity(): Int {
return PixelFormat.TRANSLUCENT
}
companion object {
private const val INDEX_RED = 0
private const val INDEX_GREEN = 1
private const val INDEX_BLUE = 2
private const val INDEX_HUE = 0
private const val INDEX_SATURATION = 1
private const val INDEX_LUMINATION = 2
fun getColorFromName(name: String): Int {
return try {
val hsl = calculateHSL(name)
val rgb = hslToRgb(hsl[0].toFloat(), hsl[1].toFloat(), hsl[2].toFloat(), 1f)
Color.rgb(rgb[0], rgb[1], rgb[2])
} catch (_: NoSuchAlgorithmException) {
Color.WHITE
}
}
@Throws(NoSuchAlgorithmException::class)
private fun calculateHSL(name: String): IntArray {
val result = arrayOf("0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0")
val rgb = doubleArrayOf(0.0, 0.0, 0.0)
var sat = 70
val lum = 68
val modulo = 16
var hash = name.lowercase(Locale.ROOT).replace("[^0-9a-f]".toRegex(), "")
if (!hash.matches("^[0-9a-f]{32}$".toRegex())) {
hash = md5(hash)
}
for (i in hash.indices) {
result[i % modulo] = (result[i % modulo].toInt() + hash.substring(i, i + 1).toInt(16)).toString()
}
for (count in 1 until modulo) {
rgb[count % 3] += result[count].toDouble()
}
rgb[INDEX_RED] = rgb[INDEX_RED] % 255
rgb[INDEX_GREEN] = rgb[INDEX_GREEN] % 255
rgb[INDEX_BLUE] = rgb[INDEX_BLUE] % 255
val hsl = rgbToHsl(rgb[INDEX_RED], rgb[INDEX_GREEN], rgb[INDEX_BLUE])
val bright = sqrt(
0.299 * rgb[INDEX_RED].pow(2.0) + 0.587 * rgb[INDEX_GREEN].pow(2.0) + 0.114 * rgb[INDEX_BLUE].pow(2.0)
)
if (bright >= 200) {
sat = 60
}
return intArrayOf((hsl[INDEX_HUE] * 360).toInt(), sat, lum)
}
private fun hslToRgb(hParam: Float, sParam: Float, lParam: Float, alpha: Float): IntArray {
var h = hParam
var s = sParam
var l = lParam
if (s !in 0.0f..100.0f) {
throw IllegalArgumentException("Color parameter outside of expected range - Saturation")
}
if (l !in 0.0f..100.0f) {
throw IllegalArgumentException("Color parameter outside of expected range - Luminance")
}
if (alpha !in 0.0f..1.0f) {
throw IllegalArgumentException("Color parameter outside of expected range - Alpha")
}
h %= 360.0f
h /= 360f
s /= 100f
l /= 100f
val q = if (l < 0.5) {
l * (1 + s)
} else {
(l + s) - s * l
}
val p = 2 * l - q
val r = round(max(0f, hueToRgb(p, q, h + 1.0f / 3.0f)) * 256).toInt()
val g = round(max(0f, hueToRgb(p, q, h)) * 256).toInt()
val b = round(max(0f, hueToRgb(p, q, h - 1.0f / 3.0f)) * 256).toInt()
return intArrayOf(r, g, b)
}
private fun hueToRgb(p: Float, q: Float, hParam: Float): Float {
var h = hParam
if (h < 0) h += 1f
if (h > 1) h -= 1f
if (6 * h < 1) return p + (q - p) * 6 * h
if (2 * h < 1) return q
if (3 * h < 2) return p + (q - p) * 6 * (2.0f / 3.0f - h)
return p
}
private fun rgbToHsl(rUntrimmed: Double, gUntrimmed: Double, bUntrimmed: Double): DoubleArray {
val r = rUntrimmed / 255
val g = gUntrimmed / 255
val b = bUntrimmed / 255
val max = max(r, max(g, b))
val min = r.coerceAtMost(g.coerceAtMost(b))
var h = (max + min) / 2
val s: Double
val l = (max + min) / 2
if (max == min) {
s = 0.0
h = s // achromatic
} else {
val d = max - min
s = if (l > 0.5) d / (2 - max - min) else d / (max + min)
when (max) {
r -> {
h = (g - b) / d + (if (g < b) 6 else 0)
}
g -> {
h = (b - r) / d + 2
}
b -> {
h = (r - g) / d + 4
}
}
h /= 6.0
}
val hsl = DoubleArray(3)
hsl[INDEX_HUE] = h
hsl[INDEX_SATURATION] = s
hsl[INDEX_LUMINATION] = l
return hsl
}
@Throws(NoSuchAlgorithmException::class)
private fun md5(string: String): String {
val md5 = MessageDigest.getInstance("MD5").digest(string.toByteArray())
return md5.joinToString("") { "%02x".format(it) }
}
}
}

View File

@@ -0,0 +1,9 @@
package net.helcel.cowspent.android.helper
import android.content.Context
import android.widget.Toast
fun showToast(ctx: Context, text: CharSequence?, duration: Int = Toast.LENGTH_LONG) {
Toast.makeText(ctx, text, duration).show()
}

View File

@@ -0,0 +1,110 @@
package net.helcel.cowspent.android.helper
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
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 android.graphics.BitmapFactory
import android.util.Base64
@Composable
fun UserAvatar(
name: String,
modifier: Modifier = Modifier,
size: Dp = 40.dp,
r: Int? = null,
g: Int? = null,
b: Int? = null,
avatar: String? = null,
disabled: Boolean = false,
alpha: Float = 1f
) {
val bitmap = remember(avatar) {
if (!avatar.isNullOrEmpty()) {
try {
val bytes = Base64.decode(avatar, Base64.DEFAULT)
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
} catch (_: Exception) {
null
}
} else {
null
}
}
if (bitmap != null) {
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = name,
modifier = modifier
.size(size)
.clip(CircleShape),
contentScale = ContentScale.Crop,
alpha = alpha
)
} else {
val backgroundColor = remember(name, r, g, b) {
if (r != null && g != null && b != null) {
Color(r, g, b)
} else {
Color(TextDrawable.getColorFromName(name))
}
}
val initials = name.take(1).uppercase()
val isLight = remember(backgroundColor) {
// Simple luminance check
val luminance =
0.2126 * backgroundColor.red + 0.7152 * backgroundColor.green + 0.0722 * backgroundColor.blue
luminance > 0.5
}
Box(
modifier = modifier
.size(size)
.clip(CircleShape)
.background((if (disabled) Color.Gray else backgroundColor).copy(alpha = alpha)),
contentAlignment = Alignment.Center
) {
Text(
text = initials,
color = (if (isLight) Color.Black else Color.White).copy(alpha = alpha),
fontSize = (size.value * 0.5).sp,
fontWeight = FontWeight.Bold
)
}
}
}
@Preview
@Composable
fun UserAvatarPreview() {
UserAvatar(name = "Alice")
}
@Preview
@Composable
fun UserAvatarCustomColorPreview() {
UserAvatar(name = "Bob", r = 255, g = 0, b = 0)
}
@Preview
@Composable
fun UserAvatarDisabledPreview() {
UserAvatar(name = "Charlie", disabled = true)
}

View File

@@ -0,0 +1,163 @@
package net.helcel.cowspent.android.main
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Receipt
import androidx.compose.material.icons.filled.Repeat
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import net.helcel.cowspent.R
import net.helcel.cowspent.android.helper.UserAvatar
import net.helcel.cowspent.model.DBBill
import net.helcel.cowspent.model.DBMember
import net.helcel.cowspent.util.SupportUtil
@Composable
fun EmptyProjectsState(onConfigureNextcloud: () -> Unit, onAddManually: () -> Unit) {
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(stringResource(R.string.no_projects_title), style = MaterialTheme.typography.h6)
Spacer(modifier = Modifier.height(8.dp))
Text(stringResource(R.string.no_projects_text))
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = onConfigureNextcloud) {
Text(stringResource(R.string.configure_account_choice))
}
Spacer(modifier = Modifier.height(8.dp))
Button(onClick = onAddManually) {
Text(stringResource(R.string.add_project_choice))
}
}
}
@Composable
fun EmptyMembersState() {
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(stringResource(R.string.no_members_title), style = MaterialTheme.typography.h6)
Spacer(modifier = Modifier.height(8.dp))
Text(stringResource(R.string.no_members_text))
}
}
@Composable
fun EmptyBillsState() {
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(stringResource(R.string.no_bills_title), style = MaterialTheme.typography.h6)
Spacer(modifier = Modifier.height(8.dp))
Text(stringResource(R.string.no_bills_text))
}
}
@Composable
fun BillItemRow(bill: DBBill, payer: DBMember?, onClick: () -> Unit) {
Row {
Spacer(Modifier.width(16.dp))
Divider(thickness = 1.dp, modifier=Modifier.width(40.dp))
}
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(16.dp, 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (payer != null) {
Box {
UserAvatar(
name = payer.name,
r = payer.r,
g = payer.g,
b = payer.b,
avatar = payer.avatar,
disabled = !payer.isActivated,
size = 40.dp
)
if (bill.repeat != null && bill.repeat != DBBill.NON_REPEATED) {
Icon(
Icons.Default.Repeat,
contentDescription = null,
modifier = Modifier
.align(Alignment.CenterStart)
.size(24.dp)
.offset((-8).dp)
.background(MaterialTheme.colors.onSurface, CircleShape)
.padding(2.dp),
tint = MaterialTheme.colors.surface
)
}
}
} else {
Icon(
Icons.Default.Receipt,
contentDescription = null,
modifier = Modifier.size(40.dp),
tint = MaterialTheme.colors.primary
)
}
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(bill.formattedWhat.ifEmpty { bill.what }, fontWeight = FontWeight.Bold)
Text(
text = bill.formattedSubtitle.ifEmpty { bill.comment ?: "" },
style = MaterialTheme.typography.caption,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Column(horizontalAlignment = Alignment.End) {
Text(
text = SupportUtil.normalNumberFormat.format(bill.amount),
style = MaterialTheme.typography.h6,
fontWeight = FontWeight.Bold
)
}
}
}
@Composable
fun SectionHeader(title: String) {
Surface(
color = MaterialTheme.colors.background,
modifier = Modifier.fillMaxWidth()
) {
Divider(thickness = 2.dp)
Text(
text = title,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
style = MaterialTheme.typography.caption.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colors.primary
)
}
}
@Composable
fun EmptyState() {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(stringResource(R.string.no_bills_title), style = MaterialTheme.typography.h6)
Text(stringResource(R.string.no_bills_text), modifier = Modifier.padding(16.dp))
}
}

View File

@@ -0,0 +1,586 @@
package net.helcel.cowspent.android.main
import android.annotation.SuppressLint
import android.content.Intent
import android.widget.Toast
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.FloatingActionButton
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TextField
import androidx.compose.material.TextFieldDefaults
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Category
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.material.rememberScaffoldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.preference.PreferenceManager
import kotlinx.coroutines.launch
import net.helcel.cowspent.R
import net.helcel.cowspent.android.drawer.Drawer
import net.helcel.cowspent.android.helper.StatefulAlertDialog
import net.helcel.cowspent.android.project.ProjectOptionsDialogContent
import net.helcel.cowspent.android.project.ProjectShareDialogContent
import net.helcel.cowspent.android.project.member.MemberAddDialogContent
import net.helcel.cowspent.android.project.member.MemberEditDialogContent
import net.helcel.cowspent.android.project.member.MemberManagementDialogContent
import net.helcel.cowspent.android.project.settle.ProjectSettlementDialogContent
import net.helcel.cowspent.android.statistics.ProjectStatisticsActivity
import net.helcel.cowspent.model.DBBill
import net.helcel.cowspent.model.DBMember
import net.helcel.cowspent.model.DBProject
import net.helcel.cowspent.model.SectionItem
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.util.IRefreshBillsListCallback
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun BillsListScreen(
viewModel: BillsListViewModel,
db: CowspentSQLiteOpenHelper,
refreshCallback: IRefreshBillsListCallback,
onAddBillClick: () -> Unit,
onBillClick: (DBBill) -> Unit,
onProjectClick: (Long) -> Unit,
onProjectOptionsClick: (Long) -> Unit,
onProjectAction: (Long, Int) -> Unit,
onAccountSwitcherClick: () -> Unit,
onAddProjectClick: () -> Unit,
onAppSettingsClick: () -> Unit,
onLabelBillsClick: () -> Unit,
onRefresh: () -> Unit
) {
val scaffoldState = rememberScaffoldState()
val scope = rememberCoroutineScope()
var isSearchExpanded by remember { mutableStateOf(false) }
val focusRequester = remember { FocusRequester() }
LaunchedEffect(isSearchExpanded) {
if (isSearchExpanded) {
focusRequester.requestFocus()
}
}
LaunchedEffect(viewModel.searchQuery) {
refreshCallback.refreshLists(false)
}
val pullRefreshState = rememberPullRefreshState(viewModel.isRefreshing, onRefresh)
val context = LocalContext.current
val memberAlreadyExistsError = stringResource(R.string.member_already_exists)
val sharedPreferences = remember { PreferenceManager.getDefaultSharedPreferences(context) }
val showArchived = sharedPreferences.getBoolean(stringResource(R.string.pref_key_show_archived), false)
StatefulAlertDialog(
state = viewModel.dialogState,
onDismissRequest = { viewModel.dismissDialog() }
)
val projectOptionsProjectId = viewModel.showProjectOptionsDialogByProjectId
if (projectOptionsProjectId != null) {
val proj = remember(projectOptionsProjectId, viewModel.projects) {
viewModel.projects.find { it.id == projectOptionsProjectId }
}
Dialog(
onDismissRequest = { viewModel.showProjectOptionsDialogByProjectId = null },
) {
ProjectOptionsDialogContent(
onEditProject = {
onProjectAction(projectOptionsProjectId, 0)
viewModel.showProjectOptionsDialogByProjectId = null
},
onRemoveProject = {
onProjectAction(projectOptionsProjectId, 1)
viewModel.showProjectOptionsDialogByProjectId = null
},
onManageMembers = {
onProjectAction(projectOptionsProjectId, 2)
viewModel.showProjectOptionsDialogByProjectId = null
},
onManageCurrencies = {
onProjectAction(projectOptionsProjectId, 3)
viewModel.showProjectOptionsDialogByProjectId = null
},
onStatistics = {
onProjectAction(projectOptionsProjectId, 4)
viewModel.showProjectOptionsDialogByProjectId = null
},
onSettle = {
onProjectAction(projectOptionsProjectId, 5)
viewModel.showProjectOptionsDialogByProjectId = null
},
onShareProject = {
onProjectAction(projectOptionsProjectId, 6)
viewModel.showProjectOptionsDialogByProjectId = null
},
onExportProject = {
onProjectAction(projectOptionsProjectId, 7)
viewModel.showProjectOptionsDialogByProjectId = null
},
onDismiss = { viewModel.showProjectOptionsDialogByProjectId = null },
isArchived = proj?.isArchived == true,
accessLevel = proj?.myAccessLevel ?: DBProject.ACCESS_LEVEL_ADMIN,
isShareable = proj?.isShareable() ?: true
)
}
}
val settlementProjectId = viewModel.showSettlementDialogByProjectId
if (settlementProjectId != null) {
val proj = remember(settlementProjectId, viewModel.projects) {
viewModel.projects.find { it.id == settlementProjectId }
}
if (proj != null) {
Dialog(
onDismissRequest = { viewModel.showSettlementDialogByProjectId = null },
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
ProjectSettlementDialogContent(
proj = proj,
db = db,
onSettleBills = { transactions ->
BillsListUtils.createBillsFromTransactions(
db,
settlementProjectId,
transactions,
refreshCallback,
context
)
viewModel.showSettlementDialogByProjectId = null
},
onShare = { transactions, memberIdToName ->
BillsListUtils.shareSettlement(context, proj, transactions, memberIdToName)
},
onDismiss = { viewModel.showSettlementDialogByProjectId = null }
)
}
}
}
val statisticsProjectId = viewModel.showStatisticsDialogByProjectId
if (statisticsProjectId != null) {
LaunchedEffect(statisticsProjectId) {
context.startActivity(
ProjectStatisticsActivity.createIntent(
context,
statisticsProjectId
)
)
viewModel.showStatisticsDialogByProjectId = null
}
}
val manageMembersProjectId = viewModel.showMemberManagementDialogByProjectId
if (manageMembersProjectId != null) {
val members = viewModel.members // Use members from ViewModel as they are already loaded
Dialog(
onDismissRequest = { viewModel.showMemberManagementDialogByProjectId = null },
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
MemberManagementDialogContent(
members = members,
onAddMember = {
viewModel.showAddMemberDialogByProjectId = manageMembersProjectId
viewModel.showMemberManagementDialogByProjectId = null
},
onEditMember = { member ->
viewModel.showEditMemberDialogByProjectId = member.id
viewModel.showMemberManagementDialogByProjectId = null
},
onDismiss = { viewModel.showMemberManagementDialogByProjectId = null }
)
}
}
val addMemberProjectId = viewModel.showAddMemberDialogByProjectId
if (addMemberProjectId != null) {
Dialog(
onDismissRequest = { viewModel.showAddMemberDialogByProjectId = null },
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
MemberAddDialogContent(
onAdd = { memberName ->
val memberNames =
db.getMembersOfProject(addMemberProjectId, null).map { it.name }
if (memberNames.contains(memberName)) {
Toast.makeText(
context,
memberAlreadyExistsError,
Toast.LENGTH_SHORT
).show()
} else {
val color = net.helcel.cowspent.android.helper.TextDrawable.getColorFromName(memberName)
db.addMemberAndSync(
DBMember(
0,
0,
addMemberProjectId,
memberName,
true,
1.0,
DBBill.STATE_ADDED,
android.graphics.Color.red(color),
android.graphics.Color.green(color),
android.graphics.Color.blue(color),
null,
null
)
)
refreshCallback.refreshLists(false)
viewModel.showAddMemberDialogByProjectId = null
viewModel.showMemberManagementDialogByProjectId = addMemberProjectId
}
},
onDismiss = {
viewModel.showAddMemberDialogByProjectId = null
viewModel.showMemberManagementDialogByProjectId = addMemberProjectId
}
)
}
}
val editMemberId = viewModel.showEditMemberDialogByProjectId
if (editMemberId != null) {
val memberToEdit = remember(editMemberId, viewModel.members) {
viewModel.members.find { it.id == editMemberId }
}
if (memberToEdit != null) {
Dialog(
onDismissRequest = { viewModel.showEditMemberDialogByProjectId = null },
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
MemberEditDialogContent(
member = memberToEdit,
onSave = { name, weight, isActivated, r, g, b ->
db.updateMemberAndSync(
memberToEdit,
name,
weight,
isActivated,
r,
g,
b,
"",
""
)
refreshCallback.refreshLists(false)
viewModel.showEditMemberDialogByProjectId = null
viewModel.showMemberManagementDialogByProjectId = memberToEdit.projectId
},
onDelete = {
db.deleteMember(editMemberId)
refreshCallback.refreshLists(false)
viewModel.showEditMemberDialogByProjectId = null
viewModel.showMemberManagementDialogByProjectId = memberToEdit.projectId
},
onDismiss = {
viewModel.showEditMemberDialogByProjectId = null
viewModel.showMemberManagementDialogByProjectId = memberToEdit.projectId
}
)
}
}
}
val shareProjectId = viewModel.showShareDialogByProjectId
if (shareProjectId != null) {
val proj = remember(shareProjectId, viewModel.projects) {
viewModel.projects.find { it.id == shareProjectId }
}
if (proj != null) {
val shareIntentTitle = stringResource(R.string.share_share_intent_title, proj.name)
val shareChooserTitle = stringResource(R.string.share_share_chooser_title, proj.name)
Dialog(
onDismissRequest = { viewModel.showShareDialogByProjectId = null },
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
ProjectShareDialogContent(
proj = proj,
onShare = { shareUrl ->
val shareIntent = Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
putExtra(Intent.EXTRA_SUBJECT, shareIntentTitle)
putExtra(Intent.EXTRA_TEXT, shareUrl)
}
val chooserIntent = Intent.createChooser(shareIntent, shareChooserTitle)
context.startActivity(chooserIntent)
},
onDismiss = { viewModel.showShareDialogByProjectId = null }
)
}
}
}
Scaffold(
scaffoldState = scaffoldState,
drawerShape = RectangleShape,
topBar = {
TopAppBar(
title = {
if (isSearchExpanded) {
TextField(
value = viewModel.searchQuery,
onValueChange = { viewModel.searchQuery = it },
placeholder = { Text(stringResource(R.string.action_search), color = MaterialTheme.colors.onPrimary.copy(alpha = 0.7f)) },
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
colors = TextFieldDefaults.textFieldColors(
backgroundColor = Color.Transparent,
cursorColor = MaterialTheme.colors.onPrimary,
textColor = MaterialTheme.colors.onPrimary,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent
),
singleLine = true,
trailingIcon = {
IconButton(onClick = {
isSearchExpanded = false
viewModel.searchQuery = ""
}) {
Icon(Icons.Default.Close, contentDescription = null, tint = MaterialTheme.colors.onPrimary)
}
}
)
} else {
Column {
if (viewModel.title.isNotEmpty()) Text(viewModel.title)
else Text(stringResource(R.string.app_name))
}
}
},
navigationIcon = {
if (isSearchExpanded) {
IconButton(onClick = {
isSearchExpanded = false
viewModel.searchQuery = ""
}) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
} else {
IconButton(onClick = {
scope.launch { scaffoldState.drawerState.open() }
}) {
Icon(Icons.Default.Menu, contentDescription = null)
}
}
},
actions = {
if (!isSearchExpanded) {
if (viewModel.hasUnlabeledBills) {
IconButton(onClick = onLabelBillsClick) {
Icon(
Icons.Default.Category,
contentDescription = stringResource(R.string.action_label_bills)
)
}
}
IconButton(onClick = { isSearchExpanded = true }) {
Icon(Icons.Default.Search, contentDescription = null)
}
}
},
backgroundColor = MaterialTheme.colors.primary,
elevation = 4.dp
)
},
floatingActionButton = {
val selectedProject = viewModel.projects.find { it.id == viewModel.selectedProjectId }
if (selectedProject != null && !selectedProject.isArchived) {
FloatingActionButton(onClick = onAddBillClick) {
Icon(Icons.Default.Add, contentDescription = stringResource(R.string.action_create_bill))
}
}
},
drawerContent = {
Drawer(
projects = viewModel.projects,
members = viewModel.members,
memberBalances = viewModel.memberBalances,
selectedProjectId = viewModel.selectedProjectId,
selectedMemberId = viewModel.selectedMemberId,
lastSyncText = viewModel.lastSyncText,
showArchived = showArchived,
onProjectClick = {
viewModel.selectedMemberId = null
onProjectClick(it)
scope.launch { scaffoldState.drawerState.close() }
},
onProjectOptionsClick = {
onProjectOptionsClick(it)
},
onMemberClick = { memberId ->
viewModel.selectedMemberId = memberId
refreshCallback.refreshLists(false)
scope.launch { scaffoldState.drawerState.close() }
},
onAddProjectClick = {
onAddProjectClick()
scope.launch { scaffoldState.drawerState.close() }
},
onAppSettingsClick = {
onAppSettingsClick()
scope.launch { scaffoldState.drawerState.close() }
}
)
}
) { padding ->
// Pull-to-refresh is not officially in Material 2 basic components,
// but we can use SwipeRefresh from accompanist or implement it manually.
// For simplicity and following common practices, I'll assume standard swipe refresh logic.
// Actually, there is androidx.compose.material.pullrefresh.pullRefresh in later Material 2.
Box(modifier = Modifier
.padding(padding)
.fillMaxSize()
.pullRefresh(pullRefreshState)) {
when {
viewModel.showNoProjects -> EmptyProjectsState(onAccountSwitcherClick, onAddProjectClick)
viewModel.showNoMembers -> EmptyMembersState()
viewModel.showNoBills -> EmptyBillsState()
viewModel.bills.isEmpty() -> EmptyState()
else -> {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(viewModel.bills) { item ->
when (item) {
is DBBill -> {
val payer = viewModel.members.find { it.id == item.payerId }
BillItemRow(item, payer, onClick = { onBillClick(item) })
}
is SectionItem -> SectionHeader(item.title)
}
}
}
}
}
PullRefreshIndicator(
refreshing = viewModel.isRefreshing,
state = pullRefreshState,
modifier = Modifier.align(Alignment.TopCenter)
)
}
}
}
@Preview(showBackground = true)
@Composable
fun BillItemRowPreview() {
MaterialTheme {
BillItemRow(
bill = DBBill(0, 0, 0, 1, 15.0, System.currentTimeMillis() / 1000, "Dinner", 0, "n", null, 0, "", 0).apply {
formattedWhat = "Dinner"
formattedSubtitle = "Alice \u2192 Group"
},
payer = DBMember(1, 0, 0, "Alice", true, 1.0, 0, null, null, null, null, null),
onClick = {}
)
}
}
@Preview(showBackground = true)
@Composable
fun SectionHeaderPreview() {
MaterialTheme {
SectionHeader(title = "October 2023")
}
}
@SuppressLint("ViewModelConstructorInComposable")
@Preview(showBackground = true)
@Composable
fun BillsListScreenPreview() {
MaterialTheme {
BillsListScreen(
viewModel = BillsListViewModel().apply {
title = "Demo Project"
members = listOf(
DBMember(1, 0, 0, "Alice", true, 1.0, 0, null, null, null, null, null)
)
bills = listOf(
SectionItem("Today"),
DBBill(0, 0, 0, 1, 10.0, System.currentTimeMillis() / 1000, "Lunch", 0, "n", null, 0, "", 0).apply {
formattedWhat = "Lunch"
formattedSubtitle = "Alice \u2192 Group"
}
)
},
db = CowspentSQLiteOpenHelper.getInstance(LocalContext.current),
refreshCallback = object : IRefreshBillsListCallback {
override fun refreshLists(scrollToTop: Boolean) {}
},
onAddBillClick = {},
onBillClick = {},
onProjectClick = {},
onProjectOptionsClick = {},
onProjectAction = { _, _ -> },
onAccountSwitcherClick = {},
onAddProjectClick = {},
onAppSettingsClick = {},
onLabelBillsClick = {},
onRefresh = {}
)
}
}
@Preview(showBackground = true)
@Composable
fun EmptyProjectsStatePreview() {
MaterialTheme {
EmptyProjectsState(onConfigureNextcloud = {}, onAddManually = {})
}
}
@Preview(showBackground = true)
@Composable
fun EmptyMembersStatePreview() {
MaterialTheme {
EmptyMembersState()
}
}
@Preview(showBackground = true)
@Composable
fun EmptyBillsStatePreview() {
MaterialTheme {
EmptyBillsState()
}
}

View File

@@ -0,0 +1,117 @@
package net.helcel.cowspent.android.main
import android.content.Context
import android.content.Intent
import net.helcel.cowspent.R
import net.helcel.cowspent.model.*
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.util.IRefreshBillsListCallback
import java.text.SimpleDateFormat
import java.util.*
import kotlin.math.round
object BillsListUtils {
fun groupAndSectionBills(
bills: List<DBBill>,
membersMap: Map<Long, DBMember>,
sdf: SimpleDateFormat,
context: Context
): List<Item> {
val groupedBillsList = mutableListOf<DBBill>()
val groups = bills.groupBy { "${it.what}|${it.date}|${it.time}|${it.payerId}" }
val seenGroups = mutableSetOf<String>()
for (bill in bills) {
val groupKey = "${bill.what}|${bill.date}|${bill.time}|${bill.payerId}"
if (groupKey !in seenGroups) {
val group = groups[groupKey]!!
if (group.size == 1) {
groupedBillsList.add(bill)
} else {
val groupedBill = GroupedBill(group)
val payerName = membersMap[groupedBill.payerId]?.name ?: groupedBill.payerId.toString()
val allOwerIds = group.flatMap { it.billOwersIds }.distinct()
val owersNames = allOwerIds.joinToString(", ") { id ->
membersMap[id]?.name ?: id.toString()
}
groupedBill.formattedSubtitle = "$payerName \u2192 $owersNames"
groupedBillsList.add(groupedBill)
}
seenGroups.add(groupKey)
}
}
val itemList: MutableList<Item> = ArrayList()
var lastDate = ""
val androidDateFormat = android.text.format.DateFormat.getDateFormat(context)
for (bill in groupedBillsList) {
val billDate = bill.date
if (billDate != lastDate) {
val date = try { sdf.parse(billDate) } catch (_: Exception) { null }
val formattedDate = date?.let { androidDateFormat.format(it) } ?: billDate
itemList.add(SectionItem(formattedDate))
lastDate = billDate
}
itemList.add(bill)
}
return itemList
}
fun shareSettlement(
context: Context,
proj: DBProject,
transactions: List<Transaction>,
memberIdToName: Map<Long, String>
) {
val projectName = proj.name.ifEmpty { proj.remoteId }
var text = context.getString(R.string.share_settle_intro, projectName) + "\n"
for (t in transactions) {
val amount = round(t.amount * 100.0) / 100.0
text += "\n" + context.getString(
R.string.share_settle_sentence,
memberIdToName[t.owerMemberId],
memberIdToName[t.receiverMemberId],
amount
)
}
val shareIntent = Intent()
shareIntent.action = Intent.ACTION_SEND
shareIntent.type = "text/plain"
shareIntent.putExtra(
Intent.EXTRA_SUBJECT,
context.getString(R.string.share_settle_title, projectName)
)
shareIntent.putExtra(Intent.EXTRA_TEXT, text)
val chooserIntent = Intent.createChooser(
shareIntent,
context.getString(R.string.share_settle_title, projectName)
)
context.startActivity(chooserIntent)
}
fun createBillsFromTransactions(
db: CowspentSQLiteOpenHelper,
projectId: Long,
transactions: List<Transaction>,
refreshCallback: IRefreshBillsListCallback,
context: Context
) {
val timestamp = System.currentTimeMillis() / 1000
for (t in transactions) {
val owerId = t.owerMemberId
val receiverId = t.receiverMemberId
val amount = t.amount
val bill = DBBill(
0, 0, projectId, owerId, amount,
timestamp, context.getString(R.string.settle_bill_what),
DBBill.STATE_ADDED, DBBill.NON_REPEATED,
DBBill.PAYMODE_NONE, DBBill.CATEGORY_NONE,
"", DBBill.PAYMODE_ID_NONE
)
bill.billOwers += DBBillOwer(0, 0, receiverId)
db.addBill(bill)
}
refreshCallback.refreshLists(true)
}
}

View File

@@ -0,0 +1,827 @@
package net.helcel.cowspent.android.main
import android.Manifest
import android.app.SearchManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.graphics.BitmapFactory
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.util.Base64
import android.util.Log
import android.widget.Toast
import androidx.activity.OnBackPressedCallback
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AddCircleOutline
import androidx.compose.material.icons.filled.Sync
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.content.edit
import androidx.core.net.toUri
import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceManager
import com.nextcloud.android.sso.helper.SingleAccountHelper
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.helcel.cowspent.R
import net.helcel.cowspent.android.account.AccountActivity
import net.helcel.cowspent.android.bill_edit.EditBillActivity
import net.helcel.cowspent.android.bill_label.LabelBillsActivity
import net.helcel.cowspent.android.currencies.ManageCurrenciesActivity
import net.helcel.cowspent.android.helper.showToast
import net.helcel.cowspent.android.project.create.NewProjectActivity
import net.helcel.cowspent.android.project.edit.EditProjectActivity
import net.helcel.cowspent.android.settings.PreferencesActivity
import net.helcel.cowspent.model.Category
import net.helcel.cowspent.model.DBBill
import net.helcel.cowspent.model.DBProject
import net.helcel.cowspent.model.GroupedBill
import net.helcel.cowspent.model.ProjectType
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.persistence.CowspentServerSyncHelper
import net.helcel.cowspent.theme.ThemeUtils
import net.helcel.cowspent.util.BillFormatter
import net.helcel.cowspent.util.CospendClientUtil
import net.helcel.cowspent.util.ExportUtil
import net.helcel.cowspent.util.ICallback
import net.helcel.cowspent.util.IRefreshBillsListCallback
import net.helcel.cowspent.util.SupportUtil
import java.io.IOException
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
class BillsListViewActivity :
AppCompatActivity(),
IRefreshBillsListCallback {
private val viewModel: BillsListViewModel by viewModels()
companion object {
var DEBUG = false
private val TAG = BillsListViewActivity::class.java.simpleName
private const val SAVED_STATE_NAVIGATION_SELECTION = "navigationSelection"
private const val SAVED_STATE_NAVIGATION_OPEN = "navigationOpen"
private var contentToExport = ""
var isActivityVisible = false
private set
}
private var navigationSelection = Category(null, null)
private var navigationOpen: String? = ""
private var mActionMode: ActionMode? = null
private lateinit var db: CowspentSQLiteOpenHelper
private val syncCallBack = object : ICallback {
override fun onFinish() {
mActionMode?.finish()
refreshLists()
viewModel.isRefreshing = false
}
override fun onFinish(result: String, message: String) {}
override fun onScheduled() {
viewModel.isRefreshing = false
}
}
private val addProjectLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val data = result.data
if (result.resultCode == RESULT_OK && data != null) {
var pid = data.getLongExtra(MainConstants.CREATED_PROJECT, 0)
var created = true
if (pid == 0L) {
created = false
pid = data.getLongExtra(MainConstants.ADDED_PROJECT, 0)
}
if (DEBUG) Log.d(TAG, "BILLS request code : addproject $pid")
if (pid != 0L) {
viewModel.selectedMemberId = null
setSelectedProject(pid)
Log.d(TAG, "CREATED project id: $pid")
lifecycleScope.launch {
val addedProj = withContext(Dispatchers.IO) { db.getProject(pid) }
val message: String
val title: String
if (created) {
Log.e(TAG, "CREATED !!!")
title = getString(R.string.project_create_success_title)
message = getString(R.string.project_create_success_message, addedProj?.remoteId)
} else {
Log.e(TAG, "ADDED !!!")
title = getString(R.string.project_add_success_title)
message = getString(R.string.project_add_success_message, addedProj?.remoteId)
}
showDialog(message, title, Icons.Default.AddCircleOutline)
}
}
}
setupDrawerProjects()
}
private val serverSettingsLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
updateUsernameInDrawer()
db = CowspentSQLiteOpenHelper.getInstance(this)
if (CowspentServerSyncHelper.isNextcloudAccountConfigured(applicationContext)) {
db.cowspentServerSyncHelper.runAccountProjectsSync()
}
if (!db.cowspentServerSyncHelper.isSyncPossible) {
if (CowspentServerSyncHelper.isNextcloudAccountConfigured(applicationContext)) {
Toast.makeText(applicationContext, getString(R.string.error_sync, getString(CospendClientUtil.LoginStatus.NO_NETWORK.str)), Toast.LENGTH_LONG).show()
}
}
}
}
private val createBillLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
}
private val editBillLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val data = result.data
if (result.resultCode == RESULT_OK && data != null) {
val billId = data.getLongExtra(MainConstants.BILL_TO_DUPLICATE, 0)
if (billId != 0L) {
duplicateBill(billId)
}
}
}
private val editProjectLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val data = result.data
if (result.resultCode == RESULT_OK && data != null) {
var pid = data.getLongExtra(MainConstants.DELETED_PROJECT, 0)
if (pid != 0L) {
setSelectedProject(0)
}
pid = data.getLongExtra(MainConstants.EDITED_PROJECT, 0)
if (pid != 0L) {
viewModel.selectedMemberId = null
setSelectedProject(pid)
}
}
setupDrawerProjects()
}
private val labelBillsLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
refreshLists()
}
private val saveFileLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val data = result.data
if (result.resultCode == RESULT_OK && data != null) {
val fileUri = data.data
fileUri?.let { saveToFileUri(contentToExport, it) }
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
isActivityVisible = true
if (savedInstanceState != null) {
navigationSelection = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
savedInstanceState.getSerializable(SAVED_STATE_NAVIGATION_SELECTION, Category::class.java)!!
} else {
@Suppress("DEPRECATION")
savedInstanceState.getSerializable(SAVED_STATE_NAVIGATION_SELECTION) as Category
}
navigationOpen = savedInstanceState.getString(SAVED_STATE_NAVIGATION_OPEN)
}
db = CowspentSQLiteOpenHelper.getInstance(this)
setupDrawerProjects()
updateUsernameInDrawer()
setContent {
ThemeUtils.CowspentTheme {
BillsListScreen(
viewModel = viewModel,
db = db,
refreshCallback = this,
onAddBillClick = {
val selectedProjectId = PreferenceManager.getDefaultSharedPreferences(applicationContext).getLong("selected_project", 0)
if (selectedProjectId != 0L) {
lifecycleScope.launch {
val members = withContext(Dispatchers.IO) { db.getActivatedMembersOfProject(selectedProjectId) }
if (members.isEmpty()) {
showToast(this@BillsListViewActivity, getString(R.string.add_bill_impossible_no_member))
} else {
val proj = withContext(Dispatchers.IO) { db.getProject(selectedProjectId) }
val createIntent = Intent(applicationContext, EditBillActivity::class.java).apply {
putExtra(EditBillActivity.PARAM_PROJECT_ID, selectedProjectId)
putExtra(EditBillActivity.PARAM_PROJECT_TYPE, proj?.type?.id)
}
createBillLauncher.launch(createIntent)
}
}
}
},
onBillClick = { bill: DBBill ->
lifecycleScope.launch {
val pid = PreferenceManager.getDefaultSharedPreferences(applicationContext).getLong("selected_project", 0)
val proj = withContext(Dispatchers.IO) { db.getProject(pid) }
val intent = Intent(applicationContext, EditBillActivity::class.java).apply {
if (bill is GroupedBill) {
val ids = bill.sourceBills.map { it.id }.toLongArray()
putExtra(EditBillActivity.PARAM_GROUPED_BILL_IDS, ids)
} else {
putExtra(EditBillActivity.PARAM_BILL_ID, bill.id)
}
putExtra(EditBillActivity.PARAM_PROJECT_TYPE, proj?.type?.id)
putExtra(EditBillActivity.PARAM_PROJECT_ID, pid)
}
editBillLauncher.launch(intent)
}
},
onProjectClick = { pid: Long -> onProjectClick(pid) },
onProjectOptionsClick = { pid: Long -> onManageProjectClick(pid) },
onProjectAction = { pid, actionIndex ->
when (actionIndex) {
0 -> onEditProjectClick(pid)
1 -> onRemoveProjectClick(pid)
2 -> onManageMembersClick(pid)
3 -> onManageCurrenciesClick(pid)
4 -> onProjectStatisticsClick(pid)
5 -> onSettleProjectClick(pid)
6 -> onShareProjectClick(pid)
7 -> onExportProjectClick(pid)
}
},
onAccountSwitcherClick = {
serverSettingsLauncher.launch(Intent(this, AccountActivity::class.java))
},
onAddProjectClick = { addProject() },
onAppSettingsClick = {
serverSettingsLauncher.launch(Intent(this, PreferencesActivity::class.java))
},
onLabelBillsClick = {
val selectedProjectId = PreferenceManager.getDefaultSharedPreferences(applicationContext).getLong("selected_project", 0)
if (selectedProjectId != 0L) {
labelBillsLauncher.launch(LabelBillsActivity.createIntent(this, selectedProjectId))
}
},
onRefresh = { synchronize(true) }
)
}
}
lifecycleScope.launch {
val empty = withContext(Dispatchers.IO) { db.projects.isEmpty() }
if (empty && !CowspentServerSyncHelper.isNextcloudAccountConfigured(this@BillsListViewActivity)) {
viewModel.showNoProjects = true
}
val preferences = PreferenceManager.getDefaultSharedPreferences(applicationContext)
val selectedProjectId = preferences.getLong("selected_project", 0)
if (selectedProjectId == 0L) {
val dbProjects = withContext(Dispatchers.IO) { db.projects }
if (dbProjects.isNotEmpty()) {
setSelectedProject(dbProjects[0].id)
}
}
}
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
finish()
}
})
val projectToSelect = intent.getLongExtra(MainConstants.PARAM_PROJECT_TO_SELECT, 0)
if (projectToSelect != 0L) {
setSelectedProject(projectToSelect)
lifecycleScope.launch {
val project = withContext(Dispatchers.IO) { db.getProject(projectToSelect) }
val dialogContent = intent.getStringExtra(MainConstants.PARAM_DIALOG_CONTENT)
if (dialogContent != null && project != null) {
viewModel.showDialog(
title = getString(R.string.activity_dialog_title, project.name),
message = dialogContent,
positiveText = getString(android.R.string.ok),
icon = Icons.Default.Sync
)
}
}
}
}
override fun onResume() {
super.onResume()
val preferences = PreferenceManager.getDefaultSharedPreferences(applicationContext)
val selectedProjectId = preferences.getLong("selected_project", 0)
if (selectedProjectId != 0L) {
refreshLists()
}
viewModel.isRefreshing = false
if (db.cowspentServerSyncHelper.isSyncPossible) {
db.cowspentServerSyncHelper.addCallbackPull(syncCallBack)
synchronize()
}
registerBroadcastReceiver()
updateAvatarInDrawer(CowspentServerSyncHelper.isNextcloudAccountConfigured(this))
isActivityVisible = true
}
override fun onPause() {
super.onPause()
try {
unregisterReceiver(mBroadcastReceiver)
} catch (_: RuntimeException) {
if (DEBUG) Log.d(TAG, "RECEIVER PROBLEM, let's ignore it...")
}
isActivityVisible = false
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putSerializable(SAVED_STATE_NAVIGATION_SELECTION, navigationSelection)
outState.putString(SAVED_STATE_NAVIGATION_OPEN, navigationOpen)
}
private fun setupDrawerProjects() {
val selectedProjectId = PreferenceManager.getDefaultSharedPreferences(applicationContext).getLong("selected_project", 0)
lifecycleScope.launch {
val projects = withContext(Dispatchers.IO) { db.projects }
viewModel.projects = projects
setSelectedProject(selectedProjectId)
}
}
fun onProjectClick(projectId: Long) {
if (viewModel.selectedProjectId != projectId) {
viewModel.selectedMemberId = null
}
setSelectedProject(projectId)
navigationSelection = Category(null, null)
refreshLists(true)
synchronize()
}
fun onManageProjectClick(projectId: Long) {
viewModel.showProjectOptionsDialogByProjectId = projectId
}
private fun onEditProjectClick(projectId: Long) {
if (projectId == 0L) return
lifecycleScope.launch {
val proj = withContext(Dispatchers.IO) { db.getProject(projectId) }
if (proj?.isLocal == false) {
val intent = Intent(applicationContext, EditProjectActivity::class.java).apply {
putExtra(EditProjectActivity.PARAM_PROJECT_ID, projectId)
}
editProjectLauncher.launch(intent)
} else {
showToast(this@BillsListViewActivity, getString(R.string.edit_project_local_impossible))
}
}
}
private fun onRemoveProjectClick(projectId: Long) {
if (projectId == 0L) return
lifecycleScope.launch {
val proj = withContext(Dispatchers.IO) { db.getProject(projectId) } ?: return@launch
viewModel.showDialog(
title = getString(R.string.confirm_remove_project_dialog_title),
message = if (!proj.isLocal) getString(R.string.confirm_remove_project_dialog_message) else null,
positiveText = getString(R.string.simple_yes),
onConfirm = {
lifecycleScope.launch {
withContext(Dispatchers.IO) {
db.deleteProject(projectId)
val dbProjects = db.projects
if (dbProjects.isNotEmpty()) setSelectedProject(dbProjects[0].id) else setSelectedProject(0)
}
setupDrawerProjects()
refreshLists()
synchronize()
val projectNameString = proj.name.ifEmpty { proj.remoteId }
showToast(this@BillsListViewActivity, getString(R.string.remove_project_confirmation, projectNameString))
}
},
negativeText = getString(R.string.simple_no)
)
}
}
fun onManageMembersClick(projectId: Long) {
if (projectId == 0L) return
lifecycleScope.launch {
val project = withContext(Dispatchers.IO) { db.getProject(projectId) } ?: return@launch
if (project.myAccessLevel != DBProject.ACCESS_LEVEL_UNKNOWN && project.myAccessLevel < DBProject.ACCESS_LEVEL_MAINTAINER) {
showToast(this@BillsListViewActivity, getString(R.string.insufficient_access_level))
return@launch
}
viewModel.showMemberManagementDialogByProjectId = projectId
}
}
fun onManageCurrenciesClick(projectId: Long) {
lifecycleScope.launch {
val proj = withContext(Dispatchers.IO) { db.getProject(projectId) }
if (proj != null && proj.type == ProjectType.COSPEND) {
startActivity(Intent(applicationContext, ManageCurrenciesActivity::class.java).apply {
putExtra(ManageCurrenciesActivity.EXTRA_PROJECT_ID, projectId)
})
} else showToast(this@BillsListViewActivity, getString(R.string.currency_management_unavailable))
}
}
fun onProjectStatisticsClick(projectId: Long) {
viewModel.showStatisticsDialogByProjectId = projectId
}
fun onSettleProjectClick(projectId: Long) {
viewModel.showSettlementDialogByProjectId = projectId
}
fun onShareProjectClick(projectId: Long) {
lifecycleScope.launch {
val proj = withContext(Dispatchers.IO) { db.getProject(projectId) }
if (projectId != 0L && proj?.isShareable() == true) viewModel.showShareDialogByProjectId = projectId
else showToast(this@BillsListViewActivity, getString(R.string.share_impossible), Toast.LENGTH_LONG)
}
}
fun onExportProjectClick(projectId: Long) {
if (projectId == 0L) return
lifecycleScope.launch {
contentToExport = withContext(Dispatchers.IO) { ExportUtil.createExportContent(db, projectId) }
val fileName = withContext(Dispatchers.IO) { ExportUtil.createExportFileName(db, projectId) }
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "text/csv"
putExtra(Intent.EXTRA_TITLE, fileName)
}
saveFileLauncher.launch(intent)
}
}
private fun saveToFileUri(content: String, fileUri: Uri) {
try {
contentResolver.openOutputStream(fileUri)?.use { outputStream ->
outputStream.writer().use { it.write(content) }
}
showToast(this,getString(R.string.file_saved_success, fileUri.lastPathSegment?.replace(Environment.getExternalStorageDirectory().toString(), "")))
} catch (e: IOException) {
Log.e("Exception", "File write failed: $e")
showToast(this,e.toString())
}
}
private fun addProject() {
lifecycleScope.launch {
var defaultNcUrl = if (CowspentServerSyncHelper.isNextcloudAccountConfigured(this@BillsListViewActivity)) {
CowspentServerSyncHelper.getNextcloudAccountServerUrl(this@BillsListViewActivity)
} else "https://mynextcloud.org"
val dbProjects = withContext(Dispatchers.IO) { db.projects }
for (project in dbProjects) {
val url = project.serverUrl
if (!url.isNullOrEmpty() && url.contains("/index.php/apps/cospend")) {
defaultNcUrl = url.replace("/index.php/apps/cospend", "")
break
}
}
val intent = Intent(applicationContext, NewProjectActivity::class.java).apply {
putExtra(NewProjectActivity.PARAM_DEFAULT_NC_URL, defaultNcUrl)
}
addProjectLauncher.launch(intent)
}
}
private fun setSelectedProject(projectId: Long) {
val preferences = PreferenceManager.getDefaultSharedPreferences(applicationContext)
preferences.edit { putLong("selected_project", projectId) }
lifecycleScope.launch {
val (proj, members, memberBalances) = withContext(Dispatchers.IO) {
var proj = db.getProject(projectId)
if (proj == null) {
val dbProjects = db.projects
if (dbProjects.isNotEmpty()) {
proj = dbProjects[0]
preferences.edit { putLong("selected_project", proj.id) }
} else {
return@withContext Triple(null, emptyList(), emptyMap<Long, Double>())
}
}
val members = db.getMembersOfProject(proj.id, null)
val bills = db.getBillsOfProject(proj.id)
val balances = HashMap<Long, Double>()
SupportUtil.getStats(
members, bills,
mutableMapOf(), balances, mutableMapOf(), mutableMapOf(),
-1000, -1000, null, null
)
Triple(proj, members, balances)
}
if (proj == null) {
viewModel.selectedProjectId = 0L
return@launch
}
if (viewModel.selectedProjectId != proj.id) {
viewModel.selectedMemberId = null
}
viewModel.selectedProjectId = proj.id
viewModel.members = members
viewModel.memberBalances = memberBalances
updateLastSyncText(proj)
}
}
private fun updateLastSyncText(proj: DBProject?) {
if (proj == null || proj.isLocal) {
viewModel.lastSyncText = ""
} else {
val lastSyncTimestamp = proj.lastSyncedTimestamp ?: 0
val cal = Calendar.getInstance().apply { timeInMillis = lastSyncTimestamp * 1000 }
val text = getString(R.string.drawer_last_sync_text, cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE))
viewModel.lastSyncText = text
}
}
fun refreshLists() = refreshLists(false)
override fun refreshLists(scrollToTop: Boolean) {
val selectedProjectId = PreferenceManager.getDefaultSharedPreferences(applicationContext).getLong("selected_project", 0)
lifecycleScope.launch {
val (projId, projName) = withContext(Dispatchers.IO) {
if (selectedProjectId != 0L) {
db.getProject(selectedProjectId)?.let {
it.id to (if (it.name == "null" || it.name.isEmpty()) it.remoteId else it.name)
} ?: (0L to "")
} else {
0L to ""
}
}
val title = if (selectedProjectId != 0L) projName else getString(R.string.app_name)
setSelectedProject(selectedProjectId)
viewModel.title = title
val query = viewModel.searchQuery.ifEmpty { null }
val (ljItems, memberCount) = withContext(Dispatchers.IO) {
val db = CowspentSQLiteOpenHelper.getInstance(applicationContext)
val billList: List<DBBill> = if (projId != 0L) {
db.searchBills(query, projId)
} else {
ArrayList()
}
val bills = billList.filter {
val mid = viewModel.selectedMemberId
mid == null || mid == it.payerId || it.billOwersIds.contains(mid)
}
viewModel.hasUnlabeledBills = bills.any { it.categoryRemoteId == 0 && it.state != DBBill.STATE_DELETED }
val projectMembers = db.getMembersOfProject(projId, null)
val memberMap = projectMembers.associateBy { it.id }
val projectPaymentModes = db.getPaymentModes(projId).associateBy { it.remoteId }
val projectCategories = db.getCategories(projId).associateBy { it.remoteId }
BillFormatter.formatBills(
bills,
memberMap,
projectCategories,
projectPaymentModes
)
val sdf = SimpleDateFormat("yyyy-MM-dd", Locale.ROOT)
val itemList = BillsListUtils.groupAndSectionBills(
bills,
memberMap,
sdf,
applicationContext
)
itemList to projectMembers.size
}
viewModel.showNoProjects = false
viewModel.showNoMembers = false
viewModel.showNoBills = false
when {
memberCount == 0 -> {
viewModel.showNoMembers = true
}
ljItems.isEmpty() -> {
viewModel.showNoBills = true
viewModel.bills = emptyList()
}
else -> {
viewModel.bills = ljItems
}
}
}
}
override fun onNewIntent(intent: Intent) {
if (Intent.ACTION_SEARCH == intent.action) {
viewModel.searchQuery = intent.getStringExtra(SearchManager.QUERY) ?: ""
}
super.onNewIntent(intent)
}
private fun duplicateBill(billId: Long) {
val selectedProjectId = PreferenceManager.getDefaultSharedPreferences(applicationContext).getLong("selected_project", 0)
if (selectedProjectId != 0L) {
lifecycleScope.launch {
val members = withContext(Dispatchers.IO) { db.getActivatedMembersOfProject(selectedProjectId) }
if (members.isEmpty()) {
showToast(this@BillsListViewActivity, getString(R.string.add_bill_impossible_no_member))
} else {
val projType = withContext(Dispatchers.IO) { db.getProject(selectedProjectId)?.type?.id }
val intent = Intent(applicationContext, EditBillActivity::class.java).apply {
putExtra(EditBillActivity.PARAM_PROJECT_ID, selectedProjectId)
putExtra(EditBillActivity.PARAM_PROJECT_TYPE, projType)
putExtra(EditBillActivity.PARAM_BILL_ID_TO_DUPLICATE, billId)
}
createBillLauncher.launch(intent)
}
}
}
}
private fun showDialog(msg: String, title: String, icon: ImageVector) {
viewModel.showDialog(
title = title,
message = msg,
positiveText = getString(android.R.string.ok),
icon = icon
)
}
private fun updateUsernameInDrawer() {
if (!CowspentServerSyncHelper.isNextcloudAccountConfigured(this)) {
val text = getString(R.string.drawer_no_account)
viewModel.accountName = text
updateAvatarInDrawer(false)
} else {
val preferences = PreferenceManager.getDefaultSharedPreferences(this)
val (user, server) = if (preferences.getBoolean(AccountActivity.SETTINGS_USE_SSO, false)) {
try {
val ssoAccount = SingleAccountHelper.getCurrentSingleSignOnAccount(this)
ssoAccount.userId to ssoAccount.url.replace(Regex("/+$"), "").replace(Regex("^https?://"), "")
} catch (_: Exception) { "error" to "error" }
} else {
preferences.getString(AccountActivity.SETTINGS_USERNAME, "") to preferences.getString(
AccountActivity.SETTINGS_URL, "")?.replace(Regex("/+$"), "")?.replace(Regex("^https?://"), "")
}
val text = "$user@$server"
viewModel.accountName = text
updateAvatarInDrawer(true)
}
}
private fun updateAvatarInDrawer(isConfigured: Boolean) {
if (isConfigured) {
val avatarB64 = PreferenceManager.getDefaultSharedPreferences(this).getString(getString(R.string.pref_key_avatar), "")
if (!avatarB64.isNullOrEmpty()) {
try {
val bytes = Base64.decode(avatarB64, Base64.DEFAULT)
viewModel.userAvatar = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
} catch (_: Exception) {
viewModel.userAvatar = null
}
} else {
viewModel.userAvatar = null
}
} else {
viewModel.userAvatar = null
}
}
private fun synchronize(manual: Boolean = false) {
val preferences = PreferenceManager.getDefaultSharedPreferences(applicationContext)
val offlineMode = preferences.getBoolean(getString(R.string.pref_key_offline_mode), false)
if (offlineMode && !manual) {
return
}
if (db.cowspentServerSyncHelper.isSyncPossible) {
viewModel.isRefreshing = true
val selectedProjectId = PreferenceManager.getDefaultSharedPreferences(applicationContext).getLong("selected_project", 0)
if (selectedProjectId != 0L) {
lifecycleScope.launch {
val proj = withContext(Dispatchers.IO) { db.getProject(selectedProjectId) }
if (proj != null && !proj.isLocal) {
db.cowspentServerSyncHelper.addCallbackPull(syncCallBack)
db.cowspentServerSyncHelper.scheduleSync(false, selectedProjectId)
} else viewModel.isRefreshing = false
}
} else viewModel.isRefreshing = false
if (CowspentServerSyncHelper.isNextcloudAccountConfigured(applicationContext)) {
db.cowspentServerSyncHelper.runAccountProjectsSync()
}
}
}
private fun registerBroadcastReceiver() {
val filter = IntentFilter().apply {
addAction(MainConstants.BROADCAST_PROJECT_SYNC_FAILED)
addAction(MainConstants.BROADCAST_PROJECT_SYNCED)
addAction(MainConstants.BROADCAST_SYNC_PROJECT)
addAction(MainConstants.BROADCAST_NETWORK_AVAILABLE)
addAction(MainConstants.BROADCAST_NETWORK_UNAVAILABLE)
addAction(MainConstants.BROADCAST_AVATAR_UPDATED)
addAction(MainConstants.BROADCAST_SSO_TOKEN_MISMATCH)
addAction(MainConstants.BROADCAST_ACCOUNT_PROJECTS_SYNC_FAILED)
addAction(MainConstants.BROADCAST_ACCOUNT_PROJECTS_SYNCED)
}
ContextCompat.registerReceiver(this, mBroadcastReceiver, filter, ContextCompat.RECEIVER_EXPORTED)
}
private val mBroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent?) {
if (intent?.action == null) return
when (intent.action) {
MainConstants.BROADCAST_PROJECT_SYNC_FAILED -> {
val errorMessage = intent.getStringExtra(MainConstants.BROADCAST_ERROR_MESSAGE)
val projectId = intent.getLongExtra(MainConstants.BROADCAST_PROJECT_ID, 0)
if (projectId != 0L) {
lifecycleScope.launch {
val project = withContext(Dispatchers.IO) { db.getProject(projectId) } ?: return@launch
viewModel.showDialog(
title = getString(R.string.sync_error_dialog_title),
message = getString(R.string.sync_error_dialog_full_content, project.name, errorMessage),
positiveText = getString(R.string.simple_close),
icon = Icons.Default.Sync
)
}
}
}
MainConstants.BROADCAST_PROJECT_SYNCED -> {
setupDrawerProjects()
refreshLists()
}
MainConstants.BROADCAST_SYNC_PROJECT -> {
synchronize()
}
MainConstants.BROADCAST_NETWORK_AVAILABLE -> {
}
MainConstants.BROADCAST_NETWORK_UNAVAILABLE -> {
}
MainConstants.BROADCAST_SSO_TOKEN_MISMATCH -> {
viewModel.showDialog(
title = getString(R.string.sync_error_dialog_title),
message = getString(R.string.error_token_mismatch),
positiveText = getString(R.string.simple_close),
icon = Icons.Default.Sync
)
}
MainConstants.BROADCAST_AVATAR_UPDATED -> {
val memberId = intent.getLongExtra(MainConstants.BROADCAST_AVATAR_UPDATED_MEMBER, 0)
if (memberId == 0L) updateAvatarInDrawer(true) else refreshLists()
}
MainConstants.BROADCAST_ACCOUNT_PROJECTS_SYNCED -> {
setupDrawerProjects()
val prefs = PreferenceManager.getDefaultSharedPreferences(applicationContext)
lifecycleScope.launch {
val dbProjects = withContext(Dispatchers.IO) { db.projects }
if (prefs.getLong("selected_project", 0) == 0L && dbProjects.isNotEmpty()) {
setSelectedProject(dbProjects[0].id)
refreshLists()
if (db.cowspentServerSyncHelper.isSyncPossible) {
db.cowspentServerSyncHelper.addCallbackPull(syncCallBack)
synchronize()
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,77 @@
package net.helcel.cowspent.android.main
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import net.helcel.cowspent.model.DBProject
import net.helcel.cowspent.model.DBMember
import net.helcel.cowspent.model.Item
import net.helcel.cowspent.android.helper.DialogState
import androidx.compose.ui.graphics.vector.ImageVector
class BillsListViewModel : ViewModel() {
var projects by mutableStateOf<List<DBProject>>(emptyList())
var members by mutableStateOf<List<DBMember>>(emptyList())
var memberBalances by mutableStateOf<Map<Long, Double>>(emptyMap())
var selectedProjectId by mutableLongStateOf(0L)
var selectedMemberId by mutableStateOf<Long?>(null)
var bills by mutableStateOf<List<Item>>(emptyList())
var isRefreshing by mutableStateOf(false)
var searchQuery by mutableStateOf("")
var title by mutableStateOf("")
var accountName by mutableStateOf("")
var userAvatar by mutableStateOf<android.graphics.Bitmap?>(null)
var lastSyncText by mutableStateOf("")
var showNoProjects by mutableStateOf(false)
var showNoMembers by mutableStateOf(false)
var showNoBills by mutableStateOf(false)
var hasUnlabeledBills by mutableStateOf(false)
var dialogState by mutableStateOf<DialogState?>(null)
var showProjectOptionsDialogByProjectId by mutableStateOf<Long?>(null)
var showSettlementDialogByProjectId by mutableStateOf<Long?>(null)
var showStatisticsDialogByProjectId by mutableStateOf<Long?>(null)
var showMemberManagementDialogByProjectId by mutableStateOf<Long?>(null)
var showAddMemberDialogByProjectId by mutableStateOf<Long?>(null)
var showEditMemberDialogByProjectId by mutableStateOf<Long?>(null)
var showShareDialogByProjectId by mutableStateOf<Long?>(null)
fun showDialog(
title: String? = null,
message: String? = null,
icon: ImageVector? = null,
items: List<CharSequence>? = null,
itemIcons: List<ImageVector>? = null,
positiveText: String? = null,
negativeText: String? = null,
neutralText: String? = null,
onConfirm: (() -> Unit)? = null,
onCancel: (() -> Unit)? = null,
onNeutral: (() -> Unit)? = null,
onItemSelected: ((Int) -> Unit)? = null
) {
dialogState = DialogState(
title = title,
message = message,
icon = icon,
items = items,
itemIcons = itemIcons,
positiveText = positiveText,
negativeText = negativeText,
neutralText = neutralText,
onConfirm = onConfirm,
onCancel = onCancel,
onNeutral = onNeutral,
onItemSelected = onItemSelected
)
}
fun dismissDialog() {
dialogState = null
}
}

View File

@@ -0,0 +1,36 @@
package net.helcel.cowspent.android.main
@Suppress("unused")
object MainConstants {
const val BROADCAST_EXTRA_PARAM = "net.helcel.cowspent.broadcast_extra_param"
const val BROADCAST_ERROR_MESSAGE = "net.helcel.cowspent.broadcast_error_message"
const val BROADCAST_PROJECT_ID = "net.helcel.cowspent.broadcast_project_id"
const val BROADCAST_ACCOUNT_PROJECTS_SYNC_FAILED = "net.helcel.cowspent.broadcast_acc_proj_failed"
const val BROADCAST_SSO_TOKEN_MISMATCH = "net.helcel.cowspent.broadcast.token_mismatch"
const val BROADCAST_ACCOUNT_PROJECTS_SYNCED = "net.helcel.cowspent.broadcast.broadcast_acc_proj_synced"
const val BROADCAST_PROJECT_SYNC_FAILED = "net.helcel.cowspent.broadcast.project_sync_failed"
const val BROADCAST_PROJECT_SYNCED = "net.helcel.cowspent.broadcast.project_synced"
const val BROADCAST_SYNC_PROJECT = "net.helcel.cowspent.broadcast.sync_project"
const val BROADCAST_NETWORK_AVAILABLE = "net.helcel.cowspent.broadcast.network_available"
const val BROADCAST_NETWORK_UNAVAILABLE = "net.helcel.cowspent.broadcast.network_unavailable"
const val BROADCAST_AVATAR_UPDATED = "net.helcel.cowspent.broadcast.avatar_updated"
const val BROADCAST_AVATAR_UPDATED_MEMBER = "net.helcel.cowspent.broadcast.avatar_updated_for_member"
const val MAIN_CHANNEL_ID = 1234567890
const val PARAM_DIALOG_CONTENT = "net.helcel.cowspent.PARAM_DIALOG_CONTENT"
const val PARAM_PROJECT_TO_SELECT = "net.helcel.cowspent.PARAM_PROJECT_TO_SELECT"
const val SAVED_BILL_ID = "net.helcel.cowspent.saved_bill_id"
const val CREATED_PROJECT = "net.helcel.cowspent.created_project"
const val ADDED_PROJECT = "net.helcel.cowspent.added_project"
const val EDITED_PROJECT = "net.helcel.cowspent.edited_project"
const val DELETED_PROJECT = "net.helcel.cowspent.deleted_project"
const val DELETED_BILL = "net.helcel.cowspent.deleted_bill"
const val BILL_TO_DUPLICATE = "net.helcel.cowspent.bill_to_duplicate"
const val KEY_QR_CODE = "net.helcel.cowspent.android.helper.key_qr_code"
const val CREDENTIALS_CHANGED = "net.helcel.cowspent.CREDENTIALS_CHANGED"
}

View File

@@ -0,0 +1,175 @@
package net.helcel.cowspent.android.project
import android.annotation.SuppressLint
import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import android.provider.OpenableColumns
import android.util.Log
import com.opencsv.CSVReader
import net.helcel.cowspent.R
import net.helcel.cowspent.model.*
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import java.io.InputStreamReader
import java.text.SimpleDateFormat
import java.util.*
object ProjectImportHelper {
@SuppressLint("Range")
fun getFileName(contentResolver: ContentResolver, uri: Uri): String {
var result: String? = null
if (uri.scheme == "content") {
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
result = cursor.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME))
}
}
}
if (result == null) {
result = uri.path?.let { p ->
val cut = p.lastIndexOf('/')
if (cut != -1) p.substring(cut + 1) else p
}
}
return result ?: "project.csv"
}
fun importFromFile(
context: Context,
db: CowspentSQLiteOpenHelper,
fileUri: Uri,
onSuccess: (Long) -> Unit,
onError: (String) -> Unit
) {
val contentResolver = context.contentResolver
try {
val projectRemoteId = getFileName(contentResolver, fileUri).replace("\\.csv$".toRegex(), "")
val inputStream = contentResolver.openInputStream(fileUri) ?: return
val reader = CSVReader(InputStreamReader(inputStream))
var previousLineEmpty = false
var currentSection: String? = null
var row = 0
var mainCurrencyName: String? = null
val columns = mutableMapOf<String, Int>()
val paymentModes = mutableListOf<DBPaymentMode>()
val categories = mutableListOf<DBCategory>()
val currencies = mutableListOf<DBCurrency>()
val bills = mutableListOf<DBBill>()
val membersActive = mutableMapOf<String, Boolean>()
val membersWeight = mutableMapOf<String, Double>()
val billRemoteIdToPayerName = mutableMapOf<Long, String>()
val billRemoteIdToOwerStr = mutableMapOf<Long, String>()
var nextLine: Array<String>?
while (reader.readNext().also { nextLine = it } != null) {
val line = nextLine!!
val allFieldsEmpty = line.all { it.isEmpty() }
if (allFieldsEmpty) {
previousLineEmpty = true
} else if (row == 0 || previousLineEmpty) {
previousLineEmpty = false
columns.clear()
line.forEachIndexed { index, s -> columns[s] = index }
currentSection = when {
columns.containsKey("what") && columns.containsKey("amount") -> "bills"
columns.containsKey("categoryid") && columns.containsKey("categoryname") -> "categories"
columns.containsKey("exchange_rate") && columns.containsKey("currencyname") -> "currencies"
else -> {
onError(context.getString(R.string.import_error_header, row))
return
}
}
} else {
previousLineEmpty = false
when (currentSection) {
"categories" -> {
categories.add(DBCategory(0, line[columns["categoryid"]!!].toLong(), 0, line[columns["categoryname"]!!], line[columns["icon"]!!], line[columns["color"]!!]))
}
"paymentmodes" -> {
paymentModes.add(DBPaymentMode(0, line[columns["categoryid"]!!].toLong(), 0, line[columns["categoryname"]!!], line[columns["icon"]!!], line[columns["color"]!!]))
}
"currencies" -> {
val name = line[columns["currencyname"]!!]
val rate = line[columns["exchange_rate"]!!].toDouble()
if (rate == 1.0) mainCurrencyName = name
currencies.add(DBCurrency(0, 0, 0, name, rate, DBBill.STATE_OK))
}
"bills" -> {
val what = if (columns.containsKey("what")) line[columns["what"]!!] else ""
val comment = if (columns.containsKey("comment")) line[columns["comment"]!!] else ""
val amount = if (columns.containsKey("amount")) line[columns["amount"]!!].toDouble() else 0.0
val timestamp: Long = when {
columns.containsKey("timestamp") -> line[columns["timestamp"]!!].toLong()
columns.containsKey("date") -> {
try {
SimpleDateFormat("yyyy-MM-dd", Locale.ROOT).parse(line[columns["date"]!!])!!.time / 1000
} catch (_: Exception) {
onError(context.getString(R.string.import_error_date, row))
return
}
}
else -> 0
}
val payerName = if (columns.containsKey("payer_name")) line[columns["payer_name"]!!] else ""
val payerWeight = if (columns.containsKey("payer_weight")) line[columns["payer_weight"]!!].toDouble() else 1.0
val owersStr = if (columns.containsKey("owers")) line[columns["owers"]!!] else ""
val payerActive = columns.containsKey("payer_active") && line[columns["payer_active"]!!] == "1"
val catId = if (columns.containsKey("categoryid") && line[columns["categoryid"]!!].isNotEmpty()) line[columns["categoryid"]!!].toInt() else 0
val pmId = if (columns.containsKey("paymentmodeid") && line[columns["paymentmodeid"]!!].isNotEmpty()) line[columns["paymentmodeid"]!!].toInt() else 0
val pm = if (columns.containsKey("paymentmode")) line[columns["paymentmode"]!!] else null
membersActive[payerName] = payerActive
membersWeight[payerName] = payerWeight
if (owersStr.trim().isEmpty()) {
onError(context.getString(R.string.import_error_owers, row))
return
}
if (what != "deleteMeIfYouWant") {
billRemoteIdToOwerStr[row.toLong()] = owersStr
val owersArray = owersStr.split(", ").filter { it.isNotEmpty() }
for (ower in owersArray) {
if (!membersWeight.containsKey(ower.trim())) {
membersWeight[ower.trim()] = 1.0
}
}
bills.add(DBBill(0, row.toLong(), 0, 0, amount, timestamp, what, DBBill.STATE_OK, "n", pm, catId, comment, pmId))
billRemoteIdToPayerName[row.toLong()] = payerName
}
}
}
}
row++
}
val memberNameToId = mutableMapOf<String, Long>()
val pid = db.addProject(DBProject(0, projectRemoteId, "", projectRemoteId, null, null, null, ProjectType.LOCAL, 0L, mainCurrencyName, false, DBProject.ACCESS_LEVEL_UNKNOWN, null))
paymentModes.forEach { db.addPaymentMode(DBPaymentMode(0, it.remoteId, pid, it.name, it.icon, it.color)) }
categories.forEach { db.addCategory(DBCategory(0, it.remoteId, pid, it.name, it.icon, it.color)) }
currencies.forEach { db.addCurrency(DBCurrency(0, 0, pid, it.name, it.exchangeRate, DBBill.STATE_OK)) }
membersWeight.keys.forEach { mName ->
memberNameToId[mName] = db.addMember(DBMember(0, 0, pid, mName, membersActive[mName] ?: true, membersWeight[mName] ?: 1.0, DBBill.STATE_OK, null, null, null, null, null))
}
bills.forEach { b ->
val payerId = memberNameToId[billRemoteIdToPayerName[b.remoteId]] ?: 0L
val billId = db.addBill(DBBill(0, 0, pid, payerId, b.amount, b.timestamp, b.what, DBBill.STATE_OK, b.repeat, b.paymentMode, b.categoryRemoteId, b.comment, b.paymentModeRemoteId))
billRemoteIdToOwerStr[b.remoteId]?.split(", ")?.filter { it.isNotEmpty() }?.forEach { ower ->
memberNameToId[ower.trim()]?.let { owerId -> db.addBillower(billId, owerId) }
}
}
onSuccess(pid)
} catch (e: Exception) {
Log.e("Import", "Error importing", e)
onError("Import failed: ${e.message}")
}
}
}

View File

@@ -0,0 +1,156 @@
package net.helcel.cowspent.android.project
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import net.helcel.cowspent.R
import net.helcel.cowspent.model.DBProject
@Composable
fun ProjectOptionsDialogContent(
onEditProject: () -> Unit,
onRemoveProject: () -> Unit,
onManageMembers: () -> Unit,
onManageCurrencies: () -> Unit,
onStatistics: () -> Unit,
onSettle: () -> Unit,
onShareProject: () -> Unit,
onExportProject: () -> Unit,
onDismiss: () -> Unit,
isArchived: Boolean = false,
accessLevel: Int = DBProject.ACCESS_LEVEL_ADMIN,
isShareable: Boolean = true
) {
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colors.surface,
contentColor = contentColorFor(MaterialTheme.colors.surface)
) {
Column(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth()
) {
Text(
text = stringResource(R.string.choose_project_management_action),
style = MaterialTheme.typography.h6,
modifier = Modifier.padding(bottom = 8.dp)
)
val options = mutableListOf<ProjectOption>()
val isMaintainer = accessLevel >= DBProject.ACCESS_LEVEL_MAINTAINER || accessLevel == DBProject.ACCESS_LEVEL_UNKNOWN
val isParticipant = accessLevel >= DBProject.ACCESS_LEVEL_PARTICIPANT || accessLevel == DBProject.ACCESS_LEVEL_UNKNOWN
if (!isArchived && isMaintainer) {
options.add(ProjectOption(stringResource(R.string.action_edit_project), Icons.Default.Edit, onEditProject))
}
options.add(ProjectOption(stringResource(R.string.fab_rm_project), Icons.Default.Delete, onRemoveProject))
if (!isArchived && isMaintainer) {
options.add(ProjectOption(stringResource(R.string.fab_manage_members), Icons.Default.Group, onManageMembers))
options.add(ProjectOption(stringResource(R.string.fab_manage_currencies), Icons.Default.MonetizationOn, onManageCurrencies))
}
options.add(ProjectOption(stringResource(R.string.fab_statistics), Icons.Default.BarChart, onStatistics))
if (!isArchived && isParticipant) {
options.add(ProjectOption(stringResource(R.string.fab_settle), Icons.Default.Handshake, onSettle))
}
if (isShareable && isParticipant) {
options.add(ProjectOption(stringResource(R.string.action_share_project), Icons.Default.Share, onShareProject))
}
options.add(ProjectOption(stringResource(R.string.fab_export_project), Icons.Default.Download, onExportProject))
// Simple 2-column grid using Rows
for (i in options.indices step 2) {
Row(modifier = Modifier.fillMaxWidth()) {
ProjectOptionItem(
option = options[i],
modifier = Modifier.weight(1f)
)
if (i + 1 < options.size) {
ProjectOptionItem(
option = options[i + 1],
modifier = Modifier.weight(1f)
)
} else {
Spacer(modifier = Modifier.weight(1f))
}
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
TextButton(onClick = onDismiss) {
Text(stringResource(android.R.string.cancel))
}
}
}
}
}
data class ProjectOption(
val title: String,
val icon: ImageVector,
val onClick: () -> Unit
)
@Composable
fun ProjectOptionItem(
option: ProjectOption,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier
.clickable { option.onClick() }
.padding(8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = option.icon,
contentDescription = null,
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colors.primary
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = option.title,
style = MaterialTheme.typography.caption,
textAlign = TextAlign.Center,
maxLines = 2,
lineHeight = 14.sp
)
}
}
@Preview(showBackground = true)
@Composable
fun ProjectOptionsDialogPreview() {
MaterialTheme {
ProjectOptionsDialogContent(
onEditProject = {},
onRemoveProject = {},
onManageMembers = {},
onManageCurrencies = {},
onStatistics = {},
onSettle = {},
onShareProject = {},
onExportProject = {},
onDismiss = {},
isArchived = false,
accessLevel = DBProject.ACCESS_LEVEL_ADMIN,
isShareable = true
)
}
}

View File

@@ -0,0 +1,316 @@
package net.helcel.cowspent.android.project
import android.content.Intent
import android.graphics.Bitmap
import android.widget.Toast
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Button
import androidx.compose.material.ContentAlpha
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.LocalContentAlpha
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedButton
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.contentColorFor
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.QrCode
import androidx.compose.material.icons.filled.Share
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.net.toUri
import com.google.zxing.WriterException
import net.helcel.cowspent.R
import net.helcel.cowspent.model.DBProject
import net.helcel.cowspent.model.ProjectType
import net.helcel.cowspent.util.ColorUtils
@Composable
fun ProjectShareDialogContent(
proj: DBProject,
onShare: (String) -> Unit,
onDismiss: () -> Unit
) {
val context = LocalContext.current
val clipboardManager = LocalClipboardManager.current
val qrCodeLinkWarn = stringResource(R.string.qrcode_link_open_attempt_warning)
val shareUrl = remember { proj.getShareUrl() }
val publicWebUrl = remember { proj.getPublicWebUrl() }
val qrBitmap = remember(shareUrl) {
try {
ColorUtils.encodeAsBitmap(shareUrl)
} catch (_: WriterException) {
null
}
}
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colors.surface,
contentColor = contentColorFor(MaterialTheme.colors.surface)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 650.dp)
.padding(16.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(bottom = 8.dp)
) {
Icon(
Icons.Default.Share,
contentDescription = null,
tint = MaterialTheme.colors.primary,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = stringResource(R.string.share_dialog_title),
style = MaterialTheme.typography.h6,
fontWeight = FontWeight.Bold
)
}
Column(
modifier = Modifier
.weight(1f, fill = false)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
ShareCard(
title = stringResource(R.string.share_project_public_url_title),
url = publicWebUrl,
description = stringResource(R.string.share_project_public_url_dialog_message),
icon = Icons.Default.Link,
onUrlClick = {
val i = Intent(Intent.ACTION_VIEW).apply {
data = publicWebUrl.toUri()
}
context.startActivity(i)
},
onCopyClick = {
clipboardManager.setText(AnnotatedString(publicWebUrl))
Toast.makeText(context, "Link copied to clipboard", Toast.LENGTH_SHORT).show()
}
)
ShareCard(
title = stringResource(R.string.share_project_public_qrcode_title),
url = shareUrl,
description = stringResource(R.string.share_project_dialog_message),
icon = Icons.Default.QrCode,
qrBitmap = qrBitmap,
onUrlClick = {
Toast.makeText(context, qrCodeLinkWarn, Toast.LENGTH_SHORT).show()
},
onCopyClick = {
clipboardManager.setText(AnnotatedString(shareUrl))
Toast.makeText(context, "Link copied to clipboard", Toast.LENGTH_SHORT).show()
}
)
}
Spacer(modifier = Modifier.height(24.dp))
DialogActions(
onShare = { onShare(shareUrl) },
onDismiss = onDismiss
)
}
}
}
@Composable
private fun ShareCard(
title: String,
url: String,
description: String,
icon: ImageVector,
onUrlClick: () -> Unit,
onCopyClick: () -> Unit,
qrBitmap: Bitmap? = null
) {
Surface(
shape = MaterialTheme.shapes.medium,
color = colorResource(R.color.fg_default_low).copy(alpha = 0.04f),
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colors.primary.copy(alpha = 0.7f)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = title,
style = MaterialTheme.typography.subtitle2,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.8f)
)
}
if (qrBitmap != null) {
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier = Modifier.size(128.dp)
.clip(MaterialTheme.shapes.small)
.background(Color.White),
contentAlignment = Alignment.Center
) {
Image(
bitmap = qrBitmap.asImageBitmap(),
contentDescription = null,
modifier = Modifier.fillMaxWidth()
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Surface(
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colors.surface,
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.clickable { onUrlClick() }
.padding(start = 12.dp, end = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = url,
modifier = Modifier.weight(1f),
color = MaterialTheme.colors.primary,
style = MaterialTheme.typography.body2,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontWeight = FontWeight.Medium
)
IconButton(onClick = onCopyClick) {
Icon(
Icons.Default.ContentCopy,
contentDescription = "Copy",
modifier = Modifier.size(18.dp),
tint = colorResource(R.color.fg_default_low)
)
}
}
}
Spacer(modifier = Modifier.height(12.dp))
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
Text(
text = description,
style = MaterialTheme.typography.caption,
textAlign = TextAlign.Center,
lineHeight = 16.sp,
modifier = Modifier.padding(horizontal = 8.dp)
)
}
}
}
}
@Composable
private fun DialogActions(
onShare: () -> Unit,
onDismiss: () -> Unit
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.End),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedButton(
onClick = onDismiss,
shape = MaterialTheme.shapes.small
) {
Text(
text = stringResource(R.string.simple_ok).uppercase(),
style = MaterialTheme.typography.button,
fontWeight = FontWeight.SemiBold
)
}
Button(
onClick = onShare,
shape = MaterialTheme.shapes.small,
elevation = null
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Share, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.simple_share_share).uppercase(),
style = MaterialTheme.typography.button,
fontWeight = FontWeight.Bold
)
}
}
}
}
@Preview(showBackground = true)
@Composable
fun ProjectShareDialogContentPreview() {
MaterialTheme {
ProjectShareDialogContent(
proj = DBProject(
1, "Vacation", "", "vacation", null, null, null,
ProjectType.LOCAL, 0L, null, false, 0, null
),
onShare = {},
onDismiss = {}
)
}
}

View File

@@ -0,0 +1,347 @@
package net.helcel.cowspent.android.project.create
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.util.Patterns
import android.widget.Toast
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import net.helcel.cowspent.R
import net.helcel.cowspent.model.*
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.theme.ThemeUtils
import net.helcel.cowspent.util.*
import androidx.core.net.toUri
import androidx.core.content.edit
import net.helcel.cowspent.model.ProjectType
import net.helcel.cowspent.android.main.MainConstants
import net.helcel.cowspent.android.helper.QrCodeScannerActivity
import net.helcel.cowspent.android.project.ProjectImportHelper
class NewProjectActivity : AppCompatActivity() {
private val viewModel: NewProjectViewModel by viewModels()
private lateinit var db: CowspentSQLiteOpenHelper
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
db = CowspentSQLiteOpenHelper.getInstance(this)
if (savedInstanceState == null) {
handleIntent(intent)
}
setContent {
ThemeUtils.CowspentTheme {
NewProjectScreen(
viewModel = viewModel,
onScanQrCode = {
val createIntent = Intent(this, QrCodeScannerActivity::class.java)
scanQRCodeLauncher.launch(createIntent)
},
onImportFile = {
val intent = Intent()
.setType("*/*")
.setAction(Intent.ACTION_GET_CONTENT)
importFileLauncher.launch(Intent.createChooser(intent, "Select a file"))
},
onChooseFromNextcloud = {
chooseFromNextcloud()
},
onOkPressed = { onPressOk() },
onBack = { finish() }
)
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
handleIntent(intent)
}
private fun handleIntent(intent: Intent) {
val defaultTypeId = intent.getStringExtra(PARAM_DEFAULT_PROJECT_TYPE)
if (defaultTypeId != null) {
viewModel.projectType = ProjectType.getTypeById(defaultTypeId) ?: ProjectType.LOCAL
}
val defaultNcUrl = intent.getStringExtra(PARAM_DEFAULT_NC_URL)
if (defaultNcUrl != null) {
viewModel.defaultNcUrl = defaultNcUrl
if (viewModel.projectType != ProjectType.IHATEMONEY) {
viewModel.projectUrl = defaultNcUrl
}
}
val defaultProjectId = intent.getStringExtra(PARAM_DEFAULT_PROJECT_ID)
if (defaultProjectId != null) {
viewModel.projectId = defaultProjectId
}
val defaultPassword = intent.getStringExtra(PARAM_DEFAULT_PROJECT_PASSWORD)
if (defaultPassword != null) {
viewModel.projectPassword = defaultPassword
}
if (Intent.ACTION_VIEW == intent.action) {
val data = intent.data
if (data != null) {
viewModel.updateFromUri(data)
if (viewModel.isFormValid()) {
onPressOk()
}
}
}
}
private val scanQRCodeLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
result.data?.getStringExtra(MainConstants.KEY_QR_CODE)?.let { scannedUrl ->
viewModel.updateFromUri(scannedUrl.toUri())
if (viewModel.isFormValid()) {
onPressOk()
}
}
}
}
private val importFileLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
result.data?.data?.let { importFromFile(it) }
}
}
private fun chooseFromNextcloud() {
lifecycleScope.launch {
val accountProjects = withContext(Dispatchers.IO) { db.accountProjects }
if (accountProjects.isEmpty()) {
showToast(getString(R.string.choose_account_project_dialog_impossible), Toast.LENGTH_LONG)
return@launch
}
viewModel.nextcloudProjects = accountProjects
viewModel.showNextcloudProjectDialog = true
}
}
private fun onPressOk() {
val type = viewModel.projectType
val todoCreate = viewModel.whatTodoIsCreate
val url = getFormattedUrl()
val fakeProj = DBProject(
0, "", "", "", url,
"", 0L, type, 0L,
null, false, DBProject.ACCESS_LEVEL_UNKNOWN,
""
)
if (isValidUrl(url) && todoCreate && ProjectType.COSPEND == type &&
db.cowspentServerSyncHelper.canCreateAuthenticatedProject(fakeProj) &&
!viewModel.showAuthWarningDialog // Avoid infinite loop
) {
viewModel.showAuthWarningDialog = true
} else {
createProject()
}
}
private fun getFormattedUrl(): String {
var url = viewModel.projectUrl.trim()
if (viewModel.projectType == ProjectType.COSPEND && !isCospendSchemeLink(url)) {
url = url.replace("/+$".toRegex(), "") + "/index.php/apps/cospend"
}
if (viewModel.projectType == ProjectType.IHATEMONEY) {
url = url.replace("/+$".toRegex(), "")
}
if (!url.startsWith("http://") && !url.startsWith("https://") && isValidUrl("https://$url")) {
url = "https://$url"
}
return url
}
private fun isValidUrl(url: String): Boolean = Patterns.WEB_URL.matcher(url).matches()
private fun isCospendSchemeLink(url: String): Boolean {
val data = url.toUri()
return (("cospend" == data.scheme || "cospend+http" == data.scheme ||
"cowspent" == data.scheme || "cowspent+http" == data.scheme ||
"ihatemoney" == data.scheme || "ihatemoney+http" == data.scheme)
&& data.pathSegments.size >= 2)
}
private fun createProject() {
val isCospendScheme = isCospendSchemeLink(getFormattedUrl())
val rid = viewModel.projectId
if (!isCospendScheme && (rid == "" || rid.contains(",") || rid.contains("/"))) {
showToast(getString(R.string.error_invalid_project_remote_id), Toast.LENGTH_LONG)
return
}
if (viewModel.projectType != ProjectType.LOCAL && !isCospendScheme) {
if (!isValidUrl(getFormattedUrl())) {
showToast("Invalid URL", Toast.LENGTH_SHORT)
return
}
val passwordRequired = !viewModel.whatTodoIsCreate || viewModel.projectType == ProjectType.IHATEMONEY
if (passwordRequired && viewModel.projectPassword.isEmpty()) {
showToast("Invalid password", Toast.LENGTH_SHORT)
return
}
}
if (!viewModel.whatTodoIsCreate || viewModel.projectType == ProjectType.LOCAL) {
if (viewModel.projectType == ProjectType.LOCAL) {
val pid = saveLocalProject()
close(pid, false)
} else {
saveRemoteProject(false)
}
} else {
if (viewModel.projectName.isEmpty()) {
showToast("Invalid project name", Toast.LENGTH_SHORT)
return
}
if (!SupportUtil.isValidEmail(viewModel.projectEmail)) {
showToast("Invalid email", Toast.LENGTH_SHORT)
return
}
viewModel.isCreatingRemoteProject = true
if (!db.cowspentServerSyncHelper.createRemoteProject(
viewModel.projectId, viewModel.projectName,
viewModel.projectEmail, viewModel.projectPassword, getFormattedUrl(), viewModel.projectType, createRemoteCallBack
)
) {
viewModel.isCreatingRemoteProject = false
}
}
}
private fun saveLocalProject(): Long {
val newProject = DBProject(
0, viewModel.projectId, "", viewModel.projectId, null,
null, null, viewModel.projectType, 0L,
null, false, DBProject.ACCESS_LEVEL_UNKNOWN,
null
)
return addProjectToDb(newProject)
}
private fun saveRemoteProject(ignorePassword: Boolean) {
val newProject = getProjectFromFields(ignorePassword)
if (!db.cowspentServerSyncHelper.getRemoteProjectInfo(newProject, getRemoteInfoCallBack)) {
showToast(getString(R.string.error_no_network), Toast.LENGTH_LONG)
}
}
private fun getProjectFromFields(ignorePassword: Boolean): DBProject {
var remoteId = viewModel.projectId
var url = getFormattedUrl()
var password = if (ignorePassword) "" else viewModel.projectPassword
if (isCospendSchemeLink(url)) {
val data = url.toUri()
password = if (ignorePassword) "" else (data.lastPathSegment ?: "")
remoteId = data.pathSegments[data.pathSegments.size - 2]
val protocol = if (data.scheme?.endsWith("+http") == true) "http" else "https"
var path = protocol + "://" + data.host + (data.path ?: "").replace(("/$remoteId/$password$").toRegex(), "")
if (viewModel.projectType == ProjectType.COSPEND) {
path = path.replace("/+$".toRegex(), "") + "/index.php/apps/cospend"
}
url = path
}
return DBProject(
0, remoteId, password, viewModel.projectName, url,
viewModel.projectEmail, null, viewModel.projectType, 0L,
null, false, DBProject.ACCESS_LEVEL_UNKNOWN,
null
)
}
private fun addProjectToDb(project: DBProject): Long {
var pid = 0L
runBlocking {
pid = withContext(Dispatchers.IO) { db.addProject(project) }
}
PreferenceManager.getDefaultSharedPreferences(this).edit {
putLong(
"selected_project",
pid
)
}
showToast(getString(R.string.project_added_success), Toast.LENGTH_LONG)
return pid
}
private val getRemoteInfoCallBack = object : ICallback {
override fun onFinish() {}
override fun onFinish(result: String, message: String) {
if (message.isEmpty()) {
val pid = addProjectToDb(getProjectFromFields(false))
close(pid, true)
} else {
viewModel.errorDialogMessage = getString(R.string.error_project_connect_check, message)
}
}
override fun onScheduled() {}
}
private val createRemoteCallBack = object : IProjectCreationCallback {
override fun onFinish(result: String, message: String, usePrivateApi: Boolean) {
if (message.isEmpty()) {
saveRemoteProject(usePrivateApi)
} else {
viewModel.errorDialogMessage = getString(R.string.error_create_remote_project_helper, message)
viewModel.isCreatingRemoteProject = false
}
}
}
private fun close(pid: Long, justAdded: Boolean) {
val data = Intent()
if (justAdded) {
data.putExtra(MainConstants.ADDED_PROJECT, pid)
} else {
data.putExtra(MainConstants.CREATED_PROJECT, pid)
}
setResult(RESULT_OK, data)
finish()
}
private fun showToast(text: CharSequence?, duration: Int) {
Toast.makeText(this, text, duration).show()
}
private fun importFromFile(fileUri: Uri) {
ProjectImportHelper.importFromFile(
this,
db,
fileUri,
onSuccess = { pid -> close(pid, false) },
onError = { message -> showToast(message, Toast.LENGTH_LONG) }
)
}
companion object {
const val PARAM_DEFAULT_NC_URL = "defaultNcUrl"
const val PARAM_DEFAULT_PROJECT_ID = "defaultProjectId"
const val PARAM_DEFAULT_PROJECT_PASSWORD = "defaultProjectPassword"
const val PARAM_DEFAULT_PROJECT_TYPE = "defaultProjectType"
}
}

View File

@@ -0,0 +1,339 @@
package net.helcel.cowspent.android.project.create
import android.annotation.SuppressLint
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.LibraryBooks
import androidx.compose.material.icons.filled.Done
import androidx.compose.material.icons.filled.Email
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.QrCode2
import androidx.compose.material.icons.filled.Title
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import net.helcel.cowspent.R
import net.helcel.cowspent.model.ProjectType
@Composable
fun NewProjectScreen(
viewModel: NewProjectViewModel,
onScanQrCode: () -> Unit,
onImportFile: () -> Unit,
onChooseFromNextcloud: () -> Unit,
onOkPressed: () -> Unit,
onBack: () -> Unit
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.action_add_project)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
},
backgroundColor = MaterialTheme.colors.primary,
elevation = 0.dp
)
},
floatingActionButton = {
if (viewModel.isFormValid()) {
FloatingActionButton(onClick = onOkPressed) {
Icon(Icons.Default.Done, contentDescription = stringResource(R.string.action_save_bill))
}
}
}
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.padding(16.dp)
.verticalScroll(rememberScrollState())
.fillMaxSize()
) {
// What to do
SectionRow(
label = stringResource(R.string.new_project_what_todo)
) {
Row {
ToggleButton(
text = stringResource(R.string.todo_join_label),
selected = !viewModel.whatTodoIsCreate,
onClick = {
viewModel.whatTodoIsCreate = false
if (viewModel.projectType == ProjectType.LOCAL)
viewModel.projectType = ProjectType.COSPEND
}
)
Spacer(modifier = Modifier.width(8.dp))
ToggleButton(
text = stringResource(R.string.todo_create_label),
selected = viewModel.whatTodoIsCreate,
onClick = { viewModel.whatTodoIsCreate = true }
)
}
}
Spacer(modifier = Modifier.height(16.dp))
SectionRow(
label = stringResource(R.string.new_project_where)
) {
Row {
if (viewModel.whatTodoIsCreate) {
ToggleButton(
text = stringResource(R.string.where_local_short),
selected = viewModel.projectType == ProjectType.LOCAL,
onClick = { viewModel.projectType = ProjectType.LOCAL }
)
Spacer(modifier = Modifier.width(8.dp))
}
ToggleButton(
text = stringResource(R.string.where_cospend_short),
selected = viewModel.projectType == ProjectType.COSPEND,
onClick = { viewModel.projectType = ProjectType.COSPEND }
)
Spacer(modifier = Modifier.width(8.dp))
ToggleButton(
text = stringResource(R.string.where_ihatemoney_short),
selected = viewModel.projectType == ProjectType.IHATEMONEY,
onClick = { viewModel.projectType = ProjectType.IHATEMONEY }
)
}
}
Spacer(modifier = Modifier.height(8.dp))
if (viewModel.whatTodoIsCreate) {
if (viewModel.projectType == ProjectType.COSPEND) {
Button(
onClick = onChooseFromNextcloud
) {
Text(stringResource(R.string.new_project_from_nextcloud_tooltip))
}
} else if (viewModel.projectType == ProjectType.LOCAL) {
Button(
onClick = onImportFile
) {
Text(stringResource(R.string.import_tooltip))
}
}
} else {
Spacer(modifier = Modifier.width(16.dp))
Button(onClick = onScanQrCode) {
Text(text = stringResource(R.string.scan_qrcode))
Spacer(modifier = Modifier.width(4.dp))
Icon(
Icons.Default.QrCode2,
contentDescription = null,
modifier = Modifier.size(16.dp, 16.dp),
)
}
}
Spacer(modifier = Modifier.height(16.dp))
if (viewModel.projectType != ProjectType.LOCAL) {
OutlinedTextField(
value = viewModel.projectUrl,
onValueChange = { viewModel.projectUrl = it },
label = {
Text(
stringResource(
if (viewModel.projectType == ProjectType.COSPEND) R.string.setting_cospend_project_url
else R.string.setting_ihatemoney_project_url
)
)
},
modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.Default.Link, contentDescription = null) }
)
Spacer(modifier = Modifier.height(8.dp))
}
OutlinedTextField(
value = viewModel.projectId,
onValueChange = { viewModel.projectId = it },
label = { Text(stringResource(R.string.setting_project_id)) },
modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.AutoMirrored.Filled.LibraryBooks, contentDescription = null) }
)
if (viewModel.projectType != ProjectType.LOCAL && (!viewModel.whatTodoIsCreate || viewModel.projectType == ProjectType.IHATEMONEY)) {
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = viewModel.projectPassword,
onValueChange = { viewModel.projectPassword = it },
label = { Text(stringResource(R.string.setting_new_project_password)) },
modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) }
)
}
if (viewModel.whatTodoIsCreate && viewModel.projectType != ProjectType.LOCAL) {
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = viewModel.projectName,
onValueChange = { viewModel.projectName = it },
label = { Text(stringResource(R.string.setting_new_project_name)) },
modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.Default.Title, contentDescription = null) }
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = viewModel.projectEmail,
onValueChange = { viewModel.projectEmail = it },
label = { Text(stringResource(R.string.setting_new_project_email)) },
modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.Default.Email, contentDescription = null) }
)
}
}
}
if (viewModel.showAuthWarningDialog) {
AlertDialog(
onDismissRequest = { viewModel.showAuthWarningDialog = false },
title = { Text(stringResource(R.string.auth_project_creation_title)) },
text = { Text(stringResource(R.string.warning_auth_project_creation)) },
confirmButton = {
TextButton(onClick = {
viewModel.showAuthWarningDialog = false
onOkPressed()
}) {
Text(stringResource(android.R.string.ok))
}
},
dismissButton = {
TextButton(onClick = { viewModel.showAuthWarningDialog = false }) {
Text(stringResource(android.R.string.cancel))
}
}
)
}
if (viewModel.showNextcloudProjectDialog) {
AlertDialog(
onDismissRequest = { viewModel.showNextcloudProjectDialog = false },
title = { Text(stringResource(R.string.choose_account_project_dialog_title)) },
text = {
Column {
viewModel.nextcloudProjects.forEach { project ->
Row(
Modifier
.fillMaxWidth()
.selectable(
selected = false,
onClick = {
viewModel.projectId = project.remoteId
viewModel.projectUrl = project.ncUrl
viewModel.showNextcloudProjectDialog = false
}
)
.padding(16.dp)
) {
Text(text = project.name)
}
}
}
},
confirmButton = {},
dismissButton = {
TextButton(onClick = { viewModel.showNextcloudProjectDialog = false }) {
Text(stringResource(R.string.simple_cancel))
}
}
)
}
if (viewModel.isCreatingRemoteProject) {
AlertDialog(
onDismissRequest = { },
title = { Text(stringResource(R.string.simple_loading)) },
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator()
Spacer(modifier = Modifier.width(16.dp))
Text(stringResource(R.string.creating_remote_project))
}
},
confirmButton = {}
)
}
if (viewModel.errorDialogMessage != null) {
AlertDialog(
onDismissRequest = { viewModel.errorDialogMessage = null },
title = { Text(stringResource(R.string.simple_error)) },
text = { Text(viewModel.errorDialogMessage!!) },
confirmButton = {
TextButton(onClick = { viewModel.errorDialogMessage = null }) {
Text(stringResource(R.string.simple_ok))
}
}
)
}
}
@Composable
fun SectionRow(
label: String,
content: @Composable () -> Unit
) {
Row(verticalAlignment = Alignment.Top) {
Column {
Text(label, fontSize = 12.sp, color = MaterialTheme.colors.onSurface)
content()
}
}
}
@Composable
fun ToggleButton(
text: String,
selected: Boolean,
onClick: () -> Unit
) {
Button(
onClick = onClick,
colors = if (selected) {
ButtonDefaults.buttonColors(backgroundColor = MaterialTheme.colors.primary)
} else {
ButtonDefaults.buttonColors(backgroundColor = MaterialTheme.colors.surface)
}
) {
Text(
text,
fontSize = 12.sp,
color = if (selected) MaterialTheme.colors.onPrimary else MaterialTheme.colors.onSurface
)
}
}
@SuppressLint("ViewModelConstructorInComposable")
@Preview
@Composable
fun NewProjectScreenPreview() {
NewProjectScreen(
viewModel = NewProjectViewModel().apply {
whatTodoIsCreate = true
projectType = ProjectType.COSPEND
},
onScanQrCode = {},
onImportFile = {},
onChooseFromNextcloud = {},
onOkPressed = {},
onBack = {}
)
}

View File

@@ -0,0 +1,95 @@
package net.helcel.cowspent.android.project.create
import android.net.Uri
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import net.helcel.cowspent.model.DBAccountProject
import net.helcel.cowspent.model.ProjectType
class NewProjectViewModel : ViewModel() {
var whatTodoIsCreate by mutableStateOf(false)
private var _projectType by mutableStateOf(ProjectType.COSPEND)
var projectType: ProjectType
get() = _projectType
set(value) {
_projectType = value
if (value == ProjectType.IHATEMONEY && (projectUrl.isEmpty() || projectUrl == defaultNcUrl)) {
projectUrl = "https://ihatemoney.org"
} else if (value == ProjectType.COSPEND && (projectUrl == "https://ihatemoney.org" || projectUrl.isEmpty())) {
projectUrl = defaultNcUrl
}
}
var projectUrl by mutableStateOf("")
var defaultNcUrl by mutableStateOf("")
var projectId by mutableStateOf("")
var projectPassword by mutableStateOf("")
var projectName by mutableStateOf("")
var projectEmail by mutableStateOf("")
var showAuthWarningDialog by mutableStateOf(false)
var showNextcloudProjectDialog by mutableStateOf(false)
var nextcloudProjects by mutableStateOf<List<DBAccountProject>>(emptyList())
var isCreatingRemoteProject by mutableStateOf(false)
var errorDialogMessage by mutableStateOf<String?>(null)
fun isFormValid(): Boolean {
if (whatTodoIsCreate) {
if (projectId.isEmpty()) return false
if (projectType != ProjectType.LOCAL) {
if (projectUrl.isEmpty()) return false
if (projectName.isEmpty()) return false
if (projectEmail.isEmpty()) return false
if (projectType == ProjectType.IHATEMONEY && projectPassword.isEmpty()) return false
}
} else {
// Join
if (projectType == ProjectType.LOCAL) return false
if (projectId.isEmpty() && !isCospendSchemeLink(projectUrl)) return false
if (projectUrl.isEmpty() && !isCospendSchemeLink(projectUrl)) return false
if (projectPassword.isEmpty() && !isCospendSchemeLink(projectUrl)) return false
}
return true
}
private fun isCospendSchemeLink(url: String): Boolean {
return url.startsWith("cospend://") || url.startsWith("cospend+http://") ||
url.startsWith("cowspent://") || url.startsWith("cowspent+http://") ||
url.startsWith("ihatemoney://") || url.startsWith("ihatemoney+http://")
}
fun updateFromUri(data: Uri) {
if ((data.scheme == "cospend" || data.scheme == "cospend+http" ||
data.scheme == "cowspent" || data.scheme == "cowspent+http" ||
data.scheme == "ihatemoney" || data.scheme == "ihatemoney+http")
&& data.pathSegments.isNotEmpty()
) {
val password: String
val pid: String
if (data.path!!.endsWith("/")) {
password = ""
pid = data.lastPathSegment!!
} else {
password = data.lastPathSegment!!
pid = data.pathSegments[data.pathSegments.size - 2]
}
var protocol = "https"
if (data.scheme == "cospend+http" || data.scheme == "cowspent+http" || data.scheme == "ihatemoney+http") {
protocol = "http"
}
val url = protocol + "://" + data.host
val port = if (data.port != -1) ":${data.port}" else ""
val fullUrl = "$url$port${data.path!!.replace(("/$pid/$password$").toRegex(), "")}"
projectPassword = password
projectId = pid
projectUrl = fullUrl
projectType = if (data.scheme?.startsWith("ihatemoney") == true) ProjectType.IHATEMONEY else ProjectType.COSPEND
whatTodoIsCreate = false
}
}
}

View File

@@ -0,0 +1,192 @@
package net.helcel.cowspent.android.project.edit
import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.helcel.cowspent.R
import net.helcel.cowspent.android.helper.showToast
import net.helcel.cowspent.model.DBProject
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.theme.ThemeUtils
import net.helcel.cowspent.util.ICallback
import net.helcel.cowspent.util.SupportUtil
import net.helcel.cowspent.android.main.MainConstants
class EditProjectActivity : AppCompatActivity() {
private val viewModel: EditProjectViewModel by viewModels()
private lateinit var db: CowspentSQLiteOpenHelper
private lateinit var project: DBProject
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
db = CowspentSQLiteOpenHelper.getInstance(this)
val id = intent.getLongExtra(PARAM_PROJECT_ID, 0)
if (id <= 0) {
finish()
return
}
lifecycleScope.launch {
project = withContext(Dispatchers.IO) { db.getProject(id) }!!
viewModel.initFromProject(project)
setContent {
ThemeUtils.CowspentTheme {
EditProjectScreen(
viewModel = viewModel,
onSave = { onSave() },
onDeleteRemote = { onDeleteRemote() },
onBack = { finish() }
)
}
}
}
}
private fun onSave() {
val currentPwd = viewModel.password
val newPwd = viewModel.newPassword
val newName = viewModel.name
val newEmail = viewModel.email
if (newName.isEmpty()) {
showToast(this, getString(R.string.error_invalid_project_name), Toast.LENGTH_LONG)
return
}
if (newEmail.isNotEmpty() && !SupportUtil.isValidEmail(newEmail)) {
showToast(this, getString(R.string.error_invalid_email), Toast.LENGTH_LONG)
return
}
val nameChanged = newName != project.name
val emailChanged = newEmail != project.email
val pwdChanged = newPwd != project.password
val currentPwdChanged = currentPwd != project.password
if (!nameChanged && !emailChanged && !pwdChanged && !currentPwdChanged) {
showToast(this, getString(R.string.project_edition_no_change), Toast.LENGTH_LONG)
return
}
if (project.isLocal) {
val targetPwd = if (pwdChanged) newPwd else currentPwd
lifecycleScope.launch {
withContext(Dispatchers.IO) {
db.updateProject(
project.id, newName, newEmail, targetPwd,
null, project.type, null,
null, null,
null, null
)
}
closeOnEdit(project.id)
}
return
}
// Remote project
if (nameChanged || emailChanged || pwdChanged) {
// Update local password first if currentPwd was changed (user fixing credentials)
if (currentPwdChanged) {
project.password = currentPwd
lifecycleScope.launch {
withContext(Dispatchers.IO) {
db.updateProject(project.id, null, null, currentPwd, null, project.type, null, null, null, null, null)
}
}
}
if (!db.cowspentServerSyncHelper.editRemoteProject(
project.id,
newName,
newEmail,
if (pwdChanged) newPwd else null,
null,
editCallBack
)
) {
showToast(this, getString(R.string.remote_project_operation_no_network), Toast.LENGTH_LONG)
}
} else {
// Only current password changed locally (fixing credentials)
lifecycleScope.launch {
withContext(Dispatchers.IO) {
db.updateProject(
project.id, null, null, currentPwd,
null, project.type, null,
null, null,
null, null
)
}
closeOnEdit(project.id)
}
}
}
private fun onDeleteRemote() {
viewModel.showDialog(
message = getString(R.string.confirm_delete_project_dialog_title),
positiveText = getString(R.string.simple_yes),
onConfirm = {
if (!db.cowspentServerSyncHelper.deleteRemoteProject(project.id, deleteCallBack)) {
showToast(this, getString(R.string.remote_project_operation_no_network), Toast.LENGTH_LONG)
}
},
negativeText = getString(R.string.simple_no)
)
}
private val editCallBack = object : ICallback {
override fun onFinish() {}
override fun onFinish(result: String, message: String) {
if (message.isEmpty()) {
closeOnEdit(project.id)
} else {
showToast(this@EditProjectActivity, getString(R.string.error_edit_remote_project_helper, message), Toast.LENGTH_LONG)
}
}
override fun onScheduled() {}
}
private val deleteCallBack = object : ICallback {
override fun onFinish() {}
override fun onFinish(result: String, message: String) {
if (message.isEmpty()) {
closeOnDelete(result.toLong())
} else {
showToast(this@EditProjectActivity, getString(R.string.error_edit_remote_project_helper, message), Toast.LENGTH_LONG)
}
}
override fun onScheduled() {}
}
private fun closeOnDelete(projId: Long) {
val data = Intent()
data.putExtra(MainConstants.DELETED_PROJECT, projId)
setResult(RESULT_OK, data)
finish()
}
private fun closeOnEdit(projId: Long) {
val data = Intent()
data.putExtra(MainConstants.EDITED_PROJECT, projId)
setResult(RESULT_OK, data)
finish()
}
companion object {
const val PARAM_PROJECT_ID = "projectId"
}
}

View File

@@ -0,0 +1,145 @@
package net.helcel.cowspent.android.project.edit
import android.annotation.SuppressLint
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.DeleteForever
import androidx.compose.material.icons.filled.Done
import androidx.compose.material.icons.filled.Email
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.LockOpen
import androidx.compose.material.icons.filled.Title
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import net.helcel.cowspent.R
import net.helcel.cowspent.android.helper.AlertDialog
@Composable
fun EditProjectScreen(
viewModel: EditProjectViewModel,
onSave: () -> Unit,
onDeleteRemote: () -> Unit,
onBack: () -> Unit
) {
val dialogState = viewModel.dialogState
if (dialogState != null) {
AlertDialog(
showDialog = true,
onDismissRequest = { viewModel.dismissDialog() },
title = dialogState.title,
message = dialogState.message,
icon = dialogState.icon,
items = dialogState.items,
positiveText = dialogState.positiveText,
negativeText = dialogState.negativeText,
neutralText = dialogState.neutralText,
onConfirm = {
dialogState.onConfirm?.invoke()
viewModel.dismissDialog()
},
onCancel = {
dialogState.onCancel?.invoke()
viewModel.dismissDialog()
},
onNeutral = {
dialogState.onNeutral?.invoke()
viewModel.dismissDialog()
}
) {
dialogState.onItemSelected?.invoke(it)
viewModel.dismissDialog()
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.simple_edit_project)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
},
actions = {
IconButton(onClick = onDeleteRemote) {
Icon(Icons.Default.DeleteForever, contentDescription = stringResource(R.string.menu_delete_project_remote))
}
},
backgroundColor = MaterialTheme.colors.surface,
elevation = 0.dp
)
},
floatingActionButton = {
FloatingActionButton(onClick = onSave) {
Icon(Icons.Default.Done, contentDescription = stringResource(R.string.menu_save_project))
}
}
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.padding(16.dp)
.fillMaxSize()
) {
OutlinedTextField(
value = viewModel.name,
onValueChange = { viewModel.name = it },
label = { Text(stringResource(R.string.setting_new_project_name)) },
modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.Default.Title, contentDescription = null) }
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = viewModel.password,
onValueChange = { viewModel.password = it },
label = { Text(stringResource(R.string.setting_password)) },
modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) }
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = viewModel.newPassword,
onValueChange = { viewModel.newPassword = it },
label = { Text(stringResource(R.string.setting_new_project_password)) },
modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.Default.LockOpen, contentDescription = null) }
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = viewModel.email,
onValueChange = { viewModel.email = it },
label = { Text(stringResource(R.string.setting_new_project_email)) },
modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.Default.Email, contentDescription = null) }
)
}
}
}
@SuppressLint("ViewModelConstructorInComposable")
@Preview(showBackground = true)
@Composable
fun EditProjectScreenPreview() {
MaterialTheme {
EditProjectScreen(
viewModel = EditProjectViewModel().apply {
name = "My Awesome Project"
email = "user@example.com"
},
onSave = {},
onDeleteRemote = {},
onBack = {}
)
}
}

View File

@@ -0,0 +1,57 @@
package net.helcel.cowspent.android.project.edit
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.lifecycle.ViewModel
import net.helcel.cowspent.android.helper.DialogState
import net.helcel.cowspent.model.DBProject
class EditProjectViewModel : ViewModel() {
var name by mutableStateOf("")
var password by mutableStateOf("")
var newPassword by mutableStateOf("")
var email by mutableStateOf("")
var dialogState by mutableStateOf<DialogState?>(null)
fun showDialog(
title: String? = null,
message: String? = null,
icon: ImageVector? = null,
items: List<CharSequence>? = null,
positiveText: String? = null,
negativeText: String? = null,
neutralText: String? = null,
onConfirm: (() -> Unit)? = null,
onCancel: (() -> Unit)? = null,
onNeutral: (() -> Unit)? = null,
onItemSelected: ((Int) -> Unit)? = null
) {
dialogState = DialogState(
title = title,
message = message,
icon = icon,
items = items,
positiveText = positiveText,
negativeText = negativeText,
neutralText = neutralText,
onConfirm = onConfirm,
onCancel = onCancel,
onNeutral = onNeutral,
onItemSelected = onItemSelected
)
}
fun dismissDialog() {
dialogState = null
}
fun initFromProject(project: DBProject) {
name = if (project.name == "null") "" else project.name
password = project.password
newPassword = project.password
email = project.email?.let { if (it == "null") "" else it } ?: ""
}
}

View File

@@ -0,0 +1,70 @@
package net.helcel.cowspent.android.project.member
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import androidx.compose.ui.tooling.preview.Preview
import net.helcel.cowspent.R
@Composable
fun MemberAddDialogContent(
onAdd: (String) -> Unit,
onDismiss: () -> Unit
) {
var name by remember { mutableStateOf("") }
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colors.surface,
contentColor = contentColorFor(MaterialTheme.colors.surface)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(
text = stringResource(R.string.add_member_dialog_title),
style = MaterialTheme.typography.h6,
modifier = Modifier.padding(bottom = 16.dp)
)
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text(stringResource(R.string.member_edit_name)) },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences),
singleLine = true
)
Spacer(modifier = Modifier.height(24.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.simple_cancel).uppercase())
}
TextButton(onClick = {
if (name.isNotEmpty()) {
onAdd(name)
}
}) {
Text(stringResource(R.string.simple_ok).uppercase())
}
}
}
}
}
@Preview(showBackground = true)
@Composable
fun MemberAddDialogContentPreview() {
MaterialTheme {
MemberAddDialogContent(onAdd = {}, onDismiss = {})
}
}

View File

@@ -0,0 +1,208 @@
package net.helcel.cowspent.android.project.member
import android.graphics.Color
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.material.icons.filled.*
import net.helcel.cowspent.R
import net.helcel.cowspent.android.helper.ColorPicker
import net.helcel.cowspent.android.helper.TextDrawable
import net.helcel.cowspent.model.DBMember
import net.helcel.cowspent.util.ColorUtils
@Composable
fun MemberEditDialogContent(
member: DBMember,
onSave: (String, Double, Boolean, Int?, Int?, Int?) -> Unit,
onDelete: () -> Unit,
onDismiss: () -> Unit
) {
var name by remember { mutableStateOf(member.name) }
var weight by remember { mutableStateOf(member.weight.toString()) }
var isActivated by remember { mutableStateOf(member.isActivated) }
val initialColor = remember {
if (member.r != null && member.g != null && member.b != null) {
Color.rgb(member.r!!, member.g!!, member.b!!)
} else {
TextDrawable.getColorFromName(member.name)
}
}
var selectedColor by remember { mutableIntStateOf(initialColor) }
var showColorPicker by remember { mutableStateOf(false) }
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colors.surface,
contentColor = contentColorFor(MaterialTheme.colors.surface)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.verticalScroll(rememberScrollState())
) {
Text(
text = stringResource(R.string.edit_member_dialog_title),
style = MaterialTheme.typography.h6,
modifier = Modifier.padding(bottom = 16.dp)
)
// Name
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text(stringResource(R.string.member_edit_name)) },
modifier = Modifier.fillMaxWidth(),
leadingIcon = {
Icon(Icons.Default.Person, contentDescription = null)
},
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences)
)
Spacer(modifier = Modifier.height(8.dp))
// Weight
OutlinedTextField(
value = weight,
onValueChange = { weight = it },
label = { Text(stringResource(R.string.member_edit_weight)) },
modifier = Modifier.fillMaxWidth(),
leadingIcon = {
Icon(Icons.Default.LineWeight, contentDescription = null)
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
)
Spacer(modifier = Modifier.height(8.dp))
// Activated
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { isActivated = !isActivated }
.padding(vertical = 8.dp)
) {
Icon(Icons.Default.Block, contentDescription = null)
Spacer(modifier = Modifier.width(16.dp))
Text(stringResource(R.string.member_edit_toggle), modifier = Modifier.weight(1f))
Checkbox(checked = isActivated, onCheckedChange = { isActivated = it })
}
Spacer(modifier = Modifier.height(8.dp))
// Color
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
) {
Icon(Icons.Default.Palette, contentDescription = null)
Spacer(modifier = Modifier.width(16.dp))
Text(stringResource(R.string.member_edit_color), modifier = Modifier.weight(1f))
Box(
modifier = Modifier
.size(40.dp)
.background(androidx.compose.ui.graphics.Color(selectedColor))
.clickable { showColorPicker = true }
.padding(8.dp),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.Edit,
contentDescription = null,
tint = if (ColorUtils.isLightColor(selectedColor)) androidx.compose.ui.graphics.Color.Black else androidx.compose.ui.graphics.Color.White
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Delete
Button(
onClick = onDelete,
colors = ButtonDefaults.buttonColors(backgroundColor = MaterialTheme.colors.error),
modifier = Modifier.align(Alignment.CenterHorizontally)
) {
Icon(Icons.Default.Delete, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.member_edit_delete))
}
Spacer(modifier = Modifier.height(24.dp))
// Buttons
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.simple_cancel).uppercase())
}
TextButton(onClick = {
val w = weight.replace(',', '.').toDoubleOrNull()
if (name.isNotEmpty() && w != null) {
onSave(
name,
w,
isActivated,
Color.red(selectedColor),
Color.green(selectedColor),
Color.blue(selectedColor)
)
}
}) {
Text(stringResource(R.string.simple_ok).uppercase())
}
}
}
}
if (showColorPicker) {
AlertDialog(
onDismissRequest = { showColorPicker = false },
title = { Text(stringResource(R.string.settings_colorpicker_title)) },
text = {
ColorPicker(initialColor = selectedColor) {
selectedColor = it
}
},
confirmButton = {
TextButton(onClick = { showColorPicker = false }) {
Text(stringResource(R.string.simple_ok).uppercase())
}
},
dismissButton = {
TextButton(onClick = { showColorPicker = false }) {
Text(stringResource(R.string.simple_cancel).uppercase())
}
}
)
}
}
@Preview(showBackground = true)
@Composable
fun MemberEditDialogContentPreview() {
MaterialTheme {
MemberEditDialogContent(
member = DBMember(1, 0, 0, "Alice", true, 1.0, 0, null, null, null, null, null),
onSave = { _, _, _, _, _, _ -> },
onDelete = {},
onDismiss = {}
)
}
}

View File

@@ -0,0 +1,132 @@
package net.helcel.cowspent.android.project.member
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.contentColorFor
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import net.helcel.cowspent.R
import net.helcel.cowspent.android.helper.UserAvatar
import net.helcel.cowspent.android.helper.lazyVerticalScrollbar
import net.helcel.cowspent.model.DBMember
@Composable
fun MemberManagementDialogContent(
members: List<DBMember>,
onAddMember: () -> Unit,
onEditMember: (DBMember) -> Unit,
onDismiss: () -> Unit
) {
val listState = rememberLazyListState()
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colors.surface,
contentColor = contentColorFor(MaterialTheme.colors.surface)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 500.dp)
.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(R.string.fab_manage_members),
style = MaterialTheme.typography.h6
)
IconButton(onClick = onAddMember) {
Icon(Icons.Default.Add,modifier=Modifier.size(32.dp),
contentDescription = stringResource(R.string.fab_add_member))
}
}
Spacer(modifier = Modifier.height(8.dp))
LazyColumn(
state = listState,
modifier = Modifier
.weight(1f, fill = false)
.lazyVerticalScrollbar(listState)
) {
items(members) { member ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onEditMember(member) }
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
UserAvatar(
name = member.name,
r = member.r,
g = member.g,
b = member.b,
avatar = member.avatar,
disabled = !member.isActivated,
size = 40.dp
)
Spacer(modifier = Modifier.width(16.dp))
Text(
text = member.name,
style = MaterialTheme.typography.body1
)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.simple_close).uppercase())
}
}
}
}
}
@Preview(showBackground = true)
@Composable
fun MemberManagementDialogContentPreview() {
MaterialTheme {
MemberManagementDialogContent(
members = listOf(
DBMember(1, 0, 1, "Alice", true, 1.0, 0, 255, 100, 100, null, null),
DBMember(2, 0, 1, "Bob", true, 1.0, 0, 100, 255, 100, null, null),
DBMember(3, 0, 1, "Charlie", false, 1.0, 0, 100, 100, 255, null, null)
),
onAddMember = {},
onEditMember = {},
onDismiss = {}
)
}
}

View File

@@ -0,0 +1,401 @@
package net.helcel.cowspent.android.project.settle
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.Divider
import androidx.compose.material.DropdownMenu
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.contentColorFor
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.CenterFocusStrong
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import net.helcel.cowspent.R
import net.helcel.cowspent.model.DBProject
import net.helcel.cowspent.model.Transaction
import net.helcel.cowspent.model.UserItem
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.util.SupportUtil
import net.helcel.cowspent.util.SupportUtil.SETTLE_OPTIMAL
import net.helcel.cowspent.util.SupportUtil.settleBills
@Composable
fun ProjectSettlementDialogContent(
proj: DBProject,
db: CowspentSQLiteOpenHelper,
onSettleBills: (List<Transaction>) -> Unit,
onShare: (List<Transaction>, Map<Long, String>) -> Unit,
onDismiss: () -> Unit
) {
val centerNoneStr = stringResource(R.string.center_none)
val memberList = remember(proj.id) { db.getMembersOfProject(proj.id, null) }
val userList = remember(memberList, centerNoneStr) {
buildList {
add(UserItem(SETTLE_OPTIMAL, centerNoneStr))
addAll(memberList.map { UserItem(it.id, it.name) })
}
}
val membersBalance = remember(proj.id) {
val balance = mutableMapOf<Long, Double>()
SupportUtil.getStatsOfProject(
proj.id, db,
mutableMapOf(), balance, mutableMapOf(), mutableMapOf(),
-1000, -1000, null, null
)
balance
}
val membersSortedByName = remember(proj.id) {
db.getMembersOfProject(proj.id, CowspentSQLiteOpenHelper.key_name)
}
val memberIdToName = remember(membersSortedByName) {
membersSortedByName.associate { it.id to it.name }
}
var selectedMemberId by remember(userList) {
mutableLongStateOf(userList.firstOrNull()?.id ?: SETTLE_OPTIMAL)
}
val transactions = remember(selectedMemberId, membersBalance, membersSortedByName) {
settleBills(membersSortedByName, membersBalance, selectedMemberId)
}
ProjectSettlementUI(
transactions = transactions,
userList = userList,
selectedMemberId = selectedMemberId,
memberIdToName = memberIdToName,
onMemberSelected = { selectedMemberId = it },
onSettleBills = onSettleBills,
onShare = onShare,
onDismiss = onDismiss
)
}
@Composable
fun ProjectSettlementUI(
transactions: List<Transaction>,
userList: List<UserItem>,
selectedMemberId: Long,
memberIdToName: Map<Long, String>,
onMemberSelected: (Long) -> Unit,
onSettleBills: (List<Transaction>) -> Unit,
onShare: (List<Transaction>, Map<Long, String>) -> Unit,
onDismiss: () -> Unit
) {
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colors.surface,
contentColor = contentColorFor(MaterialTheme.colors.surface)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 480.dp)
.padding(16.dp)
) {
Text(
text = stringResource(R.string.settle_dialog_title),
style = MaterialTheme.typography.h6,
modifier = Modifier.padding(bottom = 16.dp)
)
if (transactions.isEmpty()) {
BalancedStateMessage()
} else {
MemberSelector(
selectedMemberId = selectedMemberId,
userList = userList,
onMemberSelected = onMemberSelected
)
Spacer(modifier = Modifier.height(16.dp))
TransactionTable(
transactions = transactions,
memberIdToName = memberIdToName,
modifier = Modifier.weight(1f, fill = false)
)
}
Spacer(modifier = Modifier.height(24.dp))
ActionButtons(
transactions = transactions,
memberIdToName = memberIdToName,
onShare = onShare,
onSettleBills = onSettleBills,
onDismiss = onDismiss
)
}
}
}
@Composable
private fun BalancedStateMessage() {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 32.dp),
contentAlignment = Alignment.Center
) {
Text(
text = stringResource(R.string.settle_dialog_balanced),
style = MaterialTheme.typography.body1
)
}
}
@OptIn(ExperimentalMaterialApi::class)
@Composable
private fun MemberSelector(
selectedMemberId: Long,
userList: List<UserItem>,
onMemberSelected: (Long) -> Unit
) {
var expanded by remember { mutableStateOf(false) }
Surface(
onClick = { expanded = true },
shape = MaterialTheme.shapes.medium,
color = colorResource(R.color.fg_default_low).copy(alpha = 0.05f),
modifier = Modifier.fillMaxWidth()
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp)
) {
Icon(
Icons.Default.CenterFocusStrong,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = colorResource(R.color.fg_default_low)
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = userList.find { it.id == selectedMemberId }?.name ?: "",
style = MaterialTheme.typography.body1,
fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1f)
)
Icon(
Icons.Default.ArrowDropDown,
contentDescription = null,
tint = colorResource(R.color.fg_default_low)
)
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
userList.forEach { user ->
DropdownMenuItem(onClick = {
onMemberSelected(user.id)
expanded = false
}) {
Text(
text = user.name,
style = MaterialTheme.typography.body1
)
}
}
}
}
}
}
@Composable
private fun TransactionTable(
transactions: List<Transaction>,
memberIdToName: Map<Long, String>,
modifier: Modifier = Modifier
) {
Column(modifier = modifier) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
) {
TableHeaderText(stringResource(R.string.settle_who), Modifier.weight(1.2f))
TableHeaderText(stringResource(R.string.settle_to_whom), Modifier.weight(1.2f))
TableHeaderText(stringResource(R.string.settle_how_much), Modifier.weight(1f), TextAlign.End)
}
Divider(color = colorResource(R.color.fg_default_low).copy(alpha = 0.12f))
LazyColumn(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(transactions) { t ->
TransactionRow(t, memberIdToName)
}
}
}
}
@Composable
private fun TableHeaderText(
text: String,
modifier: Modifier = Modifier,
textAlign: TextAlign = TextAlign.Start
) {
Text(
text = text.uppercase(),
modifier = modifier,
fontWeight = FontWeight.SemiBold,
style = MaterialTheme.typography.overline,
color = colorResource(R.color.fg_default_low),
textAlign = textAlign,
letterSpacing = 0.8.sp
)
}
@Composable
private fun TransactionRow(
transaction: Transaction,
memberIdToName: Map<Long, String>
) {
Surface(
shape = MaterialTheme.shapes.medium,
color = Color.Transparent
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = memberIdToName[transaction.owerMemberId] ?: "-",
modifier = Modifier.weight(1.2f),
style = MaterialTheme.typography.body2,
color = colorResource(R.color.fg_default)
)
Text(
text = memberIdToName[transaction.receiverMemberId] ?: "-",
modifier = Modifier.weight(1.2f),
style = MaterialTheme.typography.body2,
color = colorResource(R.color.fg_default)
)
Text(
text = SupportUtil.normalNumberFormat.format(transaction.amount),
modifier = Modifier.weight(1f),
textAlign = TextAlign.End,
style = MaterialTheme.typography.body1,
fontWeight = FontWeight.Bold,
color = colorResource(R.color.fg_default)
)
}
}
}
@Composable
private fun ActionButtons(
transactions: List<Transaction>,
memberIdToName: Map<Long, String>,
onShare: (List<Transaction>, Map<Long, String>) -> Unit,
onSettleBills: (List<Transaction>) -> Unit,
onDismiss: () -> Unit
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
verticalAlignment = Alignment.CenterVertically
) {
if (transactions.isNotEmpty()) {
TextButton(onClick = { onShare(transactions, memberIdToName) }) {
Text(stringResource(R.string.simple_settle_share).uppercase())
}
TextButton(onClick = { onSettleBills(transactions) }) {
Text(stringResource(R.string.simple_create_bills).uppercase())
}
}
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.simple_ok).uppercase())
}
}
}
@Preview(showBackground = true, name = "With Transactions")
@Composable
fun ProjectSettlementUIPreview() {
MaterialTheme {
ProjectSettlementUI(
transactions = listOf(
Transaction(1, 2, 25.50),
Transaction(3, 1, 10.00),
Transaction(2, 3, 5.25)
),
userList = listOf(
UserItem(SETTLE_OPTIMAL, "None (Optimal)"),
UserItem(1, "Alice"),
UserItem(2, "Bob"),
UserItem(3, "Charlie")
),
selectedMemberId = SETTLE_OPTIMAL,
memberIdToName = mapOf(1L to "Alice", 2L to "Bob", 3L to "Charlie"),
onMemberSelected = {},
onSettleBills = {},
onShare = { _, _ -> },
onDismiss = {}
)
}
}
@Preview(showBackground = true, name = "Balanced")
@Composable
fun ProjectSettlementUIBalancedPreview() {
MaterialTheme {
ProjectSettlementUI(
transactions = emptyList(),
userList = listOf(
UserItem(SETTLE_OPTIMAL, "None (Optimal)"),
UserItem(1, "Alice"),
UserItem(2, "Bob")
),
selectedMemberId = SETTLE_OPTIMAL,
memberIdToName = mapOf(1L to "Alice", 2L to "Bob"),
onMemberSelected = {},
onSettleBills = {},
onShare = { _, _ -> },
onDismiss = {}
)
}
}

View File

@@ -0,0 +1,71 @@
package net.helcel.cowspent.android.settings
import android.content.Intent
import android.os.Bundle
import androidx.activity.OnBackPressedCallback
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.core.app.NavUtils
import androidx.preference.PreferenceManager
import net.helcel.cowspent.android.about.AboutActivity
import net.helcel.cowspent.android.account.AccountActivity
import net.helcel.cowspent.theme.ThemeUtils
import net.helcel.cowspent.util.ColorUtils
/**
* Allows to change application settings.
*/
class PreferencesActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
NavUtils.navigateUpFromSameTask(this@PreferencesActivity)
}
})
setResult(RESULT_CANCELED)
setContent {
val context = LocalContext.current
val sharedPreferences = remember { PreferenceManager.getDefaultSharedPreferences(context) }
val nightModeKey = stringResource(net.helcel.cowspent.R.string.pref_key_night_mode)
var appColor by remember {
mutableIntStateOf(ColorUtils.primaryColor(context))
}
var nightMode by remember {
mutableStateOf(sharedPreferences.getString(nightModeKey, "-1") ?: "-1")
}
val isDarkTheme = when (nightMode) {
"1" -> false
"2" -> true
else -> androidx.compose.foundation.isSystemInDarkTheme()
}
ThemeUtils.CowspentTheme(accentColor = appColor, darkTheme = isDarkTheme) {
SettingsScreen(
onBack = { NavUtils.navigateUpFromSameTask(this) },
onAccountSettingsClick = {
startActivity(Intent(this, AccountActivity::class.java))
},
onAboutClick = {
startActivity(Intent(this, AboutActivity::class.java))
},
onColorSelected = { appColor = it },
onNightModeChanged = { nightMode = it }
)
}
}
}
}

View File

@@ -0,0 +1,449 @@
package net.helcel.cowspent.android.settings
import android.graphics.Color
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.AlertDialog
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.RadioButton
import androidx.compose.material.Scaffold
import androidx.compose.material.Switch
import androidx.compose.material.SwitchDefaults
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Archive
import androidx.compose.material.icons.filled.Brightness2
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.Sync
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.content.edit
import androidx.preference.PreferenceManager
import net.helcel.cowspent.R
import net.helcel.cowspent.android.helper.ColorPicker
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.util.ColorUtils
import net.helcel.cowspent.util.Cowspent
@Composable
fun SettingsScreen(
onBack: () -> Unit,
onAccountSettingsClick: () -> Unit,
onAboutClick: () -> Unit,
onColorSelected: (Int) -> Unit,
onNightModeChanged: (String) -> Unit = {}
) {
val context = LocalContext.current
val sharedPreferences = remember { PreferenceManager.getDefaultSharedPreferences(context) }
val keyNightMode = stringResource(R.string.pref_key_night_mode)
val valNightModeSystem = stringResource(R.string.pref_value_night_mode_system)
val valNightModeNo = stringResource(R.string.pref_value_night_mode_no)
val valNightModeYes = stringResource(R.string.pref_value_night_mode_yes)
val keyColorMode = stringResource(R.string.pref_key_color_mode)
val keyUseServerColor = stringResource(R.string.pref_key_use_server_color)
val keyUseSystemColor = stringResource(R.string.pref_key_use_system_color)
val keyColor = stringResource(R.string.pref_key_color)
val keyOfflineMode = stringResource(R.string.pref_key_offline_mode)
val keyShowArchived = stringResource(R.string.pref_key_show_archived)
// States for preferences
var nightMode by remember(keyNightMode) {
mutableStateOf(sharedPreferences.getString(keyNightMode, "-1") ?: "-1")
}
var colorMode by remember(keyColorMode, keyUseServerColor, keyUseSystemColor) {
mutableStateOf(sharedPreferences.getString(keyColorMode, null) ?: run {
val useServer = sharedPreferences.getBoolean(keyUseServerColor, true)
val useSystem = sharedPreferences.getBoolean(keyUseSystemColor, true)
when {
useServer -> "server"
useSystem -> "system"
else -> "manual"
}
})
}
// Apply theme globally only when leaving settings to avoid flickering/restarts during selection
DisposableEffect(nightMode) {
onDispose {
Cowspent.setAppTheme(nightMode.toInt())
}
}
var appColor by remember(keyColor) {
mutableIntStateOf(sharedPreferences.getInt(keyColor, Color.BLUE))
}
var offlineMode by remember(keyOfflineMode) {
mutableStateOf(sharedPreferences.getBoolean(keyOfflineMode, false))
}
var showArchived by remember(keyShowArchived) {
mutableStateOf(sharedPreferences.getBoolean(keyShowArchived, false))
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.action_settings)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
},
backgroundColor = MaterialTheme.colors.primary,
elevation = 0.dp
)
}
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
// Appearance
SettingsCategory(stringResource(R.string.settings_appearance_category))
SettingsSwitchPreference(
title = stringResource(R.string.setting_show_archived),
icon = Icons.Default.Archive,
checked = showArchived,
onCheckedChange = {
showArchived = it
sharedPreferences.edit {
putBoolean(keyShowArchived, it)
if (!it) {
val selectedProjectId = sharedPreferences.getLong("selected_project", 0)
if (selectedProjectId != 0L) {
val db = CowspentSQLiteOpenHelper.getInstance(context)
val project = db.getProject(selectedProjectId)
if (project?.isArchived == true) {
putLong("selected_project", 0)
}
}
}
}
}
)
SettingsListPreference(
title = stringResource(R.string.settings_night_mode_title),
icon = Icons.Default.Brightness2,
value = nightMode,
entries = mapOf(
valNightModeSystem to stringResource(R.string.pref_value_theme_system),
valNightModeNo to stringResource(R.string.pref_value_theme_light),
valNightModeYes to stringResource(R.string.pref_value_theme_dark)
),
onValueChange = {
nightMode = it
sharedPreferences.edit {
putString(keyNightMode, it)
}
onNightModeChanged(it)
}
)
SettingsListPreference(
title = stringResource(R.string.settings_color_mode_title),
icon = Icons.Default.Palette,
value = colorMode,
entries = mapOf(
"system" to stringResource(R.string.pref_value_color_system),
"server" to stringResource(R.string.pref_value_color_server),
"manual" to stringResource(R.string.pref_value_color_manual)
),
onValueChange = { mode ->
colorMode = mode
sharedPreferences.edit {
putString(keyColorMode, mode)
// Keep legacy flags in sync just in case
putBoolean(keyUseServerColor, mode == "server")
putBoolean(keyUseSystemColor, mode == "system")
}
if (mode == "server") {
CowspentSQLiteOpenHelper.getInstance(context).cowspentServerSyncHelper.runAccountProjectsSync()
}
onColorSelected(ColorUtils.primaryColor(context))
}
)
if (colorMode == "manual") {
SettingsColorPreference(
title = stringResource(R.string.settings_color_title),
summary = stringResource(R.string.settings_color_summary),
icon = Icons.Default.Palette,
initialColor = appColor,
onColorSelected = {
appColor = it
sharedPreferences.edit {
putInt(keyColor, it)
}
onColorSelected(it)
}
)
}
// Network
SettingsCategory(stringResource(R.string.settings_network_category))
SettingsSwitchPreference(
title = stringResource(R.string.settings_offline_mode_title),
summary = stringResource(R.string.settings_offline_mode_summary),
icon = Icons.Default.Sync,
checked = offlineMode,
onCheckedChange = {
offlineMode = it
sharedPreferences.edit {
putBoolean(keyOfflineMode, it)
}
}
)
SettingsPreference(
title = stringResource(R.string.settings_server_settings),
icon = Icons.Default.AccountCircle,
onClick = onAccountSettingsClick
)
// Other
SettingsCategory(stringResource(R.string.settings_other_category))
SettingsPreference(
title = stringResource(R.string.settings_about),
icon = Icons.Default.Info,
onClick = onAboutClick
)
}
}
}
@Composable
fun SettingsCategory(title: String) {
Text(
text = title.uppercase(),
modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 8.dp),
color = MaterialTheme.colors.primary,
style = MaterialTheme.typography.overline,
fontWeight = FontWeight.Bold
)
}
@Composable
fun SettingsPreference(
title: String,
summary: String? = null,
icon: Any? = null,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(16.dp, 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
SettingsIcon(icon)
Spacer(modifier = Modifier.width(32.dp))
Column {
Text(text = title, style = MaterialTheme.typography.subtitle1)
if (summary != null) {
Text(text = summary, style = MaterialTheme.typography.caption)
}
}
}
}
@Composable
fun SettingsSwitchPreference(
title: String,
summary: String? = null,
icon: Any? = null,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onCheckedChange(!checked) }
.padding(16.dp, 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
SettingsIcon(icon)
Spacer(modifier = Modifier.width(32.dp))
Column(modifier = Modifier.weight(1f)) {
Text(text = title, style = MaterialTheme.typography.subtitle1)
if (summary != null) {
Text(text = summary, style = MaterialTheme.typography.caption)
}
}
Switch(
checked = checked,
onCheckedChange = onCheckedChange,
colors = SwitchDefaults.colors(
uncheckedThumbColor = MaterialTheme.colors.onSurface
)
)
}
}
@Composable
fun SettingsListPreference(
title: String,
icon: Any? = null,
value: String,
entries: Map<String, String>,
onValueChange: (String) -> Unit
) {
var showDialog by remember { mutableStateOf(false) }
SettingsPreference(
title = title,
summary = entries[value],
icon = icon,
onClick = { showDialog = true }
)
if (showDialog) {
AlertDialog(
onDismissRequest = { showDialog = false },
title = { Text(title) },
text = {
Column {
entries.forEach { (key, label) ->
Row(
Modifier.fillMaxWidth()
.selectable(
selected = (key == value),
onClick = {
onValueChange(key)
showDialog = false
}
),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = (key == value),
onClick = {
onValueChange(key)
showDialog = false
}
)
Spacer(Modifier.width(8.dp))
Text(text = label)
}
}
}
},
confirmButton = {
TextButton(onClick = { showDialog = false }) {
Text(stringResource(R.string.simple_cancel))
}
}
)
}
}
@Composable
fun SettingsColorPreference(
title: String,
summary: String? = null,
icon: Any? = null,
initialColor: Int,
onColorSelected: (Int) -> Unit
) {
var showDialog by remember { mutableStateOf(false) }
var tempColor by remember { mutableIntStateOf(initialColor) }
SettingsPreference(
title = title,
summary = summary,
icon = icon,
onClick = {
tempColor = initialColor
showDialog = true
}
)
if (showDialog) {
AlertDialog(
onDismissRequest = { showDialog = false },
title = { Text(title) },
text = {
ColorPicker(initialColor = tempColor) {
tempColor = it
}
},
confirmButton = {
TextButton(onClick = {
onColorSelected(tempColor)
showDialog = false
}) {
Text(stringResource(R.string.simple_ok))
}
},
dismissButton = {
TextButton(onClick = { showDialog = false }) {
Text(stringResource(R.string.simple_cancel))
}
}
)
}
}
@Composable
fun SettingsIcon(icon: Any?) {
Box(modifier = Modifier.size(24.dp), contentAlignment = Alignment.Center) {
when (icon) {
is ImageVector -> Icon(icon, contentDescription = null, tint = MaterialTheme.colors.onSurface.copy(alpha = 0.6f))
is Painter -> Icon(icon, contentDescription = null, tint = MaterialTheme.colors.onSurface.copy(alpha = 0.6f))
}
}
}
@Preview(showBackground = true)
@Composable
fun SettingsScreenPreview() {
MaterialTheme {
SettingsScreen(
onBack = {},
onAccountSettingsClick = {},
onAboutClick = {},
onColorSelected = {},
onNightModeChanged = {}
)
}
}

View File

@@ -0,0 +1,469 @@
package net.helcel.cowspent.android.statistics
import android.annotation.SuppressLint
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Group
import androidx.compose.runtime.*
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.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.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 net.helcel.cowspent.R
import net.helcel.cowspent.android.helper.*
import net.helcel.cowspent.model.DBBill
import net.helcel.cowspent.model.DBCategory
import net.helcel.cowspent.model.DBMember
import androidx.core.graphics.toColorInt
@OptIn(ExperimentalMaterialApi::class)
@SuppressLint("UseKtx")
@Composable
fun ProjectSankeyDiagram(
projectName: String,
allMembers: List<DBMember>,
allBills: List<DBBill>,
customCategories: List<DBCategory>,
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
}
}
val membersMap = remember(allMembers) { allMembers.associateBy { it.id } }
val categoriesMap = remember(customCategories) { customCategories.associateBy { it.remoteId.toInt() } }
if (activeBills.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("No data to display", style = MaterialTheme.typography.h6)
}
return
}
// Consolidated spending calculation
val spendings = remember(activeBills, selectedMemberId, membersMap) {
val spentMap = mutableMapOf<Long, Double>()
val catMap = mutableMapOf<Int, Double>()
membersMap.keys.forEach { spentMap[it] = 0.0 }
activeBills.forEach { bill ->
val totalWeight = bill.billOwers.sumOf { bo ->
membersMap[bo.memberId]?.weight ?: 1.0
}
if (totalWeight > 0) {
if (selectedMemberId == -1L) {
catMap[bill.categoryRemoteId] = (catMap[bill.categoryRemoteId] ?: 0.0) + bill.amount
bill.billOwers.forEach { bo ->
val weight = membersMap[bo.memberId]?.weight ?: 1.0
val share = (bill.amount / totalWeight) * weight
spentMap[bo.memberId] = (spentMap[bo.memberId] ?: 0.0) + share
}
} else {
bill.billOwers.find { it.memberId == selectedMemberId }?.let { bo ->
val weight = membersMap[bo.memberId]?.weight ?: 1.0
val share = (bill.amount / totalWeight) * weight
catMap[bill.categoryRemoteId] = (catMap[bill.categoryRemoteId] ?: 0.0) + share
spentMap[selectedMemberId] = (spentMap[selectedMemberId] ?: 0.0) + share
}
}
}
}
val memberList = spentMap.toList().filter { it.second > 0 }.sortedByDescending { it.second }
val categoryList = catMap.toList().sortedByDescending { it.second }
memberList to categoryList
}
val displayMemberSpendings = spendings.first
val displayCategorySpendings = spendings.second
val totalAmount = remember(displayMemberSpendings) { displayMemberSpendings.sumOf { it.second } }
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")
}
}
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)
}
}
}
)
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
}
}
}
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
) {
val path = Path().apply {
moveTo(startX, startY)
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
)
close()
}
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 = {}
)
}
}

View File

@@ -0,0 +1,436 @@
package net.helcel.cowspent.android.statistics
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ShowChart
import androidx.compose.material.icons.filled.Timeline
import androidx.compose.runtime.*
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.graphics.*
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLocale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import net.helcel.cowspent.R
import net.helcel.cowspent.android.helper.formatShortValue
import net.helcel.cowspent.model.DBBill
import net.helcel.cowspent.model.DBMember
import java.text.SimpleDateFormat
import java.util.*
import kotlin.math.max
enum class SpendingTimeView(val label: String) {
WEEKLY("Weekly"),
MONTHLY("Monthly"),
YEARLY("Yearly")
}
@Composable
fun ProjectSpendingGraph(
projectName: String,
allMembers: List<DBMember>,
allBills: List<DBBill>,
onShareReady: (String) -> Unit
) {
val shareStatsIntro = stringResource(R.string.share_stats_intro, projectName)
if (allBills.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("No data to display", style = MaterialTheme.typography.h6)
}
return
}
var timeView by remember { mutableStateOf(SpendingTimeView.MONTHLY) }
var showTotal by remember { mutableStateOf(false) }
var showMovingAverage by remember { mutableStateOf(true) }
val sortedBills = remember(allBills) { allBills.sortedBy { it.timestamp } }
val projectMaxTimestamp = sortedBills.last().timestamp
// Period generation logic
val periods = remember(timeView, sortedBills.first().timestamp, projectMaxTimestamp) {
val cal = Calendar.getInstance()
cal.timeInMillis = sortedBills.first().timestamp * 1000
cal.set(Calendar.HOUR_OF_DAY, 0)
cal.set(Calendar.MINUTE, 0)
cal.set(Calendar.SECOND, 0)
cal.set(Calendar.MILLISECOND, 0)
when (timeView) {
SpendingTimeView.WEEKLY -> cal.set(Calendar.DAY_OF_WEEK, cal.firstDayOfWeek)
SpendingTimeView.MONTHLY -> cal.set(Calendar.DAY_OF_MONTH, 1)
SpendingTimeView.YEARLY -> cal.set(Calendar.DAY_OF_YEAR, 1)
}
val list = mutableListOf<Long>()
val endLimit = projectMaxTimestamp * 1000
while (cal.timeInMillis <= endLimit) {
list.add(cal.timeInMillis / 1000)
when (timeView) {
SpendingTimeView.WEEKLY -> cal.add(Calendar.WEEK_OF_YEAR, 1)
SpendingTimeView.MONTHLY -> cal.add(Calendar.MONTH, 1)
SpendingTimeView.YEARLY -> cal.add(Calendar.YEAR, 1)
}
}
list
}
// Consolidated spending data calculation
val chartData = remember(allMembers, sortedBills, periods, showTotal) {
val memberSpending = mutableMapOf<Long, List<Double>>()
allMembers.forEach { member ->
val spending = mutableListOf<Double>()
for (i in periods.indices) {
val start = periods[i]
val end = if (i + 1 < periods.size) periods[i + 1] else Long.MAX_VALUE
val amount = sortedBills.asSequence()
.filter { it.timestamp in start..<end && it.payerId == member.id }
.sumOf { it.amount }
spending.add(amount)
}
memberSpending[member.id] = spending
}
// EMA Trend Line calculation
val alpha = 0.7
var lastEma = 0.0
val trend = periods.indices.map { i ->
val totalInPeriod = allMembers.sumOf { memberSpending[it.id]?.get(i) ?: 0.0 }
val currentMetric = if (showTotal) totalInPeriod else totalInPeriod / allMembers.size.coerceAtLeast(1)
lastEma = if (i == 0) currentMetric else alpha * currentMetric + (1 - alpha) * lastEma
lastEma
}
val maxBar = if (showTotal) {
periods.indices.maxOfOrNull { i ->
allMembers.sumOf { memberSpending[it.id]?.get(i) ?: 0.0 }
} ?: 1.0
} else {
periods.indices.maxOfOrNull { i ->
allMembers.maxOfOrNull { memberSpending[it.id]?.get(i) ?: 0.0 } ?: 0.0
} ?: 1.0
}
val maxVal = maxOf(maxBar, trend.maxOrNull() ?: 0.0).coerceAtLeast(1.0)
Triple(memberSpending, trend, maxVal)
}
val memberSpendingByPeriod = chartData.first
val trendLine = chartData.second
val maxSpendingInPeriod = chartData.third
LaunchedEffect(memberSpendingByPeriod, periods, timeView, projectName) {
val statsText = StringBuilder()
statsText.append(shareStatsIntro).append("\n\n")
statsText.append("Spending Trend (${timeView.label}):\n")
periods.indices.forEach { i ->
val timestamp = periods[i]
val dateStr = SimpleDateFormat("yyyy-MM-dd", Locale.ROOT).format(Date(timestamp * 1000))
val total = allMembers.sumOf { memberSpendingByPeriod[it.id]?.get(i) ?: 0.0 }
if (total > 0) {
statsText.append("- $dateStr: ${total.toInt()}\n")
}
}
val grandTotal = allMembers.sumOf { m -> memberSpendingByPeriod[m.id]?.sum() ?: 0.0 }
statsText.append("\nTotal: ${grandTotal.toInt()}")
onShareReady(statsText.toString())
}
Column(modifier = Modifier.padding(16.dp).fillMaxSize()) {
Card(
elevation = 4.dp,
shape = MaterialTheme.shapes.medium,
modifier = Modifier.weight(1f).fillMaxWidth()
) {
BoxWithConstraints(modifier = Modifier.fillMaxSize().padding(12.dp)) {
val totalAvailableWidth = constraints.maxWidth.toFloat()
val totalHeight = constraints.maxHeight.toFloat()
val yAxisWidth = 44.dp
val xAxisHeight = 32.dp
val density = LocalDensity.current
val yAxisWidthPx = with(density) { yAxisWidth.toPx() }
val xAxisHeightPx = with(density) { xAxisHeight.toPx() }
val chartHeight = totalHeight - xAxisHeightPx
// Y-Axis Labels
Box(modifier = Modifier.width(yAxisWidth).height(with(density) { chartHeight.toDp() })) {
for (i in 0..5) {
val value = (i.toFloat() / 5) * maxSpendingInPeriod
val yOffset = chartHeight - (i.toFloat() / 5) * chartHeight
Text(
text = formatShortValue(value),
fontSize = 10.sp,
color = Color.Gray,
textAlign = TextAlign.End,
modifier = Modifier
.fillMaxWidth()
.offset(y = with(density) { (yOffset - 6.sp.toPx()).toDp() })
.padding(end = 8.dp)
)
}
}
val scrollState = rememberScrollState()
LaunchedEffect(scrollState.maxValue) {
if (scrollState.maxValue > 0) {
scrollState.scrollTo(scrollState.maxValue)
}
}
val minPeriodWidth = when(timeView) {
SpendingTimeView.WEEKLY -> 48.dp
SpendingTimeView.MONTHLY -> 72.dp
SpendingTimeView.YEARLY -> 110.dp
}
val contentWidth = max(totalAvailableWidth - yAxisWidthPx, with(density) { periods.size * minPeriodWidth.toPx() })
Box(
modifier = Modifier
.offset(x = yAxisWidth)
.width(with(density) { (totalAvailableWidth - yAxisWidthPx).toDp() })
.horizontalScroll(scrollState)
) {
Column(modifier = Modifier.width(with(density) { contentWidth.toDp() })) {
// Chart area
val primaryColor = MaterialTheme.colors.primary
Canvas(
modifier = Modifier
.fillMaxWidth()
.height(with(density) { chartHeight.toDp() })
) {
val gridColor = Color.Gray.copy(alpha = 0.15f)
for (i in 0..5) {
val y = size.height - (i.toFloat() / 5) * size.height
drawLine(
color = gridColor,
start = Offset(0f, y),
end = Offset(size.width, y),
strokeWidth = 1.dp.toPx()
)
}
val fullWidthPerPeriod = size.width / periods.size
val groupWidth = fullWidthPerPeriod * 0.75f
val groupSpacing = fullWidthPerPeriod * 0.25f
periods.indices.forEach { i ->
if (showTotal) {
val totalAmount = allMembers.sumOf { memberSpendingByPeriod[it.id]?.get(i) ?: 0.0 }
if (totalAmount > 0) {
val barHeight = (totalAmount.toFloat() / maxSpendingInPeriod.toFloat()) * size.height
val x = i * fullWidthPerPeriod + groupSpacing / 2
drawRoundRect(
color = primaryColor.copy(alpha = 0.8f),
topLeft = Offset(x, size.height - barHeight),
size = Size(groupWidth, barHeight),
cornerRadius = CornerRadius(4.dp.toPx(), 4.dp.toPx())
)
}
} else {
val activeMembers = allMembers.filter { (memberSpendingByPeriod[it.id]?.get(i) ?: 0.0) > 0 }
if (activeMembers.isNotEmpty()) {
val memberBarWidth = groupWidth / allMembers.size
val groupStartX = i * fullWidthPerPeriod + groupSpacing / 2
val totalActiveWidth = activeMembers.size * memberBarWidth
val centeringOffset = (groupWidth - totalActiveWidth) / 2
activeMembers.forEachIndexed { activeIndex, member ->
val amount = memberSpendingByPeriod[member.id]!![i]
val barHeight = (amount.toFloat() / maxSpendingInPeriod.toFloat()) * size.height
val x = groupStartX + centeringOffset + activeIndex * memberBarWidth
drawRoundRect(
color = Color(android.graphics.Color.rgb(member.r ?: 0, member.g ?: 0, member.b ?: 0)),
topLeft = Offset(x, size.height - barHeight),
size = Size(memberBarWidth * 0.85f, barHeight),
cornerRadius = CornerRadius(2.dp.toPx(), 2.dp.toPx())
)
}
}
}
}
if (showMovingAverage && periods.isNotEmpty()) {
val linePath = Path()
trendLine.forEachIndexed { i, value ->
val x = i * fullWidthPerPeriod + fullWidthPerPeriod / 2f
val y = size.height - (value.toFloat() / maxSpendingInPeriod.toFloat()) * size.height
if (i == 0) {
linePath.moveTo(x, y)
} else {
val prevX = (i - 1) * fullWidthPerPeriod + fullWidthPerPeriod / 2f
val prevY = size.height - (trendLine[i-1].toFloat() / maxSpendingInPeriod.toFloat()) * size.height
linePath.cubicTo(
prevX + (x - prevX) / 2f, prevY,
prevX + (x - prevX) / 2f, y,
x, y
)
}
}
drawPath(
path = linePath,
color = primaryColor,
style = Stroke(
width = 2.5.dp.toPx(),
cap = StrokeCap.Round,
join = StrokeJoin.Round
)
)
}
}
Box(modifier = Modifier.fillMaxWidth().height(xAxisHeight)) {
val locale = LocalLocale.current.platformLocale
val labelIndices = when(timeView) {
SpendingTimeView.YEARLY -> periods.indices.toList()
SpendingTimeView.MONTHLY -> periods.indices.filter { it % 2 == 0 || it == periods.size - 1 }
SpendingTimeView.WEEKLY -> periods.indices.filter { it % 4 == 0 || it == periods.size - 1 }
}
labelIndices.forEach { index ->
val timestamp = periods[index]
val cal = Calendar.getInstance().apply { timeInMillis = timestamp * 1000 }
val dateStr = when (timeView) {
SpendingTimeView.WEEKLY -> {
val week = cal.get(Calendar.WEEK_OF_YEAR)
val year = cal.get(Calendar.YEAR) % 100
String.format(Locale.ROOT, "%02d/%02d", week, year)
}
SpendingTimeView.MONTHLY -> SimpleDateFormat("MM/yy", locale).format(Date(timestamp * 1000))
SpendingTimeView.YEARLY -> "${cal.get(Calendar.YEAR)}"
}
val fullWidthPerPeriod = contentWidth / periods.size
val xPosition = index * fullWidthPerPeriod + (fullWidthPerPeriod / 2)
Text(
text = dateStr,
fontSize = 10.sp,
color = Color.Gray,
textAlign = TextAlign.Center,
modifier = Modifier
.offset(x = with(density) { (xPosition - 40.dp.toPx()).toDp() })
.width(80.dp)
)
}
}
}
}
}
}
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.background(Color.Black.copy(alpha = 0.05f), MaterialTheme.shapes.medium)
.border(1.dp, Color.Black.copy(alpha = 0.1f), MaterialTheme.shapes.medium)
) {
Row(modifier = Modifier.padding(4.dp)) {
SpendingTimeView.entries.forEach { view ->
val isSelected = timeView == view
Box(
modifier = Modifier
.background(
color = if (isSelected) MaterialTheme.colors.primary else Color.Transparent,
shape = MaterialTheme.shapes.medium
)
.clickable { timeView = view }
.padding(horizontal = 12.dp, vertical = 6.dp)
) {
Text(
text = view.label,
fontSize = 12.sp,
color = if (isSelected) MaterialTheme.colors.onPrimary else Color.Gray,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal
)
}
}
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = { showMovingAverage = !showMovingAverage }) {
Icon(
if (showMovingAverage) Icons.Default.Timeline else Icons.AutoMirrored.Filled.ShowChart,
contentDescription = "Trend",
tint = if (showMovingAverage) MaterialTheme.colors.primary else Color.Gray
)
}
Spacer(Modifier.width(4.dp))
Text("Total", style = MaterialTheme.typography.caption, color = Color.Gray)
Switch(
checked = showTotal,
onCheckedChange = { showTotal = it },
colors = SwitchDefaults.colors(checkedThumbColor = MaterialTheme.colors.primary)
)
}
}
Spacer(modifier = Modifier.height(16.dp))
if (!showTotal) {
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 100.dp)
.verticalScroll(scrollState)
) {
allMembers.chunked(3).forEach { rowMembers ->
Row(modifier = Modifier.fillMaxWidth()) {
rowMembers.forEach { member ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f).padding(bottom = 8.dp)
) {
Box(
modifier = Modifier
.size(12.dp)
.background(
Color(android.graphics.Color.rgb(member.r ?: 0, member.g ?: 0, member.b ?: 0)),
shape = MaterialTheme.shapes.small
)
)
Spacer(modifier = Modifier.width(8.dp))
Text(member.name, fontSize = 12.sp, maxLines = 1)
}
}
if (rowMembers.size == 1) Spacer(modifier = Modifier.weight(1f))
}
}
}
}
}
}
@Preview(showBackground = true)
@Composable
fun ProjectSpendingGraphPreview() {
MaterialTheme {
ProjectSpendingGraph(
projectName = "Test Project",
allMembers = StatisticsMockData.members,
allBills = StatisticsMockData.bills,
onShareReady = {}
)
}
}

View File

@@ -0,0 +1,56 @@
package net.helcel.cowspent.android.statistics
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.theme.ThemeUtils
class ProjectStatisticsActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val projectId = intent.getLongExtra(EXTRA_PROJECT_ID, -1L)
if (projectId == -1L) {
finish()
return
}
val db = CowspentSQLiteOpenHelper.getInstance(this)
lifecycleScope.launch {
val proj = withContext(Dispatchers.IO) { db.getProject(projectId) }
if (proj == null) {
finish()
return@launch
}
setContent {
ThemeUtils.CowspentTheme {
ProjectStatisticsScreen(
proj = proj,
db = db,
onBack = { finish() }
)
}
}
}
}
companion object {
private const val EXTRA_PROJECT_ID = "extra_project_id"
fun createIntent(context: Context, projectId: Long): Intent {
return Intent(context, ProjectStatisticsActivity::class.java).apply {
putExtra(EXTRA_PROJECT_ID, projectId)
}
}
}
}

View File

@@ -0,0 +1,48 @@
package net.helcel.cowspent.android.statistics
import net.helcel.cowspent.model.DBBill
import net.helcel.cowspent.model.DBBillOwer
import net.helcel.cowspent.model.DBMember
import net.helcel.cowspent.model.DBProject
import net.helcel.cowspent.model.ProjectType
object StatisticsMockData {
val project = DBProject(
id = 1L,
remoteId = "vacation",
password = "",
name = "Vacation 2024",
serverUrl = null,
email = null,
lastPayerId = 1L,
type = ProjectType.LOCAL,
lastSyncedTimestamp = null,
currencyName = "EUR",
isDeletionDisabled = false,
myAccessLevel = 4,
bearerToken = null,
archivedTs = null
)
val members = listOf(
DBMember(1L, 0, 1L, "Alice", true, 1.0, 0, 255, 100, 100, null, null),
DBMember(2L, 0, 1L, "Bob", true, 1.0, 0, 100, 255, 100, null, null),
DBMember(3L, 0, 1L, "Charlie", true, 1.0, 0, 100, 100, 255, null, null)
)
val bills = listOf(
DBBill(1L, 0, 1L, 1L, 120.5, System.currentTimeMillis() / 1000 - 86400 * 10, "Hotel", 0, null, null, -13, "", -1).apply {
billOwers = listOf(DBBillOwer(1L, 1L, 1L), DBBillOwer(2L, 1L, 2L), DBBillOwer(3L, 1L, 3L))
},
DBBill(2L, 0, 1L, 2L, 50.0, System.currentTimeMillis() / 1000 - 86400 * 8, "Groceries", 0, null, null, -1, "", -2).apply {
billOwers = listOf(DBBillOwer(4L, 2L, 1L), DBBillOwer(5L, 2L, 2L), DBBillOwer(6L, 2L, 3L))
},
DBBill(3L, 0, 1L, 3L, 1.0, System.currentTimeMillis() / 1000 - 86400 * 5, "Gas", 0, null, null, -14, "", -1).apply {
billOwers = listOf(DBBillOwer(7L, 3L, 1L), DBBillOwer(8L, 3L, 2L), DBBillOwer(9L, 3L, 3L))
},
DBBill(4L, 0, 1L, 1L, 30.0, System.currentTimeMillis() / 1000 - 86400 * 2, "Dinner", 0, null, null, -12, "", -2).apply {
billOwers = listOf(DBBillOwer(10L, 4L, 1L), DBBillOwer(11L, 4L, 2L))
}
)
}

View File

@@ -0,0 +1,155 @@
package net.helcel.cowspent.android.statistics
import android.content.Intent
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Share
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.Alignment
import androidx.core.content.edit
import androidx.preference.PreferenceManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import net.helcel.cowspent.R
import net.helcel.cowspent.model.*
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.util.CategoryUtils
@Composable
fun ProjectStatisticsScreen(
proj: DBProject,
db: CowspentSQLiteOpenHelper,
onBack: () -> Unit
) {
val context = LocalContext.current
val prefs = remember { PreferenceManager.getDefaultSharedPreferences(context) }
var selectedTab by rememberSaveable {
mutableIntStateOf(prefs.getInt("last_statistics_tab", 0))
}
var currentShareText by remember { mutableStateOf("") }
val tabs = listOf(
stringResource(R.string.statistic_title),
"Trend",
"Sankey"
)
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.statistic_title)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
},
actions = {
IconButton(onClick = {
if (currentShareText.isNotEmpty()) {
val sendIntent = Intent().apply {
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_TEXT, currentShareText)
type = "text/plain"
}
context.startActivity(Intent.createChooser(sendIntent, null))
}
}) {
Icon(Icons.Default.Share, contentDescription = "Share")
}
},
backgroundColor = MaterialTheme.colors.primary,
elevation = 0.dp
)
}
) { padding ->
Column(modifier = Modifier.padding(padding).fillMaxSize()) {
TabRow(selectedTabIndex = selectedTab) {
tabs.forEachIndexed { index, title ->
Tab(
selected = selectedTab == index,
onClick = {
selectedTab = index
prefs.edit { putInt("last_statistics_tab", index) }
},
text = { Text(title) }
)
}
}
val statsData by produceState<StatisticsData?>(null, proj.id) {
value = withContext(Dispatchers.IO) {
val members = db.getMembersOfProject(proj.id, null)
val bills = db.getBillsOfProject(proj.id)
val categories = db.getCategories(proj.id)
val paymentModes = db.getPaymentModes(proj.id)
StatisticsData(members, bills, categories, paymentModes)
}
}
if (statsData != null) {
val data = statsData!!
val defaultCategories = remember(proj.id) { CategoryUtils.getDefaultCategories(context, proj.id) }
val categories = remember(proj.type, data.categories, defaultCategories) {
val hardcoded = if (proj.type == ProjectType.LOCAL) {
defaultCategories
} else {
listOfNotNull(defaultCategories.find { it.remoteId.toInt() == DBBill.CATEGORY_REIMBURSEMENT })
}
(data.categories + hardcoded).distinctBy { it.remoteId }
}
val categoryNoneLabel = stringResource(R.string.category_none)
val sankeyCategories = remember(proj.id, data.categories, defaultCategories, categoryNoneLabel) {
val noneCategory = DBCategory(0, 0, proj.id, categoryNoneLabel, "", "#9E9E9E")
(data.categories + defaultCategories + noneCategory).distinctBy { it.remoteId }
}
when (selectedTab) {
0 -> {
ProjectStatisticsTable(
proj = proj,
allMembers = data.members,
allBills = data.bills,
customCategories = categories,
customPaymentModes = data.paymentModes,
onShareReady = { currentShareText = it }
)
}
1 -> {
ProjectSpendingGraph(
projectName = proj.name.ifEmpty { proj.remoteId },
allMembers = data.members,
allBills = data.bills,
onShareReady = { currentShareText = it }
)
}
2 -> {
ProjectSankeyDiagram(
projectName = proj.name.ifEmpty { proj.remoteId },
allMembers = data.members,
allBills = data.bills,
customCategories = sankeyCategories,
onShareReady = { currentShareText = it }
)
}
}
} else {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
}
}
}
data class StatisticsData(
val members: List<DBMember>,
val bills: List<DBBill>,
val categories: List<DBCategory>,
val paymentModes: List<DBPaymentMode>
)

View File

@@ -0,0 +1,390 @@
package net.helcel.cowspent.android.statistics
import android.app.DatePickerDialog
import android.content.Context
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import net.helcel.cowspent.R
import net.helcel.cowspent.android.helper.*
import net.helcel.cowspent.model.*
import net.helcel.cowspent.util.CategoryUtils
import net.helcel.cowspent.util.SupportUtil
import java.text.SimpleDateFormat
import java.util.*
import kotlin.math.abs
import kotlin.math.round
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun ProjectStatisticsTable(
proj: DBProject,
allMembers: List<DBMember>,
allBills: List<DBBill>,
customCategories: List<DBCategory>,
customPaymentModes: List<DBPaymentMode>,
onShareReady: (String) -> Unit
) {
val context = LocalContext.current
val sdf = remember { SimpleDateFormat("yyyy-MM-dd", Locale.ROOT) }
val dateFormat = remember { android.text.format.DateFormat.getDateFormat(context) }
var categoryId by remember { mutableIntStateOf(-1000) }
var paymentModeId by remember { mutableIntStateOf(-1000) }
var dateMin by remember { mutableStateOf<String?>(null) }
var dateMax by remember { mutableStateOf<String?>(null) }
val categoryAll = stringResource(R.string.category_all)
val categoryNone = stringResource(R.string.category_none)
val categoryReimbursement = stringResource(R.string.category_reimbursement)
val categoryAllExceptReimbursement = stringResource(R.string.category_all_except_reimbursement)
val paymentModeAll = stringResource(R.string.payment_mode_all)
val paymentModeNone = stringResource(R.string.payment_mode_none)
val shareStatsHeader = stringResource(R.string.share_stats_header)
val shareStatsIntro = stringResource(R.string.share_stats_intro, proj.name.ifEmpty { proj.remoteId })
val categories = remember(proj.id, customCategories, categoryAll, categoryNone, categoryReimbursement, categoryAllExceptReimbursement) {
val list = mutableListOf<Triple<Int, String, String>>()
list.add(Triple(-1000, "📋", categoryAll))
list.add(Triple(-100, "🧾", categoryAllExceptReimbursement))
list.add(Triple(0, "", categoryNone))
val catsToUse = if (proj.type == ProjectType.LOCAL) {
CategoryUtils.getDefaultCategories(context, proj.id)
} else {
customCategories.ifEmpty {
CategoryUtils.getDefaultCategories(context, proj.id)
}
}
catsToUse.forEach {
list.add(Triple(it.remoteId.toInt(), it.icon, it.name ?: ""))
}
list.distinctBy { it.first }
}
val paymentModes = remember(proj.id, customPaymentModes, paymentModeAll, paymentModeNone) {
val list = mutableListOf<Triple<Int, String, String>>()
list.add(Triple(-1000, "💳", paymentModeAll))
list.add(Triple(0, "", paymentModeNone))
val pmsToUse = if (proj.type == ProjectType.LOCAL) {
CategoryUtils.getDefaultPaymentModes(context, proj.id)
} else {
customPaymentModes.ifEmpty {
CategoryUtils.getDefaultPaymentModes(context, proj.id)
}
}
pmsToUse.forEach {
list.add(Triple(it.remoteId.toInt(), it.icon, it.name ?: ""))
}
list.distinctBy { it.first }
}
val stats = remember(allMembers, allBills, categoryId, paymentModeId, dateMin, dateMax, shareStatsHeader, shareStatsIntro) {
val membersNbBills = mutableMapOf<Long, Int>()
val membersBalance = HashMap<Long, Double>()
val membersPaid = HashMap<Long, Double>()
val membersSpent = HashMap<Long, Double>()
SupportUtil.getStats(
allMembers, allBills,
membersNbBills, membersBalance, membersPaid, membersSpent,
categoryId, paymentModeId, dateMin, dateMax
)
var statsText = shareStatsIntro + "\n\n"
statsText += shareStatsHeader + "\n"
var totalPaid = 0.0
val memberStats = allMembers.map { m ->
val mPaid = membersPaid[m.id] ?: 0.0
totalPaid += mPaid
val mSpent = membersSpent[m.id] ?: 0.0
val mBalance = membersBalance[m.id] ?: 0.0
val rpaid = round(mPaid * 100.0) / 100.0
val rspent = round(mSpent * 100.0) / 100.0
val rbalance = round(abs(mBalance) * 100.0) / 100.0
val sign = if (mBalance > 0.01) "+" else if (mBalance < -0.01) "-" else ""
statsText += "\n${m.name} ("
statsText += (if (rpaid == 0.0) "--" else SupportUtil.normalNumberFormat.format(rpaid)) + " | "
statsText += (if (rspent == 0.0) "--" else SupportUtil.normalNumberFormat.format(rspent)) + " | "
statsText += "$sign${SupportUtil.normalNumberFormat.format(rbalance)})"
MemberStat(m.name, mPaid, mSpent, mBalance)
}
StatsResult(memberStats, totalPaid, statsText)
}
val dateMinLong = remember(dateMin) {
dateMin?.let { try { sdf.parse(it)?.time } catch (_: Exception) { null } }
}
val dateMaxLong = remember(dateMax) {
dateMax?.let { try { sdf.parse(it)?.time } catch (_: Exception) { null } }
}
LaunchedEffect(stats.statsText) {
onShareReady(stats.statsText)
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Card(
elevation = 2.dp,
shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(12.dp)) {
var categoryExpanded by remember { mutableStateOf(value = false) }
val selectedCategory = categories.find { it.first == categoryId }
EditableExposedDropdownMenu(
value = selectedCategory?.third ?: "",
placeholder = stringResource(R.string.setting_category),
expanded = categoryExpanded,
onExpandedChange = { categoryExpanded = it },
onDismissRequest = { categoryExpanded = false },
leadingIcon = {
Box(modifier = Modifier.padding(start = 12.dp)) {
if (selectedCategory != null) {
Text(text = selectedCategory.second, fontSize = 20.sp)
} else {
Icon(Icons.Default.Category, contentDescription = null)
}
}
},
content = {
categories.forEach { category ->
DropdownMenuItem(onClick = {
categoryId = category.first
categoryExpanded = false
}) {
Text(text = category.second, fontSize = 20.sp)
Spacer(modifier = Modifier.width(12.dp))
Text(category.third)
}
}
}
)
Spacer(modifier = Modifier.height(12.dp))
var pmExpanded by remember { mutableStateOf(false) }
val selectedPm = paymentModes.find { it.first == paymentModeId }
EditableExposedDropdownMenu(
value = selectedPm?.third ?: "",
placeholder = stringResource(R.string.setting_payment_mode),
expanded = pmExpanded,
onExpandedChange = { pmExpanded = it },
onDismissRequest = { pmExpanded = false },
leadingIcon = {
Box(modifier = Modifier.padding(start = 12.dp)) {
if (selectedPm != null) {
Text(text = selectedPm.second, fontSize = 20.sp)
} else {
Icon(Icons.Default.Payment, contentDescription = null)
}
}
},
content = {
paymentModes.forEach { pm ->
DropdownMenuItem(onClick = {
paymentModeId = pm.first
pmExpanded = false
}) {
Text(text = pm.second, fontSize = 20.sp)
Spacer(modifier = Modifier.width(12.dp))
Text(pm.third)
}
}
}
)
Spacer(modifier = Modifier.height(12.dp))
Row(modifier = Modifier.fillMaxWidth()) {
ClickableOutlinedTextField(
value = dateMin?.let { sdf.parse(it)?.let { d -> dateFormat.format(d) } } ?: "",
onClick = {
showDatePicker(context, dateMin, sdf, maxDate = dateMaxLong) { dateMin = it }
},
modifier = Modifier.weight(1f),
placeholder = { Text(stringResource(R.string.stats_date_min)) },
leadingIcon = { Icon(Icons.Default.Event, contentDescription = null) },
trailingIcon = if (dateMin != null) {
{
IconButton(onClick = { dateMin = null }) {
Icon(Icons.Default.Close, contentDescription = null, tint = Color.Red.copy(alpha = 0.6f))
}
}
} else null
)
Spacer(modifier = Modifier.width(8.dp))
ClickableOutlinedTextField(
value = dateMax?.let { sdf.parse(it)?.let { d -> dateFormat.format(d) } } ?: "",
onClick = {
showDatePicker(context, dateMax, sdf, minDate = dateMinLong) { dateMax = it }
},
modifier = Modifier.weight(1f),
placeholder = { Text(stringResource(R.string.stats_date_max)) },
leadingIcon = { Icon(Icons.Default.Event, contentDescription = null) },
trailingIcon = if (dateMax != null) {
{
IconButton(onClick = { dateMax = null }) {
Icon(Icons.Default.Close, contentDescription = null, tint = Color.Red.copy(alpha = 0.6f))
}
}
} else null
)
}
}
}
Spacer(modifier = Modifier.height(24.dp))
// Table Header
Surface(
color = MaterialTheme.colors.onSurface.copy(alpha = 0.05f),
shape = MaterialTheme.shapes.small,
modifier = Modifier.fillMaxWidth()
) {
Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp)) {
Text(stringResource(R.string.stats_who), modifier = Modifier.weight(2f), fontWeight = FontWeight.Bold, color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f), fontSize = 12.sp)
Text(stringResource(R.string.stats_paid), modifier = Modifier.weight(1.5f), fontWeight = FontWeight.Bold, textAlign = TextAlign.End, color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f), fontSize = 12.sp)
Text(stringResource(R.string.stats_spent), modifier = Modifier.weight(1.5f), fontWeight = FontWeight.Bold, textAlign = TextAlign.End, color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f), fontSize = 12.sp)
Text(stringResource(R.string.stats_balance), modifier = Modifier.weight(1.5f), fontWeight = FontWeight.Bold, textAlign = TextAlign.End, color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f), fontSize = 12.sp)
}
}
Spacer(modifier = Modifier.height(4.dp))
LazyColumn(modifier = Modifier.weight(1f)) {
items(stats.memberStats) { m ->
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp, horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically) {
Text(m.name, modifier = Modifier.weight(2f), color = MaterialTheme.colors.onSurface, fontWeight = FontWeight.Medium)
Text(
if (m.paid == 0.0) "--" else SupportUtil.normalNumberFormat.format(m.paid),
modifier = Modifier.weight(1.5f),
textAlign = TextAlign.End,
color = MaterialTheme.colors.onSurface
)
Text(
if (m.spent == 0.0) "--" else SupportUtil.normalNumberFormat.format(m.spent),
modifier = Modifier.weight(1.5f),
textAlign = TextAlign.End,
color = MaterialTheme.colors.onSurface
)
val balanceColor = if (m.balance > 0.01) colorResource(R.color.green) else if (m.balance < -0.01) colorResource(R.color.red) else MaterialTheme.colors.onSurface
val sign = if (m.balance > 0.01) "+" else if (m.balance < -0.01) "-" else ""
Text(
"$sign${SupportUtil.normalNumberFormat.format(abs(m.balance))}",
modifier = Modifier.weight(1.5f),
textAlign = TextAlign.End,
color = balanceColor,
fontWeight = FontWeight.Bold
)
}
Divider(thickness = 0.5.dp, color = MaterialTheme.colors.onSurface.copy(alpha = 0.05f))
}
}
Card(
elevation = 4.dp,
shape = MaterialTheme.shapes.medium,
backgroundColor = MaterialTheme.colors.primary,
modifier = Modifier.fillMaxWidth()
) {
Box(
modifier = Modifier.padding(16.dp, 12.dp),
contentAlignment = Alignment.Center
) {
Text(
text = stringResource(R.string.total, SupportUtil.normalNumberFormat.format(stats.totalPaid)),
style = MaterialTheme.typography.h6,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.onPrimary
)
}
}
}
}
private fun showDatePicker(
context: Context,
currentDate: String?,
sdf: SimpleDateFormat,
minDate: Long? = null,
maxDate: Long? = null,
onDateSelected: (String) -> Unit
) {
val calendar = Calendar.getInstance()
currentDate?.let {
try { sdf.parse(it)?.let { date -> calendar.time = date } } catch (_: Exception) {}
}
val dialog = DatePickerDialog(
context,
{ _, year, month, day ->
val cal = Calendar.getInstance().apply {
set(Calendar.YEAR, year)
set(Calendar.MONTH, month)
set(Calendar.DAY_OF_MONTH, day)
}
onDateSelected(sdf.format(cal.time))
},
calendar[Calendar.YEAR],
calendar[Calendar.MONTH],
calendar[Calendar.DAY_OF_MONTH]
)
minDate?.let { dialog.datePicker.minDate = it }
maxDate?.let { dialog.datePicker.maxDate = it }
dialog.show()
}
data class MemberStat(val name: String, val paid: Double, val spent: Double, val balance: Double)
data class StatsResult(val memberStats: List<MemberStat>, val totalPaid: Double, val statsText: String)
@Preview(showBackground = true)
@Composable
fun ProjectStatisticsTablePreview() {
MaterialTheme {
ProjectStatisticsTable(
proj = StatisticsMockData.project,
allMembers = StatisticsMockData.members,
allBills = StatisticsMockData.bills,
customCategories = emptyList(),
customPaymentModes = emptyList(),
onShareReady = {}
) }
}

View File

@@ -0,0 +1,5 @@
package net.helcel.cowspent.model
import java.io.Serializable
class Category(val memberName: String?, val memberId: Long?) : Serializable

View File

@@ -0,0 +1,3 @@
package net.helcel.cowspent.model
class CreditDebt(var memberId: Long, var balance: Double)

View File

@@ -0,0 +1,17 @@
package net.helcel.cowspent.model
import java.io.Serializable
class DBAccountProject(
var id: Long,
var remoteId: String,
var password: String?,
var name: String,
var ncUrl: String,
var archivedTs: Long? = null
) : Serializable {
override fun toString(): String {
return "#DBAccountProject$id/$remoteId,$name, $ncUrl, $password, archivedTs=$archivedTs"
}
}

View File

@@ -0,0 +1,121 @@
package net.helcel.cowspent.model
import android.util.Log
import java.io.Serializable
import java.util.Calendar
import java.util.Locale
open class DBBill(
var id: Long,
var remoteId: Long,
var projectId: Long,
var payerId: Long,
var amount: Double,
var timestamp: Long,
var what: String,
var state: Int,
var repeat: String?,
var paymentMode: String?,
var categoryRemoteId: Int,
var comment: String?,
var paymentModeRemoteId: Int
) : Item, Serializable {
var formattedWhat: String = ""
var formattedSubtitle: String = ""
var billOwers: List<DBBillOwer> = ArrayList()
val billOwersIds: List<Long>
get() {
val result: MutableList<Long> = ArrayList()
for (bo in billOwers) {
result.add(bo.memberId)
}
return result
}
val date: String
get() {
val cal = Calendar.getInstance()
cal.timeInMillis = timestamp * 1000
Log.v("ll", "[$what] get date ts $timestamp year ${cal[Calendar.YEAR]}")
val month = cal[Calendar.MONTH] + 1
val day = cal[Calendar.DAY_OF_MONTH]
return "${cal[Calendar.YEAR]}-${String.format(Locale.ROOT, "%02d", month)}-${
String.format(Locale.ROOT, "%02d", day)
}"
}
val time: String
get() {
val cal = Calendar.getInstance()
cal.timeInMillis = timestamp * 1000
return "${String.format(Locale.ROOT, "%02d", cal[Calendar.HOUR_OF_DAY])}:${
String.format(Locale.ROOT, "%02d", cal[Calendar.MINUTE])
}"
}
override fun toString(): String {
return "#DBBill$id/$remoteId,$projectId, $payerId, $amount, $timestamp, $what, $state, $repeat, $paymentMode, $categoryRemoteId"
}
override fun isSection(): Boolean {
return false
}
companion object {
const val PAYMODE_NONE = "n"
const val PAYMODE_CARD = "c"
const val PAYMODE_CASH = "b"
const val PAYMODE_CHECK = "f"
const val PAYMODE_TRANSFER = "t"
const val PAYMODE_ONLINE_SERVICE = "o"
const val PAYMODE_ID_NONE = 0
const val PAYMODE_ID_CARD = -1
const val PAYMODE_ID_CASH = -2
const val PAYMODE_ID_CHECK = -3
const val PAYMODE_ID_TRANSFER = -4
const val PAYMODE_ID_ONLINE_SERVICE = -5
@JvmField
val oldPmIdToNew: Map<String, Int> = object : HashMap<String, Int>() {
init {
put(PAYMODE_NONE, PAYMODE_ID_NONE)
put(PAYMODE_CARD, PAYMODE_ID_CARD)
put(PAYMODE_CASH, PAYMODE_ID_CASH)
put(PAYMODE_CHECK, PAYMODE_ID_CHECK)
put(PAYMODE_TRANSFER, PAYMODE_ID_TRANSFER)
put(PAYMODE_ONLINE_SERVICE, PAYMODE_ID_ONLINE_SERVICE)
}
}
const val CATEGORY_NONE = 0
const val CATEGORY_GROCERIES = -1
const val CATEGORY_LEISURE = -2
const val CATEGORY_RENT = -3
const val CATEGORY_BILLS = -4
const val CATEGORY_CULTURE = -5
const val CATEGORY_HEALTH = -6
const val CATEGORY_SHOPPING = -10
const val CATEGORY_REIMBURSEMENT = -11
const val CATEGORY_RESTAURANT = -12
const val CATEGORY_ACCOMMODATION = -13
const val CATEGORY_TRANSPORT = -14
const val CATEGORY_SPORT = -15
const val STATE_OK = 0
const val STATE_ADDED = 1
const val STATE_EDITED = 2
const val STATE_DELETED = 3
const val NON_REPEATED = "n"
const val REPEAT_DAY = "d"
const val REPEAT_WEEK = "w"
const val REPEAT_FORTNIGHT = "b"
const val REPEAT_MONTH = "m"
const val REPEAT_YEAR = "y"
}
}

View File

@@ -0,0 +1,14 @@
package net.helcel.cowspent.model
import java.io.Serializable
class DBBillOwer(
var id: Long,
var billId: Long,
var memberId: Long
) : Serializable {
override fun toString(): String {
return "#DBBillOwer$id/$billId,$memberId"
}
}

View File

@@ -0,0 +1,17 @@
package net.helcel.cowspent.model
import java.io.Serializable
class DBCategory(
var id: Long,
var remoteId: Long,
var projectId: Long,
var name: String?,
var icon: String,
var color: String
) : Serializable {
override fun toString(): String {
return "#DBCategory$id/$remoteId,$name"
}
}

View File

@@ -0,0 +1,17 @@
package net.helcel.cowspent.model
import java.io.Serializable
class DBCurrency(
var id: Long,
var remoteId: Long,
var projectId: Long,
var name: String?,
var exchangeRate: Double,
var state: Int
) : Serializable {
override fun toString(): String {
return "#DBCurrency$id/$remoteId,$name , state: $state"
}
}

View File

@@ -0,0 +1,23 @@
package net.helcel.cowspent.model
import java.io.Serializable
class DBMember(
var id: Long,
var remoteId: Long,
var projectId: Long,
var name: String,
var isActivated: Boolean,
var weight: Double,
var state: Int,
var r: Int?,
var g: Int?,
var b: Int?,
var ncUserId: String?,
var avatar: String?
) : Serializable {
override fun toString(): String {
return "#DBMember$id/$remoteId,$name, p$projectId, $weight, $isActivated"
}
}

View File

@@ -0,0 +1,17 @@
package net.helcel.cowspent.model
import java.io.Serializable
class DBPaymentMode(
var id: Long,
var remoteId: Long,
var projectId: Long,
var name: String?,
var icon: String,
var color: String
) : Serializable {
override fun toString(): String {
return "#DBPaymentMode$id/$remoteId,$name"
}
}

View File

@@ -0,0 +1,75 @@
package net.helcel.cowspent.model
import java.io.Serializable
class DBProject(
var id: Long,
var remoteId: String,
var password: String,
var name: String,
var serverUrl: String?,
var email: String?,
var lastPayerId: Long?,
var type: ProjectType,
var lastSyncedTimestamp: Long?,
var currencyName: String?,
var isDeletionDisabled: Boolean,
var myAccessLevel: Int,
var bearerToken: String?,
var archivedTs: Long? = null,
var latestBillTs: Long = 0L
) : Serializable {
val isArchived: Boolean
get() = archivedTs != null && archivedTs!! > 0
val isLocal: Boolean
get() = ProjectType.LOCAL == type
fun getRequestBaseUrl(isOcsRequest: Boolean): String {
val url = serverUrl ?: ""
return if (!isOcsRequest) {
url.replace("/+$".toRegex(), "")
} else {
url.replace("/+$".toRegex(), "")
.replace("/index.php/apps/cospend", "/ocs/v2.php/apps/cospend")
}
}
override fun toString(): String {
return "#DBProject$id/$remoteId,$name, $serverUrl, $email"
}
fun isShareable(): Boolean {
return !serverUrl.isNullOrEmpty()
}
fun getShareUrl(): String {
val url = serverUrl ?: ""
val strippedUrl = url
.replace("https://", "")
.replace("http://", "")
.replace("/index.php/apps/cospend", "")
val protocol = if (type == ProjectType.IHATEMONEY) "ihatemoney" else "cospend"
return "$protocol://$strippedUrl/$remoteId/$password"
}
fun getPublicWebUrl(): String {
val url = serverUrl ?: ""
return if (url.contains("index.php/apps/cospend")) {
"$url/loginproject/$remoteId"
} else {
"$url/$remoteId"
}
}
companion object {
const val ACCESS_LEVEL_UNKNOWN = -1
const val ACCESS_LEVEL_NONE = 0
const val ACCESS_LEVEL_VIEWER = 1
const val ACCESS_LEVEL_PARTICIPANT = 2
const val ACCESS_LEVEL_MAINTAINER = 3
const val ACCESS_LEVEL_ADMIN = 4
}
}

View File

@@ -0,0 +1,26 @@
package net.helcel.cowspent.model
import java.io.Serializable
class GroupedBill(
val sourceBills: List<DBBill>
) : DBBill(
sourceBills.first().id,
sourceBills.first().remoteId,
sourceBills.first().projectId,
sourceBills.first().payerId,
sourceBills.sumOf { it.amount },
sourceBills.first().timestamp,
sourceBills.first().what,
sourceBills.first().state,
sourceBills.first().repeat,
sourceBills.first().paymentMode,
sourceBills.first().categoryRemoteId,
sourceBills.first().comment,
sourceBills.first().paymentModeRemoteId
), Serializable {
init {
this.formattedWhat = sourceBills.first().formattedWhat
this.billOwers = sourceBills.flatMap { it.billOwers }
}
}

View File

@@ -0,0 +1,5 @@
package net.helcel.cowspent.model
interface Item {
fun isSection(): Boolean
}

View File

@@ -0,0 +1,20 @@
package net.helcel.cowspent.model
enum class ProjectType(val id: String) {
LOCAL("l"), COSPEND("c"), IHATEMONEY("i");
companion object {
private val reverseMap: Map<String, ProjectType> = HashMap()
init {
for (type in entries) {
(reverseMap as MutableMap)[type.id] = type
}
}
@JvmStatic
fun getTypeById(id: String?): ProjectType? {
return reverseMap[id]
}
}
}

View File

@@ -0,0 +1,8 @@
package net.helcel.cowspent.model
class SectionItem(var title: String) : Item {
override fun isSection(): Boolean {
return true
}
}

View File

@@ -0,0 +1,3 @@
package net.helcel.cowspent.model
class Transaction(var owerMemberId: Long, var receiverMemberId: Long, var amount: Double)

View File

@@ -0,0 +1,3 @@
package net.helcel.cowspent.model
class UserItem(var id: Long, var name: String)

View File

@@ -0,0 +1,5 @@
package net.helcel.cowspent.model.parsed
import java.util.Date
class AustrianBillQrCode(val cashDeskId: String, val date: Date, val amount: Double)

View File

@@ -0,0 +1,5 @@
package net.helcel.cowspent.model.parsed
import java.time.LocalDateTime
data class CroatianBillQrCode(val date: LocalDateTime?, val amount: Double)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,119 @@
package net.helcel.cowspent.theme
import android.annotation.SuppressLint
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.preference.PreferenceManager
import net.helcel.cowspent.util.ColorUtils
object ThemeUtils {
val Shapes = Shapes(
small = RoundedCornerShape(12.dp),
medium = RoundedCornerShape(24.dp),
large = RoundedCornerShape(28.dp)
)
@SuppressLint("ConflictingOnColor")
@Composable
fun CowspentTheme(
accentColor: Int? = null,
darkTheme: Boolean? = null,
content: @Composable () -> Unit
) {
val context = LocalContext.current
val config = LocalConfiguration.current
val sharedPreferences = remember { PreferenceManager.getDefaultSharedPreferences(context) }
val nightModeKey = stringResource(net.helcel.cowspent.R.string.pref_key_night_mode)
val resolvedDarkTheme = darkTheme ?: run {
val nightMode = sharedPreferences.getString(nightModeKey, "-1") ?: "-1"
when (nightMode) {
"1" -> false
"2" -> true
else -> isSystemInDarkTheme()
}
}
val resolvedAccentColor = accentColor ?: remember(resolvedDarkTheme) {
ColorUtils.primaryColor(context, resolvedDarkTheme)
}
val themedContext = remember(resolvedDarkTheme) {
if (resolvedDarkTheme != (config.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES)) {
config.uiMode = (config.uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or
if (resolvedDarkTheme) Configuration.UI_MODE_NIGHT_YES else Configuration.UI_MODE_NIGHT_NO
context.createConfigurationContext(config)
} else {
context
}
}
val onPrimary = if (ColorUtils.isLightColor(resolvedAccentColor)) Color.Black else Color.White
val colors = if (resolvedDarkTheme) {
darkColors(
primary = Color(resolvedAccentColor),
primaryVariant = Color(resolvedAccentColor),
onPrimary = onPrimary,
secondary = Color(resolvedAccentColor),
onSecondary = onPrimary,
background = Color(0xFF121212),
surface = Color(0xFF121212),
onBackground = Color.White,
onSurface = Color.White
)
} else {
lightColors(
primary = Color(resolvedAccentColor),
primaryVariant = Color(resolvedAccentColor),
onPrimary = onPrimary,
secondary = Color(resolvedAccentColor),
onSecondary = onPrimary,
background = Color.White,
surface = Color.White,
onBackground = Color.Black,
onSurface = Color.Black
)
}
MaterialTheme(
colors = colors,
shapes = Shapes,
content = {
CompositionLocalProvider(LocalContext provides themedContext) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background
) {
Column {
Box(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colors.primary)
.statusBarsPadding()
)
Box(modifier = Modifier.fillMaxSize()) {
content()
}
}
}
}
}
)
}
}

View File

@@ -0,0 +1,58 @@
package net.helcel.cowspent.util
import net.helcel.cowspent.model.DBBill
import net.helcel.cowspent.model.DBCategory
import net.helcel.cowspent.model.DBMember
import net.helcel.cowspent.model.DBPaymentMode
object BillFormatter {
fun formatBills(
bills: List<DBBill>,
membersMap: Map<Long, DBMember>,
categoriesMap: Map<Long, DBCategory>,
paymentModesMap: Map<Long, DBPaymentMode>
) {
for (bill in bills) {
var whatPrefix = ""
val pm = paymentModesMap[bill.paymentModeRemoteId.toLong()]
if (pm != null) {
whatPrefix += pm.icon + " "
} else {
when (bill.paymentModeRemoteId) {
DBBill.PAYMODE_ID_CARD -> whatPrefix += "\uD83D\uDCB3 "
DBBill.PAYMODE_ID_CASH -> whatPrefix += "\uD83D\uDCB5 "
DBBill.PAYMODE_ID_CHECK -> whatPrefix += "\uD83C\uDFAB "
DBBill.PAYMODE_ID_TRANSFER -> whatPrefix += ""
DBBill.PAYMODE_ID_ONLINE_SERVICE -> whatPrefix += "\uD83C\uDF0E "
}
}
val cat = categoriesMap[bill.categoryRemoteId.toLong()]
if (cat != null) {
whatPrefix += cat.icon + " "
} else {
when (bill.categoryRemoteId) {
DBBill.CATEGORY_GROCERIES -> whatPrefix += "\uD83D\uDED2 "
DBBill.CATEGORY_LEISURE -> whatPrefix += "\uD83C\uDF89 "
DBBill.CATEGORY_RENT -> whatPrefix += "\uD83C\uDFE0 "
DBBill.CATEGORY_BILLS -> whatPrefix += "\uD83C\uDF29 "
DBBill.CATEGORY_CULTURE -> whatPrefix += "\uD83D\uDEB8 "
DBBill.CATEGORY_HEALTH -> whatPrefix += "\uD83D\uDC9A "
DBBill.CATEGORY_SHOPPING -> whatPrefix += "\uD83D\uDECD "
DBBill.CATEGORY_REIMBURSEMENT -> whatPrefix += "\uD83D\uDCB0 "
DBBill.CATEGORY_RESTAURANT -> whatPrefix += "\uD83C\uDF74 "
DBBill.CATEGORY_ACCOMMODATION -> whatPrefix += "\uD83D\uDECC "
DBBill.CATEGORY_TRANSPORT -> whatPrefix += "\uD83D\uDE8C "
DBBill.CATEGORY_SPORT -> whatPrefix += "\uD83C\uDFBE "
}
}
bill.formattedWhat = whatPrefix + bill.what
val payerName = membersMap[bill.payerId]?.name ?: bill.payerId.toString()
val owersNames = bill.billOwersIds.joinToString(", ") { id ->
membersMap[id]?.name ?: id.toString()
}
bill.formattedSubtitle = "$payerName \u2192 $owersNames"
}
}
}

View File

@@ -0,0 +1,68 @@
package net.helcel.cowspent.util
import net.helcel.cowspent.model.parsed.AustrianBillQrCode
import net.helcel.cowspent.model.parsed.CroatianBillQrCode
import java.text.ParseException
import java.text.SimpleDateFormat
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.Locale
import kotlin.math.round
import androidx.core.net.toUri
object BillParser {
private val austrianQrCodeDateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.ROOT)
private val croatianQrCodeDateFormat = DateTimeFormatter.ofPattern("yyyyMMdd_HHmm")
@Throws(ParseException::class)
fun parseAustrianBillFromQrCode(scannedBill: String): AustrianBillQrCode {
val splitBill = scannedBill.split("_".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
if (splitBill.size < 10) {
throw ParseException("Could not parse bill to Austrian format!", 0)
}
val date = austrianQrCodeDateFormat.parse(splitBill[4]) ?: throw ParseException("Could not parse date", 0)
var totalAmount = 0.0
for (i in 1..5) {
totalAmount += SupportUtil.commaNumberFormat.parse(splitBill[4 + i])?.toDouble() ?: 0.0
}
// some amounts may be negative that's why we have to round here
return AustrianBillQrCode(splitBill[2], date, round(totalAmount * 100.0) / 100.0)
}
@Throws(ParseException::class)
fun parseCroatianBillFromQrCode(scannedBill: String): CroatianBillQrCode {
val uri = scannedBill.toUri()
// Be defensive, and only allow the known host
if (uri.host == null || uri.host != "porezna.gov.hr") {
throw ParseException("Does not look like a Croatian QR code", 0)
}
val dates = uri.getQueryParameters("datv")
val amounts = uri.getQueryParameters("izn")
var date: LocalDateTime? = null
if (!dates.isEmpty()) {
date = LocalDateTime.parse(dates[0], croatianQrCodeDateFormat)
}
var amount: Double? = null
if (!amounts.isEmpty()) {
try {
amount = SupportUtil.commaNumberFormat.parse(amounts[0])?.toDouble()
} catch (_: NullPointerException) {
// failed to parse as double
}
}
if (date == null && amount == null) {
throw ParseException("Could not parse bill to Croatian format!", 0)
}
return CroatianBillQrCode(
date,
amount ?: 0.0
)
}
}

View File

@@ -0,0 +1,37 @@
package net.helcel.cowspent.util
import android.content.Context
import net.helcel.cowspent.R
import net.helcel.cowspent.model.DBBill
import net.helcel.cowspent.model.DBCategory
import net.helcel.cowspent.model.DBPaymentMode
object CategoryUtils {
fun getDefaultCategories(context: Context, projectId: Long): List<DBCategory> {
return listOf(
DBCategory(0, DBBill.CATEGORY_GROCERIES.toLong(), projectId, context.getString(R.string.category_groceries), "\uD83D\uDED2", "#ffaa00"),
DBCategory(0, DBBill.CATEGORY_LEISURE.toLong(), projectId, context.getString(R.string.category_leisure), "\uD83C\uDF89", "#aa55ff"),
DBCategory(0, DBBill.CATEGORY_RENT.toLong(), projectId, context.getString(R.string.category_rent), "\uD83C\uDFE0", "#da8733"),
DBCategory(0, DBBill.CATEGORY_BILLS.toLong(), projectId, context.getString(R.string.category_bills), "\uD83C\uDF29", "#4aa6b0"),
DBCategory(0, DBBill.CATEGORY_CULTURE.toLong(), projectId, context.getString(R.string.category_excursion), "\uD83D\uDEB8", "#0055ff"),
DBCategory(0, DBBill.CATEGORY_HEALTH.toLong(), projectId, context.getString(R.string.category_health), "\uD83D\uDC9A", "#bf090c"),
DBCategory(0, DBBill.CATEGORY_SHOPPING.toLong(), projectId, context.getString(R.string.category_shopping), "\uD83D\uDECD", "#e167d1"),
DBCategory(0, DBBill.CATEGORY_REIMBURSEMENT.toLong(), projectId, context.getString(R.string.category_reimbursement), "\uD83D\uDCB0", "#00ced1"),
DBCategory(0, DBBill.CATEGORY_RESTAURANT.toLong(), projectId, context.getString(R.string.category_restaurant), "\uD83C\uDF74", "#d0d5e1"),
DBCategory(0, DBBill.CATEGORY_ACCOMMODATION.toLong(), projectId, context.getString(R.string.category_accomodation), "\uD83D\uDECC", "#5de1a3"),
DBCategory(0, DBBill.CATEGORY_TRANSPORT.toLong(), projectId, context.getString(R.string.category_transport), "\uD83D\uDE8C", "#6f2ee1"),
DBCategory(0, DBBill.CATEGORY_SPORT.toLong(), projectId, context.getString(R.string.category_sport), "\uD83C\uDFBE", "#69e177")
)
}
fun getDefaultPaymentModes(context: Context, projectId: Long): List<DBPaymentMode> {
return listOf(
DBPaymentMode(0, DBBill.PAYMODE_ID_CARD.toLong(), projectId, context.getString(R.string.payment_mode_credit_card), "\uD83D\uDCB3", "#ff7f50"),
DBPaymentMode(0, DBBill.PAYMODE_ID_CASH.toLong(), projectId, context.getString(R.string.payment_mode_cash), "\uD83D\uDCB5", "#556b2f"),
DBPaymentMode(0, DBBill.PAYMODE_ID_CHECK.toLong(), projectId, context.getString(R.string.payment_mode_check), "\uD83C\uDFAB", "#a9a9a9"),
DBPaymentMode(0, DBBill.PAYMODE_ID_TRANSFER.toLong(), projectId, context.getString(R.string.payment_mode_transfer), "", "#00ced1"),
DBPaymentMode(0, DBBill.PAYMODE_ID_ONLINE_SERVICE.toLong(), projectId, context.getString(R.string.payment_mode_online), "\uD83C\uDF0E", "#9932cc")
)
}
}

View File

@@ -0,0 +1,95 @@
package net.helcel.cowspent.util
import android.content.Context
import android.content.res.Configuration
import android.graphics.*
import android.os.Build
import androidx.core.content.ContextCompat
import androidx.preference.PreferenceManager
import com.google.zxing.BarcodeFormat
import com.google.zxing.MultiFormatWriter
import com.google.zxing.WriterException
import net.helcel.cowspent.R
import androidx.core.graphics.createBitmap
object ColorUtils {
fun primaryColor(context: Context, isDark: Boolean? = null): Int {
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
val resolvedIsDark = isDark ?: ((context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES)
// Determine color mode, migrating from old boolean flags if necessary
val modeKey = context.getString(R.string.pref_key_color_mode)
val colorMode = if (prefs.contains(modeKey)) {
prefs.getString(modeKey, "system")
} else {
val useServer = prefs.getBoolean(context.getString(R.string.pref_key_use_server_color), true)
val useSystem = prefs.getBoolean(context.getString(R.string.pref_key_use_system_color), true)
when {
useServer -> "server"
useSystem -> "system"
else -> "manual"
}
}
if (colorMode == "server") {
val serverColor = prefs.getInt(context.getString(R.string.pref_key_server_color), -1)
if (serverColor != -1) {
return serverColor
}
}
if (colorMode == "system" && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
return if (resolvedIsDark) {
context.getColor(android.R.color.system_accent1_200)
} else {
context.getColor(android.R.color.system_accent1_600)
}
}
val themedContext = if (isDark != null) {
val config = Configuration(context.resources.configuration)
config.uiMode = (config.uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or
if (isDark) Configuration.UI_MODE_NIGHT_YES else Configuration.UI_MODE_NIGHT_NO
context.createConfigurationContext(config)
} else {
context
}
return prefs.getInt(
context.getString(R.string.pref_key_color),
ContextCompat.getColor(themedContext, R.color.primary)
)
}
fun isLightColor(color: Int): Boolean {
return androidx.core.graphics.ColorUtils.calculateLuminance(color) > 0.5
}
@Throws(WriterException::class)
fun encodeAsBitmap(str: String): Bitmap? {
val result = try {
MultiFormatWriter().encode(
str,
BarcodeFormat.QR_CODE, 400, 400, null
)
} catch (_: IllegalArgumentException) {
// Unsupported format
return null
}
val w = result.width
val h = result.height
val pixels = IntArray(w * h)
for (y in 0 until h) {
val offset = y * w
for (x in 0 until w) {
pixels[offset + x] = if (result[x, y]) Color.BLACK else Color.WHITE
}
}
val bitmap = createBitmap(w, h)
bitmap.setPixels(pixels, 0, 400, 0, 0, w, h)
return bitmap
}
}

View File

@@ -0,0 +1,121 @@
package net.helcel.cowspent.util
import android.util.Base64
import android.util.Log
import androidx.annotation.StringRes
import net.helcel.cowspent.R
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
import java.io.BufferedReader
import java.io.IOException
import java.io.InputStreamReader
import java.net.MalformedURLException
import java.net.SocketTimeoutException
object CospendClientUtil {
enum class LoginStatus(@param:StringRes val str: Int) {
OK(0),
AUTH_FAILED(R.string.error_username_password_invalid),
CONNECTION_FAILED(R.string.error_io),
NO_NETWORK(R.string.error_no_network),
JSON_FAILED(R.string.error_json),
SERVER_FAILED(R.string.error_server),
SSO_TOKEN_MISMATCH(R.string.error_token_mismatch),
REQ_FAILED(R.string.error_req_failed)
}
fun isHttp(url: String?): Boolean {
return url != null && url.length > 4 && url.startsWith("http") && url[4] != 's'
}
fun formatURL(urlParam: String): String {
var url = urlParam
if (!url.endsWith("/")) {
url += "/"
}
if (!url.startsWith("http://") && !url.startsWith("https://")) {
url = "https://$url"
}
val replacements = arrayOf("v0.2/", "api/", "apps/", "index.php/")
for (replacement in replacements) {
if (url.endsWith(replacement)) {
url = url.substring(0, url.length - replacement.length)
}
}
return url
}
fun isValidLogin(url: String, username: String, password: String): LoginStatus {
return try {
val targetURL = url + "index.php/apps/cospend/api/ping"
val con = SupportUtil.getHttpURLConnection(targetURL)
con.requestMethod = "GET"
con.setRequestProperty(
"Authorization",
"Basic "
+ String(
Base64.encode(
("$username:$password").toByteArray(),
Base64.NO_WRAP
)
)
)
con.connectTimeout = 10 * 1000 // 10 seconds
con.connect()
Log.v(CospendClientUtil::class.java.simpleName, "Establishing connection to server")
when (con.responseCode) {
200 -> {
Log.v(CospendClientUtil::class.java.simpleName, "" + con.responseMessage)
val result = StringBuilder()
val rd = BufferedReader(InputStreamReader(con.inputStream))
var line: String?
while ((rd.readLine().also { line = it }) != null) {
result.append(line)
}
Log.v(CospendClientUtil::class.java.simpleName, result.toString())
JSONArray(result.toString())
LoginStatus.OK
}
in 401..403 -> {
LoginStatus.AUTH_FAILED
}
else -> {
LoginStatus.SERVER_FAILED
}
}
} catch (e: MalformedURLException) {
Log.e(CospendClientUtil::class.java.simpleName, "Exception", e)
LoginStatus.CONNECTION_FAILED
} catch (e: SocketTimeoutException) {
Log.e(CospendClientUtil::class.java.simpleName, "Exception", e)
LoginStatus.CONNECTION_FAILED
} catch (e: IOException) {
Log.e(CospendClientUtil::class.java.simpleName, "Exception", e)
LoginStatus.CONNECTION_FAILED
} catch (e: JSONException) {
Log.e(CospendClientUtil::class.java.simpleName, "Exception", e)
LoginStatus.JSON_FAILED
}
}
fun isValidURL(url: String): Boolean {
val result = StringBuilder()
return try {
val con = SupportUtil.getHttpURLConnection( url + "status.php")
con.requestMethod = VersatileProjectSyncClient.METHOD_GET
con.connectTimeout = 10 * 1000 // 10 seconds
val rd = BufferedReader(InputStreamReader(con.inputStream))
var line: String?
while ((rd.readLine().also { line = it }) != null) {
result.append(line)
}
val response = JSONObject(result.toString())
response.getBoolean("installed")
} catch (_: Exception) {
false
}
}
}

View File

@@ -0,0 +1,31 @@
package net.helcel.cowspent.util
import android.app.Application
import android.content.Context
import androidx.appcompat.app.AppCompatDelegate
import androidx.preference.PreferenceManager
import net.helcel.cowspent.R
class Cowspent : Application() {
override fun onCreate() {
setAppTheme(getAppTheme(applicationContext))
super.onCreate()
}
companion object {
fun setAppTheme(mode: Int) {
AppCompatDelegate.setDefaultNightMode(mode)
}
fun getAppTheme(context: Context): Int {
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
val strValue = prefs.getString(
context.getString(R.string.pref_key_night_mode),
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM.toString()
)
return strValue?.toInt() ?: AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
}
}
}

View File

@@ -0,0 +1,88 @@
package net.helcel.cowspent.util
import net.helcel.cowspent.model.*
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
object ExportUtil {
@JvmStatic
fun createExportContent(db: CowspentSQLiteOpenHelper, projectId: Long): String {
var fileContent = ""
// get information
val project = db.getProject(projectId) ?: return ""
val membersById: MutableMap<Long, DBMember> = HashMap()
val members = db.getMembersOfProject(projectId, null)
for (m in members) {
membersById[m.id] = m
}
val bills = db.getBillsOfProject(projectId).toMutableList()
// write header
fileContent += "what,amount,date,timestamp,payer_name,payer_weight,payer_active,owers,repeat,categoryid,paymentmode\n"
// write members
for (m in members) {
val fakeBill = DBBill(
0, 0, projectId, m.id, 1.0, 666,
"deleteMeIfYouWant", DBBill.STATE_OK, DBBill.NON_REPEATED,
DBBill.PAYMODE_NONE, 0, "", 0
)
val fakeBillOwers: MutableList<DBBillOwer> = ArrayList()
fakeBillOwers.add(DBBillOwer(0, 0, m.id))
fakeBill.billOwers = fakeBillOwers
bills.add(0, fakeBill)
}
// write bills
for (b in bills) {
val payerId = b.payerId
val payer = membersById[payerId] ?: continue
val payerName = payer.name
val payerWeight = payer.weight
val payerActive = if (payer.isActivated) 1 else 0
val billOwers = b.billOwers
var owersTxt = ""
for (bo in billOwers) {
owersTxt += membersById[bo.memberId]?.name + ","
}
owersTxt = owersTxt.replace(",$".toRegex(), "")
fileContent += "\"${b.what}\",${b.amount},${b.date},${b.timestamp},\"$payerName\"," +
"$payerWeight,$payerActive,\"$owersTxt\",${b.repeat},${b.categoryRemoteId}," +
"${b.paymentMode}\n"
}
// write categories
val cats = db.getCategories(projectId)
if (cats.isNotEmpty()) {
fileContent += "\ncategoryname,categoryid,icon,color\n"
for (cat in cats) {
fileContent += "\"${cat.name}\",${cat.id},\"${cat.icon}\",\"${cat.color}\"\n"
}
}
// write currencies
val curs = db.getCurrencies(projectId)
if (curs.isNotEmpty() && project.currencyName != null &&
project.currencyName!!.isNotEmpty() && project.currencyName != "null"
) {
fileContent += "\ncurrencyname,exchange_rate\n"
fileContent += "\"${project.currencyName}\",1\n"
for (cur in curs) {
fileContent += "\"${cur.name}\",${cur.exchangeRate}\n"
}
}
return fileContent
}
@JvmStatic
fun createExportFileName(db: CowspentSQLiteOpenHelper, projectId: Long): String {
val project = db.getProject(projectId) ?: return "export.csv"
return if (project.name.isEmpty()) {
project.remoteId + ".csv"
} else {
project.name + ".csv"
}
}
}

View File

@@ -0,0 +1,10 @@
package net.helcel.cowspent.util
/**
* Callback
*/
interface ICallback {
fun onFinish()
fun onFinish(result: String, message: String)
fun onScheduled()
}

View File

@@ -0,0 +1,9 @@
package net.helcel.cowspent.util
/**
* Callback
*/
interface IProjectCreationCallback {
fun onFinish(result: String, message: String, usePrivateApi: Boolean)
}

View File

@@ -0,0 +1,8 @@
package net.helcel.cowspent.util
/**
* Call back into the BillsListActivity and ask it to refresh the list in the UI
*/
interface IRefreshBillsListCallback {
fun refreshLists(scrollToTop: Boolean)
}

View File

@@ -0,0 +1,302 @@
package net.helcel.cowspent.util
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.Base64
import android.util.Log
import androidx.annotation.WorkerThread
import com.nextcloud.android.sso.QueryParam
import com.nextcloud.android.sso.aidl.NextcloudRequest
import com.nextcloud.android.sso.api.NextcloudAPI
import com.nextcloud.android.sso.exceptions.NextcloudHttpRequestFailedException
import com.nextcloud.android.sso.exceptions.TokenMismatchException
import net.helcel.cowspent.model.DBProject
import org.json.JSONException
import org.json.JSONObject
import java.io.*
import java.net.HttpURLConnection
@WorkerThread
class NextcloudClient(
private val url: String,
private val username: String,
private val password: String,
private val nextcloudAPI: NextcloudAPI?,
private val context: Context
) {
@Throws(JSONException::class, IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class)
fun getAccountProjects(useOcsApi: Boolean): ServerResponse.AccountProjectsResponse {
val target = if (useOcsApi)
"/ocs/v2.php/apps/cospend/api/v1/projects"
else
"/index.php/apps/cospend/getProjects"
val method = if (useOcsApi) METHOD_GET else METHOD_POST
return if (nextcloudAPI != null) {
Log.d(javaClass.simpleName, "using SSO to get/sync account projects")
Log.d(javaClass.simpleName, "Sync projects target $target")
ServerResponse.AccountProjectsResponse(
requestServerWithSSO(nextcloudAPI, target, method, null, useOcsApi),
useOcsApi
)
} else {
Log.d(javaClass.simpleName, "Sync projects target $target")
ServerResponse.AccountProjectsResponse(
requestServer(target, method, null, "", true, useOcsApi),
useOcsApi
)
}
}
@Throws(JSONException::class, IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class)
fun getCapabilities(project: DBProject?): ServerResponse.CapabilitiesResponse {
val target: String = if (project == null || url != "") {
"/ocs/v2.php/cloud/capabilities"
} else {
val realServerUrl = project.serverUrl!!
.replace("/apps/cospend", "")
.replace("/index.php", "")
"$realServerUrl/ocs/v2.php/cloud/capabilities"
}
return if (nextcloudAPI != null) {
Log.d(javaClass.simpleName, "using SSO to get color")
ServerResponse.CapabilitiesResponse(requestServerWithSSO(nextcloudAPI, target, METHOD_GET, null, true))
} else {
ServerResponse.CapabilitiesResponse(requestServer(target, METHOD_GET, null, null,
needLogin = true,
isOCSRequest = true
))
}
}
@Throws(JSONException::class, IOException::class, TokenMismatchException::class, NextcloudHttpRequestFailedException::class)
fun getAvatar(otherUserName: String?): ServerResponse.AvatarResponse {
val targetUserName = otherUserName ?: username
val target = "/index.php/avatar/$targetUserName/45"
return if (nextcloudAPI != null) {
Log.d(javaClass.simpleName, "using SSO to get avatar")
ServerResponse.AvatarResponse(imageRequestServerWithSSO(nextcloudAPI, target, METHOD_GET, null))
} else {
ServerResponse.AvatarResponse(imageRequestServer(target, METHOD_GET, null, null,
needLogin = true,
isOCSRequest = false
))
}
}
@Throws(TokenMismatchException::class, NextcloudHttpRequestFailedException::class)
private fun requestServerWithSSO(
nextcloudAPI: NextcloudAPI,
target: String,
method: String,
params: Collection<QueryParam>?,
isOCSRequest: Boolean
): VersatileProjectSyncClient.ResponseData {
val result = StringBuilder()
val headers: MutableMap<String, List<String>> = HashMap()
if (isOCSRequest) {
val acceptHeader: MutableList<String> = ArrayList()
acceptHeader.add("application/json")
headers["Accept"] = acceptHeader
}
val nextcloudRequest: NextcloudRequest = if (params == null) {
NextcloudRequest.Builder()
.setMethod(method)
.setUrl(target)
.setHeader(headers)
.build()
} else {
NextcloudRequest.Builder()
.setMethod(method)
.setUrl(target)
.setParameter(params)
.setHeader(headers)
.build()
}
try {
val response = nextcloudAPI.performNetworkRequestV2(nextcloudRequest)
val inputStream = response.body
val rd = BufferedReader(InputStreamReader(inputStream))
var line: String?
while (rd.readLine().also { line = it } != null) {
result.append(line)
}
Log.d(javaClass.simpleName, "RES $result")
inputStream.close()
} catch (e: TokenMismatchException) {
Log.d(javaClass.simpleName, "Mismatcho SSO server request error $e")
throw e
} catch (e: NextcloudHttpRequestFailedException) {
Log.d(javaClass.simpleName, "SSO server HTTP request failed ${e.statusCode}")
throw e
} catch (e: Exception) {
Log.d(javaClass.simpleName, "SSO server request error $e")
}
return VersatileProjectSyncClient.ResponseData(result.toString(), "", 0)
}
@Throws(TokenMismatchException::class, NextcloudHttpRequestFailedException::class)
private fun imageRequestServerWithSSO(
nextcloudAPI: NextcloudAPI,
target: String,
method: String,
params: Collection<QueryParam>?
): VersatileProjectSyncClient.ResponseData {
var strBase64 = ""
val nextcloudRequest: NextcloudRequest = if (params == null) {
NextcloudRequest.Builder()
.setMethod(method)
.setUrl(target)
.build()
} else {
NextcloudRequest.Builder()
.setMethod(method)
.setUrl(target)
.setParameter(params)
.build()
}
try {
val response = nextcloudAPI.performNetworkRequestV2(nextcloudRequest)
val inputStream = response.body
val selectedImage = BitmapFactory.decodeStream(inputStream)
val stream = ByteArrayOutputStream()
selectedImage.compress(Bitmap.CompressFormat.PNG, 100, stream)
val byteArray = stream.toByteArray()
strBase64 = Base64.encodeToString(byteArray, 0)
inputStream.close()
} catch (e: TokenMismatchException) {
Log.d(javaClass.simpleName, "Mismatcho SSO server request error $e")
throw e
} catch (e: NextcloudHttpRequestFailedException) {
Log.d(javaClass.simpleName, "SSO server HTTP request failed ${e.statusCode}")
throw e
} catch (e: Exception) {
Log.d(javaClass.simpleName, "SSO server request error $e")
}
return VersatileProjectSyncClient.ResponseData(strBase64, "", 0)
}
@Throws(IOException::class, NextcloudHttpRequestFailedException::class)
private fun requestServer(
target: String,
method: String, params: JSONObject?, lastETag: String?, needLogin: Boolean, isOCSRequest: Boolean
): VersatileProjectSyncClient.ResponseData {
val result = StringBuilder()
val targetURL = url + target.replace("^/".toRegex(), "")
Log.d(javaClass.simpleName, "method and target URL: $method $targetURL")
val httpCon = SupportUtil.getHttpURLConnection(targetURL)
httpCon.requestMethod = method
if (needLogin) {
httpCon.setRequestProperty(
"Authorization",
"Basic " + Base64.encodeToString("$username:$password".toByteArray(), Base64.NO_WRAP)
)
}
httpCon.setRequestProperty("Connection", "Close")
httpCon.setRequestProperty("User-Agent", "cowspent-android/" + SupportUtil.getAppVersionName(context))
if (lastETag != null && METHOD_GET == method) {
httpCon.setRequestProperty("If-None-Match", lastETag)
}
if (isOCSRequest) {
httpCon.setRequestProperty("OCS-APIRequest", "true")
httpCon.setRequestProperty("Accept", "application/json")
}
httpCon.connectTimeout = 10 * 1000 // 10 seconds
var paramData: ByteArray? = null
if (params != null) {
paramData = params.toString().toByteArray()
Log.d(javaClass.simpleName, "Params: $params")
httpCon.setFixedLengthStreamingMode(paramData.size)
httpCon.setRequestProperty("Content-Type", application_json)
httpCon.doOutput = true
val os = httpCon.outputStream
os.write(paramData)
os.flush()
os.close()
}
val responseCode = httpCon.responseCode
Log.d(javaClass.simpleName, "HTTP response code: $responseCode")
if (responseCode == HttpURLConnection.HTTP_NOT_MODIFIED) {
throw ServerResponse.NotModifiedException()
}
if (responseCode >= 400) {
throw NextcloudHttpRequestFailedException(context, responseCode, IOException(""))
}
Log.i(TAG, "METHOD : $method")
val rd = BufferedReader(InputStreamReader(httpCon.inputStream))
var line: String?
while (rd.readLine().also { line = it } != null) {
result.append(line)
}
val etag = httpCon.getHeaderField("ETag")
val lastModified = httpCon.getHeaderFieldDate("Last-Modified", 0) / 1000
Log.i(
javaClass.simpleName,
"Result length: " + result.length + (if (paramData == null) "" else "; Request length: " + paramData.size)
)
Log.d(javaClass.simpleName, "ETag: $etag; Last-Modified: $lastModified (${httpCon.getHeaderField("Last-Modified")})")
return VersatileProjectSyncClient.ResponseData(result.toString(), "", 0)
}
@Throws(IOException::class, NextcloudHttpRequestFailedException::class)
private fun imageRequestServer(
target: String,
method: String, params: JSONObject?, lastETag: String?, needLogin: Boolean, isOCSRequest: Boolean
): VersatileProjectSyncClient.ResponseData {
var strBase64: String
val targetURL = url + target.replace("^/".toRegex(), "")
val httpCon = SupportUtil.getHttpURLConnection( targetURL)
httpCon.requestMethod = method
if (needLogin) {
httpCon.setRequestProperty(
"Authorization",
"Basic " + Base64.encodeToString("$username:$password".toByteArray(), Base64.NO_WRAP)
)
}
httpCon.setRequestProperty("Connection", "Close")
httpCon.setRequestProperty("User-Agent", "Cowspent-android/" + SupportUtil.getAppVersionName(context))
if (lastETag != null && METHOD_GET == method) {
httpCon.setRequestProperty("If-None-Match", lastETag)
}
if (isOCSRequest) {
httpCon.setRequestProperty("OCS-APIRequest", "true")
}
httpCon.connectTimeout = 10 * 1000 // 10 seconds
Log.d(javaClass.simpleName, "$method $targetURL")
var paramData: ByteArray?
if (params != null) {
paramData = params.toString().toByteArray()
Log.d(javaClass.simpleName, "Params: $params")
httpCon.setFixedLengthStreamingMode(paramData.size)
httpCon.setRequestProperty("Content-Type", application_json)
httpCon.doOutput = true
val os = httpCon.outputStream
os.write(paramData)
os.flush()
os.close()
}
val responseCode = httpCon.responseCode
Log.d(javaClass.simpleName, "HTTP response code: $responseCode")
if (responseCode == HttpURLConnection.HTTP_NOT_MODIFIED) {
throw ServerResponse.NotModifiedException()
}
if (responseCode >= 400) {
throw NextcloudHttpRequestFailedException(context, responseCode, IOException(""))
}
Log.i(TAG, "METHOD : $method")
val selectedImage = BitmapFactory.decodeStream(httpCon.inputStream)
val stream = ByteArrayOutputStream()
selectedImage.compress(Bitmap.CompressFormat.PNG, 100, stream)
val byteArray = stream.toByteArray()
strBase64 = Base64.encodeToString(byteArray, 0)
return VersatileProjectSyncClient.ResponseData(strBase64, "", 0)
}
companion object {
private val TAG = NextcloudClient::class.java.simpleName
const val METHOD_GET = "GET"
const val METHOD_POST = "POST"
private const val application_json = "application/json"
}
}

View File

@@ -0,0 +1,776 @@
package net.helcel.cowspent.util
import android.util.Log
import net.helcel.cowspent.model.DBAccountProject
import net.helcel.cowspent.model.DBBill
import net.helcel.cowspent.model.DBBillOwer
import net.helcel.cowspent.model.DBCategory
import net.helcel.cowspent.model.DBCurrency
import net.helcel.cowspent.model.DBMember
import net.helcel.cowspent.model.DBPaymentMode
import net.helcel.cowspent.model.DBProject
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
import org.xml.sax.SAXException
import java.io.ByteArrayInputStream
import java.io.IOException
import java.io.InputStream
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import javax.xml.parsers.DocumentBuilderFactory
import javax.xml.parsers.ParserConfigurationException
/**
* Provides entity classes for handling server responses
*/
@Suppress("unused")
open class ServerResponse(
private val response: VersatileProjectSyncClient.ResponseData,
protected val isOcsResponse: Boolean
) {
private val sdf = SimpleDateFormat("yyyy-MM-dd", Locale.ROOT)
class NotModifiedException : IOException()
protected val content: String
get() = response.content
val lastModified: Long
get() = response.lastModified
@Throws(JSONException::class)
fun getResponseObjectData(): JSONObject {
val rawData = JSONObject(content)
if (!isOcsResponse) {
return rawData
}
val data = rawData.getJSONObject("ocs")
return data.getJSONObject("data")
}
@Throws(JSONException::class)
fun getResponseArrayData(): JSONArray {
if (!isOcsResponse) {
return JSONArray(content)
}
val rawData = JSONObject(content)
val data = rawData.getJSONObject("ocs")
return data.getJSONArray("data")
}
@Throws(JSONException::class)
fun getResponseStringData(): String {
if (!isOcsResponse) {
return content
}
val rawData = JSONObject(content)
val data = rawData.getJSONObject("ocs")
return data.getString("data")
}
class ProjectResponse(response: VersatileProjectSyncClient.ResponseData, isOcsResponse: Boolean) :
ServerResponse(response, isOcsResponse) {
@get:Throws(JSONException::class)
val email: String
get() = getEmailFromJSON(getResponseObjectData())
@get:Throws(JSONException::class)
val name: String
get() = getNameFromJSON(getResponseObjectData())
@get:Throws(JSONException::class)
val deletionDisabled: Boolean
get() = getDeletionDisabledFromJSON(getResponseObjectData())
@get:Throws(JSONException::class)
val archivedTs: Long?
get() = getArchivedTsFromJSON(getResponseObjectData())
@get:Throws(JSONException::class)
val myAccessLevel: Int
get() = getMyAccessLevelFromJSON(getResponseObjectData())
@get:Throws(JSONException::class)
val currencyName: String
get() = getCurrencyNameFromJSON(getResponseObjectData())
@Throws(JSONException::class)
fun getMembers(projId: Long): List<DBMember> {
return getMembersFromJSON(getResponseObjectData(), projId)
}
@Throws(JSONException::class)
fun getCategories(projId: Long): List<DBCategory> {
return getCategoriesFromJSON(getResponseObjectData(), projId)
}
@Throws(JSONException::class)
fun getPaymentModes(projId: Long): List<DBPaymentMode> {
return getPaymentModesFromJSON(getResponseObjectData(), projId)
}
@Throws(JSONException::class)
fun getCurrencies(projId: Long): List<DBCurrency> {
return getCurrenciesFromJSON(getResponseObjectData(), projId)
}
}
class CreateRemoteMemberResponse(
response: VersatileProjectSyncClient.ResponseData,
isOcsResponse: Boolean,
private val isJsonMember: Boolean
) : ServerResponse(response, isOcsResponse) {
@get:Throws(JSONException::class)
val stringContent: String
get() = getResponseStringData()
@get:Throws(JSONException::class)
val remoteMemberId: Long
get() = if (isJsonMember)
getRemoteMemberIdFromJSON(getResponseObjectData())
else
getResponseStringData().toLong()
}
class CreateRemoteCurrencyResponse(
response: VersatileProjectSyncClient.ResponseData,
isOcsResponse: Boolean
) : ServerResponse(response, isOcsResponse) {
@get:Throws(JSONException::class)
val stringContent: String
get() = getResponseStringData()
}
class EditRemoteCurrencyResponse(
response: VersatileProjectSyncClient.ResponseData,
isOcsResponse: Boolean
) : ServerResponse(response, isOcsResponse) {
@get:Throws(JSONException::class)
val stringContent: String
get() = getResponseStringData()
}
class DeleteRemoteCurrencyResponse(
response: VersatileProjectSyncClient.ResponseData,
isOcsResponse: Boolean
) : ServerResponse(response, isOcsResponse) {
@get:Throws(JSONException::class)
val stringContent: String
get() = getResponseStringData()
}
class EditRemoteProjectResponse(
response: VersatileProjectSyncClient.ResponseData,
isOcsResponse: Boolean
) : ServerResponse(response, isOcsResponse) {
@get:Throws(JSONException::class)
val stringContent: String
get() = getResponseStringData()
}
class EditRemoteMemberResponse(
response: VersatileProjectSyncClient.ResponseData,
isOcsResponse: Boolean
) : ServerResponse(response, isOcsResponse) {
@Throws(JSONException::class)
fun getRemoteId(projectId: Long): Long {
return getMemberFromJSON(getResponseObjectData(), projectId).remoteId
}
}
class EditRemoteBillResponse(
response: VersatileProjectSyncClient.ResponseData,
isOcsResponse: Boolean
) : ServerResponse(response, isOcsResponse) {
@get:Throws(JSONException::class)
val stringContent: String
get() = getResponseStringData()
}
class CreateRemoteBillResponse(
response: VersatileProjectSyncClient.ResponseData,
isOcsResponse: Boolean
) : ServerResponse(response, isOcsResponse) {
@get:Throws(JSONException::class)
val stringContent: String
get() = getResponseStringData()
}
class DeleteRemoteBillResponse(
response: VersatileProjectSyncClient.ResponseData,
isOcsResponse: Boolean
) : ServerResponse(response, isOcsResponse) {
@get:Throws(JSONException::class)
val stringContent: String
get() = getResponseStringData()
}
class DeleteRemoteProjectResponse(
response: VersatileProjectSyncClient.ResponseData,
isOcsResponse: Boolean
) : ServerResponse(response, isOcsResponse) {
@get:Throws(JSONException::class)
val stringContent: String
get() = getResponseStringData()
}
class CreateRemoteProjectResponse(
response: VersatileProjectSyncClient.ResponseData,
isOcsResponse: Boolean
) : ServerResponse(response, isOcsResponse) {
@get:Throws(JSONException::class)
val stringContent: String
get() = getResponseStringData()
}
class BillsResponse(response: VersatileProjectSyncClient.ResponseData, isOcsResponse: Boolean) :
ServerResponse(response, isOcsResponse) {
@Throws(JSONException::class)
fun getBillsCospend(projId: Long, memberRemoteIdToId: Map<Long, Long>): List<DBBill> {
return getBillsFromJSONObject(getResponseObjectData(), projId, memberRemoteIdToId)
}
@Throws(JSONException::class)
fun getBillsIHM(projId: Long, memberRemoteIdToId: Map<Long, Long>): List<DBBill> {
return getBillsFromJSONArray(JSONArray(content), projId, memberRemoteIdToId)
}
@get:Throws(JSONException::class)
val allBillIds: List<Long>
get() = getAllBillIdsFromJSON(getResponseObjectData())
@get:Throws(JSONException::class)
val syncTimestamp: Long
get() = getSyncTimestampFromJSON(getResponseObjectData())
}
class MembersResponse(response: VersatileProjectSyncClient.ResponseData, isOcsResponse: Boolean) :
ServerResponse(response, isOcsResponse) {
@Throws(JSONException::class)
fun getMembers(projId: Long): List<DBMember> {
return getMembersFromJSONArray(getResponseArrayData(), projId)
}
}
class AccountProjectsResponse(
response: VersatileProjectSyncClient.ResponseData,
isOcsResponse: Boolean
) : ServerResponse(response, isOcsResponse) {
@Throws(JSONException::class)
fun getAccountProjects(ncUrl: String): List<DBAccountProject> {
return getAccountProjectsFromJSONArray(getResponseArrayData(), ncUrl)
}
}
class CapabilitiesResponse(response: VersatileProjectSyncClient.ResponseData) :
ServerResponse(response, true) {
@get:Throws(IOException::class, JSONException::class)
val color: String?
get() = getColorFromJsonContent(JSONObject(content))
@get:Throws(JSONException::class)
val cospendVersion: String?
get() = getCospendVersionFromCapabilitiesContent(JSONObject(content))
}
class AvatarResponse(response: VersatileProjectSyncClient.ResponseData) :
ServerResponse(response, false) {
@get:Throws(IOException::class)
val avatarString: String
get() = content
}
@Throws(JSONException::class)
protected fun getPublicTokenFromJSON(json: JSONObject): String? {
if (json.has("code") && json.has("sharetoken")) {
val done = json.getInt("code")
val publicToken = json.getString("sharetoken")
if (done == 1) {
return publicToken
}
}
return null
}
@Throws(JSONException::class)
protected fun getNameFromJSON(json: JSONObject): String {
return if (json.has("name") && !json.isNull("name")) {
json.getString("name")
} else ""
}
@Throws(JSONException::class)
protected fun getDeletionDisabledFromJSON(json: JSONObject): Boolean {
return if (json.has("deletiondisabled")) {
json.getBoolean("deletiondisabled")
} else false
}
@Throws(JSONException::class)
protected fun getArchivedTsFromJSON(json: JSONObject): Long? {
return if (json.has("archived_ts") && !json.isNull("archived_ts")) {
val ts = json.optLong("archived_ts", 0)
if (ts > 0) ts else null
} else null
}
@Throws(JSONException::class)
protected fun getMyAccessLevelFromJSON(json: JSONObject): Int {
return if (json.has("myaccesslevel")) {
json.getInt("myaccesslevel")
} else DBProject.ACCESS_LEVEL_UNKNOWN
}
@Throws(JSONException::class)
protected fun getCurrencyNameFromJSON(json: JSONObject): String {
return if (json.has("currencyname") && !json.isNull("currencyname")) {
json.getString("currencyname")
} else ""
}
@Throws(JSONException::class)
protected fun getEmailFromJSON(json: JSONObject): String {
return if (json.has("contact_email") && !json.isNull("contact_email")) {
json.getString("contact_email")
} else ""
}
@Throws(JSONException::class)
protected fun getMembersFromJSONArray(jsonMs: JSONArray, projId: Long): List<DBMember> {
val members: MutableList<DBMember> = ArrayList()
for (i in 0 until jsonMs.length()) {
val jsonM = jsonMs.getJSONObject(i)
members.add(getMemberFromJSON(jsonM, projId))
}
return members
}
@Throws(JSONException::class)
protected fun getCategoriesFromJSON(json: JSONObject, projId: Long): List<DBCategory> {
val categories: MutableList<DBCategory> = ArrayList()
if (json.has("categories") && json.get("categories") is JSONObject) {
val jsonCats = json.getJSONObject("categories")
val keys = jsonCats.keys()
while (keys.hasNext()) {
val key = keys.next()
if (jsonCats.get(key) is JSONObject) {
categories.add(getCategoryFromJSON(jsonCats.getJSONObject(key), key, projId))
}
}
}
return categories
}
@Throws(JSONException::class)
protected fun getCategoryFromJSON(json: JSONObject, remoteIdStr: String, projId: Long): DBCategory {
val remoteId = remoteIdStr.toLong()
var name = ""
var color = ""
var icon = ""
if (json.has("color") && !json.isNull("color")) {
color = json.getString("color")
}
if (json.has("icon") && !json.isNull("icon")) {
icon = json.getString("icon")
}
if (json.has("name") && !json.isNull("name")) {
name = json.getString("name")
}
return DBCategory(0, remoteId, projId, name, icon, color)
}
@Throws(JSONException::class)
protected fun getPaymentModesFromJSON(json: JSONObject, projId: Long): List<DBPaymentMode> {
val paymentModes: MutableList<DBPaymentMode> = ArrayList()
if (json.has("paymentmodes") && json.get("paymentmodes") is JSONObject) {
val jsonPms = json.getJSONObject("paymentmodes")
val keys = jsonPms.keys()
while (keys.hasNext()) {
val key = keys.next()
if (jsonPms.get(key) is JSONObject) {
paymentModes.add(getPaymentModeFromJSON(jsonPms.getJSONObject(key), key, projId))
}
}
}
return paymentModes
}
@Throws(JSONException::class)
protected fun getPaymentModeFromJSON(json: JSONObject, remoteIdStr: String, projId: Long): DBPaymentMode {
val remoteId = remoteIdStr.toLong()
var name = ""
var color = ""
var icon = ""
if (json.has("color") && !json.isNull("color")) {
color = json.getString("color")
}
if (json.has("icon") && !json.isNull("icon")) {
icon = json.getString("icon")
}
if (json.has("name") && !json.isNull("name")) {
name = json.getString("name")
}
return DBPaymentMode(0, remoteId, projId, name, icon, color)
}
@Throws(JSONException::class)
protected fun getCurrenciesFromJSON(json: JSONObject, projId: Long): List<DBCurrency> {
val currencies: MutableList<DBCurrency> = ArrayList()
if (json.has("currencies") && json.get("currencies") is JSONArray) {
val jsonCurs = json.getJSONArray("currencies")
for (i in 0 until jsonCurs.length()) {
if (jsonCurs.get(i) is JSONObject) {
currencies.add(getCurrencyFromJSON(jsonCurs.getJSONObject(i), projId))
}
}
}
return currencies
}
@Throws(JSONException::class)
protected fun getCurrencyFromJSON(json: JSONObject, projId: Long): DBCurrency {
var remoteId: Long = 0
var name = ""
var exchangeRate = 1.0
if (json.has("exchange_rate") && !json.isNull("exchange_rate")) {
exchangeRate = json.getDouble("exchange_rate")
}
if (json.has("id") && !json.isNull("id")) {
remoteId = json.getLong("id")
}
if (json.has("name") && !json.isNull("name")) {
name = json.getString("name")
}
return DBCurrency(0, remoteId, projId, name, exchangeRate, DBBill.STATE_OK)
}
@Throws(JSONException::class)
protected fun getMembersFromJSON(json: JSONObject, projId: Long): List<DBMember> {
val members: MutableList<DBMember> = ArrayList()
if (json.has("members")) {
val jsonMs = json.getJSONArray("members")
for (i in 0 until jsonMs.length()) {
val jsonM = jsonMs.getJSONObject(i)
members.add(getMemberFromJSON(jsonM, projId))
}
}
return members
}
@Throws(JSONException::class)
protected fun getMemberFromJSON(json: JSONObject, projId: Long): DBMember {
var activated = true
var weight = 1.0
var remoteId: Long = 0
var name = ""
var r: Int? = null
var g: Int? = null
var b: Int? = null
var ncUserId: String? = null
if (!json.isNull("id")) {
remoteId = json.getLong("id")
}
if (!json.isNull("weight")) {
weight = json.getDouble("weight")
}
if (!json.isNull("activated")) {
activated = json.getBoolean("activated")
}
if (!json.isNull("name")) {
name = json.getString("name")
}
if (json.has("color") && !json.isNull("color")) {
val obj = json.get("color")
if (obj is String) {
val color = json.getString("color").replace("#", "")
if (color.length == 6) {
r = color.substring(0, 2).toInt(16)
g = color.substring(2, 4).toInt(16)
b = color.substring(4, 6).toInt(16)
}
} else if (obj is JSONObject) {
val color = json.getJSONObject("color")
if (color.has("r") && !color.isNull("r")) {
r = color.getInt("r")
}
if (color.has("g") && !color.isNull("g")) {
g = color.getInt("g")
}
if (color.has("b") && !color.isNull("b")) {
b = color.getInt("b")
}
}
}
if (json.has("userid") && !json.isNull("userid")) {
ncUserId = json.getString("userid")
}
return DBMember(
0, remoteId, projId, name, activated, weight, DBBill.STATE_OK,
r, g, b, ncUserId, null
)
}
@Throws(JSONException::class)
protected fun getAllBillIdsFromJSON(json: JSONObject): List<Long> {
val billIds: MutableList<Long> = ArrayList()
if (json.has("allBillIds") && !json.isNull("allBillIds")) {
val jsonBillIds = json.getJSONArray("allBillIds")
for (i in 0 until jsonBillIds.length()) {
billIds.add(jsonBillIds.getLong(i))
}
}
return billIds
}
@Throws(JSONException::class)
protected fun getSyncTimestampFromJSON(json: JSONObject): Long {
var ts = 0L
if (json.has("timestamp") && !json.isNull("timestamp")) {
ts = json.getLong("timestamp")
}
return ts
}
@Throws(JSONException::class)
protected fun getBillsFromJSONArray(
json: JSONArray,
projId: Long,
memberRemoteIdToId: Map<Long, Long>
): List<DBBill> {
val bills: MutableList<DBBill> = ArrayList()
for (i in 0 until json.length()) {
val jsonBill = json.getJSONObject(i)
bills.add(getBillFromJSON(jsonBill, projId, memberRemoteIdToId))
}
return bills
}
@Throws(JSONException::class)
protected fun getBillsFromJSONObject(
json: JSONObject,
projId: Long,
memberRemoteIdToId: Map<Long, Long>
): List<DBBill> {
val bills: List<DBBill>
if (json.has("bills") && !json.isNull("bills")) {
val jsonBills = json.getJSONArray("bills")
bills = getBillsFromJSONArray(jsonBills, projId, memberRemoteIdToId)
} else {
bills = ArrayList()
}
return bills
}
@Throws(JSONException::class)
protected fun getBillFromJSON(
json: JSONObject,
projId: Long,
memberRemoteIdToId: Map<Long, Long>
): DBBill {
var remoteId: Long = 0
var payerRemoteId: Long
var payerId: Long = 0
var amount = 0.0
var dateStr: String
var date: Date
var timestamp: Long = 0
var what = ""
var comment = ""
var repeat = DBBill.NON_REPEATED
var paymentMode = DBBill.PAYMODE_NONE
var paymentModeRemoteId = DBBill.PAYMODE_ID_NONE
var categoryId = DBBill.CATEGORY_NONE
if (!json.isNull("id")) {
remoteId = json.getLong("id")
}
if (!json.isNull("payer_id")) {
payerRemoteId = json.getLong("payer_id")
payerId = memberRemoteIdToId[payerRemoteId] ?: 0
} else if (!json.isNull("payer")) {
payerRemoteId = json.getLong("payer")
payerId = memberRemoteIdToId[payerRemoteId] ?: 0
}
if (!json.isNull("amount")) {
amount = json.getDouble("amount")
}
// get timestamp in priority
if (!json.isNull("timestamp")) {
timestamp = json.getLong("timestamp")
} else if (!json.isNull("date")) {
dateStr = json.getString("date")
try {
date = sdf.parse(dateStr)!!
timestamp = date.time / 1000
} catch (_: Exception) {
timestamp = 0
}
}
if (!json.isNull("what")) {
what = json.getString("what")
} else if (!json.isNull("label")) {
what = json.getString("label")
}
if (!json.isNull("comment")) {
comment = json.getString("comment")
}
if (json.has("repeat") && !json.isNull("repeat")) {
repeat = json.getString("repeat")
}
if (json.has("paymentmode") && !json.isNull("paymentmode")) {
paymentMode = json.getString("paymentmode")
}
if (json.has("categoryid") && !json.isNull("categoryid")) {
categoryId = json.getInt("categoryid")
Log.d("PLOP", "LOADED CATTTTTTTTTTTT $categoryId")
}
if (json.has("paymentmodeid") && !json.isNull("paymentmodeid")) {
paymentModeRemoteId = json.getInt("paymentmodeid")
}
// old MB, new Cospend is ok as Cospend provides the old pm ID
// new MB, old Cospend => set payment mode ID from old one
if (DBBill.PAYMODE_NONE != paymentMode && "" != paymentMode && paymentModeRemoteId == DBBill.PAYMODE_ID_NONE) {
Log.d("PaymentMode", "old: $paymentMode and new: 0")
paymentModeRemoteId = DBBill.oldPmIdToNew[paymentMode] ?: DBBill.PAYMODE_ID_NONE
}
val bill = DBBill(
0, remoteId, projId, payerId, amount, timestamp, what,
DBBill.STATE_OK, repeat, paymentMode, categoryId, comment, paymentModeRemoteId
)
bill.billOwers = getBillOwersFromJson(json, memberRemoteIdToId)
return bill
}
@Throws(JSONException::class)
protected fun getBillOwersFromJson(
json: JSONObject,
memberRemoteIdToId: Map<Long, Long>
): List<DBBillOwer> {
val billOwers: MutableList<DBBillOwer> = ArrayList()
if (json.has("owers")) {
val jsonOs = json.getJSONArray("owers")
for (i in 0 until jsonOs.length()) {
val obj = jsonOs.get(i)
val memberRemoteId = if (obj is JSONObject) {
obj.getLong("id")
} else {
jsonOs.getLong(i)
}
val memberLocalId = memberRemoteIdToId[memberRemoteId] ?: 0
billOwers.add(DBBillOwer(0, 0, memberLocalId))
}
}
return billOwers
}
@Throws(JSONException::class)
protected fun getAccountProjectsFromJSONArray(jsonMs: JSONArray, ncUrl: String): List<DBAccountProject> {
val accountProjects: MutableList<DBAccountProject> = ArrayList()
for (i in 0 until jsonMs.length()) {
val jsonAP = jsonMs.getJSONObject(i)
accountProjects.add(getAccountProjectFromJSON(jsonAP, ncUrl))
}
return accountProjects
}
@Throws(JSONException::class)
protected fun getAccountProjectFromJSON(json: JSONObject, accountNcUrl: String): DBAccountProject {
var remoteId = ""
var name = ""
var ncUrl = ""
if (!json.isNull("name")) {
name = json.getString("name")
}
if (!json.isNull("id")) {
remoteId = json.getString("id")
}
if (!json.isNull("ncurl")) {
ncUrl = json.getString("ncUrl")
}
val archivedTs: Long? = getArchivedTsFromJSON(json)
if (ncUrl.isEmpty()) {
ncUrl = accountNcUrl
}
return DBAccountProject(0, remoteId, null, name, ncUrl, archivedTs)
}
@Throws(IOException::class)
protected fun getColorFromContent(content: String): String? {
var result: String? = null
try {
val dbf = DocumentBuilderFactory.newInstance()
val db = dbf.newDocumentBuilder()
val stream: InputStream = ByteArrayInputStream(content.toByteArray())
val doc = db.parse(stream)
doc.documentElement.normalize()
// Locate the Tag Name
val nodeList = doc.getElementsByTagName("color")
if (nodeList.length > 0) {
result = nodeList.item(0).textContent
Log.i(TAG, "I GOT THE COLOR from server: $result")
}
} catch (_: ParserConfigurationException) {
} catch (_: SAXException) {
}
return result
}
protected fun getColorFromJsonContent(json: JSONObject): String? {
return try {
val ocs = json.getJSONObject("ocs")
val data = ocs.getJSONObject("data")
val capabilities = data.getJSONObject("capabilities")
val theming = capabilities.getJSONObject("theming")
val color = theming.getString("color")
Log.i(TAG, "I GOT THE COLOR from server's JSON response: $color")
color
} catch (e: JSONException) {
Log.e(TAG, "Failed to get the color from OCS capabilities response $e")
null
}
}
protected fun getCospendVersionFromCapabilitiesContent(json: JSONObject): String? {
return try {
val ocs = json.getJSONObject("ocs")
val data = ocs.getJSONObject("data")
val capabilities = data.getJSONObject("capabilities")
val cospend = capabilities.getJSONObject("cospend")
val version = cospend.getString("version")
Log.i(TAG, "I GOT THE Cospend version: $version")
version
} catch (e: JSONException) {
Log.i(TAG, "Failed to get the Cospend version$e")
null
}
}
@Throws(JSONException::class)
protected fun getRemoteMemberIdFromJSON(json: JSONObject): Long {
return json.getLong("id")
}
companion object {
private val TAG = ServerResponse::class.java.simpleName
}
}

View File

@@ -0,0 +1,338 @@
package net.helcel.cowspent.util
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.content.pm.PackageManager
import android.util.Log
import net.helcel.cowspent.model.*
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import org.json.JSONException
import org.json.JSONObject
import java.io.IOException
import java.net.HttpURLConnection
import java.net.MalformedURLException
import java.net.URL
import java.security.KeyManagementException
import java.security.NoSuchAlgorithmException
import java.text.NumberFormat
import java.util.Locale
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.SSLContext
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.round
object SupportUtil {
@JvmField
val normalNumberFormat: NumberFormat = NumberFormat.getInstance()
@JvmField
val dotNumberFormat: NumberFormat = NumberFormat.getNumberInstance(Locale.UK)
@JvmField
val commaNumberFormat: NumberFormat = NumberFormat.getNumberInstance(Locale.GERMANY)
init {
normalNumberFormat.maximumFractionDigits = 2
normalNumberFormat
dotNumberFormat.maximumFractionDigits = Int.MAX_VALUE
dotNumberFormat.isGroupingUsed = false
commaNumberFormat.maximumFractionDigits = Int.MAX_VALUE
commaNumberFormat.isGroupingUsed = false
}
@JvmStatic
@Throws(MalformedURLException::class, IOException::class)
fun getHttpURLConnection(strUrl: String): HttpURLConnection {
val url = URL(strUrl)
val httpCon = url.openConnection() as HttpURLConnection
if (url.protocol == "https") {
val httpsCon = httpCon as HttpsURLConnection
try {
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(null, null, null)
httpsCon.sslSocketFactory = sslContext.socketFactory
} catch (e: NoSuchAlgorithmException) {
Log.e(SupportUtil::class.java.simpleName, "Exception", e)
} catch (e: KeyManagementException) {
Log.e(SupportUtil::class.java.simpleName, "Exception", e)
}
}
return httpCon
}
@JvmStatic
fun isDouble(s: String?): Boolean {
if (s == null) return false
return try {
s.toDouble()
true
} catch (_: NumberFormatException) {
false
}
}
@JvmStatic
fun isValidEmail(target: CharSequence?): Boolean {
return if (target == null) false else android.util.Patterns.EMAIL_ADDRESS.matcher(target).matches()
}
@JvmStatic
fun getStatsOfProject(
projId: Long, db: CowspentSQLiteOpenHelper,
membersNbBills: MutableMap<Long, Int>,
membersBalance: MutableMap<Long, Double>,
membersPaid: MutableMap<Long, Double>,
membersSpent: MutableMap<Long, Double>,
catId: Int, paymentModeId: Int,
dateMin: String?, dateMax: String?
): Int {
return getStats(
db.getMembersOfProject(projId, null),
db.getBillsOfProject(projId),
membersNbBills, membersBalance, membersPaid, membersSpent,
catId, paymentModeId, dateMin, dateMax
)
}
@JvmStatic
fun getStats(
dbMembers: List<DBMember>,
dbBills: List<DBBill>,
membersNbBills: MutableMap<Long, Int>,
membersBalance: MutableMap<Long, Double>,
membersPaid: MutableMap<Long, Double>,
membersSpent: MutableMap<Long, Double>,
catId: Int, paymentModeId: Int,
dateMin: String?, dateMax: String?
): Int {
val nbBillsTotal = 0
val membersWeight: MutableMap<Long, Double> = HashMap()
// init
for (m in dbMembers) {
membersNbBills[m.id] = 0
membersBalance[m.id] = 0.0
membersPaid[m.id] = 0.0
membersSpent[m.id] = 0.0
membersWeight[m.id] = m.weight
}
for (b in dbBills) {
// don't take deleted bills and respect category filter
if (b.state != DBBill.STATE_DELETED &&
((catId == -1000 || catId == -100 || b.categoryRemoteId == catId) &&
(catId != -100 || b.categoryRemoteId != DBBill.CATEGORY_REIMBURSEMENT) &&
(paymentModeId == -1000 || b.paymentModeRemoteId == paymentModeId)) &&
(dateMin == null || b.date >= dateMin) &&
(dateMax == null || b.date <= dateMax)
) {
val nb = membersNbBills[b.payerId] ?: 0
membersNbBills[b.payerId] = nb + 1
val amount = b.amount
val balPayer = membersBalance[b.payerId] ?: 0.0
membersBalance[b.payerId] = balPayer + amount
val paid = membersPaid[b.payerId] ?: 0.0
membersPaid[b.payerId] = paid + amount
var nbOwerShares = 0.0
for (bo in b.billOwers) {
nbOwerShares += membersWeight[bo.memberId] ?: 0.0
}
for (bo in b.billOwers) {
val owerWeight = membersWeight[bo.memberId] ?: 0.0
val spent = if (nbOwerShares > 0) amount / nbOwerShares * owerWeight else 0.0
val balOwer = membersBalance[bo.memberId] ?: 0.0
membersBalance[bo.memberId] = balOwer - spent
val spentOwer = membersSpent[bo.memberId] ?: 0.0
membersSpent[bo.memberId] = spentOwer + spent
}
}
}
return nbBillsTotal
}
@JvmStatic
fun round2(n: Double): Double {
var r = round(abs(n) * 100.0) / 100.0
if (n < 0.0) r = -r
return r
}
const val SETTLE_OPTIMAL: Long = 0
@JvmStatic
fun settleBills(
members: List<DBMember>, membersBalance: Map<Long, Double>,
centerOnMemberId: Long
): List<Transaction> {
return if (centerOnMemberId == SETTLE_OPTIMAL) {
settleBillsOptimal(members, membersBalance)
} else {
val results: MutableList<Transaction> = ArrayList()
for (mid in membersBalance.keys) {
if (mid != centerOnMemberId) {
val balance = membersBalance[mid] ?: 0.0
if (balance > 0.0) {
results.add(Transaction(centerOnMemberId, mid, balance))
} else if (balance < 0.0) {
results.add(Transaction(mid, centerOnMemberId, -balance))
}
}
}
results
}
}
@JvmStatic
fun settleBillsOptimal(members: List<DBMember>, membersBalance: Map<Long, Double>): List<Transaction> {
val crediters: MutableList<CreditDebt> = ArrayList()
val debiters: MutableList<CreditDebt> = ArrayList()
// Create lists of credits and debts
for (m in members) {
val memberId = m.id
val balance = membersBalance[memberId] ?: 0.0
if (round2(balance) > 0.0) {
crediters.add(CreditDebt(memberId, balance))
} else if (round2(balance) < 0.0) {
debiters.add(CreditDebt(memberId, balance))
}
}
return reduceBalance(crediters, debiters, null)
}
@JvmStatic
fun reduceBalance(
crediters: MutableList<CreditDebt>,
debiters: MutableList<CreditDebt>,
resultsParam: MutableList<Transaction>?
): List<Transaction> {
var results = resultsParam
if (debiters.isEmpty() || crediters.isEmpty()) {
return results ?: emptyList()
}
if (results == null) {
results = ArrayList()
}
crediters.sortWith { cd2, cd1 ->
if (cd1.balance == cd2.balance) {
0
} else {
if (cd1.balance < cd2.balance) 1 else -1
}
}
for (c in crediters) {
Log.e(SupportUtil::class.java.simpleName, "* " + c.memberId + " : " + c.balance)
}
debiters.sortWith { cd2, cd1 ->
if (cd1.balance == cd2.balance) {
0
} else {
if (cd1.balance > cd2.balance) 1 else -1
}
}
val deb = debiters.removeAt(debiters.size - 1)
val debiter = deb.memberId
val debiterBalance = deb.balance
val cred = crediters.removeAt(crediters.size - 1)
val crediter = cred.memberId
val crediterBalance = cred.balance
val amount: Double = if (abs(debiterBalance) > abs(crediterBalance)) {
abs(crediterBalance)
} else {
abs(debiterBalance)
}
results.add(Transaction(debiter, crediter, amount))
val newDebiterBalance = debiterBalance + amount
if (newDebiterBalance < 0.0) {
debiters.add(CreditDebt(debiter, newDebiterBalance))
debiters.sortWith { cd2, cd1 ->
if (cd1.balance == cd2.balance) {
0
} else {
if (cd1.balance > cd2.balance) 1 else -1
}
}
}
val newCrediterBalance = crediterBalance - amount
if (newCrediterBalance > 0.0) {
crediters.add(CreditDebt(crediter, newCrediterBalance))
crediters.sortWith { cd2, cd1 ->
if (cd1.balance == cd2.balance) {
0
} else {
if (cd1.balance < cd2.balance) 1 else -1
}
}
}
return reduceBalance(crediters, debiters, results)
}
@JvmStatic
fun getVersionName(context: Context): String {
var versionName = "0.0.0"
try {
val pInfo = context.packageManager.getPackageInfo(context.packageName, 0)
versionName = pInfo.versionName ?: "0.0.0"
} catch (e: PackageManager.NameNotFoundException) {
e.printStackTrace()
}
return versionName
}
@JvmStatic
fun getJsonObject(text: String?): JSONObject? {
if (text == null) return null
return try {
JSONObject(text)
} catch (_: JSONException) {
null
}
}
@JvmStatic
fun compareVersions(version1: String, version2: String): Int {
val levels1 = version1.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
val levels2 = version2.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
val length = max(levels1.size, levels2.size)
for (i in 0 until length) {
val v1 = if (i < levels1.size) levels1[i].toInt() else 0
val v2 = if (i < levels2.size) levels2[i].toInt() else 0
val compare = v1.compareTo(v2)
if (compare != 0) {
return compare
}
}
return 0
}
@JvmStatic
fun getAppVersionName(context: Context): String {
var versionName = "???"
try {
val pInfo = context.packageManager.getPackageInfo(context.packageName, 0)
versionName = pInfo.versionName ?: "0.0.0"
} catch (e: PackageManager.NameNotFoundException) {
Log.e(SupportUtil::class.java.simpleName, "Failed to get app version name", e)
e.printStackTrace()
}
Log.d(SupportUtil::class.java.simpleName, "app version name is $versionName")
return versionName
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
package net.helcel.cowspent.util
import android.database.sqlite.SQLiteDatabase
fun evalMath(expression: String): Double {
var result = 0.0
var db: SQLiteDatabase? = null
try {
// Opens a temporary, in-memory system database block
db = SQLiteDatabase.create(null)
val cursor = db.rawQuery("SELECT ($expression);", null)
if (cursor.moveToFirst()) {
result = cursor.getDouble(0)
}
cursor.close()
} catch (e: Exception) {
e.printStackTrace()
} finally {
db?.close()
}
return result
}

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="100"
android:viewportHeight="100">
<!-- Matches #0F172A Base -->
<path
android:pathData="M0,0h100v100h-100z"
android:fillColor="#0F172A"/>
</vector>

View File

@@ -0,0 +1,61 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="72"
android:viewportHeight="72">
<group android:translateY="0.33333334">
<group android:translateY="-0.33333334">
<group android:scaleX="0.63461536"
android:scaleY="0.63461536"
android:translateX="13.153846"
android:translateY="13.153846">
<path
android:fillColor="#FFD700"
android:pathData="M36,2 C17.22,2 2,17.22 2,36 C2,54.78 17.22,70 36,70 C54.78,70 70,54.78 70,36 C70,17.22 54.78,2 36,2 Z" />
<!-- Inner Face (Expanded path) -->
<path
android:fillColor="#DAA520"
android:pathData="M36,8 C20.54,8 8,20.54 8,36 C8,51.46 20.54,64 36,64 C51.46,64 64,51.46 64,36 C64,20.54 51.46,8 36,8 Z" />
<!-- Center Detail (Pure path replacement for the center circle) -->
<path
android:fillColor="#FFD700"
android:pathData="M36,22 C28.27,22 22,28.27 22,36 C22,43.73 28.27,50 36,50 C43.73,50 50,43.73 50,36 C50,28.27 43.73,22 36,22 Z" />
<group android:name="color">
<path android:fillColor="#fff" android:pathData="m15,12.0211h0c0,.6116.3056,1.1827.8145,1.5219l4.2449,2.83c.5764.3843,1.774.8721,1.688,1.5596l-1.7051,6.7284c-.028.2241-.0143.4514.0405.6705l1.8343,7.3371c.0548.2191.0685.4464.0405.6705l-.9152,7.322c-.028.2241-.0143.4514.0405.6705l1.7456,6.9825c.1104.4414.3813.8259.7599,1.0783l1.7312,1.1541c.433.2887.7225.7481.7961,1.2632l-.2598,3.7979s-.2916,3.1088,2.3012,6.051c1.6332,1.8533,5.7148,3.8846,8.2656,3.8847,3.3409,0,5.9547-1.9378,7.3949-3.6465.8914-1.0576,2.5398-2.4978,3.2821-5.8244.2014-.9023-.0996-3.6967-.0996-3.6967,0-.2477.4009-1.311.4985-1.5386l1.3063-2.5077c.0158-.0369.0264-.0595.0389-.0795.5394-.8642.7885-1.8786.8851-2.8927,0,0,.9043-4.9747,1.1585-6.6406.005-.0325-.4996-5.5158-.4953-5.5485l1.5496-6.8172c.0386-.2315.0323-.4683-.0187-.6975l-1.5364-6.9139c-.2151-.9678.3801-1.9308,1.3419-2.1712l1.6416-.4104c.4075-.1019.7676-.341,1.0196-.677l2.2445-2.9927c.2375-.3166.3658-.7017.3658-1.0974v-.1102c0-1.1701-1.1152-1.1434-2.2575-.8896l-6.191.4868c-.3593.0798-.7343.0494-1.0761-.0873l-4.3602-1.7441c-.0767-.0307-.1555-.0562-.2357-.0762l-7.3628-1.8407c-.3375-.0844-.692-.0708-1.022.0393l-5.3714,1.7905c-.0821.0274-.1622.0605-.2396.0992l-3.4104,1.7052c-.3092.1546-.656.2184-1,.184l-7.4623-.7462c-1.0768-.1077-2.0111.7379-2.0111,1.82Z"/>
<path android:fillColor="#a57939" android:pathData="m65.1818,16.5746l-3.5658.2422c-.1304.0089-.2591.0347-.3828.077l-7.081,2.42c-.043.0147-.0854.0314-.1269.0499l-2.6409,1.1807c-.1171.0524-.2341.0883-.3607.1091-1.3737.2264-10.1731,1.8874-10.0194,7.2658,0,0-.4646,3.6003,1.951,6.1473,1.8803,1.9826,2.9889,4.6478,2.5192,7.3395-.3101,1.7768-1.2515,3.5306-3.4702,4.594h0l2.9104,1.0217c.4295.1508.767.4887.9174.9183l1.4763,4.2192,1.6616-1.7712c1.1136-1.1945,1.9689-5.8742,1.9689-5.8742.4855-3.6065.5032-7.264.0031-10.8685-.0148-.1064-.0287-.2031-.0417-.2889h0s1.2606-.965,1.2606-.965c.1608-.1231.3449-.2123.5411-.2622l5.5633-1.4149c.1716-.0436.3341-.1174.4799-.2177l4.3017-2.9608c.1811-.1247.3326-.2877.4436-.4775l2.6005-4.4465c.1016-.1737.1671-.3661.1927-.5658l.4849-3.7874c.1198-.9359-.6453-1.748-1.5867-1.6841Z"/>
<path android:fillColor="#a57939" android:pathData="m6.867,16.5746l3.5658.2422c.1304.0089.2591.0347.3828.077l7.081,2.42c.043.0147.0854.0314.1269.0499l2.6409,1.1807c.1171.0524.2341.0883.3607.1091,1.3737.2264,10.1731,1.8874,10.0194,7.2658,0,0,.4645,3.6003-1.951,6.1473-1.8803,1.9826-2.9889,4.6478-2.5192,7.3395.3101,1.7768,1.2515,3.5306,3.4702,4.594h0l-2.9104,1.0217c-.4295.1508-.767.4887-.9174.9183l-1.4763,4.2192-1.6616-1.7712c-1.1136-1.1945-2.0412-6.9083-2.0412-6.9083-.3413-3.1673-.1698-6.3689.5077-9.4817h0c.1234-.5672-.092-1.1546-.553-1.5075l-.4665-.3571c-.1858-.1422-.4024-.2389-.6323-.2824l-6.0587-1.1441c-.2049-.0387-.3994-.1197-.5711-.2379l-4.2616-2.9332c-.1811-.1247-.3326-.2877-.4436-.4775l-2.6005-4.4465c-.1016-.1737-.1671-.3661-.1927-.5657l-.485-3.7875c-.1198-.9359.6453-1.7481,1.5867-1.6841Z"/>
<path android:fillColor="#f4aa41" android:pathData="m27.3197,47.9945l-.6598,1.1318c-.7217,2.1691-.9617,4.4695-.7033,6.7408h0c.1194,1.0495.9592,1.871,2.0111,1.9674l.0811.0074,6.4963-.5146c1.249-.0989,2.5045-.0796,3.7499.0577l5.9262.6536c.7598.0838,1.5171-.1772,2.064-.7112h0c.4669-.4559.7436-1.0718.7746-1.7237l.0484-1.0203c.1133-2.3893-.4008-4.7667-1.4912-6.8958h0c-.4317-.843-1.2874-1.3848-2.234-1.4148l-8.6286-.2728h-3.9632c-1.4296,0-2.7515.7595-3.4715,1.9945Z"/>
</group>
<group android:name="line">
<path android:fillColor="#000" android:pathData="m26.1616,31.0595s-3.0474-.219-3.8284-1-.781-2.0474,0-2.8284,2.0474-.781,2.8284,0c.7811.781,1,3.8284,1,3.8284Z"/>
<path android:strokeColor="#000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2" android:pathData="m19.6191,26.2353l2.0377-9.248s-7.6806-.627-6.505-7.4454c0,0,9.9534,3.2917,12.7748.4702,0,0,6.2375-6.6642,16.1956-.0429,3.3226,2.2093,12.7748-.4702,12.7748-.4702,1.1756,6.8184-6.505,7.4454-6.505,7.4454l2.0377,9.248"/>
<path android:strokeColor="#000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2" android:pathData="m28.4825,46h15.7265c.272,0,.5324.1138.7082.3213.8467.9992,3.1442,4.3471,2.0943,10.2966-.1836,1.0404-1.1647,1.7418-2.2037,1.5503-3.4759-.6406-11.324-1.7893-16.7592-.3263-1.1058.2976-2.2065-.454-2.3432-1.5909-.302-2.5115-.3157-6.6284,2.0103-9.8682.1751-.2438.4667-.3828.7668-.3828Z"/>
<path android:strokeColor="#000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2" android:pathData="m29.8671,50.277s-.6865,2.855,3.1538,2.926"/>
<path android:strokeColor="#000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2" android:pathData="m29.1276,61.4663s6.3482,7.9157,13.7936.3135"/>
<path android:strokeColor="#000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2" android:pathData="m17.5177,20.1544c-3.0642-1.9576-7.3791-4.5873-12.4353-3.3426,0,0-1.4891,14.0288,15.3611,15.5179l1.2133.881s-2.2906,8.9177,1.2618,16.1018"/>
<path android:fillColor="#000" android:pathData="m45.8871,31.0139s3.0474-.219,3.8284-1c.7811-.781.7811-2.0474,0-2.8284-.781-.781-2.0474-.781-2.8284,0-.781.781-1,3.8284-1,3.8284Z"/>
<path android:strokeColor="#000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2" android:pathData="m43.1817,50.2341s.6865,2.855-3.1538,2.926"/>
<path android:strokeColor="#000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2" android:pathData="m54.7743,19.9585c3.0474-1.888,7.2683-4.4017,12.1921-3.1896,0,0,1.4891,14.0288-15.3611,15.5179l-1.2133.881s2.5382,7.9772-1.0141,15.1613"/>
</group>
<!-- Massive Background Coin Base -->
<path
android:pathData="M 256,76 A 180,180 0 1,1 255.9,76 Z"
android:fillColor="#FBBF24"/>
<!-- Coin Decorative Dotted Inner Rim -->
<path
android:pathData="M 256,101 A 155,155 0 1,1 255.9,101 Z"
android:strokeColor="#D97706"
android:strokeWidth="4"/>
</group>
</group>
</group>
</vector>

View File

@@ -0,0 +1,52 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="128dp"
android:height="128dp"
android:viewportWidth="72"
android:viewportHeight="72">
<path
android:fillColor="#FFD700"
android:pathData="M36,2 C17.22,2 2,17.22 2,36 C2,54.78 17.22,70 36,70 C54.78,70 70,54.78 70,36 C70,17.22 54.78,2 36,2 Z" />
<!-- Inner Face (Expanded path) -->
<path
android:fillColor="#DAA520"
android:pathData="M36,8 C20.54,8 8,20.54 8,36 C8,51.46 20.54,64 36,64 C51.46,64 64,51.46 64,36 C64,20.54 51.46,8 36,8 Z" />
<!-- Center Detail (Pure path replacement for the center circle) -->
<path
android:fillColor="#FFD700"
android:pathData="M36,22 C28.27,22 22,28.27 22,36 C22,43.73 28.27,50 36,50 C43.73,50 50,43.73 50,36 C50,28.27 43.73,22 36,22 Z" />
<group android:name="color">
<path android:fillColor="#fff" android:pathData="m15,12.0211h0c0,.6116.3056,1.1827.8145,1.5219l4.2449,2.83c.5764.3843,1.774.8721,1.688,1.5596l-1.7051,6.7284c-.028.2241-.0143.4514.0405.6705l1.8343,7.3371c.0548.2191.0685.4464.0405.6705l-.9152,7.322c-.028.2241-.0143.4514.0405.6705l1.7456,6.9825c.1104.4414.3813.8259.7599,1.0783l1.7312,1.1541c.433.2887.7225.7481.7961,1.2632l-.2598,3.7979s-.2916,3.1088,2.3012,6.051c1.6332,1.8533,5.7148,3.8846,8.2656,3.8847,3.3409,0,5.9547-1.9378,7.3949-3.6465.8914-1.0576,2.5398-2.4978,3.2821-5.8244.2014-.9023-.0996-3.6967-.0996-3.6967,0-.2477.4009-1.311.4985-1.5386l1.3063-2.5077c.0158-.0369.0264-.0595.0389-.0795.5394-.8642.7885-1.8786.8851-2.8927,0,0,.9043-4.9747,1.1585-6.6406.005-.0325-.4996-5.5158-.4953-5.5485l1.5496-6.8172c.0386-.2315.0323-.4683-.0187-.6975l-1.5364-6.9139c-.2151-.9678.3801-1.9308,1.3419-2.1712l1.6416-.4104c.4075-.1019.7676-.341,1.0196-.677l2.2445-2.9927c.2375-.3166.3658-.7017.3658-1.0974v-.1102c0-1.1701-1.1152-1.1434-2.2575-.8896l-6.191.4868c-.3593.0798-.7343.0494-1.0761-.0873l-4.3602-1.7441c-.0767-.0307-.1555-.0562-.2357-.0762l-7.3628-1.8407c-.3375-.0844-.692-.0708-1.022.0393l-5.3714,1.7905c-.0821.0274-.1622.0605-.2396.0992l-3.4104,1.7052c-.3092.1546-.656.2184-1,.184l-7.4623-.7462c-1.0768-.1077-2.0111.7379-2.0111,1.82Z"/>
<path android:fillColor="#a57939" android:pathData="m65.1818,16.5746l-3.5658.2422c-.1304.0089-.2591.0347-.3828.077l-7.081,2.42c-.043.0147-.0854.0314-.1269.0499l-2.6409,1.1807c-.1171.0524-.2341.0883-.3607.1091-1.3737.2264-10.1731,1.8874-10.0194,7.2658,0,0-.4646,3.6003,1.951,6.1473,1.8803,1.9826,2.9889,4.6478,2.5192,7.3395-.3101,1.7768-1.2515,3.5306-3.4702,4.594h0l2.9104,1.0217c.4295.1508.767.4887.9174.9183l1.4763,4.2192,1.6616-1.7712c1.1136-1.1945,1.9689-5.8742,1.9689-5.8742.4855-3.6065.5032-7.264.0031-10.8685-.0148-.1064-.0287-.2031-.0417-.2889h0s1.2606-.965,1.2606-.965c.1608-.1231.3449-.2123.5411-.2622l5.5633-1.4149c.1716-.0436.3341-.1174.4799-.2177l4.3017-2.9608c.1811-.1247.3326-.2877.4436-.4775l2.6005-4.4465c.1016-.1737.1671-.3661.1927-.5658l.4849-3.7874c.1198-.9359-.6453-1.748-1.5867-1.6841Z"/>
<path android:fillColor="#a57939" android:pathData="m6.867,16.5746l3.5658.2422c.1304.0089.2591.0347.3828.077l7.081,2.42c.043.0147.0854.0314.1269.0499l2.6409,1.1807c.1171.0524.2341.0883.3607.1091,1.3737.2264,10.1731,1.8874,10.0194,7.2658,0,0,.4645,3.6003-1.951,6.1473-1.8803,1.9826-2.9889,4.6478-2.5192,7.3395.3101,1.7768,1.2515,3.5306,3.4702,4.594h0l-2.9104,1.0217c-.4295.1508-.767.4887-.9174.9183l-1.4763,4.2192-1.6616-1.7712c-1.1136-1.1945-2.0412-6.9083-2.0412-6.9083-.3413-3.1673-.1698-6.3689.5077-9.4817h0c.1234-.5672-.092-1.1546-.553-1.5075l-.4665-.3571c-.1858-.1422-.4024-.2389-.6323-.2824l-6.0587-1.1441c-.2049-.0387-.3994-.1197-.5711-.2379l-4.2616-2.9332c-.1811-.1247-.3326-.2877-.4436-.4775l-2.6005-4.4465c-.1016-.1737-.1671-.3661-.1927-.5657l-.485-3.7875c-.1198-.9359.6453-1.7481,1.5867-1.6841Z"/>
<path android:fillColor="#f4aa41" android:pathData="m27.3197,47.9945l-.6598,1.1318c-.7217,2.1691-.9617,4.4695-.7033,6.7408h0c.1194,1.0495.9592,1.871,2.0111,1.9674l.0811.0074,6.4963-.5146c1.249-.0989,2.5045-.0796,3.7499.0577l5.9262.6536c.7598.0838,1.5171-.1772,2.064-.7112h0c.4669-.4559.7436-1.0718.7746-1.7237l.0484-1.0203c.1133-2.3893-.4008-4.7667-1.4912-6.8958h0c-.4317-.843-1.2874-1.3848-2.234-1.4148l-8.6286-.2728h-3.9632c-1.4296,0-2.7515.7595-3.4715,1.9945Z"/>
</group>
<group android:name="line">
<path android:fillColor="#000" android:pathData="m26.1616,31.0595s-3.0474-.219-3.8284-1-.781-2.0474,0-2.8284,2.0474-.781,2.8284,0c.7811.781,1,3.8284,1,3.8284Z"/>
<path android:strokeColor="#000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2" android:pathData="m19.6191,26.2353l2.0377-9.248s-7.6806-.627-6.505-7.4454c0,0,9.9534,3.2917,12.7748.4702,0,0,6.2375-6.6642,16.1956-.0429,3.3226,2.2093,12.7748-.4702,12.7748-.4702,1.1756,6.8184-6.505,7.4454-6.505,7.4454l2.0377,9.248"/>
<path android:strokeColor="#000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2" android:pathData="m28.4825,46h15.7265c.272,0,.5324.1138.7082.3213.8467.9992,3.1442,4.3471,2.0943,10.2966-.1836,1.0404-1.1647,1.7418-2.2037,1.5503-3.4759-.6406-11.324-1.7893-16.7592-.3263-1.1058.2976-2.2065-.454-2.3432-1.5909-.302-2.5115-.3157-6.6284,2.0103-9.8682.1751-.2438.4667-.3828.7668-.3828Z"/>
<path android:strokeColor="#000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2" android:pathData="m29.8671,50.277s-.6865,2.855,3.1538,2.926"/>
<path android:strokeColor="#000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2" android:pathData="m29.1276,61.4663s6.3482,7.9157,13.7936.3135"/>
<path android:strokeColor="#000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2" android:pathData="m17.5177,20.1544c-3.0642-1.9576-7.3791-4.5873-12.4353-3.3426,0,0-1.4891,14.0288,15.3611,15.5179l1.2133.881s-2.2906,8.9177,1.2618,16.1018"/>
<path android:fillColor="#000" android:pathData="m45.8871,31.0139s3.0474-.219,3.8284-1c.7811-.781.7811-2.0474,0-2.8284-.781-.781-2.0474-.781-2.8284,0-.781.781-1,3.8284-1,3.8284Z"/>
<path android:strokeColor="#000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2" android:pathData="m43.1817,50.2341s.6865,2.855-3.1538,2.926"/>
<path android:strokeColor="#000" android:strokeLineCap="round" android:strokeLineJoin="round" android:strokeWidth="2" android:pathData="m54.7743,19.9585c3.0474-1.888,7.2683-4.4017,12.1921-3.1896,0,0,1.4891,14.0288-15.3611,15.5179l-1.2133.881s2.5382,7.9772-1.0141,15.1613"/>
</group>
<!-- Massive Background Coin Base -->
<path
android:pathData="M 256,76 A 180,180 0 1,1 255.9,76 Z"
android:fillColor="#FBBF24"/>
<!-- Coin Decorative Dotted Inner Rim -->
<path
android:pathData="M 256,101 A 155,155 0 1,1 255.9,101 Z"
android:strokeColor="#D97706"
android:strokeWidth="4"/>
</vector>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View File

@@ -0,0 +1,2 @@
# https://developer.android.com/guide/topics/resources/app-languages
unqualifiedResLocale=en

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<bool name="isDayMode">false</bool>
</resources>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="primary">#72D1FF</color>
<color name="on_primary">#000000</color>
<color name="accent">#72D1FF</color>
<color name="surface">#121212</color>
<color name="on_surface">#ffffff</color>
<color name="bg_normal">#121212</color>
<color name="fg_default">#ffffff</color>
<color name="fg_default_low">#aaaaaa</color>
</resources>

Some files were not shown because too many files have changed in this diff Show More