diff --git a/api/agata/build.gradle.kts b/api/agata/build.gradle.kts
index 0e811674..5f287787 100644
--- a/api/agata/build.gradle.kts
+++ b/api/agata/build.gradle.kts
@@ -30,14 +30,15 @@ dependencies {
commonMainImplementation(projects.core)
commonMainImplementation(projects.api.core)
+ // skrape-it
+ // androidMainImplementation(projects.htmlParser)
+ androidMainImplementation("it.skrape:skrapeit:1.2.2")
+
commonMainImplementation(libs.ktor.client.core)
commonMainImplementation(libs.ktor.client.contentNegotiation)
commonMainImplementation(libs.ktor.client.logging)
commonMainImplementation(libs.ktor.client.serialization)
- commonMainImplementation(projects.entity)
- commonMainImplementation(projects.htmlParser)
-
commonMainImplementation(libs.bundles.russhwolf.settings)
}
diff --git a/api/agata/src/androidMain/kotlin/cz/lastaapps/menza/api/agata/api/AgataWallet.kt b/api/agata/src/androidMain/kotlin/cz/lastaapps/menza/api/agata/api/AgataWallet.kt
deleted file mode 100644
index a1570ab2..00000000
--- a/api/agata/src/androidMain/kotlin/cz/lastaapps/menza/api/agata/api/AgataWallet.kt
+++ /dev/null
@@ -1,125 +0,0 @@
-/*
- * Copyright 2023, Petr Laštovička as Lasta apps, All rights reserved
- *
- * This file is part of Menza.
- *
- * Menza is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * Menza is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with Menza. If not, see .
- */
-
-package cz.lastaapps.menza.api.agata.api
-
-import cz.lastaapps.core.domain.Outcome
-import cz.lastaapps.core.util.extensions.catchingNetwork
-import io.ktor.client.HttpClient
-import io.ktor.client.plugins.BrowserUserAgent
-import io.ktor.client.request.forms.FormDataContent
-import io.ktor.client.request.get
-import io.ktor.client.request.header
-import io.ktor.client.request.post
-import io.ktor.client.request.setBody
-import io.ktor.client.statement.bodyAsText
-import io.ktor.http.Cookie
-import io.ktor.http.Parameters
-import it.skrape.core.htmlDocument
-import java.net.URLDecoder
-
-actual class AgataWallet actual constructor(httpClient: HttpClient) {
-
- private val client: HttpClient
-
- init {
- client = httpClient.config {
- // Disable redirects, because when I was testing it with Python I got into loop sometimes
- // Also it has to extract some cookies from some of the requests, so redirects manually handled
- followRedirects = false
-
- BrowserUserAgent()
- }
- }
-
- /// Get balance from Agata (wrapper with Outcome)
- actual suspend fun getBalance(username: String, password: String): Outcome = catchingNetwork {
- getBalanceRaw(username, password)
- }
-
- /// Get balance from Agata
- private suspend fun getBalanceRaw(username: String, password: String): Float {
- // Go to the auth provider
- var response = client.get("https://agata.suz.cvut.cz/secure/index.php")
- var url = response.headers["Location"]
- response = client.get(url!!)
-
- // Get new url params
- val body = response.bodyAsText()
- val returnUrl = Regex("var returnURL = \"(.+?)\"").find(body)?.groups?.get(1)?.value
- val otherParams = Regex("var otherParams = \"(.+?)\"").find(body)?.groups?.get(1)?.value
- url = "${returnUrl}&entityID=https://idp2.civ.cvut.cz/idp/shibboleth${otherParams}"
-
- // Get to SSO
- response = client.get(url)
- response = client.get(response.headers["Location"]!!)
-
- // Extract JSESSIONID cookie
- val jsessionid = response.headers["Set-Cookie"]!!.split(";")[0].split("=")[1]
-
- // Resolve SSO
- url = "https://idp2.civ.cvut.cz${response.headers["Location"]}"
- client.get(url)
- response = client.post(url) {
- setBody(FormDataContent(Parameters.build {
- append("j_username", username)
- append("j_password", password)
- append("_eventId_proceed", "")
- }))
- Cookie("JSESSIONID", jsessionid)
- header("Referer", url)
- header("Content-Type", "application/x-www-form-urlencoded")
- }
-
- // Extract response codes from html
- var html = htmlDocument(response.bodyAsText())
- var relayState: String? = null
- var samlResponse: String? = null
- html.findAll("input").forEach {
- if (it.attribute("name") == "RelayState") {
- relayState = URLDecoder.decode(it.attribute("value"), "UTF-8")
- }
- if (it.attribute("name") == "SAMLResponse") {
- samlResponse = it.attribute("value")
- }
- }
-
- // Send the shit back to Agata and get session cookie
- response = client.post("https://agata.suz.cvut.cz/Shibboleth.sso/SAML2/POST") {
- setBody(FormDataContent(Parameters.build {
- append("RelayState", relayState!!)
- append("SAMLResponse", samlResponse!!)
- }))
- header("Referer", "https://idp2.civ.cvut.cz/")
- }
- val sessionCookie = response.headers["Set-Cookie"]?.split(";")?.get(0)
-
- // Get balance from Agata
- response = client.get("https://agata.suz.cvut.cz/secure/index.php") {
- // The session cookie has variable name, so using raw headers here
- header("Cookie", sessionCookie)
- }
- html = htmlDocument(response.bodyAsText())
-
- // Parse
- return html.findFirst("h4 span.badge").text.lowercase()
- .replace("kč", "").replace(",", ".").replace(" ", "").trim().toFloat()
- }
-}
-
diff --git a/api/agata/src/androidMain/kotlin/cz/lastaapps/menza/api/agata/api/AndroidAgataCtuWalletApi.kt b/api/agata/src/androidMain/kotlin/cz/lastaapps/menza/api/agata/api/AndroidAgataCtuWalletApi.kt
new file mode 100644
index 00000000..960c13fe
--- /dev/null
+++ b/api/agata/src/androidMain/kotlin/cz/lastaapps/menza/api/agata/api/AndroidAgataCtuWalletApi.kt
@@ -0,0 +1,181 @@
+/*
+ * Copyright 2023, Petr Laštovička as Lasta apps, All rights reserved
+ *
+ * This file is part of Menza.
+ *
+ * Menza is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Menza is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Menza. If not, see .
+ */
+
+package cz.lastaapps.menza.api.agata.api
+
+import arrow.core.Either
+import arrow.core.Either.Left
+import arrow.core.Either.Right
+import arrow.core.flatten
+import arrow.core.left
+import arrow.core.raise.nullable
+import arrow.core.right
+import cz.lastaapps.core.domain.Outcome
+import cz.lastaapps.core.domain.error.ApiError.WalletError
+import cz.lastaapps.core.util.extensions.catchingNetwork
+import io.ktor.client.HttpClient
+import io.ktor.client.plugins.BrowserUserAgent
+import io.ktor.client.request.forms.FormDataContent
+import io.ktor.client.request.get
+import io.ktor.client.request.header
+import io.ktor.client.request.post
+import io.ktor.client.request.setBody
+import io.ktor.client.statement.bodyAsText
+import io.ktor.http.Cookie
+import io.ktor.http.HttpHeaders
+import io.ktor.http.Parameters
+import it.skrape.core.htmlDocument
+import java.net.URLDecoder
+
+internal class AndroidAgataCtuWalletApi(
+ httpClient: HttpClient,
+) : AgataCtuWalletApi {
+
+ private val client: HttpClient
+
+ init {
+ client = httpClient.config {
+ // Disable redirects, because when I was testing it with Python I got into loop sometimes
+ // Also it has to extract some cookies from some of the requests, so redirects manually handled
+ followRedirects = false
+ // Because of many redirects
+ expectSuccess = false
+
+ BrowserUserAgent()
+ }
+ }
+
+ // Get balance from Agata
+ // Originally written by Marekkon5
+ override suspend fun getBalance(
+ username: String,
+ password: String,
+ ): Outcome = catchingNetwork {
+ nullable {
+ // Go to the auth provider
+ client.get("https://agata.suz.cvut.cz/secure/index.php")
+ .headers[HttpHeaders.Location]
+ .bind()
+ .let { url -> client.get(url) }
+
+ // Get new url params
+ .let { request ->
+ val body = request.bodyAsText()
+ val returnUrl = Regex("var returnURL = \"(.+?)\"")
+ .find(body)?.groups?.get(1)?.value.bind()
+ val otherParams = Regex("var otherParams = \"(.+?)\"")
+ .find(body)?.groups?.get(1)?.value.bind()
+ "${returnUrl}&entityID=https://idp2.civ.cvut.cz/idp/shibboleth${otherParams}"
+ }
+ // Get to SSO
+ .let { url -> client.get(url).headers[HttpHeaders.Location].bind() }
+ .let { sso -> client.get(sso) }
+ .let { ssoResponse ->
+ // Extract JSESSIONID cookie
+ val jsessionid = ssoResponse.headers[HttpHeaders.SetCookie]
+ ?.split(";")
+ ?.firstOrNull()
+ ?.split("=")
+ ?.getOrNull(1)
+ .bind()
+
+ // Resolve SSO
+ val url =
+ "https://idp2.civ.cvut.cz${ssoResponse.headers[HttpHeaders.Location]}"
+
+ // result ignored, must happen
+ client.get(url)
+
+ client.post(url) {
+ setBody(
+ FormDataContent(
+ Parameters.build {
+ append("j_username", username)
+ append("j_password", password)
+ append("_eventId_proceed", "")
+ },
+ ),
+ )
+ Cookie("JSESSIONID", jsessionid)
+ header(HttpHeaders.Referrer, url)
+ header(HttpHeaders.ContentType, "application/x-www-form-urlencoded")
+ }
+ }
+
+ // Extract response codes from html
+ .let { htmlDocument(it.bodyAsText()) }
+ .let { html ->
+ var relayState: String? = null
+ var samlResponse: String? = null
+
+ val inputField = Either.catch {
+ html.findAll("input")
+ }
+ when (inputField) {
+ is Left -> return@catchingNetwork WalletError.InvalidCredentials.left()
+ is Right -> inputField.value
+ }.forEach {
+ if (it.attribute("name") == "RelayState") {
+ relayState = URLDecoder.decode(it.attribute("value"), "UTF-8")
+ }
+ if (it.attribute("name") == "SAMLResponse") {
+ samlResponse = it.attribute("value")
+ }
+ }
+
+ // Send the shit back to Agata and get session cookie
+ val response =
+ client.post("https://agata.suz.cvut.cz/Shibboleth.sso/SAML2/POST") {
+ setBody(
+ FormDataContent(
+ Parameters.build {
+ append("RelayState", relayState.bind())
+ append("SAMLResponse", samlResponse.bind())
+ },
+ ),
+ )
+ header(HttpHeaders.Referrer, "https://idp2.civ.cvut.cz/")
+ }
+
+ response.headers[HttpHeaders.SetCookie]
+ ?.split(";")
+ ?.getOrNull(0)
+ .bind()
+ }.let { sessionCookie ->
+ // Get balance from Agata
+ client.get("https://agata.suz.cvut.cz/secure/index.php") {
+ // The session cookie has variable name, so using raw headers here
+ header("Cookie", sessionCookie)
+ }
+ }.let { finalResponse ->
+ val html = htmlDocument(finalResponse.bodyAsText())
+
+ // Parse
+ html.findFirst("h4 span.badge").text
+ .lowercase()
+ .replace("kč", "")
+ .replace(",", ".")
+ .replace(" ", "")
+ .trim()
+ .toFloatOrNull()
+ .bind()
+ }
+ }?.right() ?: WalletError.TotallyBroken.left()
+ }.flatten()
+}
diff --git a/api/agata/src/androidMain/kotlin/cz/lastaapps/menza/api/agata/di/PlatformModule.kt b/api/agata/src/androidMain/kotlin/cz/lastaapps/menza/api/agata/di/PlatformModule.kt
index 0aae5471..4b559ba8 100644
--- a/api/agata/src/androidMain/kotlin/cz/lastaapps/menza/api/agata/di/PlatformModule.kt
+++ b/api/agata/src/androidMain/kotlin/cz/lastaapps/menza/api/agata/di/PlatformModule.kt
@@ -19,10 +19,15 @@
package cz.lastaapps.menza.api.agata.di
+import cz.lastaapps.menza.api.agata.api.AgataCtuWalletApi
+import cz.lastaapps.menza.api.agata.api.AndroidAgataCtuWalletApi
import cz.lastaapps.menza.api.agata.data.createAgataDBDriver
import org.koin.core.module.Module
+import org.koin.core.module.dsl.factoryOf
+import org.koin.dsl.bind
import org.koin.dsl.module
internal actual val platform: Module = module {
factory { createAgataDBDriver(get()) }
+ factoryOf(::AndroidAgataCtuWalletApi) bind AgataCtuWalletApi::class
}
diff --git a/api/agata/src/commonMain/kotlin/cz/lastaapps/menza/api/agata/api/AgataWallet.kt b/api/agata/src/commonMain/kotlin/cz/lastaapps/menza/api/agata/api/WalletApi.kt
similarity index 91%
rename from api/agata/src/commonMain/kotlin/cz/lastaapps/menza/api/agata/api/AgataWallet.kt
rename to api/agata/src/commonMain/kotlin/cz/lastaapps/menza/api/agata/api/WalletApi.kt
index 0470fa9a..3df63ef5 100644
--- a/api/agata/src/commonMain/kotlin/cz/lastaapps/menza/api/agata/api/AgataWallet.kt
+++ b/api/agata/src/commonMain/kotlin/cz/lastaapps/menza/api/agata/api/WalletApi.kt
@@ -20,8 +20,9 @@
package cz.lastaapps.menza.api.agata.api
import cz.lastaapps.core.domain.Outcome
-import io.ktor.client.HttpClient
-expect class AgataWallet(httpClient: HttpClient) {
+interface WalletApi {
suspend fun getBalance(username: String, password: String): Outcome
-}
\ No newline at end of file
+}
+
+interface AgataCtuWalletApi : WalletApi
diff --git a/api/core/build.gradle.kts b/api/core/build.gradle.kts
index cbbfa00d..a32a9a36 100644
--- a/api/core/build.gradle.kts
+++ b/api/core/build.gradle.kts
@@ -32,4 +32,7 @@ dependencies {
commonMainImplementation(libs.kotlinx.atomicfu)
commonMainImplementation(libs.sqldelight.runtime)
commonMainImplementation(libs.bundles.russhwolf.settings)
+
+ androidMainImplementation(libs.androidx.security)
+ androidMainImplementation(libs.androidx.datastore)
}
diff --git a/api/core/src/androidMain/kotlin/cz/lastaapps/api/core/data/AndroidWalletCredentialsProvider.kt b/api/core/src/androidMain/kotlin/cz/lastaapps/api/core/data/AndroidWalletCredentialsProvider.kt
new file mode 100644
index 00000000..6966564d
--- /dev/null
+++ b/api/core/src/androidMain/kotlin/cz/lastaapps/api/core/data/AndroidWalletCredentialsProvider.kt
@@ -0,0 +1,114 @@
+/*
+ * Copyright 2023, Petr Laštovička as Lasta apps, All rights reserved
+ *
+ * This file is part of Menza.
+ *
+ * Menza is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Menza is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Menza. If not, see .
+ */
+
+package cz.lastaapps.api.core.data
+
+import android.content.Context
+import android.content.SharedPreferences
+import androidx.core.content.edit
+import androidx.security.crypto.EncryptedSharedPreferences
+import androidx.security.crypto.MasterKey
+import arrow.core.left
+import arrow.core.raise.nullable
+import arrow.core.right
+import cz.lastaapps.api.core.data.model.BalanceAccountTypeSett
+import cz.lastaapps.api.core.data.model.LoginCredentialsSett
+import cz.lastaapps.core.domain.Outcome
+import cz.lastaapps.core.domain.error.CommonError.NotLoggedIn
+import kotlinx.atomicfu.locks.synchronized
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import org.lighthousegames.logging.logging
+
+/**
+ * @author Marekkon5, rewriten by LastaApps (for better or worse)
+ */
+internal class AndroidWalletCredentialsProvider(
+ private val context: Context,
+) : WalletCredentialsProvider {
+ companion object {
+ /// Get encrypted shared preferences to store username & password
+ private fun getSharedPreferences(context: Context): SharedPreferences {
+ val masterKey = MasterKey.Builder(context)
+ .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
+ .build()
+ return EncryptedSharedPreferences.create(
+ context,
+ // Update backup and extraction rules if changed!!!
+ "balance_credentials",
+ masterKey,
+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
+ )
+ }
+
+ private val log = logging()
+ }
+
+ private val notLoggedIn = NotLoggedIn.left()
+ private val sharedPreferences by lazy { getSharedPreferences(context) }
+ private var credentialsFlow: MutableStateFlow>? = null
+
+ override suspend fun store(credentials: LoginCredentialsSett) = synchronized(this) {
+ log.i { "Storing new credentials for ${credentials.username}" }
+
+ sharedPreferences.edit {
+ with(credentials) {
+ putString("username", username)
+ putString("password", password)
+ putString("type", type.name)
+ }
+ }
+
+ credentialsFlow?.value = credentials.right()
+ }
+
+ override suspend fun clear() = synchronized(this) {
+ log.i { "Clearing credentials" }
+ sharedPreferences.edit { clear() }
+ credentialsFlow?.value = notLoggedIn
+ }
+
+ override fun get(): Flow> = synchronized(this) {
+ if (credentialsFlow == null) {
+ credentialsFlow = MutableStateFlow(
+ nullable {
+ val username = sharedPreferences.getString("username", null).bind()
+ val password = sharedPreferences.getString("password", null).bind()
+ val typeName = sharedPreferences.getString("type", null).bind()
+
+ val type = BalanceAccountTypeSett.entries
+ .firstOrNull { it.name == typeName }
+ .bind()
+
+ log.i { "Read credentials for $username" }
+ LoginCredentialsSett(
+ username, password, type,
+ )
+ }?.right() ?: let {
+ log.i { "Read empty credentials" }
+ notLoggedIn
+ },
+ )
+ }
+
+ credentialsFlow!!.asSharedFlow()
+ }
+}
diff --git a/api/core/src/androidMain/kotlin/cz/lastaapps/api/core/di/PlatformModule.kt b/api/core/src/androidMain/kotlin/cz/lastaapps/api/core/di/PlatformModule.kt
index 2bba7605..7d911ea5 100644
--- a/api/core/src/androidMain/kotlin/cz/lastaapps/api/core/di/PlatformModule.kt
+++ b/api/core/src/androidMain/kotlin/cz/lastaapps/api/core/di/PlatformModule.kt
@@ -20,15 +20,27 @@
package cz.lastaapps.api.core.di
import android.content.Context
+import androidx.datastore.preferences.preferencesDataStore
+import com.russhwolf.settings.ExperimentalSettingsApi
+import com.russhwolf.settings.ExperimentalSettingsImplementation
import com.russhwolf.settings.SharedPreferencesSettings
+import com.russhwolf.settings.datastore.DataStoreSettings
+import cz.lastaapps.api.core.data.AndroidWalletCredentialsProvider
+import cz.lastaapps.api.core.data.SimpleProperties
+import cz.lastaapps.api.core.data.SimplePropertiesImpl
import cz.lastaapps.api.core.data.ValiditySettings
+import cz.lastaapps.api.core.data.WalletCredentialsProvider
import cz.lastaapps.core.util.datastructures.StateFlowSettings
import org.koin.core.module.Module
+import org.koin.core.module.dsl.singleOf
import org.koin.core.scope.Scope
+import org.koin.dsl.bind
import org.koin.dsl.module
internal actual val platform: Module = module {
single { createValiditySettings() }
+ single { SimplePropertiesSettings.create(get()) } bind SimpleProperties::class
+ singleOf(::AndroidWalletCredentialsProvider) bind WalletCredentialsProvider::class
}
private fun Scope.createValiditySettings() =
@@ -39,3 +51,11 @@ private fun Scope.createValiditySettings() =
StateFlowSettings(it)
},
)
+
+private object SimplePropertiesSettings {
+ private val Context.store by preferencesDataStore("simple_properties")
+
+ @OptIn(ExperimentalSettingsApi::class, ExperimentalSettingsImplementation::class)
+ fun create(context: Context): SimpleProperties =
+ SimplePropertiesImpl(DataStoreSettings(context.store))
+}
diff --git a/api/core/src/commonMain/kotlin/cz/lastaapps/api/core/data/SimpleProperties.kt b/api/core/src/commonMain/kotlin/cz/lastaapps/api/core/data/SimpleProperties.kt
new file mode 100644
index 00000000..4c3b44c0
--- /dev/null
+++ b/api/core/src/commonMain/kotlin/cz/lastaapps/api/core/data/SimpleProperties.kt
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2023, Petr Laštovička as Lasta apps, All rights reserved
+ *
+ * This file is part of Menza.
+ *
+ * Menza is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Menza is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Menza. If not, see .
+ */
+
+package cz.lastaapps.api.core.data
+
+import com.russhwolf.settings.ExperimentalSettingsApi
+import com.russhwolf.settings.coroutines.FlowSettings
+import kotlinx.coroutines.flow.Flow
+
+interface SimpleProperties {
+ suspend fun setBalance(balance: Float?)
+
+ fun getBalance(): Flow
+}
+
+@OptIn(ExperimentalSettingsApi::class)
+@JvmInline
+internal value class SimplePropertiesImpl(
+ private val properties: FlowSettings,
+) : SimpleProperties {
+ override suspend fun setBalance(balance: Float?) {
+ balance?.let { properties.putFloat(KEY_BALANCE, balance) } ?: run {
+ properties.remove(KEY_BALANCE)
+ }
+ }
+
+ override fun getBalance(): Flow = properties.getFloatOrNullFlow(KEY_BALANCE)
+
+ companion object {
+ private const val KEY_BALANCE = "balance"
+ }
+}
diff --git a/api/core/src/commonMain/kotlin/cz/lastaapps/api/core/data/WalletCredentialsProvider.kt b/api/core/src/commonMain/kotlin/cz/lastaapps/api/core/data/WalletCredentialsProvider.kt
new file mode 100644
index 00000000..ca56e25a
--- /dev/null
+++ b/api/core/src/commonMain/kotlin/cz/lastaapps/api/core/data/WalletCredentialsProvider.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2023, Petr Laštovička as Lasta apps, All rights reserved
+ *
+ * This file is part of Menza.
+ *
+ * Menza is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Menza is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Menza. If not, see .
+ */
+
+package cz.lastaapps.api.core.data
+
+import cz.lastaapps.api.core.data.model.LoginCredentialsSett
+import cz.lastaapps.core.domain.Outcome
+import kotlinx.coroutines.flow.Flow
+
+interface WalletCredentialsProvider {
+ suspend fun store(credentials: LoginCredentialsSett)
+
+ suspend fun clear()
+
+ fun get(): Flow>
+}
diff --git a/api/core/src/commonMain/kotlin/cz/lastaapps/api/core/data/model/BalanceAccountTypeSett.kt b/api/core/src/commonMain/kotlin/cz/lastaapps/api/core/data/model/BalanceAccountTypeSett.kt
new file mode 100644
index 00000000..2b49b114
--- /dev/null
+++ b/api/core/src/commonMain/kotlin/cz/lastaapps/api/core/data/model/BalanceAccountTypeSett.kt
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2023, Petr Laštovička as Lasta apps, All rights reserved
+ *
+ * This file is part of Menza.
+ *
+ * Menza is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Menza is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Menza. If not, see .
+ */
+
+package cz.lastaapps.api.core.data.model
+
+import androidx.annotation.Keep
+import cz.lastaapps.api.core.domain.model.BalanceAccountType
+
+@Keep
+enum class BalanceAccountTypeSett {
+ CTU,
+}
+
+fun BalanceAccountTypeSett.toDomain() = when (this) {
+ BalanceAccountTypeSett.CTU -> BalanceAccountType.CTU
+}
+
+fun BalanceAccountType.toSett() = when (this) {
+ BalanceAccountType.CTU -> BalanceAccountTypeSett.CTU
+}
diff --git a/api/core/src/commonMain/kotlin/cz/lastaapps/api/core/data/model/LoginCredentialsSett.kt b/api/core/src/commonMain/kotlin/cz/lastaapps/api/core/data/model/LoginCredentialsSett.kt
new file mode 100644
index 00000000..1a856639
--- /dev/null
+++ b/api/core/src/commonMain/kotlin/cz/lastaapps/api/core/data/model/LoginCredentialsSett.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright 2023, Petr Laštovička as Lasta apps, All rights reserved
+ *
+ * This file is part of Menza.
+ *
+ * Menza is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Menza is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Menza. If not, see .
+ */
+
+package cz.lastaapps.api.core.data.model
+
+data class LoginCredentialsSett(
+ val username: String,
+ val password: String,
+ val type: BalanceAccountTypeSett,
+)
diff --git a/api/core/src/commonMain/kotlin/cz/lastaapps/api/core/domain/model/BalanceAccountType.kt b/api/core/src/commonMain/kotlin/cz/lastaapps/api/core/domain/model/BalanceAccountType.kt
new file mode 100644
index 00000000..0ce1f6ee
--- /dev/null
+++ b/api/core/src/commonMain/kotlin/cz/lastaapps/api/core/domain/model/BalanceAccountType.kt
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2023, Petr Laštovička as Lasta apps, All rights reserved
+ *
+ * This file is part of Menza.
+ *
+ * Menza is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Menza is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Menza. If not, see .
+ */
+
+package cz.lastaapps.api.core.domain.model
+
+enum class BalanceAccountType {
+ CTU,
+}
diff --git a/api/core/src/commonMain/kotlin/cz/lastaapps/api/core/domain/model/UserBalance.kt b/api/core/src/commonMain/kotlin/cz/lastaapps/api/core/domain/model/UserBalance.kt
new file mode 100644
index 00000000..05421c84
--- /dev/null
+++ b/api/core/src/commonMain/kotlin/cz/lastaapps/api/core/domain/model/UserBalance.kt
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2023, Petr Laštovička as Lasta apps, All rights reserved
+ *
+ * This file is part of Menza.
+ *
+ * Menza is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Menza is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Menza. If not, see .
+ */
+
+package cz.lastaapps.api.core.domain.model
+
+data class UserBalance(
+ val username: String,
+ val balance: Float,
+)
+
+fun UserBalance.formattedBalance() = String.format("%.2f Kč", balance)
diff --git a/api/core/src/commonMain/kotlin/cz/lastaapps/api/core/domain/validity/ValidityKey.kt b/api/core/src/commonMain/kotlin/cz/lastaapps/api/core/domain/validity/ValidityKey.kt
index 996f212e..288a8480 100644
--- a/api/core/src/commonMain/kotlin/cz/lastaapps/api/core/domain/validity/ValidityKey.kt
+++ b/api/core/src/commonMain/kotlin/cz/lastaapps/api/core/domain/validity/ValidityKey.kt
@@ -29,5 +29,7 @@ value class ValidityKey private constructor(val name: String) {
fun agataMenza() = ValidityKey("agata_menza")
fun strahov() = ValidityKey("strahov")
fun buffetDish() = ValidityKey("buffet_dish")
+
+ fun agataCtuBalance() = ValidityKey("balance_agata_ctu")
}
}
diff --git a/api/main/src/commonMain/kotlin/cz/lastaapps/api/main/data/WalletMasterRepository.kt b/api/main/src/commonMain/kotlin/cz/lastaapps/api/main/data/WalletMasterRepository.kt
new file mode 100644
index 00000000..82cee327
--- /dev/null
+++ b/api/main/src/commonMain/kotlin/cz/lastaapps/api/main/data/WalletMasterRepository.kt
@@ -0,0 +1,153 @@
+/*
+ * Copyright 2023, Petr Laštovička as Lasta apps, All rights reserved
+ *
+ * This file is part of Menza.
+ *
+ * Menza is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Menza is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Menza. If not, see .
+ */
+
+package cz.lastaapps.api.main.data
+
+import arrow.core.Either.Left
+import arrow.core.Either.Right
+import arrow.core.right
+import arrow.core.rightIor
+import cz.lastaapps.api.core.data.SimpleProperties
+import cz.lastaapps.api.core.data.WalletCredentialsProvider
+import cz.lastaapps.api.core.data.model.BalanceAccountTypeSett
+import cz.lastaapps.api.core.data.model.LoginCredentialsSett
+import cz.lastaapps.api.core.data.model.toDomain
+import cz.lastaapps.api.core.data.model.toSett
+import cz.lastaapps.api.core.domain.model.BalanceAccountType
+import cz.lastaapps.api.core.domain.model.BalanceAccountType.CTU
+import cz.lastaapps.api.core.domain.model.UserBalance
+import cz.lastaapps.api.core.domain.sync.SyncJob
+import cz.lastaapps.api.core.domain.sync.SyncOutcome
+import cz.lastaapps.api.core.domain.sync.SyncProcessor
+import cz.lastaapps.api.core.domain.sync.SyncResult
+import cz.lastaapps.api.core.domain.sync.SyncSource
+import cz.lastaapps.api.core.domain.sync.runSync
+import cz.lastaapps.api.core.domain.validity.ValidityChecker
+import cz.lastaapps.api.core.domain.validity.ValidityKey
+import cz.lastaapps.api.core.domain.validity.withCheckRecent
+import cz.lastaapps.core.domain.Outcome
+import cz.lastaapps.core.domain.outcome
+import cz.lastaapps.menza.api.agata.api.AgataCtuWalletApi
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.channelFlow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import org.lighthousegames.logging.logging
+
+internal interface WalletMasterRepository : SyncSource {
+ suspend fun login(username: String, password: String, type: BalanceAccountType): Outcome
+ suspend fun logout(): Outcome
+}
+
+internal class WalletMasterRepositoryImpl(
+ private val ctuApi: AgataCtuWalletApi,
+ private val credentialsProvider: WalletCredentialsProvider,
+ private val simpleProperties: SimpleProperties,
+ private val processor: SyncProcessor,
+ private val checker: ValidityChecker,
+) : WalletMasterRepository {
+
+ private val validityKey = ValidityKey.agataCtuBalance()
+ private val scope = CoroutineScope(Dispatchers.Default)
+
+ companion object {
+ private val log = logging()
+ }
+
+ private fun selectApi(type: BalanceAccountType) = when (type) {
+ CTU -> ctuApi
+ }
+
+ override suspend fun login(
+ username: String,
+ password: String,
+ type: BalanceAccountType,
+ ): Outcome = outcome {
+ log.i { "Trying to login" }
+
+ val api = selectApi(type)
+ api.getBalance(username, password).bind()
+
+ credentialsProvider.store(LoginCredentialsSett(username, password, type.toSett()))
+ log.i { "Login successful" }
+ }
+
+ override suspend fun logout(): Outcome = outcome {
+ credentialsProvider.clear()
+ log.i { "Logout successful" }
+ }
+
+ override fun getData(): Flow = channelFlow {
+ credentialsProvider.get().collectLatest { credentials ->
+ when (credentials) {
+ is Left -> send(null)
+ is Right -> {
+ simpleProperties.getBalance()
+ .collectLatest { balance ->
+ send(
+ balance?.let {
+ UserBalance(credentials.value.username, balance)
+ },
+ )
+ }
+ }
+ }
+ }
+ }.distinctUntilChanged()
+
+ private val job = object : SyncJob(
+ shouldRun = { _ ->
+ if (credentialsProvider.get().first()
+ .map {
+ it.type == BalanceAccountTypeSett.CTU
+ }
+ .getOrNull() == true
+ ) {
+ arrow.core.Some {}
+ } else {
+ arrow.core.None
+ }
+ },
+ fetchApi = {
+ val credentials = credentialsProvider.get().first().bind()
+ val api = selectApi(credentials.type.toDomain())
+ api.getBalance(credentials.username, credentials.password).bind()
+ },
+ convert = { it.rightIor() },
+ store = { data -> scope.launch { simpleProperties.setBalance(data) } },
+ ) {}
+
+ override suspend fun sync(isForced: Boolean): SyncOutcome = run {
+ log.i { "Requesting data sync" }
+
+ credentialsProvider.get().first().let { credentials ->
+ when (credentials) {
+ is Left -> SyncResult.Unavailable.right()
+ is Right ->
+ checker.withCheckRecent(validityKey, isForced) {
+ processor.runSync(job, listOf(), isForced = isForced)
+ }
+ }
+ }
+ }
+}
diff --git a/api/main/src/commonMain/kotlin/cz/lastaapps/api/main/di/ApiModule.kt b/api/main/src/commonMain/kotlin/cz/lastaapps/api/main/di/ApiModule.kt
index 15d1230a..720364c3 100644
--- a/api/main/src/commonMain/kotlin/cz/lastaapps/api/main/di/ApiModule.kt
+++ b/api/main/src/commonMain/kotlin/cz/lastaapps/api/main/di/ApiModule.kt
@@ -24,6 +24,8 @@ import cz.lastaapps.api.core.di.apiCoreModule
import cz.lastaapps.api.core.domain.model.MenzaType
import cz.lastaapps.api.core.domain.repo.MenzaRepo
import cz.lastaapps.api.main.data.MenzaMasterRepoImpl
+import cz.lastaapps.api.main.data.WalletMasterRepository
+import cz.lastaapps.api.main.data.WalletMasterRepositoryImpl
import cz.lastaapps.api.main.domain.usecase.GetInfoUC
import cz.lastaapps.api.main.domain.usecase.GetMenzaListUC
import cz.lastaapps.api.main.domain.usecase.GetTodayDishListUC
@@ -34,8 +36,13 @@ import cz.lastaapps.api.main.domain.usecase.SyncInfoUC
import cz.lastaapps.api.main.domain.usecase.SyncMenzaListUC
import cz.lastaapps.api.main.domain.usecase.SyncTodayDishListUC
import cz.lastaapps.api.main.domain.usecase.SyncWeekDishListUC
+import cz.lastaapps.api.main.domain.usecase.wallet.WalletGetBalanceUC
+import cz.lastaapps.api.main.domain.usecase.wallet.WalletLoginUC
+import cz.lastaapps.api.main.domain.usecase.wallet.WalletLogoutUC
+import cz.lastaapps.api.main.domain.usecase.wallet.WalletRefreshUC
import cz.lastaapps.menza.api.agata.di.apiAgataModule
import org.koin.core.module.dsl.factoryOf
+import org.koin.core.module.dsl.singleOf
import org.koin.core.qualifier.named
import org.koin.core.scope.Scope
import org.koin.dsl.bind
@@ -50,6 +57,7 @@ val apiModule = module {
val rootName = named()
single(rootName) { MenzaMasterRepoImpl() } bind MenzaRepo::class
+ singleOf(::WalletMasterRepositoryImpl) bind WalletMasterRepository::class
factory { GetMenzaListUC(get(), get(rootName)) }
factory { SyncMenzaListUC(get(), get(rootName)) }
@@ -61,6 +69,10 @@ val apiModule = module {
factoryOf(::GetWeekDishListUC)
factoryOf(::SyncWeekDishListUC)
factoryOf(::OpenMenuUC)
+ factoryOf(::WalletGetBalanceUC)
+ factoryOf(::WalletLoginUC)
+ factoryOf(::WalletLogoutUC)
+ factoryOf(::WalletRefreshUC)
}
private fun Scope.MenzaMasterRepoImpl() =
diff --git a/api/main/src/commonMain/kotlin/cz/lastaapps/api/main/domain/usecase/wallet/WalletGetBalanceUC.kt b/api/main/src/commonMain/kotlin/cz/lastaapps/api/main/domain/usecase/wallet/WalletGetBalanceUC.kt
new file mode 100644
index 00000000..392350e0
--- /dev/null
+++ b/api/main/src/commonMain/kotlin/cz/lastaapps/api/main/domain/usecase/wallet/WalletGetBalanceUC.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2023, Petr Laštovička as Lasta apps, All rights reserved
+ *
+ * This file is part of Menza.
+ *
+ * Menza is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Menza is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Menza. If not, see .
+ */
+
+package cz.lastaapps.api.main.domain.usecase.wallet
+
+import cz.lastaapps.api.main.data.WalletMasterRepository
+import cz.lastaapps.core.domain.UCContext
+import cz.lastaapps.core.domain.UseCase
+
+class WalletGetBalanceUC internal constructor(
+ ucContext: UCContext,
+ private val repo: WalletMasterRepository,
+) : UseCase(ucContext) {
+ operator fun invoke() = repo.getData()
+}
diff --git a/api/main/src/commonMain/kotlin/cz/lastaapps/api/main/domain/usecase/wallet/WalletLoginUC.kt b/api/main/src/commonMain/kotlin/cz/lastaapps/api/main/domain/usecase/wallet/WalletLoginUC.kt
new file mode 100644
index 00000000..d0ba4ef9
--- /dev/null
+++ b/api/main/src/commonMain/kotlin/cz/lastaapps/api/main/domain/usecase/wallet/WalletLoginUC.kt
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2023, Petr Laštovička as Lasta apps, All rights reserved
+ *
+ * This file is part of Menza.
+ *
+ * Menza is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Menza is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Menza. If not, see .
+ */
+
+package cz.lastaapps.api.main.domain.usecase.wallet
+
+import cz.lastaapps.api.core.domain.model.BalanceAccountType
+import cz.lastaapps.api.main.data.WalletMasterRepository
+import cz.lastaapps.core.domain.UCContext
+import cz.lastaapps.core.domain.UseCase
+
+class WalletLoginUC internal constructor(
+ ucContext: UCContext,
+ private val repo: WalletMasterRepository,
+) : UseCase(ucContext) {
+ suspend operator fun invoke(
+ username: String, password: String, type: BalanceAccountType,
+ ) = launch {
+ repo.login(username, password, type)
+ .onRight { repo.sync() }
+ }
+}
\ No newline at end of file
diff --git a/api/main/src/commonMain/kotlin/cz/lastaapps/api/main/domain/usecase/wallet/WalletLogoutUC.kt b/api/main/src/commonMain/kotlin/cz/lastaapps/api/main/domain/usecase/wallet/WalletLogoutUC.kt
new file mode 100644
index 00000000..e5c59bb2
--- /dev/null
+++ b/api/main/src/commonMain/kotlin/cz/lastaapps/api/main/domain/usecase/wallet/WalletLogoutUC.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2023, Petr Laštovička as Lasta apps, All rights reserved
+ *
+ * This file is part of Menza.
+ *
+ * Menza is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Menza is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Menza. If not, see .
+ */
+
+package cz.lastaapps.api.main.domain.usecase.wallet
+
+import cz.lastaapps.api.main.data.WalletMasterRepository
+import cz.lastaapps.core.domain.UCContext
+import cz.lastaapps.core.domain.UseCase
+
+class WalletLogoutUC internal constructor(
+ ucContext: UCContext,
+ private val repo: WalletMasterRepository,
+) : UseCase(ucContext) {
+ suspend operator fun invoke() = launch {
+ repo.logout()
+ }
+}
diff --git a/api/main/src/commonMain/kotlin/cz/lastaapps/api/main/domain/usecase/wallet/WalletRefreshUC.kt b/api/main/src/commonMain/kotlin/cz/lastaapps/api/main/domain/usecase/wallet/WalletRefreshUC.kt
new file mode 100644
index 00000000..85973e85
--- /dev/null
+++ b/api/main/src/commonMain/kotlin/cz/lastaapps/api/main/domain/usecase/wallet/WalletRefreshUC.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2023, Petr Laštovička as Lasta apps, All rights reserved
+ *
+ * This file is part of Menza.
+ *
+ * Menza is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Menza is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Menza. If not, see .
+ */
+
+package cz.lastaapps.api.main.domain.usecase.wallet
+
+import cz.lastaapps.api.main.data.WalletMasterRepository
+import cz.lastaapps.core.domain.UCContext
+import cz.lastaapps.core.domain.UseCase
+
+class WalletRefreshUC internal constructor(
+ ucContext: UCContext,
+ private val repo: WalletMasterRepository,
+) : UseCase(ucContext) {
+ suspend operator fun invoke(isForced: Boolean) = launch { repo.sync(isForced = isForced) }
+}
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 05459549..3dadcb06 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -46,9 +46,13 @@ android {
}
}
- // Remove some conflict between atomic-fu and datetime
packaging {
+ // Remove some conflict between atomic-fu and datetime
resources.pickFirsts.add("META-INF/versions/9/previous-compilation-data.bin")
+
+ // Remove some crap skrape-it dependencies
+ resources.pickFirsts.add("META-INF/DEPENDENCIES")
+ resources.pickFirsts.add("mozilla/public-suffix-list.txt")
}
}
@@ -66,7 +70,6 @@ dependencies {
implementation(libs.androidx.datastore)
implementation(libs.androidx.emoji2.bundled)
- implementation(libs.androidx.security)
implementation(libs.androidx.startup)
implementation(libs.androidx.splashscreen)
implementation(libs.androidx.vectorDrawables)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 2d51b78d..d2a7e116 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -40,6 +40,8 @@
.
+ */
+
+package cz.lastaapps.menza.features.main.ui.components
+
+import androidx.compose.animation.animateContentSize
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.Logout
+import androidx.compose.material.icons.rounded.Warning
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.PlainTooltipBox
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import arrow.core.None
+import arrow.core.Option
+import arrow.core.Some
+import cz.lastaapps.api.core.domain.model.UserBalance
+import cz.lastaapps.api.core.domain.model.formattedBalance
+import cz.lastaapps.core.ui.vm.HandleAppear
+import cz.lastaapps.menza.R
+import cz.lastaapps.menza.features.main.ui.vm.AgataWalletViewModel
+import cz.lastaapps.menza.ui.theme.Padding
+import cz.lastaapps.menza.ui.util.HandleError
+import cz.lastaapps.menza.ui.util.PreviewWrapper
+
+@Composable
+internal fun AgataWalletButton(
+ viewModel: AgataWalletViewModel,
+ snackbarHostState: SnackbarHostState,
+ onShowLoginDialog: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ HandleAppear(viewModel)
+ HandleError(viewModel, hostState = snackbarHostState)
+
+ val state by viewModel.flowState
+
+ AgataWalletButton(
+ balance = state.balance,
+ isLoading = state.isLoading,
+ isWarning = state.isWarning,
+ onShowLoginDialog = onShowLoginDialog,
+ onReload = viewModel::refresh,
+ onLogout = viewModel::logout,
+ modifier = modifier,
+ )
+}
+
+@Composable
+internal fun AgataWalletButton(
+ balance: Option,
+ isLoading: Boolean,
+ isWarning: Boolean,
+ onShowLoginDialog: () -> Unit,
+ onReload: () -> Unit,
+ onLogout: () -> Unit,
+ modifier: Modifier = Modifier,
+) = Column(
+ modifier = modifier.animateContentSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(Padding.Smaller),
+) {
+ Text(
+ text = stringResource(id = R.string.wallet_description),
+ style = MaterialTheme.typography.titleMedium,
+ )
+
+ when (balance) {
+ None -> {}
+ is Some -> {
+ val value = balance.value
+ if (value == null) {
+ if (isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(Padding.More.Icon),
+ )
+ } else {
+ Button(
+ onClick = onShowLoginDialog,
+ shape = MaterialTheme.shapes.medium,
+ ) {
+ Text(text = stringResource(id = R.string.wallet_login))
+ }
+ }
+ } else {
+ ButtonContent(
+ balance = value,
+ isLoading = isLoading,
+ isWarning = isWarning,
+ onReload = onReload,
+ onLogout = onLogout,
+ )
+ }
+ }
+ }
+}
+
+@Suppress("UnusedReceiverParameter")
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun ColumnScope.ButtonContent(
+ balance: UserBalance,
+ isLoading: Boolean,
+ isWarning: Boolean,
+ onReload: () -> Unit,
+ onLogout: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ // Balance || Loading || Warning
+ Row(
+ modifier = modifier,
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(Padding.Small),
+ ) {
+ Button(
+ shape = MaterialTheme.shapes.medium,
+ onClick = onReload,
+ ) {
+ Row(
+ modifier = Modifier
+ .animateContentSize(),
+ horizontalArrangement = Arrangement.spacedBy(Padding.Small),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = balance.formattedBalance(),
+ style = MaterialTheme.typography.labelLarge,
+ // fontWeight = FontWeight.Bold,
+ )
+ if (isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(Padding.More.Icon),
+ color = MaterialTheme.colorScheme.surfaceVariant,
+ trackColor = MaterialTheme.colorScheme.primary,
+ )
+ }
+ if (isWarning) {
+ Icon(
+ Icons.Rounded.Warning,
+ contentDescription = stringResource(R.string.wallet_update_error),
+ )
+ }
+ }
+ }
+
+ PlainTooltipBox(
+ tooltip = {
+ Text(text = stringResource(id = R.string.wallet_logout))
+ },
+ ) {
+ IconButton(onClick = onLogout, modifier = Modifier.tooltipAnchor()) {
+ Icon(
+ Icons.AutoMirrored.Default.Logout,
+ contentDescription = stringResource(id = R.string.wallet_logout),
+ )
+ }
+ }
+ }
+
+ Text(
+ text = stringResource(id = R.string.wallet_logged_in_as, balance.username),
+ style = MaterialTheme.typography.bodySmall,
+ )
+}
+
+@Preview
+@Composable
+private fun AgataWalletButtonLogInPreview() = PreviewWrapper {
+ AgataWalletButton(
+ balance = Some(null),
+ isLoading = false,
+ isWarning = false,
+ onShowLoginDialog = {},
+ onReload = {},
+ onLogout = {},
+ )
+}
+
+@Preview
+@Composable
+private fun AgataWalletButtonPreview() = PreviewWrapper {
+ AgataWalletButton(
+ balance = Some(UserBalance("Jára", 420f)),
+ isLoading = true,
+ isWarning = true,
+ onShowLoginDialog = {},
+ onReload = {},
+ onLogout = {},
+ )
+}
+
diff --git a/app/src/main/kotlin/cz/lastaapps/menza/features/main/ui/layout/NavItem.kt b/app/src/main/kotlin/cz/lastaapps/menza/features/main/ui/layout/NavItem.kt
index 834ab27d..055546c5 100644
--- a/app/src/main/kotlin/cz/lastaapps/menza/features/main/ui/layout/NavItem.kt
+++ b/app/src/main/kotlin/cz/lastaapps/menza/features/main/ui/layout/NavItem.kt
@@ -20,20 +20,20 @@
package cz.lastaapps.menza.features.main.ui.layout
import androidx.annotation.StringRes
-import androidx.compose.material.icons.Icons.Filled
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.MenuBook
import androidx.compose.material.icons.filled.DinnerDining
import androidx.compose.material.icons.filled.Info
-import androidx.compose.material.icons.filled.MenuBook
import androidx.compose.material.icons.filled.Settings
import androidx.compose.ui.graphics.vector.ImageVector
-import cz.lastaapps.menza.R.string
+import cz.lastaapps.menza.R
internal enum class NavItem(
@StringRes val label: Int,
val icon: ImageVector,
) {
- Today(string.nav_today, Filled.DinnerDining),
- Week(string.nav_week, Filled.MenuBook),
- Info(string.nav_info, Filled.Info),
- Settings(string.nav_settings, Filled.Settings),
+ Today(R.string.nav_today, Icons.Filled.DinnerDining),
+ Week(R.string.nav_week, Icons.AutoMirrored.Filled.MenuBook),
+ Info(R.string.nav_info, Icons.Filled.Info),
+ Settings(R.string.nav_settings, Icons.Filled.Settings),
}
diff --git a/app/src/main/kotlin/cz/lastaapps/menza/features/main/ui/navigation/MainNode.kt b/app/src/main/kotlin/cz/lastaapps/menza/features/main/ui/navigation/MainNode.kt
index 6e7e4178..20744048 100644
--- a/app/src/main/kotlin/cz/lastaapps/menza/features/main/ui/navigation/MainNode.kt
+++ b/app/src/main/kotlin/cz/lastaapps/menza/features/main/ui/navigation/MainNode.kt
@@ -76,12 +76,13 @@ class MainNode(
) : ParentNode(backStack, buildContext) {
private var currentDrawerState: DrawerState? = null
+ private val snackbarHostState = SnackbarHostState()
private val onOsturak = { backStack.push(OsturakNav) }
override fun resolve(interactionTarget: MainNavType, buildContext: BuildContext): Node =
when (interactionTarget) {
- DrawerContent -> DrawerNode(buildContext, ::currentDrawerState)
+ DrawerContent -> DrawerNode(buildContext, ::currentDrawerState, snackbarHostState)
TodayNav -> TodayNode(
buildContext = buildContext,
@@ -123,8 +124,6 @@ class MainNode(
currentDrawerState = drawerState
}
- val hostState = remember { SnackbarHostState() }
-
val active by remember(backStack) {
backStack.active()
}.collectAsStateWithLifecycle(initialValue = null)
@@ -133,7 +132,7 @@ class MainNode(
currentDest = active,
drawerState = drawerState,
settingsEverOpened = state.settingsViewed,
- hostState = hostState,
+ hostState = snackbarHostState,
selectedMenza = state.selectedMenza,
isFlip = state.isFlip && LocalMayBeFlipCover.current,
onNavItemTopBar = { backStack.push(it) },
diff --git a/app/src/main/kotlin/cz/lastaapps/menza/features/main/ui/node/DrawerNode.kt b/app/src/main/kotlin/cz/lastaapps/menza/features/main/ui/node/DrawerNode.kt
index ddbd42c0..2ebb2ad3 100644
--- a/app/src/main/kotlin/cz/lastaapps/menza/features/main/ui/node/DrawerNode.kt
+++ b/app/src/main/kotlin/cz/lastaapps/menza/features/main/ui/node/DrawerNode.kt
@@ -20,6 +20,7 @@
package cz.lastaapps.menza.features.main.ui.node
import androidx.compose.material3.DrawerState
+import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.components.backstack.BackStack
@@ -43,6 +44,7 @@ internal enum class DrawerNavType {
internal class DrawerNode(
buildContext: BuildContext,
private val drawableStateProvider: () -> DrawerState?,
+ private val snackbarHostState: SnackbarHostState,
private val backstack: BackStack = BackStack(
model = BackStackModel(
initialTargets = listOf(MENZA_LIST_NAV),
@@ -58,6 +60,7 @@ internal class DrawerNode(
buildContext,
onEdit = { backstack.push(EDIT_NAV) },
updateDrawer = drawableStateProvider,
+ snackbarHostState = snackbarHostState,
)
EDIT_NAV -> ReorderMenzaNode(buildContext, backstack::pop)
diff --git a/app/src/main/kotlin/cz/lastaapps/menza/features/main/ui/node/MenzaSelectionNode.kt b/app/src/main/kotlin/cz/lastaapps/menza/features/main/ui/node/MenzaSelectionNode.kt
index 344b309c..eb1daf9b 100644
--- a/app/src/main/kotlin/cz/lastaapps/menza/features/main/ui/node/MenzaSelectionNode.kt
+++ b/app/src/main/kotlin/cz/lastaapps/menza/features/main/ui/node/MenzaSelectionNode.kt
@@ -21,14 +21,22 @@ package cz.lastaapps.menza.features.main.ui.node
import androidx.compose.animation.core.spring
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.DrawerState
import androidx.compose.material3.DrawerValue.Closed
import androidx.compose.material3.DrawerValue.Open
+import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import com.bumble.appyx.navigation.modality.BuildContext
import com.bumble.appyx.navigation.node.Node
+import cz.lastaapps.menza.features.main.ui.components.AgataWalletButton
+import cz.lastaapps.menza.features.main.ui.screen.AgataLoginDialog
import cz.lastaapps.menza.features.main.ui.screen.MenzaSelectionScreen
import cz.lastaapps.menza.ui.util.nodeViewModel
import kotlinx.coroutines.launch
@@ -37,12 +45,30 @@ class MenzaSelectionNode(
buildContext: BuildContext,
private val onEdit: () -> Unit,
private val updateDrawer: () -> DrawerState?,
+ private val snackbarHostState: SnackbarHostState,
) : Node(buildContext) {
@Composable
override fun View(modifier: Modifier) {
val scope = rememberCoroutineScope()
+ var balanceLoginDialogShown by rememberSaveable { mutableStateOf(false) }
+
+ val accountBalance: @Composable () -> Unit = {
+ AgataWalletButton(
+ viewModel = nodeViewModel(),
+ snackbarHostState = snackbarHostState,
+ onShowLoginDialog = { balanceLoginDialogShown = true },
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+
+ if (balanceLoginDialogShown) {
+ AgataLoginDialog(viewModel = nodeViewModel()) {
+ balanceLoginDialogShown = false
+ }
+ }
+
MenzaSelectionScreen(
onEdit = onEdit,
onMenzaSelected = {
@@ -55,6 +81,7 @@ class MenzaSelectionNode(
}
},
viewModel = nodeViewModel(),
+ accountBalance = accountBalance,
modifier = modifier.fillMaxSize(),
)
}
diff --git a/app/src/main/kotlin/cz/lastaapps/menza/features/main/ui/screen/AgataLoginDialog.kt b/app/src/main/kotlin/cz/lastaapps/menza/features/main/ui/screen/AgataLoginDialog.kt
new file mode 100644
index 00000000..6c1b4583
--- /dev/null
+++ b/app/src/main/kotlin/cz/lastaapps/menza/features/main/ui/screen/AgataLoginDialog.kt
@@ -0,0 +1,273 @@
+/*
+ * Copyright 2023, Petr Laštovička as Lasta apps, All rights reserved
+ *
+ * This file is part of Menza.
+ *
+ * Menza is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Menza is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Menza. If not, see .
+ */
+
+package cz.lastaapps.menza.features.main.ui.screen
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.Crossfade
+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.padding
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.HelpOutline
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.autofill.AutofillType
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import cz.lastaapps.core.domain.error.ApiError.WalletError
+import cz.lastaapps.core.domain.error.DomainError
+import cz.lastaapps.core.ui.text
+import cz.lastaapps.menza.R
+import cz.lastaapps.menza.features.main.ui.vm.AgataWalletLoginViewModel
+import cz.lastaapps.menza.ui.components.MenzaDialog
+import cz.lastaapps.menza.ui.theme.Padding
+import cz.lastaapps.menza.ui.util.PreviewWrapper
+import cz.lastaapps.menza.ui.util.withAutofill
+
+/**
+ * @author Marekkon5 (base implementation, rewritten by me)
+ */
+@Composable
+internal fun AgataLoginDialog(
+ viewModel: AgataWalletLoginViewModel,
+ onDismissRequest: () -> Unit,
+) {
+ val state by viewModel.flowState
+
+ LaunchedEffect(key1 = state.loginDone) {
+ if (state.loginDone) {
+ viewModel.dismissLoginDone()
+ onDismissRequest()
+ }
+ }
+
+ AgataLoginDialog(
+ username = state.username,
+ password = state.password,
+ onUsername = viewModel::setUsername,
+ onPassword = viewModel::setPassword,
+ onDismissRequest = onDismissRequest,
+ isLoading = state.isLoading,
+ loginEnabled = state.enabled,
+ onLogin = viewModel::logIn,
+ error = state.error,
+ )
+}
+
+@Composable
+private fun AgataLoginDialog(
+ username: String,
+ password: String,
+ onUsername: (String) -> Unit,
+ onPassword: (String) -> Unit,
+ onDismissRequest: () -> Unit,
+ isLoading: Boolean,
+ loginEnabled: Boolean,
+ error: DomainError?,
+ onLogin: () -> Unit,
+) {
+ MenzaDialog(onDismissRequest = onDismissRequest) {
+ AgataLoginDialogContent(
+ username = username,
+ password = password,
+ onUsername = onUsername,
+ onPassword = onPassword,
+ onDismissRequest = onDismissRequest,
+ isLoading = isLoading,
+ loginEnabled = loginEnabled,
+ error = error,
+ onLogin = onLogin,
+ )
+ }
+}
+
+@OptIn(ExperimentalComposeUiApi::class)
+@Composable
+private fun AgataLoginDialogContent(
+ username: String,
+ password: String,
+ onUsername: (String) -> Unit,
+ onPassword: (String) -> Unit,
+ onDismissRequest: () -> Unit,
+ isLoading: Boolean,
+ loginEnabled: Boolean,
+ onLogin: () -> Unit,
+ error: DomainError?,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(Padding.Small),
+ ) {
+ Text(
+ text = stringResource(R.string.wallet_login_title),
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.headlineMedium,
+ )
+ Text(
+ text = stringResource(R.string.wallet_login_subtitle),
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ Spacer(
+ modifier = Modifier.padding(Padding.Small),
+ )
+
+ OutlinedTextField(
+ modifier = Modifier.withAutofill(
+ autofillTypes = listOf(AutofillType.Username),
+ onFill = onUsername,
+ ),
+ value = username,
+ onValueChange = onUsername,
+ label = { Text(stringResource(R.string.wallet_login_username)) },
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Ascii,
+ imeAction = ImeAction.Next,
+ ),
+ )
+
+ var showPasswordInfo by rememberSaveable { mutableStateOf(false) }
+
+ OutlinedTextField(
+ modifier = Modifier.withAutofill(
+ autofillTypes = listOf(AutofillType.Password),
+ onFill = onPassword,
+ ),
+ value = password,
+ onValueChange = onPassword,
+ label = { Text(stringResource(R.string.wallet_login_password)) },
+ visualTransformation = PasswordVisualTransformation(),
+ keyboardActions = KeyboardActions {
+ if (loginEnabled) {
+ onLogin()
+ }
+ },
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Password,
+ imeAction = ImeAction.Go,
+ ),
+ trailingIcon = {
+ IconButton(onClick = { showPasswordInfo = !showPasswordInfo }) {
+ Icon(
+ Icons.AutoMirrored.Default.HelpOutline,
+ contentDescription = stringResource(id = R.string.wallet_login_password_policy_hint),
+ )
+ }
+ },
+ )
+
+ error?.let {
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.errorContainer,
+ ),
+ ) {
+ Text(
+ text = error.text(),
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(Padding.Medium),
+ )
+ }
+ }
+
+ AnimatedVisibility(showPasswordInfo) {
+ Text(
+ text = stringResource(id = R.string.wallet_login_password_policy),
+ style = MaterialTheme.typography.bodySmall,
+ textAlign = TextAlign.Center,
+ )
+ }
+
+ Crossfade(targetState = isLoading, label = "isLoading_login_switch") { isLoading ->
+ if (isLoading) {
+ CircularProgressIndicator(Modifier.align(Alignment.CenterHorizontally))
+ } else {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(Padding.Small, Alignment.End),
+ ) {
+ TextButton(
+ onClick = { onDismissRequest() },
+ ) {
+ Text(stringResource(R.string.wallet_login_cancel))
+ }
+
+ Button(
+ onClick = onLogin,
+ enabled = loginEnabled,
+ ) {
+ Text(stringResource(R.string.wallet_login_save))
+ }
+ }
+ }
+ }
+
+ Text(
+ text = stringResource(id = R.string.wallet_login_password_appeal),
+ style = MaterialTheme.typography.bodySmall,
+ textAlign = TextAlign.Center,
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun AgataLoginDialogPreview() = PreviewWrapper {
+ AgataLoginDialogContent(
+ username = "Sultán",
+ password = "Solimán",
+ onUsername = {},
+ onPassword = {},
+ onDismissRequest = {},
+ isLoading = false,
+ loginEnabled = true,
+ onLogin = { },
+ error = WalletError.InvalidCredentials,
+ )
+}
diff --git a/app/src/main/kotlin/cz/lastaapps/menza/features/main/ui/screen/MenzaSelectionScreen.kt b/app/src/main/kotlin/cz/lastaapps/menza/features/main/ui/screen/MenzaSelectionScreen.kt
index e1df160b..902873ab 100644
--- a/app/src/main/kotlin/cz/lastaapps/menza/features/main/ui/screen/MenzaSelectionScreen.kt
+++ b/app/src/main/kotlin/cz/lastaapps/menza/features/main/ui/screen/MenzaSelectionScreen.kt
@@ -57,7 +57,6 @@ import cz.lastaapps.core.ui.vm.HandleAppear
import cz.lastaapps.menza.R
import cz.lastaapps.menza.features.main.ui.vm.MenzaSelectionState
import cz.lastaapps.menza.features.main.ui.vm.MenzaSelectionViewModel
-import cz.lastaapps.menza.ui.components.AgataWalletButton
import cz.lastaapps.menza.ui.components.MenzaLetter
import cz.lastaapps.menza.ui.theme.Padding
import kotlinx.collections.immutable.ImmutableList
@@ -67,6 +66,7 @@ internal fun MenzaSelectionScreen(
onEdit: () -> Unit,
onMenzaSelected: () -> Unit,
viewModel: MenzaSelectionViewModel,
+ accountBalance: @Composable () -> Unit,
modifier: Modifier = Modifier,
) {
MenzaSelectionListEffects(viewModel)
@@ -79,6 +79,7 @@ internal fun MenzaSelectionScreen(
viewModel.selectMenza(it)
onMenzaSelected()
},
+ accountBalance = accountBalance,
modifier = modifier,
)
}
@@ -95,6 +96,7 @@ private fun MenzaSelectionListContent(
state: MenzaSelectionState,
onEdit: () -> Unit,
onMenzaSelected: (Menza) -> Unit,
+ accountBalance: @Composable () -> Unit,
modifier: Modifier = Modifier,
lazyState: LazyListState = rememberLazyListState(),
) {
@@ -114,6 +116,7 @@ private fun MenzaSelectionListContent(
onMenzaSelected = onMenzaSelected,
onEdit = onEdit,
lazyState = lazyState,
+ accountBalance = accountBalance,
modifier = Modifier
.fillMaxWidth()
.animateContentSize(),
@@ -129,6 +132,7 @@ private fun MenzaList(
onMenzaSelected: (Menza) -> Unit,
onEdit: () -> Unit,
lazyState: LazyListState,
+ accountBalance: @Composable () -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(
@@ -152,7 +156,7 @@ private fun MenzaList(
}
item {
- AgataWalletButton()
+ accountBalance()
}
items(menzaList) { menza ->
diff --git a/app/src/main/kotlin/cz/lastaapps/menza/features/main/ui/vm/AgataWalletLoginViewModel.kt b/app/src/main/kotlin/cz/lastaapps/menza/features/main/ui/vm/AgataWalletLoginViewModel.kt
new file mode 100644
index 00000000..f21e9db4
--- /dev/null
+++ b/app/src/main/kotlin/cz/lastaapps/menza/features/main/ui/vm/AgataWalletLoginViewModel.kt
@@ -0,0 +1,83 @@
+/*
+ * Copyright 2023, Petr Laštovička as Lasta apps, All rights reserved
+ *
+ * This file is part of Menza.
+ *
+ * Menza is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Menza is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Menza. If not, see .
+ */
+
+package cz.lastaapps.menza.features.main.ui.vm
+
+import androidx.compose.runtime.Composable
+import arrow.core.Either.Left
+import arrow.core.Either.Right
+import cz.lastaapps.api.core.domain.model.BalanceAccountType.CTU
+import cz.lastaapps.api.main.domain.usecase.wallet.WalletLoginUC
+import cz.lastaapps.core.domain.error.DomainError
+import cz.lastaapps.core.ui.vm.ErrorHolder
+import cz.lastaapps.core.ui.vm.StateViewModel
+import cz.lastaapps.core.ui.vm.VMContext
+import cz.lastaapps.core.ui.vm.VMState
+
+internal class AgataWalletLoginViewModel(
+ vmContext: VMContext,
+ private val walletLoginUC: WalletLoginUC,
+) : StateViewModel(AgataWalletLoginState(), vmContext), ErrorHolder {
+
+ fun logIn() = launchVM {
+ withLoading({ copy(isLoading = it) }) { state ->
+ if (!state.enabled) {
+ return@withLoading
+ }
+
+ val username = state.username.trim()
+ val password = state.password.trim()
+
+ when (val res = walletLoginUC(username, password, CTU)) {
+ is Left -> updateState { copy(error = res.value) }
+ is Right -> updateState { copy(loginDone = true) }
+ }
+ }
+ }
+
+ fun setUsername(username: String) {
+ updateState { copy(username = username) }
+ }
+
+ fun setPassword(password: String) {
+ updateState { copy(password = password) }
+ }
+
+ fun dismissLoginDone() {
+ updateState { copy(loginDone = false) }
+ }
+
+ @Composable
+ override fun getError(): DomainError? = flowState.value.error
+
+ override fun dismissError() {
+ updateState { copy(error = null) }
+ }
+}
+
+internal data class AgataWalletLoginState(
+ val isLoading: Boolean = false,
+ val username: String = "",
+ val password: String = "",
+ val error: DomainError? = null,
+ val loginDone: Boolean = false,
+) : VMState {
+ val enabled: Boolean =
+ username.isNotBlank() && password.isNotBlank()
+}
diff --git a/app/src/main/kotlin/cz/lastaapps/menza/features/main/ui/vm/AgataWalletViewModel.kt b/app/src/main/kotlin/cz/lastaapps/menza/features/main/ui/vm/AgataWalletViewModel.kt
new file mode 100644
index 00000000..1d58c4ba
--- /dev/null
+++ b/app/src/main/kotlin/cz/lastaapps/menza/features/main/ui/vm/AgataWalletViewModel.kt
@@ -0,0 +1,107 @@
+/*
+ * Copyright 2023, Petr Laštovička as Lasta apps, All rights reserved
+ *
+ * This file is part of Menza.
+ *
+ * Menza is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Menza is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Menza. If not, see .
+ */
+
+package cz.lastaapps.menza.features.main.ui.vm
+
+import androidx.compose.runtime.Composable
+import arrow.core.Either.Left
+import arrow.core.Either.Right
+import arrow.core.None
+import arrow.core.Option
+import arrow.core.Some
+import cz.lastaapps.api.core.domain.model.UserBalance
+import cz.lastaapps.api.main.domain.usecase.wallet.WalletGetBalanceUC
+import cz.lastaapps.api.main.domain.usecase.wallet.WalletLogoutUC
+import cz.lastaapps.api.main.domain.usecase.wallet.WalletRefreshUC
+import cz.lastaapps.core.domain.error.DomainError
+import cz.lastaapps.core.ui.vm.Appearing
+import cz.lastaapps.core.ui.vm.ErrorHolder
+import cz.lastaapps.core.ui.vm.StateViewModel
+import cz.lastaapps.core.ui.vm.VMContext
+import cz.lastaapps.core.ui.vm.VMState
+import kotlinx.coroutines.flow.mapLatest
+import org.lighthousegames.logging.logging
+
+internal class AgataWalletViewModel(
+ vmContext: VMContext,
+ private val walletGetBalanceUC: WalletGetBalanceUC,
+ private val walletRefreshUC: WalletRefreshUC,
+ private val walletLogoutUC: WalletLogoutUC,
+) : StateViewModel(AgataWalletState(), vmContext),
+ Appearing, ErrorHolder {
+ override var hasAppeared: Boolean = false
+
+ companion object {
+ private val log = logging()
+ }
+
+ override fun onAppeared() = launchVM {
+ walletGetBalanceUC().mapLatest { balance ->
+ log.i { "New balance: $balance" }
+ processBalance(balance)
+ }.launchInVM()
+
+ load(false)
+ }
+
+ private fun processBalance(balance: UserBalance?) =
+ if (balance != null) {
+ updateState { copy(balance = Some(balance)) }
+ true
+ } else {
+ updateState { copy(balance = Some(null)) }
+ false
+ }
+
+ fun logout() = launchVM {
+ walletLogoutUC()
+ }
+
+ fun refresh() = launchVM {
+ load(true)
+ }
+
+ private suspend fun load(force: Boolean) {
+ withLoading({ copy(isLoading = it) }) {
+ log.d { "Starting refresh: force=$force" }
+
+ when (val res = walletRefreshUC(isForced = force)) {
+ is Right -> updateState { copy(isWarning = false) }
+ is Left -> updateState { copy(isWarning = true, error = res.value) }
+ }
+
+ log.d { "Refresh done" }
+ }
+ }
+
+ @Composable
+ override fun getError(): DomainError? = flowState.value.error
+
+ override fun dismissError() {
+ updateState { copy(error = null) }
+ }
+}
+
+internal data class AgataWalletState(
+ val error: DomainError? = null,
+ val isLoading: Boolean = false,
+ val isWarning: Boolean = false,
+ val balance: Option = None,
+) : VMState {
+}
diff --git a/app/src/main/kotlin/cz/lastaapps/menza/ui/components/AgataWalletButton.kt b/app/src/main/kotlin/cz/lastaapps/menza/ui/components/AgataWalletButton.kt
deleted file mode 100644
index 19ce3303..00000000
--- a/app/src/main/kotlin/cz/lastaapps/menza/ui/components/AgataWalletButton.kt
+++ /dev/null
@@ -1,273 +0,0 @@
-package cz.lastaapps.menza.ui.components
-
-import android.widget.Toast
-import androidx.compose.foundation.ExperimentalFoundationApi
-import androidx.compose.foundation.combinedClickable
-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.foundation.layout.width
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.rounded.Warning
-import androidx.compose.material3.Card
-import androidx.compose.material3.CardDefaults
-import androidx.compose.material3.CircularProgressIndicator
-import androidx.compose.material3.Icon
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedTextField
-import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
-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.Modifier
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.platform.LocalInspectionMode
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.input.PasswordVisualTransformation
-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 androidx.compose.ui.window.Dialog
-import cz.lastaapps.menza.R
-import cz.lastaapps.menza.util.AgataWalletCredentials
-import cz.lastaapps.menza.api.agata.api.AgataWallet
-import io.ktor.client.HttpClient
-import kotlinx.coroutines.launch
-
-@OptIn(ExperimentalFoundationApi::class)
-@Composable
-fun AgataWalletButton() {
- val scope = rememberCoroutineScope()
- val context = LocalContext.current
- val buttonText = remember { mutableStateOf(context.resources.getString(R.string.wallet_login)) }
- val loading = remember { mutableStateOf(false) }
- val error = remember { mutableStateOf(false) }
- val agataWallet = AgataWallet(HttpClient())
-
- // Preview
- if (LocalInspectionMode.current) {
- buttonText.value = "0.0 Kč"
- loading.value = true
- error.value = true
- }
-
- // Set the button text to formatted balance
- fun setBalanceText(balance: Float) {
- buttonText.value = String.format("%.2f Kč", balance)
- }
-
- // Update the balance
- fun update(force: Boolean = false) {
- // Credentials
- val saved = AgataWalletCredentials.getSavedCredentials(context) ?: return
- error.value = false
-
- scope.launch {
- // Try cached
- val cached = AgataWalletCredentials.getCachedBalance(context)
- if (!force && cached != null) {
- setBalanceText(cached);
- return@launch
- }
-
- // Fetch
- loading.value = true
- buttonText.value = ""
-
- agataWallet.getBalance(saved.first, saved.second).fold(
- // Error
- {
- it.throwable?.printStackTrace()
- Toast.makeText(context, "${context.resources.getString(R.string.wallet_update_error)}: $it", Toast.LENGTH_LONG).show()
- error.value = true
- },
- // Success
- {
- setBalanceText(it)
- AgataWalletCredentials.cacheBalance(context, it)
- }
- )
- loading.value = false
- }
- }
-
- // Login dialog
- val openDialog = remember { mutableStateOf(false) }
- if (openDialog.value) {
- AgataLoginDialog(
- onDismissRequest = {
- openDialog.value = false
- if (it) {
- update(true)
- }
- }
- )
- }
-
- // Update on start
- if (!LocalInspectionMode.current) {
- LaunchedEffect(Unit) {
- update()
- }
- }
-
- // Using card because buttons couldn't do long tap
- Card(
- colors = CardDefaults.cardColors(
- containerColor = MaterialTheme.colorScheme.primary,
- contentColor = MaterialTheme.colorScheme.onPrimary
- ),
-
- // Handle clicks
- modifier = Modifier.combinedClickable(
- // Open dialog if no credentials
- onClick = {
- val saved = AgataWalletCredentials.getSavedCredentials(context)
- if (saved == null) {
- openDialog.value = true
- return@combinedClickable
- }
- update(true)
- },
- // Open dialog on long press
- onLongClick = {
- openDialog.value = true
- }
- ),
-
- // Balance || Loading || Error
- content = {
- Row(
- modifier = Modifier.padding(8.dp)
- ) {
- Text(
- buttonText.value,
- // 18sp == 24dp in preview, to match the icon & progress
- // for some god forsaken reason dp doesn't work here
- fontSize = 18.sp,
- fontWeight = FontWeight.Bold,
- )
- if (loading.value) {
- CircularProgressIndicator(
- modifier = Modifier.height(24.dp).width(24.dp),
- color = MaterialTheme.colorScheme.surfaceVariant,
- trackColor = MaterialTheme.colorScheme.primary
- )
- }
- if (error.value) {
- Icon(
- Icons.Rounded.Warning,
- contentDescription = stringResource(R.string.wallet_update_error)
- )
- }
- }
-
- }
- )
-
-}
-
-@Preview(showBackground = true)
-@Composable
-fun AgataWalletButtonPreview() {
- AgataWalletButton()
-}
-
-
-@Composable
-fun AgataLoginDialog(
- onDismissRequest: (Boolean) -> Unit
-) {
- val context = LocalContext.current
- var username by remember { mutableStateOf("") }
- var password by remember { mutableStateOf("") }
-
- // Load initial username & password
- if (!LocalInspectionMode.current) {
- val saved = AgataWalletCredentials.getSavedCredentials(context)
- if (saved != null) {
- username = saved.first
- password = saved.second
- }
- }
-
-
- Dialog(onDismissRequest = { onDismissRequest(false) }) {
- Card(
- modifier = Modifier
- .fillMaxWidth()
- .padding(32.dp),
- shape = RoundedCornerShape(16.dp)
- ) {
- Column(
- modifier = Modifier.padding(8.dp)
- ) {
- Text(
- text = stringResource(R.string.wallet_login_title),
- modifier = Modifier
- .fillMaxWidth(),
- textAlign = TextAlign.Center,
- style = MaterialTheme.typography.headlineSmall
- )
- Text(
- text = stringResource(R.string.wallet_login_subtitle),
- modifier = Modifier
- .fillMaxWidth(),
- textAlign = TextAlign.Center,
- style = MaterialTheme.typography.bodySmall
- )
- Spacer(
- modifier = Modifier.padding(8.dp)
- )
- OutlinedTextField(
- value = username,
- onValueChange = { username = it },
- label = { Text(stringResource(R.string.wallet_login_username)) }
- )
- OutlinedTextField(
- value = password,
- onValueChange = { password = it },
- label = { Text(stringResource(R.string.wallet_login_password)) },
- visualTransformation = PasswordVisualTransformation()
- )
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.End,
- ) {
- TextButton(
- onClick = { onDismissRequest(false) },
- ) {
- Text(stringResource(R.string.wallet_login_cancel))
- }
- TextButton(
- onClick = {
- AgataWalletCredentials.saveCredentials(context, username, password)
- onDismissRequest(true)
- },
- enabled = username.isNotBlank() && password.isNotBlank()
- ) {
- Text(stringResource(R.string.wallet_login_save))
- }
- }
- }
- }
- }
-}
-
-@Preview
-@Composable
-fun AgataLoginDialogPreview() {
- AgataLoginDialog {}
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/cz/lastaapps/menza/ui/theme/Padding.kt b/app/src/main/kotlin/cz/lastaapps/menza/ui/theme/Padding.kt
index 567d9023..315b6141 100644
--- a/app/src/main/kotlin/cz/lastaapps/menza/ui/theme/Padding.kt
+++ b/app/src/main/kotlin/cz/lastaapps/menza/ui/theme/Padding.kt
@@ -35,5 +35,6 @@ object Padding {
val Screen = MidSmall
val Dialog = Medium
val ScrollBottomSpace = 64.dp
+ val Icon = 24.dp
}
}
diff --git a/app/src/main/kotlin/cz/lastaapps/menza/ui/util/Autofill.kt b/app/src/main/kotlin/cz/lastaapps/menza/ui/util/Autofill.kt
new file mode 100644
index 00000000..7ca8e3b6
--- /dev/null
+++ b/app/src/main/kotlin/cz/lastaapps/menza/ui/util/Autofill.kt
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2023, Petr Laštovička as Lasta apps, All rights reserved
+ *
+ * This file is part of Menza.
+ *
+ * Menza is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Menza is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Menza. If not, see .
+ */
+
+package cz.lastaapps.menza.ui.util
+
+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.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.autofill.AutofillNode
+import androidx.compose.ui.autofill.AutofillType
+import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.layout.boundsInWindow
+import androidx.compose.ui.layout.onGloballyPositioned
+
+@OptIn(ExperimentalComposeUiApi::class)
+@Composable
+fun Modifier.withAutofill(
+ autofillTypes: List = listOf(),
+ onFill: (String) -> Unit,
+): Modifier {
+ var rect by remember { mutableStateOf(null) }
+ AutofillNode(
+ autofillTypes = autofillTypes,
+ boundingBox = rect,
+ onFill = onFill,
+ )
+
+ return onGloballyPositioned {
+ rect = it.boundsInWindow()
+ }
+}
+
diff --git a/app/src/main/kotlin/cz/lastaapps/menza/util/AgataWalletCredentials.kt b/app/src/main/kotlin/cz/lastaapps/menza/util/AgataWalletCredentials.kt
deleted file mode 100644
index a80be0fa..00000000
--- a/app/src/main/kotlin/cz/lastaapps/menza/util/AgataWalletCredentials.kt
+++ /dev/null
@@ -1,64 +0,0 @@
-package cz.lastaapps.menza.util
-
-import android.content.Context
-import android.content.SharedPreferences
-import androidx.core.content.edit
-import androidx.security.crypto.EncryptedSharedPreferences
-import androidx.security.crypto.MasterKey
-
-class AgataWalletCredentials {
- companion object {
- /// Get encrypted shared preferences to store username & password
- private fun getSharedPreferences(context: Context): SharedPreferences {
- val masterKey =
- MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build()
- return EncryptedSharedPreferences.create(
- context,
- "agata",
- masterKey,
- EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
- EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
- )
- }
-
- /// Return saved username & password
- fun getSavedCredentials(context: Context): Pair? {
- val sharedPreferences = getSharedPreferences(context)
- val username = sharedPreferences.getString("username", null)
- val password = sharedPreferences.getString("password", null)
- if (username == null || password == null) {
- return null
- }
- return Pair(username, password)
- }
-
- /// Save credentials to shared preferences
- fun saveCredentials(context: Context, username: String, password: String) {
- getSharedPreferences(context).edit {
- putString("username", username)
- putString("password", password)
- }
- }
-
- /// Add balance to cache
- fun cacheBalance(context: Context, balance: Float) {
- getSharedPreferences(context).edit {
- putFloat("balance", balance)
- putLong("balanceAge", System.currentTimeMillis())
- }
- }
-
- /// Get balance from cache
- fun getCachedBalance(context: Context): Float? {
- val invalid = -99999f
- val sharedPreferences = getSharedPreferences(context)
- val balance = sharedPreferences.getFloat("balance", invalid)
- val balanceAge = sharedPreferences.getLong("balanceAge", 0)
- // 5 minutes expiration
- if (balance == invalid || (System.currentTimeMillis() - balanceAge) > 300_000) {
- return null
- }
- return balance
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml
index 2b304195..a7c77295 100644
--- a/app/src/main/res/values-cs/strings.xml
+++ b/app/src/main/res/values-cs/strings.xml
@@ -240,12 +240,18 @@
Později…
Nikdy!
- Klikni aby se přihlásit :3
+ Stav konta
+ Přihlásit
+ Přihlášen jako: %s
+ Odhlásit
Něco se nám pokakalo 💩
Přihlášení do Agáty
Použij svoje ČVUT údaje
Uživatelské jmeno
Velmi tajné heslo
+ Uložení hesla
+ Heslo je uložené zašifrované, pouze na tomto zařízení a je použito pouze v rámci zabezpečené komunikace.
+ Jsi z VŠCHT nebo běžný občan? Kontaktuj vývojáře v nastavení a pomož přidat tvvoji metodu přihlášení!
Zrušit
Uložit
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 26aa803f..44f9424a 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -261,12 +261,18 @@
Later…
Never!
- Click to login 🥺
+ Account balance
+ Login
+ Logged in as: %s
+ Logout
Failed to update balance 💀
Login to Agata
- Use your CTU username and password
+ Use your CTU credentials
Username
Password
+ Password storage
+ Password is stored encrypted, only on this device and is used only for secured communication with university servers.
+ Are you from UCT or an ordinary citizen? Contact the app developer in settings and help to add your login method!
Cancel
Save
diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml
new file mode 100644
index 00000000..1828bc30
--- /dev/null
+++ b/app/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
diff --git a/app/src/main/res/xml/extraction_rules.xml b/app/src/main/res/xml/extraction_rules.xml
new file mode 100644
index 00000000..481615bd
--- /dev/null
+++ b/app/src/main/res/xml/extraction_rules.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
diff --git a/core/src/androidMain/kotlin/cz/lastaapps/core/ui/ErrorText.kt b/core/src/androidMain/kotlin/cz/lastaapps/core/ui/ErrorText.kt
index f0320c26..eacd7e13 100644
--- a/core/src/androidMain/kotlin/cz/lastaapps/core/ui/ErrorText.kt
+++ b/core/src/androidMain/kotlin/cz/lastaapps/core/ui/ErrorText.kt
@@ -30,6 +30,9 @@ import cz.lastaapps.core.domain.error.ApiError.SyncError
import cz.lastaapps.core.domain.error.ApiError.SyncError.Closed
import cz.lastaapps.core.domain.error.ApiError.SyncError.Problem
import cz.lastaapps.core.domain.error.ApiError.SyncError.Unavailable
+import cz.lastaapps.core.domain.error.ApiError.WalletError
+import cz.lastaapps.core.domain.error.ApiError.WalletError.InvalidCredentials
+import cz.lastaapps.core.domain.error.ApiError.WalletError.TotallyBroken
import cz.lastaapps.core.domain.error.ApiError.WeekNotAvailable
import cz.lastaapps.core.domain.error.CommonError
import cz.lastaapps.core.domain.error.CommonError.AppNotFound
@@ -40,6 +43,7 @@ import cz.lastaapps.core.domain.error.CommonError.AppNotFound.Link
import cz.lastaapps.core.domain.error.CommonError.AppNotFound.Map
import cz.lastaapps.core.domain.error.CommonError.AppNotFound.PhoneCall
import cz.lastaapps.core.domain.error.CommonError.AppNotFound.Telegram
+import cz.lastaapps.core.domain.error.CommonError.NotLoggedIn
import cz.lastaapps.core.domain.error.CommonError.WorkTimeout
import cz.lastaapps.core.domain.error.DomainError
import cz.lastaapps.core.domain.error.DomainError.Unknown
@@ -100,11 +104,18 @@ val ApiError.text: AppText
Unavailable -> E(R.string.error_api_module_unavailable)
Closed -> E(R.string.error_api_menza_cloned)
}
+
+ is WalletError ->
+ when (this) {
+ TotallyBroken -> E(R.string.error_wallet_login_failed_critical)
+ InvalidCredentials -> E(R.string.error_wallet_login_failed_credentials)
+ }
}
val CommonError.text: AppText
get() = when (this) {
is WorkTimeout -> E(R.string.error_network_timeout)
+ is NotLoggedIn -> E(R.string.error_not_logged_in)
is AppNotFound -> when (this) {
AddContact -> E(R.string.error_no_app_contacts)
Email -> E(R.string.error_no_app_email)
diff --git a/core/src/androidMain/kotlin/cz/lastaapps/core/ui/vm/StateViewModel.kt b/core/src/androidMain/kotlin/cz/lastaapps/core/ui/vm/StateViewModel.kt
index 20cbe6a0..561c346a 100644
--- a/core/src/androidMain/kotlin/cz/lastaapps/core/ui/vm/StateViewModel.kt
+++ b/core/src/androidMain/kotlin/cz/lastaapps/core/ui/vm/StateViewModel.kt
@@ -43,9 +43,9 @@ abstract class StateViewModel(
@Composable
get() = myState.collectAsStateWithLifecycle()
- suspend fun withLoading(
+ protected suspend fun withLoading(
loading: State.(isLoading: Boolean) -> State,
- block: suspend () -> R,
+ block: suspend (State) -> R,
) = resource(
acquire = { updateState { loading(true) } },
release = { _, _ -> updateState { loading(false) } },
@@ -53,7 +53,7 @@ abstract class StateViewModel(
.let {
resourceScope {
it.bind()
- block()
+ block(lastState())
}
}
}
diff --git a/core/src/androidMain/res/values-cs/errors.xml b/core/src/androidMain/res/values-cs/errors.xml
index a7087c27..82957fce 100644
--- a/core/src/androidMain/res/values-cs/errors.xml
+++ b/core/src/androidMain/res/values-cs/errors.xml
@@ -23,6 +23,7 @@
Internet nedorazil 🙎♀️
Limit spojení vypršel 🌧️
Menza API poslala neplatná data 💀
+ A co přihlášení? 🧐
Nepodařilo se ověřit časový rozsah ⌛
Některé dny se nepodařilo načíst ⏰
Některá jídla nebyla správně načtena 🍲
@@ -38,4 +39,6 @@
Nemáte nainstalován prohlížeč
Telegram není nainstalován
Facebook není nainstalován
+ Přihlašení přestalo fungovat. Prosím, kontaktujte vývojáře. 🙏
+ Neplatné údaje 🤨
\ No newline at end of file
diff --git a/core/src/androidMain/res/values/errors.xml b/core/src/androidMain/res/values/errors.xml
index 90acdd97..c38a16c7 100644
--- a/core/src/androidMain/res/values/errors.xml
+++ b/core/src/androidMain/res/values/errors.xml
@@ -23,6 +23,7 @@
No internet 🙎♀️
Timeout 🌧️
Failed to process API content 💀
+ You are not logged in 🧐
Failed to parse valid dates ⌛
Failed to parse some day dishes ⏰
Failed to parse a dish 🍲
@@ -38,4 +39,6 @@
No browser installed
Telegram not installed
Facebook not installed
+ App login process does not work anymore. Contact the developer, please. 🙏
+ Invalid credentials 🤨
\ No newline at end of file
diff --git a/core/src/commonMain/kotlin/cz/lastaapps/core/domain/error/ApiError.kt b/core/src/commonMain/kotlin/cz/lastaapps/core/domain/error/ApiError.kt
index 53bb2cba..979ea8a2 100644
--- a/core/src/commonMain/kotlin/cz/lastaapps/core/domain/error/ApiError.kt
+++ b/core/src/commonMain/kotlin/cz/lastaapps/core/domain/error/ApiError.kt
@@ -34,4 +34,9 @@ sealed interface ApiError : DomainError.Logic {
@JvmInline
value class Problem(val errors: Nel) : SyncError
}
+
+ sealed interface WalletError : ApiError {
+ data object TotallyBroken : WalletError
+ data object InvalidCredentials : WalletError
+ }
}
diff --git a/core/src/commonMain/kotlin/cz/lastaapps/core/domain/error/CommonError.kt b/core/src/commonMain/kotlin/cz/lastaapps/core/domain/error/CommonError.kt
index 04d358a6..eec71e8f 100644
--- a/core/src/commonMain/kotlin/cz/lastaapps/core/domain/error/CommonError.kt
+++ b/core/src/commonMain/kotlin/cz/lastaapps/core/domain/error/CommonError.kt
@@ -22,6 +22,8 @@ package cz.lastaapps.core.domain.error
sealed interface CommonError : DomainError.Runtime {
data class WorkTimeout(override val throwable: Throwable) : CommonError
+ data object NotLoggedIn : CommonError
+
sealed interface AppNotFound : CommonError {
data object PhoneCall : AppNotFound
data object Email : AppNotFound
diff --git a/core/src/commonMain/kotlin/cz/lastaapps/core/domain/error/ShouldBeReported.kt b/core/src/commonMain/kotlin/cz/lastaapps/core/domain/error/ShouldBeReported.kt
index 293dae4b..6cb5004b 100644
--- a/core/src/commonMain/kotlin/cz/lastaapps/core/domain/error/ShouldBeReported.kt
+++ b/core/src/commonMain/kotlin/cz/lastaapps/core/domain/error/ShouldBeReported.kt
@@ -20,6 +20,7 @@
package cz.lastaapps.core.domain.error
import cz.lastaapps.core.domain.error.CommonError.AppNotFound
+import cz.lastaapps.core.domain.error.CommonError.NotLoggedIn
import cz.lastaapps.core.domain.error.CommonError.WorkTimeout
import cz.lastaapps.core.domain.error.NetworkError.ConnectionClosed
import cz.lastaapps.core.domain.error.NetworkError.NoInternet
@@ -28,6 +29,7 @@ import cz.lastaapps.core.domain.error.NetworkError.Timeout
val DomainError.shouldBeReported: Boolean
get() = when (this) {
+ is ApiError.WalletError.TotallyBroken -> true
is DomainError.Logic -> false
is DomainError.Unknown -> true
@@ -50,6 +52,7 @@ val NetworkError.shouldBeReported: Boolean
val CommonError.shouldBeReported: Boolean
get() = when (this) {
is WorkTimeout,
+ NotLoggedIn,
is AppNotFound,
-> false
}
diff --git a/gradle/plugins/convention/src/main/kotlin/cz/lastaapps/plugin/multiplatform/KMPLibraryConvention.kt b/gradle/plugins/convention/src/main/kotlin/cz/lastaapps/plugin/multiplatform/KMPLibraryConvention.kt
index 1bf02a14..8cabf40f 100644
--- a/gradle/plugins/convention/src/main/kotlin/cz/lastaapps/plugin/multiplatform/KMPLibraryConvention.kt
+++ b/gradle/plugins/convention/src/main/kotlin/cz/lastaapps/plugin/multiplatform/KMPLibraryConvention.kt
@@ -41,149 +41,153 @@ import org.gradle.kotlin.dsl.withType
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
@Suppress("unused")
-class KMPLibraryConvention : BasePlugin({
- pluginManager {
- alias(libs.plugins.kotlin.multiplatform)
- alias(libs.plugins.kotlin.serialization)
- alias(libs.plugins.android.library)
- }
+class KMPLibraryConvention : BasePlugin(
+ {
+ pluginManager {
+ alias(libs.plugins.kotlin.multiplatform)
+ alias(libs.plugins.kotlin.serialization)
+ alias(libs.plugins.android.library)
+ }
- apply()
- apply()
- apply()
- apply()
- apply()
+ apply()
+ apply()
+ apply()
+ apply()
+ apply()
- extensions.configure {
+ extensions.configure {
- sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml")
+ sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml")
- configureKotlinAndroid(this)
+ configureKotlinAndroid(this)
// configureComposeCompiler(this)
- // KMP plugin is fucking broken
- // https://issuetracker.google.com/issues/155536223#comment7
- val composeCompilerDependency = libs.androidx.compose.compiler
- dependencies {
- val namePrefixAndroid = "kotlinCompilerPluginClasspathAndroid"
- val namePrefixCommon = "kotlinCompilerPluginClasspathCommon"
- configurations.configureEach {
- if (name.startsWith(namePrefixAndroid) || name.startsWith(namePrefixCommon)) {
- add(name, composeCompilerDependency)
+ // KMP plugin is fucking broken
+ // https://issuetracker.google.com/issues/155536223#comment7
+ val composeCompilerDependency = libs.androidx.compose.compiler
+ dependencies {
+ val namePrefixAndroid = "kotlinCompilerPluginClasspathAndroid"
+ val namePrefixCommon = "kotlinCompilerPluginClasspathCommon"
+ configurations.configureEach {
+ if (name.startsWith(namePrefixAndroid) || name.startsWith(namePrefixCommon)) {
+ add(name, composeCompilerDependency)
+ }
}
}
}
- }
-
- tasks.withType {
- useJUnitPlatform()
- }
- tasks.withType {
- kotlinOptions {
- languageVersion = libs.versions.kotlin.language.get()
- apiVersion = libs.versions.kotlin.api.get()
- }
- }
-
- multiplatform {
- sourceSets.all {
- languageSettings.apply {
- optIn("kotlin.RequiresOptIn")
- optIn("kotlinx.coroutines.ExperimentalCoroutinesApi")
+ tasks.withType {
+ useJUnitPlatform()
+ }
+ tasks.withType {
+ kotlinOptions {
languageVersion = libs.versions.kotlin.language.get()
apiVersion = libs.versions.kotlin.api.get()
}
}
- targets.all {
- compilations.all { }
- }
+ multiplatform {
- androidTarget {
- compilations.all {
- kotlinOptions { }
+ sourceSets.all {
+ languageSettings.apply {
+ optIn("kotlin.RequiresOptIn")
+ optIn("kotlinx.coroutines.ExperimentalCoroutinesApi")
+ languageVersion = libs.versions.kotlin.language.get()
+ apiVersion = libs.versions.kotlin.api.get()
+ }
}
- }
- jvm {
- compilations.all {
- kotlinOptions { }
+
+ targets.all {
+ compilations.all { }
}
- }
- sourceSets.apply {
- getByName("commonMain") {
- dependencies {
- implementation(project.dependencies.platform(libs.kotlin.bom))
- implementation(libs.kotlin.coroutines.common)
- implementation(libs.kotlinx.dateTime)
- implementation(libs.kotlinx.collection)
- implementation(libs.kotlinx.serializationJson)
- implementation(libs.koin.core)
-// implementation(libs.koin.annotations)
- implementation(libs.kmLogging)
+ androidTarget {
+ compilations.all {
+ kotlinOptions { }
+ }
+ }
+ jvm {
+ compilations.all {
+ kotlinOptions { }
}
}
- getByName("commonTest") {
- dependencies {
- implementation(libs.kotlin.test.annotation)
- implementation(libs.kotlin.test.common)
- implementation(libs.kotlin.test.core)
- implementation(libs.kotlin.test.jUnit5)
- implementation(libs.kotest.assertion)
- implementation(libs.kotlin.coroutines.test)
+ sourceSets.apply {
+ getByName("commonMain") {
+ dependencies {
+ implementation(project.dependencies.platform(libs.kotlin.bom))
+ implementation(libs.kotlin.coroutines.common)
+ implementation(libs.kotlinx.dateTime)
+ implementation(libs.kotlinx.collection)
+ implementation(libs.kotlinx.serializationJson)
+ implementation(libs.koin.core)
+// implementation(libs.koin.annotations)
+ implementation(libs.kmLogging)
+
+ implementation(libs.androidx.annotation)
+ }
+ }
+
+ getByName("commonTest") {
+ dependencies {
+ implementation(libs.kotlin.test.annotation)
+ implementation(libs.kotlin.test.common)
+ implementation(libs.kotlin.test.core)
+ implementation(libs.kotlin.test.jUnit5)
+ implementation(libs.kotest.assertion)
+ implementation(libs.kotlin.coroutines.test)
// implementation(libs.koin.test.jUnit5)
+ }
}
- }
- getByName("androidMain") {
- dependencies {
- implementation(libs.koin.android.core)
+ getByName("androidMain") {
+ dependencies {
+ implementation(libs.koin.android.core)
+ }
}
- }
- getByName("androidUnitTest") {
- dependencies {
- implementation(libs.kotlin.coroutines.test)
- implementation(libs.kotest.jUnit5runner)
- implementation(project.dependencies.platform(libs.junit5.bom))
- implementation(libs.junit5.jupiter.api)
- implementation(libs.junit5.jupiter.runtime)
+ getByName("androidUnitTest") {
+ dependencies {
+ implementation(libs.kotlin.coroutines.test)
+ implementation(libs.kotest.jUnit5runner)
+ implementation(project.dependencies.platform(libs.junit5.bom))
+ implementation(libs.junit5.jupiter.api)
+ implementation(libs.junit5.jupiter.runtime)
+ }
}
- }
- getByName("jvmMain") {
- dependencies {
- implementation(libs.logback.core)
- implementation(libs.logback.classic)
- implementation(libs.slf4j)
+ getByName("jvmMain") {
+ dependencies {
+ implementation(libs.logback.core)
+ implementation(libs.logback.classic)
+ implementation(libs.slf4j)
+ }
}
- }
- getByName("jvmTest") {
- dependencies {
- implementation(libs.kotlin.coroutines.test)
- implementation(libs.kotest.jUnit5runner)
- implementation(project.dependencies.platform(libs.junit5.bom))
- implementation(libs.junit5.jupiter.api)
- implementation(libs.junit5.jupiter.runtime)
+ getByName("jvmTest") {
+ dependencies {
+ implementation(libs.kotlin.coroutines.test)
+ implementation(libs.kotest.jUnit5runner)
+ implementation(project.dependencies.platform(libs.junit5.bom))
+ implementation(libs.junit5.jupiter.api)
+ implementation(libs.junit5.jupiter.runtime)
+ }
}
}
}
- }
-
- dependencies {
- try {
- add("kspCommonMainMetadata", libs.koin.annotations.compiler)
- add("kspAndroid", libs.koin.annotations.compiler)
- add("kspJvm", libs.koin.annotations.compiler)
- } catch (_: Exception) {
- }
- commonImplementation(project.dependencies.platform(libs.arrowkt.bom))
- commonImplementation(libs.arrowkt.core)
- commonImplementation(libs.arrowkt.fx.coroutines)
- commonImplementation(libs.arrowkt.fx.stm)
- }
-})
+ dependencies {
+ try {
+ add("kspCommonMainMetadata", libs.koin.annotations.compiler)
+ add("kspAndroid", libs.koin.annotations.compiler)
+ add("kspJvm", libs.koin.annotations.compiler)
+ } catch (_: Exception) {
+ }
+
+ commonImplementation(project.dependencies.platform(libs.arrowkt.bom))
+ commonImplementation(libs.arrowkt.core)
+ commonImplementation(libs.arrowkt.fx.coroutines)
+ commonImplementation(libs.arrowkt.fx.stm)
+ }
+ },
+)