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) + } + }, +)