diff --git a/.idea/misc.xml b/.idea/misc.xml index ddba1308a6b3133e32d83fa7b8e5174bc5e67922..5f04613c84047e3fcff7491e61f09c4ebe5c1245 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,4 +1,3 @@ -<?xml version="1.0" encoding="UTF-8"?> <project version="4"> <component name="EntryPointsManager"> <list size="1"> diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8d39981a7c3b1ffd92f89342c925a310b2e41e0a..4543eaba43dad18c2e393a40ebfac72d719dc824 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -43,12 +43,24 @@ android { buildConfigField("String", "I11_KAFFEEKASSE_DEBUG_DEVICEID", "null") buildConfigField("String", "I11_KAFFEEKASSE_DEBUG_APIKEY", "null") } - debug { + create("debug-preview") { applicationIdSuffix = ".debug" + signingConfig = signingConfigs.getByName("debug") proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) + buildConfigField("String", "I11_PORTAL_DEBUG_USERNAME", "null") + buildConfigField("String", "I11_PORTAL_DEBUG_PASSWORD", "null") + buildConfigField("String", "I11_KAFFEEKASSE_DEBUG_DEVICEID", "null") + buildConfigField("String", "I11_KAFFEEKASSE_DEBUG_APIKEY", "null") + } + debug { + applicationIdSuffix = ".debug" signingConfig = signingConfigs.getByName("debug") + proguardFiles( + "proguard-rules.pro" + ) gradleLocalProperties(rootDir).let { properties -> var username = properties["i11.portal.debug.username"] var password = properties["i11.portal.debug.password"] diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/AppModule.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/AppModule.kt index a185fa55f968d591439f8b96e32bd5fdead74d35..c3ad6c1810afd9b73e073c4c3789ab986478b8e9 100644 --- a/app/src/main/java/net/novagamestudios/kaffeekasse/AppModule.kt +++ b/app/src/main/java/net/novagamestudios/kaffeekasse/AppModule.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.mutableStateMapOf import net.novagamestudios.kaffeekasse.model.kaffeekasse.Cart import net.novagamestudios.kaffeekasse.model.kaffeekasse.Item import net.novagamestudios.kaffeekasse.model.kaffeekasse.MutableCart +import net.novagamestudios.kaffeekasse.repositories.RepositoryProvider sealed interface AppModule { @@ -43,6 +44,10 @@ class KaffeekasseModule : AppModule { override operator fun contains(item: Item) = item in content override val itemCount get() = content.values.sum() } + + companion object { + val RepositoryProvider.kaffeekasseCart get() = modules.require<KaffeekasseModule>().cart + } } class HiwiTrackerModule : AppModule { diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/MainActivity.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/MainActivity.kt index 68481caa2d9167ded5321b0e3bbce47b2a24d270..a396c9fcad5337d9f34f152505f4358c8fc8b3b8 100644 --- a/app/src/main/java/net/novagamestudios/kaffeekasse/MainActivity.kt +++ b/app/src/main/java/net/novagamestudios/kaffeekasse/MainActivity.kt @@ -1,14 +1,22 @@ package net.novagamestudios.kaffeekasse +import android.annotation.SuppressLint +import android.os.Build import android.os.Bundle +import android.view.WindowInsets import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.runtime.CompositionLocalProvider +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsControllerCompat +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch import net.novagamestudios.common_utils.LocalLogger import net.novagamestudios.common_utils.Logger import net.novagamestudios.common_utils.error import net.novagamestudios.kaffeekasse.ui.App -import net.novagamestudios.kaffeekasse.ui.theme.KaffeekasseTheme class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -21,6 +29,37 @@ class MainActivity : ComponentActivity() { App() } } + lifecycleScope.launch { + App.instance.settingsRepository.values + .map { it.fullscreen } + .distinctUntilChanged() + .collect { fullscreen -> + if (fullscreen) { + hideSystemUI() + } else { + showSystemUI() + } + } + } + + val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView) + windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } + + @SuppressLint("WrongConstant") + private fun hideSystemUI() { + val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + windowInsetsController.hide(WindowInsets.Type.systemBars()) + } + } + + @SuppressLint("WrongConstant") + private fun showSystemUI() { + val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + windowInsetsController.show(WindowInsets.Type.systemBars()) + } } private companion object : Logger { diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/api/kaffeekasse/model/BasicUserInfo.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/api/kaffeekasse/model/BasicUserInfo.kt index ce8efcd77dacc21c1a7ed1f9bd4dba36f0a7ae79..e9f493409678e07f2658348672317aba57bebc75 100644 --- a/app/src/main/java/net/novagamestudios/kaffeekasse/api/kaffeekasse/model/BasicUserInfo.kt +++ b/app/src/main/java/net/novagamestudios/kaffeekasse/api/kaffeekasse/model/BasicUserInfo.kt @@ -15,4 +15,6 @@ data class BasicUserInfo( ) : APIPurchaseAccount { val firstName by lazy { name.split(", ")[1] } val lastName by lazy { name.split(", ")[0] } + + val mayHavePin get() = noPinSet == null || !noPinSet } \ No newline at end of file diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/model/kaffeekasse/UserAuthCredentials.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/model/kaffeekasse/UserAuthCredentials.kt new file mode 100644 index 0000000000000000000000000000000000000000..028c611667043607c0e4d76a1853d23451eb6195 --- /dev/null +++ b/app/src/main/java/net/novagamestudios/kaffeekasse/model/kaffeekasse/UserAuthCredentials.kt @@ -0,0 +1,11 @@ +package net.novagamestudios.kaffeekasse.model.kaffeekasse + +data class UserAuthCredentials( + val pin: String? = null, + val rwthId: String? = null, + val key: String? = null +) { + companion object { + val Empty = UserAuthCredentials() + } +} diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/model/session/Session.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/model/session/Session.kt index 46512c356bc05fd626ee64ecb74b9426b437187c..a44768c517814df3e5711974541cd247981d7b42 100644 --- a/app/src/main/java/net/novagamestudios/kaffeekasse/model/session/Session.kt +++ b/app/src/main/java/net/novagamestudios/kaffeekasse/model/session/Session.kt @@ -1,20 +1,50 @@ package net.novagamestudios.kaffeekasse.model.session -data class Session( - private val commonUser: User?, - val device: Device? -) : java.io.Serializable { - val realUser get() = commonUser?.takeIf { it.isRealUser } - - val isRealUserLoggedIn get() = realUser != null - val isKaffeekasseLoggedIn get() = commonUser?.takeIf { it.isKaffeekasse } != null - val isDeviceLoggedIn get() = device != null +sealed interface Session : java.io.Serializable { + data object Empty : Session + interface WithDevice : Session { + val device: Device + } + interface WithRealUser : Session { + val realUser: User + } + interface WithDeviceAndUser : Session, WithDevice, WithRealUser companion object { - val Empty = Session(null, null) - - private val User.isKaffeekasse get() = user == "kaffeekasse" - private val User.isRealUser get() = !isKaffeekasse + operator fun invoke( + device: Device?, + user: User? + ): Session { + val realUser = user?.takeIf { it.isRealUser } + return when { + device != null && realUser != null -> SessionWithDeviceAndRealUser(device, realUser) + device != null -> SessionWithDevice(device) + realUser != null -> SessionWithRealUser(realUser) + else -> Empty + } + } } } + + +val Session.deviceOrNull: Device? get() = (this as? Session.WithDevice)?.device +val Session.realUserOrNull: User? get() = (this as? Session.WithRealUser)?.realUser + + +private val User.isKaffeekasse get() = user == "kaffeekasse" +private val User.isRealUser get() = !isKaffeekasse + + +private data class SessionWithDevice( + override val device: Device +) : Session.WithDevice + +private data class SessionWithRealUser( + override val realUser: User +) : Session.WithRealUser + +private data class SessionWithDeviceAndRealUser( + override val device: Device, + override val realUser: User +) : Session.WithDeviceAndUser diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/LoginRepository.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/LoginRepository.kt index c5b2411f8d2edb1b117ec83a4502c99ce0efd87d..6c50325bdf09cfef1d04ca1c3971ede55d22e101 100644 --- a/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/LoginRepository.kt +++ b/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/LoginRepository.kt @@ -8,12 +8,15 @@ import net.novagamestudios.common_utils.Logger import net.novagamestudios.common_utils.debug import net.novagamestudios.common_utils.info import net.novagamestudios.common_utils.toastShort +import net.novagamestudios.common_utils.verbose import net.novagamestudios.common_utils.warn import net.novagamestudios.kaffeekasse.api.kaffeekasse.KaffeekasseAPI import net.novagamestudios.kaffeekasse.api.kaffeekasse.model.BasicUserInfo import net.novagamestudios.kaffeekasse.model.credentials.DeviceCredentials import net.novagamestudios.kaffeekasse.model.credentials.Login import net.novagamestudios.kaffeekasse.model.credentials.isValid +import net.novagamestudios.kaffeekasse.model.kaffeekasse.UserAuthCredentials +import net.novagamestudios.kaffeekasse.model.session.Session import net.novagamestudios.kaffeekasse.repositories.i11.KaffeekasseRepository import net.novagamestudios.kaffeekasse.repositories.i11.PortalRepository import net.novagamestudios.kaffeekasse.util.mapState @@ -31,11 +34,14 @@ class LoginRepository( val isPerformingAction = _isPerformingAction.asStateFlow() private suspend fun <R> action(block: suspend () -> R) = mutex.withReentrantLock { + val prev = _isPerformingAction.value + verbose { "Locking action: ${!prev}" } _isPerformingAction.value = true try { block() } finally { - _isPerformingAction.value = false + verbose { "Unlocking action: ${!prev}" } + _isPerformingAction.value = prev } } @@ -66,8 +72,7 @@ class LoginRepository( } suspend fun tryAutoLogin(activityContext: Context): Login? = action { - if (portal.session.value.isRealUserLoggedIn) return@action null - if (portal.session.value.isDeviceLoggedIn) return@action null + if (portal.session.value !is Session.Empty) return@action null if (!autoLoginAttemptAvailable) return@action null debug { "Auto login enabled: ${autoLogin.value}" } @@ -118,20 +123,37 @@ class LoginRepository( - suspend fun login(user: BasicUserInfo): String? = action { + suspend fun login(user: BasicUserInfo, auth: UserAuthCredentials = UserAuthCredentials.Empty): LoginResult = action { try { - when (portal.loginUser(user.id)) { - KaffeekasseAPI.UserLoginResult.Failure.PrivateDevice -> "Private device" - is KaffeekasseAPI.UserLoginResult.Failure.UnknownError -> "Unknown error" - KaffeekasseAPI.UserLoginResult.Failure.UserAuthenticationFailure -> "User authentication failure" - else -> null + when (portal.loginUser( + user.id, + pin = auth.pin, + rwthId = auth.rwthId, + key = auth.key + )) { + KaffeekasseAPI.UserLoginResult.Failure.PrivateDevice -> LoginResult.Failure("Private device") + is KaffeekasseAPI.UserLoginResult.Failure.UnknownError -> LoginResult.Failure("Unknown error") + KaffeekasseAPI.UserLoginResult.Failure.UserAuthenticationFailure -> LoginResult.Failure("User authentication failure") + is KaffeekasseAPI.UserLoginResult.LoggedIn -> LoginResult.Success(portal.session.value) } } catch (e: Exception) { warn(e) { "Failed to login user" } - e.message ?: e::class.simpleName ?: "Unknown error" + LoginResult.Failure(e.message ?: e::class.simpleName ?: "Unknown error") } } + sealed interface LoginResult { + data class Success(val session: Session) : LoginResult + data class Failure(val error: String) : LoginResult + } + + + suspend fun logoutUser() = action { + portal.logoutUser() + } + + + private suspend fun performDeviceLogin(credentials: DeviceCredentials): Boolean = action { info { "Logging in device: ${credentials.deviceId}" } val success = kaffeekasse.loginDevice(credentials) @@ -140,7 +162,7 @@ class LoginRepository( } suspend fun tryAutoLoginDevice(): Unit = action { - if (portal.session.value.isRealUserLoggedIn || portal.session.value.isDeviceLoggedIn) return@action + if (portal.session.value !is Session.Empty) return@action info { "Trying to auto login device" } val credentials = credentials.deviceCredentials() ?.takeIf { it.isValid } diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/RepositoryProvider.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/RepositoryProvider.kt index f2a8fa43ce14eb22107bebf3af5962187f6645d2..7f35ced67789863be530d5c15d70fb20ba958f7d 100644 --- a/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/RepositoryProvider.kt +++ b/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/RepositoryProvider.kt @@ -25,4 +25,4 @@ interface RepositoryProvider { // Active app modules val modules: AppModules -} \ No newline at end of file +} diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/Settings.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/Settings.kt index 821d6685d1e493cccce722fd3700740b0851b48c..ff57c0a6a01d8be268e7865114da220a25463295 100644 --- a/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/Settings.kt +++ b/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/Settings.kt @@ -20,6 +20,7 @@ import java.io.File @Serializable data class Settings( val themeMode: ThemeMode = ThemeMode.Dark, + val fullscreen: Boolean = false, val autoLogin: Boolean = false, val deviceCredentials: DeviceCredentials? = null, val developerMode: Boolean = false, diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/SettingsRepository.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/SettingsRepository.kt index 888f93621d2c5f877c66862c8a6f6d1a9973565d..b586c6cad1827c6407dda022f7d7d502c4ec02b9 100644 --- a/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/SettingsRepository.kt +++ b/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/SettingsRepository.kt @@ -2,6 +2,7 @@ package net.novagamestudios.kaffeekasse.repositories import kotlinx.coroutines.flow.StateFlow import net.novagamestudios.common_utils.compose.state.MutableDataStoreState +import net.novagamestudios.kaffeekasse.model.session.realUserOrNull import net.novagamestudios.kaffeekasse.util.mapState class SettingsRepository( @@ -10,7 +11,7 @@ class SettingsRepository( ) : MutableSettingsStore by settingsStore { val userSettings: StateFlow<MutableDataStoreState<UserSettings>?> by lazy { repositoryProvider.portalRepository.session.mapState { session -> - val key = session.realUser?.user ?: return@mapState null + val key = session.realUserOrNull?.user ?: return@mapState null UserSettingsStore(key, settingsStore) } } diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/i11/HiwiTrackerRepository.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/i11/HiwiTrackerRepository.kt index 21f151c64aee0fce26238c5657b57da29d68dd9d..0d8d366e60aa433874a042305903f3e79073ed76 100644 --- a/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/i11/HiwiTrackerRepository.kt +++ b/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/i11/HiwiTrackerRepository.kt @@ -32,7 +32,7 @@ class HiwiTrackerRepository( } } - override suspend fun markUserDataDirty() { + override fun markUserDataDirty() { dataByMonth.clear() } diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/i11/KaffeekasseRepository.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/i11/KaffeekasseRepository.kt index c7bf0c196f3d8491e4d6252868d0f94fddecff8a..0cd93d672e1d32d4733cad0a534e863a14642728 100644 --- a/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/i11/KaffeekasseRepository.kt +++ b/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/i11/KaffeekasseRepository.kt @@ -4,16 +4,15 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import net.novagamestudios.common_utils.Logger -import net.novagamestudios.kaffeekasse.api.kaffeekasse.model.APIPurchaseAccount import net.novagamestudios.kaffeekasse.api.kaffeekasse.KaffeekasseAPI import net.novagamestudios.kaffeekasse.api.kaffeekasse.KaffeekasseScraper +import net.novagamestudios.kaffeekasse.api.kaffeekasse.model.APIPurchaseAccount import net.novagamestudios.kaffeekasse.api.kaffeekasse.model.ExtendedUserInfo import net.novagamestudios.kaffeekasse.data.category import net.novagamestudios.kaffeekasse.data.cleanName import net.novagamestudios.kaffeekasse.data.cleanProductName import net.novagamestudios.kaffeekasse.data.cleanVariantName import net.novagamestudios.kaffeekasse.data.drawableResource -import net.novagamestudios.kaffeekasse.model.session.Device import net.novagamestudios.kaffeekasse.model.credentials.DeviceCredentials import net.novagamestudios.kaffeekasse.model.kaffeekasse.Account import net.novagamestudios.kaffeekasse.model.kaffeekasse.Cart @@ -26,6 +25,10 @@ import net.novagamestudios.kaffeekasse.model.kaffeekasse.ManualBillDetails import net.novagamestudios.kaffeekasse.model.kaffeekasse.Stock import net.novagamestudios.kaffeekasse.model.kaffeekasse.Transaction import net.novagamestudios.kaffeekasse.model.kaffeekasse.isEmpty +import net.novagamestudios.kaffeekasse.model.session.Device +import net.novagamestudios.kaffeekasse.model.session.Session +import net.novagamestudios.kaffeekasse.model.session.User +import net.novagamestudios.kaffeekasse.util.richdata.KeyedMultiDataSource import net.novagamestudios.kaffeekasse.util.richdata.RichData import net.novagamestudios.kaffeekasse.util.richdata.RichDataSource import net.novagamestudios.kaffeekasse.util.richdata.RichDataSource.Companion.RichDataSource @@ -56,15 +59,14 @@ class KaffeekasseRepository( } suspend fun logoutDevice() { - if (session.value.device == null) return + if (session.value !is Session.WithDevice) return mutableCurrentDevice.value = null client.logoutAll() } - - val account: RichDataSource<Account> = RichDataSource { - requireLoggedInUser() + val account: KeyedMultiDataSource<User, Account> = KeyedMultiDataSource { user -> + requireLoggedIn(user) api.loggedInUser().mapToRichDataState { Account( firstName = balance.myBalance.firstName, @@ -76,17 +78,31 @@ class KaffeekasseRepository( } } + val transactions: KeyedMultiDataSource<User, List<Transaction>> = KeyedMultiDataSource { user -> + requireLoggedIn(user) + scraper.transactions().let { + RichData.Data(it) + } + } + + val manualBillAccounts: KeyedMultiDataSource<User, List<ManualBillDetails.PurchaseAccount>> = KeyedMultiDataSource { user -> + requireLoggedIn(user) + scraper.manualBillDetails().accounts.let { + RichData.Data(it) + } + } + + + val stock: RichDataSource<Stock> = RichDataSource { - requireLoggedInUser() + requireAnyLoggedInUser() progress(0f / 3f) val baseItemGroups = scraper.manualBillDetails().itemGroups progress(1f / 3f) - if (!session.value.isDeviceLoggedIn) return@RichDataSource RichData.Data( - StockImpl( - itemGroups = baseItemGroups - ) + if (session.value !is Session.WithDevice) return@RichDataSource RichData.Data( + StockImpl(itemGroups = baseItemGroups) ) val itemGroupsById: Map<Int?, MutableItemGroup> = baseItemGroups.map { @@ -141,13 +157,6 @@ class KaffeekasseRepository( } } - val transactions: RichDataSource<List<Transaction>> = RichDataSource { - requireLoggedInUser() - scraper.transactions().let { - RichData.Data(it) - } - } - val basicUserInfoList = RichDataSource { @@ -156,15 +165,6 @@ class KaffeekasseRepository( } private val extendedUserInfoById = mutableMapOf<Int, RichDataSource<ExtendedUserInfo>>() - - val manualBillAccounts = RichDataSource { - requireLoggedInUser() - scraper.manualBillDetails().accounts.let { - RichData.Data(it) - } - } - - fun getExtendedUserInfo(userId: Int): RichDataSource<ExtendedUserInfo> = extendedUserInfoById.getOrPut(userId) { RichDataSource { requireLoggedInDevice() @@ -173,16 +173,16 @@ class KaffeekasseRepository( } - suspend fun purchase(cart: Cart, account: ManualBillDetails.PurchaseAccount) { + suspend fun purchase(asUser: User, cart: Cart, account: ManualBillDetails.PurchaseAccount) { if (cart.isEmpty()) return - requireLoggedInUser() + requireLoggedIn(asUser) scraper.submitCart(account, cart) markBalanceDataDirty() } - suspend fun purchase(cart: Cart, targetAccount: APIPurchaseAccount? = null) { + suspend fun purchase(asUser: User, cart: Cart, targetAccount: APIPurchaseAccount? = null) { if (cart.isEmpty()) return - requireLoggedInUser() + requireLoggedIn(asUser) requireLoggedInDevice() cart.forEach { (item, count) -> val result = api.purchase( @@ -196,15 +196,14 @@ class KaffeekasseRepository( } - private suspend fun markBalanceDataDirty() { + private fun markBalanceDataDirty() { account.markDirty() transactions.markDirty() } - override suspend fun markUserDataDirty() { - account.markDirty() - stock.markDirty() - transactions.markDirty() + override fun markUserDataDirty() { + account.clear() + transactions.clear() extendedUserInfoById.values.forEach { it.markDirty() } } diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/i11/PortalRepository.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/i11/PortalRepository.kt index 3eec06ea5ea0cb773e64b80d1e32d9fde34a56a7..9358c16b0432d731f37443e06481f7817ee07ec2 100644 --- a/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/i11/PortalRepository.kt +++ b/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/i11/PortalRepository.kt @@ -11,6 +11,7 @@ import net.novagamestudios.kaffeekasse.api.kaffeekasse.KaffeekasseAPI import net.novagamestudios.kaffeekasse.model.credentials.Login import net.novagamestudios.kaffeekasse.model.session.Session import net.novagamestudios.kaffeekasse.model.session.User +import net.novagamestudios.kaffeekasse.model.session.realUserOrNull class PortalRepository( @@ -30,13 +31,13 @@ class PortalRepository( kaffeekasse.currentDevice ) { session, device -> Session( - commonUser = if (session.isLoggedIn) User(session.username, session.displayName) else null, - device = device + device = device, + user = if (session.isLoggedIn) User(session.username, session.displayName) else null ) }.stateIn(coroutineScope, SharingStarted.Eagerly, Session.Empty) suspend fun loginUser(login: Login) { - if (session.value.isRealUserLoggedIn) throw IllegalStateException("User already logged in") + if (session.value is Session.WithRealUser) throw IllegalStateException("User already logged in") client.login(login) markUserDataDirty() } @@ -47,8 +48,8 @@ class PortalRepository( rwthId: String? = null, key: String? = null ): KaffeekasseAPI.UserLoginResult { - if (session.value.isRealUserLoggedIn) throw IllegalStateException("User already logged in") - if (!session.value.isDeviceLoggedIn) throw IllegalStateException("No permissions to login user") + if (session.value is Session.WithRealUser) throw IllegalStateException("User already logged in") + if (session.value !is Session.WithDevice) throw IllegalStateException("No permissions to login user") val result = kaffeekasse.api.performUserLogin(userId, pin, rwthId, key) info { "User login result: $result" } markUserDataDirty() @@ -56,13 +57,13 @@ class PortalRepository( } suspend fun logoutUser() { - requireLoggedInUser() - if (session.value.isDeviceLoggedIn) kaffeekasse.api.logoutUser() + requireAnyLoggedInUser() + if (session.value is Session.WithDevice) kaffeekasse.api.logoutUser() else client.logoutAll() markUserDataDirty() } - override suspend fun markUserDataDirty() { + override fun markUserDataDirty() { modules.forEach { if (it != this) it.markUserDataDirty() } } } @@ -70,8 +71,9 @@ class PortalRepository( abstract class PortalRepositoryModule internal constructor() { internal lateinit var portal: PortalRepository protected open val session get() = portal.session - protected fun requireLoggedInUser() = require(session.value.isRealUserLoggedIn) { "Logged in user required" } - protected fun requireLoggedInDevice() = require(session.value.isDeviceLoggedIn) { "Logged in device required" } - abstract suspend fun markUserDataDirty() + protected fun requireAnyLoggedInUser() = require(session.value is Session.WithRealUser) { "Logged in user required" } + protected fun requireLoggedIn(user: User) = require(session.value.realUserOrNull == user) { "User \"${user.user}\" not logged in" } + protected fun requireLoggedInDevice() = require(session.value is Session.WithDevice) { "Logged in device required" } + abstract fun markUserDataDirty() } diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/releases/GitLabReleases.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/releases/GitLabReleases.kt index 970b65fda7a276ab743f56c0cfe3741f1e46e5f5..73b30eac6dc5eba8e7e09ea3a1eb6575b1fb0bfc 100644 --- a/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/releases/GitLabReleases.kt +++ b/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/releases/GitLabReleases.kt @@ -35,90 +35,3 @@ class GitLabReleases( } -/* - - { - "name": "1.0.0", - "tag_name": "v1.0.0", - "description": null, - "created_at": "2024-03-03T22:38:29.368+01:00", - "released_at": "2024-03-03T22:38:29.368+01:00", - "upcoming_release": false, - "author": { - "id": 13529, - "username": "jonas.broeckmann", - "name": "Jonas Broeckmann", - "state": "active", - "locked": false, - "avatar_url": "https://git.rwth-aachen.de/uploads/-/system/user/avatar/13529/avatar.png", - "web_url": "https://git.rwth-aachen.de/jonas.broeckmann" - }, - "commit": { - "id": "0f3ed979a3aa506e5fbbe37ac2b25929dcea5718", - "short_id": "0f3ed979", - "created_at": "2024-03-03T22:32:51.000+01:00", - "parent_ids": [ - "4a566100b8648a1a452966e9bdb97c6ae37b47f1" - ], - "title": "CI use package registry", - "message": "CI use package registry\n", - "author_name": "JojoIV", - "author_email": "jonas.broeckmann@gmx.de", - "authored_date": "2024-03-03T22:32:51.000+01:00", - "committer_name": "JojoIV", - "committer_email": "jonas.broeckmann@gmx.de", - "committed_date": "2024-03-03T22:32:51.000+01:00", - "trailers": {}, - "extended_trailers": {}, - "web_url": "https://git.rwth-aachen.de/jonas.broeckmann/kaffeekasse/-/commit/0f3ed979a3aa506e5fbbe37ac2b25929dcea5718" - }, - "commit_path": "/jonas.broeckmann/kaffeekasse/-/commit/0f3ed979a3aa506e5fbbe37ac2b25929dcea5718", - "tag_path": "/jonas.broeckmann/kaffeekasse/-/tags/v1.0.0", - "assets": { - "count": 5, - "sources": [ - { - "format": "zip", - "url": "https://git.rwth-aachen.de/jonas.broeckmann/kaffeekasse/-/archive/v1.0.0/kaffeekasse-v1.0.0.zip" - }, - { - "format": "tar.gz", - "url": "https://git.rwth-aachen.de/jonas.broeckmann/kaffeekasse/-/archive/v1.0.0/kaffeekasse-v1.0.0.tar.gz" - }, - { - "format": "tar.bz2", - "url": "https://git.rwth-aachen.de/jonas.broeckmann/kaffeekasse/-/archive/v1.0.0/kaffeekasse-v1.0.0.tar.bz2" - }, - { - "format": "tar", - "url": "https://git.rwth-aachen.de/jonas.broeckmann/kaffeekasse/-/archive/v1.0.0/kaffeekasse-v1.0.0.tar" - } - ], - "links": [ - { - "id": 802, - "name": "kaffeekasse-1.0.0.apk", - "url": "https://git.rwth-aachen.de/api/v4/projects/95637/packages/generic/kaffeekasse/1.0.0/kaffeekasse-1.0.0.apk", - "direct_asset_url": "https://git.rwth-aachen.de/api/v4/projects/95637/packages/generic/kaffeekasse/1.0.0/kaffeekasse-1.0.0.apk", - "link_type": "other" - } - ] - }, - "evidences": [ - { - "sha": "e93469e2ef6de10312003fd88eca1f021342d1c20eed", - "filepath": "https://git.rwth-aachen.de/jonas.broeckmann/kaffeekasse/-/releases/v1.0.0/evidences/5410.json", - "collected_at": "2024-03-03T22:38:29.729+01:00" - } - ], - "_links": { - "closed_issues_url": "https://git.rwth-aachen.de/jonas.broeckmann/kaffeekasse/-/issues?release_tag=v1.0.0&scope=all&state=closed", - "closed_merge_requests_url": "https://git.rwth-aachen.de/jonas.broeckmann/kaffeekasse/-/merge_requests?release_tag=v1.0.0&scope=all&state=closed", - "merged_merge_requests_url": "https://git.rwth-aachen.de/jonas.broeckmann/kaffeekasse/-/merge_requests?release_tag=v1.0.0&scope=all&state=merged", - "opened_issues_url": "https://git.rwth-aachen.de/jonas.broeckmann/kaffeekasse/-/issues?release_tag=v1.0.0&scope=all&state=opened", - "opened_merge_requests_url": "https://git.rwth-aachen.de/jonas.broeckmann/kaffeekasse/-/merge_requests?release_tag=v1.0.0&scope=all&state=opened", - "self": "https://git.rwth-aachen.de/jonas.broeckmann/kaffeekasse/-/releases/v1.0.0" - } - } - */ - diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/App.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/App.kt index f33c6cbf9a773fd3f69653e8890886392120478c..13747a9ada3cfa14953e70e9b0dd70ce361691b8 100644 --- a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/App.kt +++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/App.kt @@ -2,21 +2,33 @@ package net.novagamestudios.kaffeekasse.ui import androidx.compose.foundation.background import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CloseFullscreen +import androidx.compose.material.icons.filled.OpenInFull +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import cafe.adriel.voyager.core.model.ScreenModel +import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.navigator.Navigator import net.novagamestudios.common_utils.Logger -import net.novagamestudios.common_utils.ProvideLogger +import net.novagamestudios.common_utils.LoggerForFun +import net.novagamestudios.common_utils.compose.components.BoxCenter +import net.novagamestudios.common_utils.debug import net.novagamestudios.common_utils.verbose import net.novagamestudios.kaffeekasse.App +import net.novagamestudios.kaffeekasse.model.session.Session import net.novagamestudios.kaffeekasse.repositories.i11.PortalRepository import net.novagamestudios.kaffeekasse.ui.navigation.AppModulesScreen import net.novagamestudios.kaffeekasse.ui.navigation.AppScreenTransition import net.novagamestudios.kaffeekasse.ui.navigation.LoginNavigation import net.novagamestudios.kaffeekasse.ui.theme.KaffeekasseTheme +import net.novagamestudios.kaffeekasse.ui.util.requireWithKey import net.novagamestudios.kaffeekasse.util.collectAsStateHere @@ -43,29 +55,78 @@ fun App( verbose { "recompose app" } Navigator( LoginNavigation, - key = "app-navigator" + key = NavigatorKey ) { navigator -> - AppScreenTransition( - navigator = navigator, - Modifier - .background(MaterialTheme.colorScheme.background) - .fillMaxSize(), - ) - autoNavigateLogin(navigator, vm) + if (navigator.lastItem matches vm.session) { + AppScreenTransition( + navigator = navigator, + Modifier + .background(MaterialTheme.colorScheme.background) + .fillMaxSize(), + ) + } else { + autoNavigateLogin(navigator, vm) + Surface { + BoxCenter(Modifier.fillMaxSize()) { + CircularProgressIndicator() + } + } + } } UpdateDialogs() } +private infix fun Screen.matches(session: Session) = when (this) { + is LoginNavigation -> session !is Session.WithRealUser + is AppModulesScreen -> session is Session.WithRealUser + else -> false +} + +private const val NavigatorKey = "app-navigator" @Composable private fun autoNavigateLogin( navigator: Navigator, vm: AppViewModel ) { - if (vm.session.isRealUserLoggedIn) { - navigator.replaceAll(AppModulesScreen(vm.session)) - } else { - navigator.replaceAll(LoginNavigation) + navigator.handleSession(vm.session) +} + + +fun Navigator.handleSession(session: Session) { + val logger = LoggerForFun() + val appNavigator = requireWithKey(NavigatorKey) + when (appNavigator.lastItem) { + is LoginNavigation -> { + if (session is Session.WithRealUser) { + logger.debug { "Auto-navigate to AppModulesScreen because $session" } + appNavigator.replaceAll(AppModulesScreen(session)) + } + } + is AppModulesScreen -> { + if (session !is Session.WithRealUser) { + logger.debug { "Auto-navigate to LoginNavigation because $session" } + appNavigator.replaceAll(LoginNavigation) + } + } + } +} + + +@Composable +fun FullscreenIconButton( + modifier: Modifier = Modifier +) { + val settings = App.settings() + IconButton( + onClick = { settings.tryUpdate { it.copy(fullscreen = !it.fullscreen) } }, + modifier + ) { + if (!settings.value.fullscreen) { + Icon(Icons.Default.OpenInFull, "Vollbild") + } else { + Icon(Icons.Default.CloseFullscreen, "Vollbild verlassen") + } } } diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/AppModules.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/AppModules.kt index fc87a227be28d48bb8e12722893d3ee1c991e243..cf7f33295ff285cebfe7ea18b99f195ad96e81b1 100644 --- a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/AppModules.kt +++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/AppModules.kt @@ -32,8 +32,8 @@ import net.novagamestudios.kaffeekasse.AppModule import net.novagamestudios.kaffeekasse.AppModules import net.novagamestudios.kaffeekasse.HiwiTrackerModule import net.novagamestudios.kaffeekasse.KaffeekasseModule -import net.novagamestudios.kaffeekasse.repositories.RepositoryProvider import net.novagamestudios.kaffeekasse.model.session.Session +import net.novagamestudios.kaffeekasse.repositories.RepositoryProvider import net.novagamestudios.kaffeekasse.repositories.SettingsRepository import net.novagamestudios.kaffeekasse.ui.navigation.AppModulesScreen import net.novagamestudios.kaffeekasse.ui.navigation.HiwiTrackerNavigation @@ -43,7 +43,7 @@ import net.novagamestudios.kaffeekasse.util.collectAsStateHere class AppModulesViewModel( - val session: Session, + val session: Session.WithRealUser, private val settingsRepository: SettingsRepository, val allModules: AppModules ) : ScreenModel { @@ -52,7 +52,7 @@ class AppModulesViewModel( private val userSettings by settingsRepository.userSettings.collectAsStateHere() val modules: AppModules get() = when { settingsRepository.value.developerMode -> allModules - session.isDeviceLoggedIn -> AppModules(allModules.filterIsInstance<KaffeekasseModule>()) + session is Session.WithDevice -> AppModules(allModules.filterIsInstance<KaffeekasseModule>()) else -> allModules } private val initialModule get() = modules @@ -60,8 +60,7 @@ class AppModulesViewModel( ?: modules.first() - val AppModule.moduleTab: ModuleTab - get() = when (this) { + val AppModule.moduleTab: ModuleTab get() = when (this) { is KaffeekasseModule -> KaffeekasseNavigation.Tab(session) is HiwiTrackerModule -> HiwiTrackerNavigation.Tab(session) } diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/Account.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/Account.kt index 3d307bf4f066ef8d20c1d3467ed2e9f520f8468f..7b399e7012d8db08fc747b9fa8d9ed5b104e43d3 100644 --- a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/Account.kt +++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/Account.kt @@ -44,21 +44,21 @@ import net.novagamestudios.kaffeekasse.ui.util.PullToRefreshBox import net.novagamestudios.kaffeekasse.util.richdata.collectAsRichStateHere class AccountViewModel private constructor( - val session: Session, + val session: Session.WithRealUser, private val kaffeekasse: KaffeekasseRepository ) : ScreenModel, Logger { - private val accountState = kaffeekasse.account.collectAsRichStateHere() + private val accountState = kaffeekasse.account[session.realUser].collectAsRichStateHere() val account get() = accountState.dataOrNull val errors get() = accountState.errorOrNull?.messages val isLoading get() = accountState.isLoading fun refresh() { screenModelScope.launch { - kaffeekasse.account.refresh() + accountState.refresh() } } suspend fun refreshIfNeeded() { - kaffeekasse.account.ensureCleanData() + accountState.ensureCleanData() } companion object { diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/KaffeekasseModule.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/KaffeekasseModule.kt index 1d7873c9b0d71fd0b7d4cafd69e9d0a70e7c55a4..9d500a064c914ad2291e56dcb149bcf97f5916cb 100644 --- a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/KaffeekasseModule.kt +++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/KaffeekasseModule.kt @@ -11,26 +11,25 @@ import androidx.compose.runtime.Composable import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.navigator.Navigator import net.novagamestudios.common_utils.Logger -import net.novagamestudios.kaffeekasse.KaffeekasseModule +import net.novagamestudios.kaffeekasse.KaffeekasseModule.Companion.kaffeekasseCart import net.novagamestudios.kaffeekasse.model.kaffeekasse.MutableCart import net.novagamestudios.kaffeekasse.model.kaffeekasse.isNotEmpty import net.novagamestudios.kaffeekasse.model.session.Session import net.novagamestudios.kaffeekasse.repositories.RepositoryProvider import net.novagamestudios.kaffeekasse.ui.AppModuleSelection +import net.novagamestudios.kaffeekasse.ui.kaffeekasse.ManualBillViewModel.Companion.backNavigationHandler import net.novagamestudios.kaffeekasse.ui.navigation.AppSubpageTitle -import net.novagamestudios.kaffeekasse.ui.util.BackNavigationHandler import net.novagamestudios.kaffeekasse.ui.navigation.KaffeekasseNavigation -import net.novagamestudios.kaffeekasse.ui.kaffeekasse.ManualBillViewModel.Companion.backNavigationHandler +import net.novagamestudios.kaffeekasse.ui.util.BackNavigationHandler class KaffeekasseModuleViewModel private constructor( - session: Session, + session: Session.WithRealUser, val cart: MutableCart ) : ScreenModel, Logger { - val accountName = if (session.isDeviceLoggedIn) { - session.realUser?.displayName ?: "Unknown User" - } else { - null + val accountName = when (session) { + is Session.WithDevice -> session.realUser.displayName ?: "Unknown User" + else -> null } @@ -38,7 +37,7 @@ class KaffeekasseModuleViewModel private constructor( context (RepositoryProvider) fun create(screen: KaffeekasseNavigation.Tab) = KaffeekasseModuleViewModel( session = screen.session, - cart = modules.require<KaffeekasseModule>().cart + cart = kaffeekasseCart ) } } @@ -84,7 +83,7 @@ fun KaffeekasseTopBarActions( navigator: Navigator ) { when (val screen = navigator.lastItem) { - is KaffeekasseNavigation.ManualBillScreen -> ManualBillTopBarActions(screen.vm(), screen) + is KaffeekasseNavigation.ManualBillScreen -> ManualBillTopBarActions(screen.vm()) is KaffeekasseNavigation.AccountScreen -> AccountTopBarActions(screen.vm()) is KaffeekasseNavigation.TransactionsScreen -> TransactionsTopBarActions(screen.vm()) } diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/ManualBill.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/ManualBill.kt index 00dd3bda61b4e2bc26c500c0019c3b1b4cd0e4e6..ee56746a0761034db282431cbe321f2b38685d53 100644 --- a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/ManualBill.kt +++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/ManualBill.kt @@ -36,7 +36,6 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.screenModelScope -import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.Navigator import cafe.adriel.voyager.navigator.currentOrThrow @@ -44,29 +43,30 @@ import kotlinx.coroutines.launch import net.novagamestudios.common_utils.Logger import net.novagamestudios.common_utils.compose.tabIndicatorOffset import net.novagamestudios.common_utils.verbose -import net.novagamestudios.kaffeekasse.KaffeekasseModule -import net.novagamestudios.kaffeekasse.repositories.RepositoryProvider +import net.novagamestudios.kaffeekasse.KaffeekasseModule.Companion.kaffeekasseCart import net.novagamestudios.kaffeekasse.model.kaffeekasse.MutableCart import net.novagamestudios.kaffeekasse.model.kaffeekasse.isNotEmpty import net.novagamestudios.kaffeekasse.model.session.Session +import net.novagamestudios.kaffeekasse.model.session.deviceOrNull +import net.novagamestudios.kaffeekasse.repositories.RepositoryProvider import net.novagamestudios.kaffeekasse.repositories.i11.KaffeekasseRepository -import net.novagamestudios.kaffeekasse.ui.util.BackNavigationHandler -import net.novagamestudios.kaffeekasse.ui.navigation.KaffeekasseNavigation -import net.novagamestudios.kaffeekasse.ui.login.LogoutTopBarAction -import net.novagamestudios.kaffeekasse.ui.kaffeekasse.components.APICheckoutViewModel import net.novagamestudios.kaffeekasse.ui.kaffeekasse.components.CategorizedItems import net.novagamestudios.kaffeekasse.ui.kaffeekasse.components.CategorizedItemsViewModel import net.novagamestudios.kaffeekasse.ui.kaffeekasse.components.Checkout +import net.novagamestudios.kaffeekasse.ui.kaffeekasse.components.CheckoutViewModel import net.novagamestudios.kaffeekasse.ui.kaffeekasse.components.CustomItems import net.novagamestudios.kaffeekasse.ui.kaffeekasse.components.CustomItemsViewModel import net.novagamestudios.kaffeekasse.ui.kaffeekasse.components.FailureRetryScreen -import net.novagamestudios.kaffeekasse.ui.kaffeekasse.components.ScraperCheckoutViewModel +import net.novagamestudios.kaffeekasse.ui.login.LogoutTopBarAction +import net.novagamestudios.kaffeekasse.ui.navigation.KaffeekasseNavigation +import net.novagamestudios.kaffeekasse.ui.util.BackNavigationHandler import net.novagamestudios.kaffeekasse.util.richdata.collectAsRichStateHere +import net.novagamestudios.kaffeekasse.util.richdata.isLoadingOrNone class ManualBillViewModel private constructor( repositoryProvider: RepositoryProvider, - val session: Session, + val session: Session.WithRealUser, private val kaffeekasse: KaffeekasseRepository, val cart: MutableCart ) : ScreenModel, Logger { @@ -90,25 +90,20 @@ class ManualBillViewModel private constructor( } val customItemsViewModel by lazy { - with(repositoryProvider) { CustomItemsViewModel.create(screenModelScope) } + with(repositoryProvider) { + CustomItemsViewModel.create( + user = session.realUser, + computationScope = screenModelScope + ) + } } val checkoutViewModel by derivedStateOf { - val onSubmitted = { categorizedItemsByGroup.forEach { it.reset() } } - if (session.isDeviceLoggedIn) { - APICheckoutViewModel( - session, - screenModelScope, - kaffeekasse, - cart, - onSubmitted - ) - } else { - ScraperCheckoutViewModel( - screenModelScope, - kaffeekasse, - cart, - onSubmitted + with(repositoryProvider) { + CheckoutViewModel.create( + session = session, + coroutineScope = screenModelScope, + onSubmitted = { categorizedItemsByGroup.forEach { it.reset() } } ) } } @@ -134,8 +129,9 @@ class ManualBillViewModel private constructor( kaffeekasse.stock.ensureCleanData() } + @Suppress("unused") suspend fun autoScrollToDeviceItemGroup() { - val initialItemGroupIndex = session.device?.itemTypeId + val initialItemGroupIndex = session.deviceOrNull?.itemTypeId ?.let { id -> itemGroups.indexOfFirst { it.id == id }.takeIf { it >= 0 } } if (initialItemGroupIndex != null) { currentGroupIndex = initialItemGroupIndex @@ -149,7 +145,7 @@ class ManualBillViewModel private constructor( repositoryProvider = this@RepositoryProvider, session = screen.session, kaffeekasse = kaffeekasseRepository, - cart = modules.require<KaffeekasseModule>().cart + cart = kaffeekasseCart ) @Composable @@ -288,8 +284,7 @@ private fun TabbedItemGroups( @Composable fun ManualBillTopBarActions( - vm: ManualBillViewModel, - screen: Screen + vm: ManualBillViewModel ) { val navigator = LocalNavigator.currentOrThrow IconButton(onClick = { diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/Transactions.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/Transactions.kt index e644627fe9ddbe55dd99abd25467ab131b4eccc3..ec098acc48aac651e54f94a88c6658052191bff0 100644 --- a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/Transactions.kt +++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/Transactions.kt @@ -59,11 +59,11 @@ import java.time.LocalDate import java.time.format.DateTimeFormatter class TransactionsViewModel private constructor( - @Suppress("UNUSED_PARAMETER") session: Session, + private val session: Session.WithRealUser, private val kaffeekasse: KaffeekasseRepository ) : ScreenModel, Logger { - private val transactionsState = kaffeekasse.transactions.collectAsRichStateHere() + private val transactionsState = kaffeekasse.transactions[session.realUser].collectAsRichStateHere() val transactions get() = transactionsState.dataOrNull val errors get() = transactionsState.errorOrNull?.messages val isLoading get() = transactionsState.isLoading @@ -81,11 +81,11 @@ class TransactionsViewModel private constructor( fun fetchTransactions() { screenModelScope.launch { - kaffeekasse.transactions.refresh() + transactionsState.refresh() } } suspend fun refreshIfNeeded() { - kaffeekasse.transactions.ensureCleanData() + transactionsState.ensureCleanData() } fun openInBrowser(context: Context) { context.openInBrowser(kaffeekasse.scraper.transactionsUrl) diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/components/Checkout.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/components/Checkout.kt index f7f61dd898add272c64f0859acb9371c0604ea53..b9af47722dc9b40a7aa31f99954f9a86b1d1700b 100644 --- a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/components/Checkout.kt +++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/components/Checkout.kt @@ -54,6 +54,7 @@ import net.novagamestudios.common_utils.compose.maskedCircleIcon import net.novagamestudios.common_utils.compose.state.ReentrantActionState import net.novagamestudios.common_utils.toastLong import net.novagamestudios.common_utils.warn +import net.novagamestudios.kaffeekasse.KaffeekasseModule.Companion.kaffeekasseCart import net.novagamestudios.kaffeekasse.api.kaffeekasse.model.APIPurchaseAccount import net.novagamestudios.kaffeekasse.model.kaffeekasse.Cart import net.novagamestudios.kaffeekasse.model.kaffeekasse.ManualBillDetails @@ -62,11 +63,12 @@ import net.novagamestudios.kaffeekasse.model.kaffeekasse.PurchaseAccount import net.novagamestudios.kaffeekasse.model.kaffeekasse.isEmpty import net.novagamestudios.kaffeekasse.model.kaffeekasse.isNotEmpty import net.novagamestudios.kaffeekasse.model.session.Session +import net.novagamestudios.kaffeekasse.model.session.User +import net.novagamestudios.kaffeekasse.repositories.RepositoryProvider import net.novagamestudios.kaffeekasse.repositories.i11.KaffeekasseRepository import net.novagamestudios.kaffeekasse.ui.kaffeekasse.components.cards.CategoryIcon import net.novagamestudios.kaffeekasse.util.richdata.RichDataFlow import net.novagamestudios.kaffeekasse.util.richdata.RichDataState -import net.novagamestudios.kaffeekasse.util.richdata.asRichDataFlow import net.novagamestudios.kaffeekasse.util.richdata.collectAsRichStateIn import net.novagamestudios.kaffeekasse.util.richdata.combineRich import net.novagamestudios.kaffeekasse.util.richdata.dataOrNull @@ -127,9 +129,35 @@ abstract class CheckoutViewModel( } } } + + companion object { + context (RepositoryProvider) + fun create( + session: Session.WithRealUser, + coroutineScope: CoroutineScope, + onSubmitted: suspend () -> Unit + ): CheckoutViewModel = if (session is Session.WithDevice) { + APICheckoutViewModel( + session.realUser, + coroutineScope, + kaffeekasseRepository, + kaffeekasseCart, + onSubmitted + ) + } else { + ScraperCheckoutViewModel( + session.realUser, + coroutineScope, + kaffeekasseRepository, + kaffeekasseCart, + onSubmitted + ) + } + } } -class ScraperCheckoutViewModel( +private class ScraperCheckoutViewModel( + private val user: User, coroutineScope: CoroutineScope, kaffeekasse: KaffeekasseRepository, cart: MutableCart, @@ -141,22 +169,26 @@ class ScraperCheckoutViewModel( onSubmitted ) { - private val purchaseAccounts: RichDataFlow<List<PurchaseAccount>> = kaffeekasse.manualBillAccounts + private val purchaseAccounts = kaffeekasse.manualBillAccounts[user] override val accountSelectionState = purchaseAccounts.mapRich { accounts -> AccountSelectionState(accounts) }.collectAsRichStateIn(coroutineScope) override suspend fun refreshPurchaseAccountsIfNeeded() { - kaffeekasse.manualBillAccounts.ensureCleanData() + purchaseAccounts.ensureCleanData() } override suspend fun performSubmitCart(purchaseAccount: PurchaseAccount) { - kaffeekasse.purchase(cart, purchaseAccount as ManualBillDetails.PurchaseAccount) + kaffeekasse.purchase( + asUser = user, + cart = cart, + account = purchaseAccount as ManualBillDetails.PurchaseAccount + ) } } -class APICheckoutViewModel( - session: Session, +private class APICheckoutViewModel( + private val user: User, coroutineScope: CoroutineScope, kaffeekasse: KaffeekasseRepository, cart: MutableCart, @@ -164,7 +196,7 @@ class APICheckoutViewModel( ) : CheckoutViewModel(coroutineScope, kaffeekasse, cart, onSubmitted) { private val selfUserBasic = kaffeekasse.basicUserInfoList.mapRich { userList -> - val selfDisplayName = session.realUser?.displayName ?: return@mapRich null + val selfDisplayName = user.displayName ?: return@mapRich null userList.firstOrNull { "${it.firstName} ${it.lastName}" == selfDisplayName } }.stateIn(coroutineScope) @@ -198,7 +230,13 @@ class APICheckoutViewModel( } } override suspend fun performSubmitCart(purchaseAccount: PurchaseAccount) { - kaffeekasse.purchase(cart, purchaseAccount.takeUnless { it.id == selfUserBasic.value.dataOrNull?.id } as? APIPurchaseAccount) + kaffeekasse.purchase( + asUser = user, + cart = cart, + targetAccount = purchaseAccount.takeUnless { + it.id == selfUserBasic.value.dataOrNull?.id + } as? APIPurchaseAccount + ) } } diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/components/CustomItems.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/components/CustomItems.kt index bbe38dd8b249adb616e50647a003ce33145c02fb..2ffadf561e2583c31fee8a30077a479eb9213836 100644 --- a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/components/CustomItems.kt +++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/components/CustomItems.kt @@ -36,10 +36,11 @@ import net.novagamestudios.common_utils.compose.DashedShape import net.novagamestudios.common_utils.compose.components.ColumnCenter import net.novagamestudios.common_utils.compose.state.ReentrantActionState import net.novagamestudios.common_utils.verbose -import net.novagamestudios.kaffeekasse.repositories.RepositoryProvider import net.novagamestudios.kaffeekasse.model.kaffeekasse.Item import net.novagamestudios.kaffeekasse.model.kaffeekasse.MutableCart import net.novagamestudios.kaffeekasse.model.kaffeekasse.Transaction +import net.novagamestudios.kaffeekasse.model.session.User +import net.novagamestudios.kaffeekasse.repositories.RepositoryProvider import net.novagamestudios.kaffeekasse.repositories.SettingsRepository import net.novagamestudios.kaffeekasse.repositories.i11.KaffeekasseRepository import net.novagamestudios.kaffeekasse.ui.theme.disabled @@ -52,6 +53,7 @@ import net.novagamestudios.kaffeekasse.util.richdata.stateIn import java.time.LocalDateTime class CustomItemsViewModel private constructor( + private val user: User, private val computationScope: CoroutineScope, settingsRepository: SettingsRepository, private val kaffeekasse: KaffeekasseRepository @@ -82,13 +84,19 @@ class CustomItemsViewModel private constructor( displayed.take(SuggestedItemsCount) } + private val userTransactions = kaffeekasse.transactions[user] + private val userTransactionsState = userTransactions.collectAsRichStateIn(computationScope) + private val suggestionsMutex = ReentrantActionState() - val isLoadingSuggestions by suggestionsMutex + val isLoadingSuggestions by derivedStateOf { + suggestionsMutex.value || userTransactionsState.isLoading + } + init { verbose { "CustomItemsViewModel initialized" } computationScope.launch { - kaffeekasse.transactions + userTransactions .map { it.dataOrNull } .filterNotNull() .collectLatest { transactions -> @@ -108,7 +116,7 @@ class CustomItemsViewModel private constructor( fun fetch() { computationScope.launch { - kaffeekasse.transactions.ensureCleanData() + userTransactions.ensureCleanData() } } @@ -170,7 +178,11 @@ class CustomItemsViewModel private constructor( context (RepositoryProvider) - fun create(computationScope: CoroutineScope) = CustomItemsViewModel( + fun create( + user: User, + computationScope: CoroutineScope + ) = CustomItemsViewModel( + user = user, computationScope = computationScope, settingsRepository = settingsRepository, kaffeekasse = kaffeekasseRepository @@ -213,10 +225,20 @@ fun CustomItems( ) if (vm.isLoadingSuggestions) { CircularProgressIndicator(Modifier.padding(vertical = 64.dp)) - } else ItemCardGrid( - items = vm.displayedSuggestedItems, - cart = cart, - spacerForFAB = true - ) + } else if (vm.displayedSuggestedItems.isEmpty()) { + Text( + "Keine Vorschläge", + Modifier.padding(horizontal = 16.dp, vertical = 64.dp), + color = LocalContentColor.current.disabled(), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.labelLarge + ) + } else { + ItemCardGrid( + items = vm.displayedSuggestedItems, + cart = cart, + spacerForFAB = true + ) + } } } \ No newline at end of file diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/components/cards/Item.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/components/cards/Item.kt index 28dbaeecbca62c4e42d5ad9bb48547145443b750..4b93751b3e85a5f751efea3f738524d7c8779778 100644 --- a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/components/cards/Item.kt +++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/components/cards/Item.kt @@ -33,6 +33,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -61,6 +62,7 @@ import net.novagamestudios.kaffeekasse.app import net.novagamestudios.kaffeekasse.model.kaffeekasse.Item import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem import net.novagamestudios.kaffeekasse.model.kaffeekasse.Transaction +import net.novagamestudios.kaffeekasse.model.session.Session import net.novagamestudios.kaffeekasse.util.richdata.collectAsRichState @Composable @@ -143,20 +145,30 @@ private fun ItemInformation( ) } Spacer(Modifier.weight(1f)) - val transactionsState = app().kaffeekasseRepository.transactions.collectAsRichState() - val lastUnitPrice by remember { derivedStateOf { transactionsState.dataOrNull?.findLastUnitPrice(item) } } - (item.price ?: lastUnitPrice ?: item.estimatedPrice)?.let { - val highlighted = it != item.estimatedPrice && App.developerMode - Text( - remember(it) { - listOfNotNull( - if (item.price != null) null else "≈", - String.format("%.2f€", it) - ).joinToString(" ") - }, - color = if (highlighted) Color.Yellow else Color.Unspecified, - style = MaterialTheme.typography.bodyMedium - ) + + // Very bad code + val app = app() + val session = app.portalRepository.session.collectAsState() + if (session is Session.WithRealUser) { + val transactionsState = app.kaffeekasseRepository.transactions[session.realUser].collectAsRichState() + val lastUnitPrice by remember { + derivedStateOf { + transactionsState.dataOrNull?.findLastUnitPrice(item) + } + } + (item.price ?: lastUnitPrice ?: item.estimatedPrice)?.let { + val highlighted = it != item.estimatedPrice && App.developerMode + Text( + remember(it) { + listOfNotNull( + if (item.price != null) null else "≈", + String.format("%.2f€", it) + ).joinToString(" ") + }, + color = if (highlighted) Color.Yellow else Color.Unspecified, + style = MaterialTheme.typography.bodyMedium + ) + } } } diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/login/Login.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/login/Login.kt index 3a6376b5f39f69b13fe3c9ca447d78dc68aaf38e..e152c1539f903da64151ffbdaee01814f0de6120 100644 --- a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/login/Login.kt +++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/login/Login.kt @@ -1,5 +1,6 @@ package net.novagamestudios.kaffeekasse.ui.login +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize @@ -7,6 +8,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions @@ -28,6 +30,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -36,6 +39,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp @@ -45,16 +49,19 @@ import cafe.adriel.voyager.navigator.Navigator import kotlinx.coroutines.launch import net.novagamestudios.common_utils.Logger import net.novagamestudios.common_utils.compose.components.CircularLoadingBox +import net.novagamestudios.kaffeekasse.R import net.novagamestudios.kaffeekasse.app import net.novagamestudios.kaffeekasse.data.cleanName import net.novagamestudios.kaffeekasse.model.credentials.DeviceCredentials import net.novagamestudios.kaffeekasse.model.credentials.isValid import net.novagamestudios.kaffeekasse.model.session.Device import net.novagamestudios.kaffeekasse.model.session.Session +import net.novagamestudios.kaffeekasse.model.session.deviceOrNull import net.novagamestudios.kaffeekasse.repositories.LoginRepository import net.novagamestudios.kaffeekasse.repositories.RepositoryProvider import net.novagamestudios.kaffeekasse.repositories.i11.PortalRepository import net.novagamestudios.kaffeekasse.ui.AppInfoTopBarAction +import net.novagamestudios.kaffeekasse.ui.FullscreenIconButton import net.novagamestudios.kaffeekasse.ui.navigation.LoginNavigation import net.novagamestudios.kaffeekasse.ui.util.rememberSerializableState import net.novagamestudios.kaffeekasse.util.collectAsStateHere @@ -85,7 +92,6 @@ class LoginViewModel private constructor( } } - companion object { context (RepositoryProvider) fun create(@Suppress("UNUSED_PARAMETER") screen: LoginNavigation.Companion) = LoginViewModel( @@ -102,13 +108,13 @@ fun LoginAdditional( vm: LoginViewModel, navigator: Navigator ) { - val device = vm.session.device + val session = vm.session when (navigator.lastItem) { is LoginNavigation.FormScreen -> { - if (device != null) navigator.replace(LoginNavigation.UserSelectionScreen(device)) + if (session is Session.WithDevice) navigator.replace(LoginNavigation.UserSelectionScreen(session.device)) } is LoginNavigation.UserSelectionScreen -> { - if (device == null) navigator.replace(LoginNavigation.FormScreen) + if (session !is Session.WithDevice) navigator.replace(LoginNavigation.FormScreen) } } if (vm.showLoginDeviceDialog) LoginDeviceDialog(vm) @@ -200,8 +206,17 @@ private fun LoginDeviceForm( fun LoginTopBarTitle( vm: LoginViewModel ) { - vm.session.device?.let { - DeviceInfo(it, Modifier.padding(16.dp)) + vm.session.deviceOrNull?.let { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painterResource(R.drawable.logo_edited), + "Kaffeekasse", + Modifier.size(32.dp) + ) + DeviceInfo(it, Modifier.padding(16.dp)) + } } } @@ -210,31 +225,39 @@ context (RowScope) fun LoginTopBarActions( vm: LoginViewModel ) { - if (!vm.session.isRealUserLoggedIn) { - if (vm.session.isDeviceLoggedIn) { - var confirmLogout by remember { mutableStateOf(false) } - IconButton( - onClick = { confirmLogout = true }, - enabled = !vm.isLoading - ) { - Icon(Icons.Default.PhonelinkErase, "Gerät ausloggen") + val session = vm.session + if (session !is Session.WithRealUser) { + when (session) { + is Session.WithDevice -> { + var confirmLogout by remember { mutableStateOf(false) } + IconButton( + onClick = { confirmLogout = true }, + enabled = !vm.isLoading + ) { + Icon(Icons.Default.PhonelinkErase, "Gerät ausloggen") + } + if (confirmLogout) ConfirmLogoutDeviceDialog( + onDismiss = { confirmLogout = false }, + onConfirm = { vm.logoutDevice() } + ) } - if (confirmLogout) ConfirmLogoutDeviceDialog( - onDismiss = { confirmLogout = false }, - onConfirm = { vm.logoutDevice() } - ) - } else { - IconButton( - onClick = { vm.showLoginDeviceDialog = true }, - enabled = !vm.isLoading - ) { - Icon(Icons.Default.ScreenLockLandscape, "Gerät einloggen") + else -> { + IconButton( + onClick = { vm.showLoginDeviceDialog = true }, + enabled = !vm.isLoading + ) { + Icon(Icons.Default.ScreenLockLandscape, "Gerät einloggen") + } } } } AppInfoTopBarAction() + FullscreenIconButton() } + + + @Composable private fun DeviceInfo( device: Device, @@ -286,11 +309,19 @@ private fun ConfirmLogoutDeviceDialog( @Composable fun LogoutTopBarAction(session: Session) { val app = app() - IconButton( - onClick = { app.launch { app.portalRepository.logoutUser() } }, - enabled = session.isRealUserLoggedIn || session.isDeviceLoggedIn - ) { - Icon(Icons.AutoMirrored.Filled.Logout, "Ausloggen") + val loginRepository = app.loginRepository + val isLoading by loginRepository.isPerformingAction.collectAsState() + CircularLoadingBox(loading = isLoading) { + IconButton( + onClick = { app.launch { loginRepository.logoutUser() } }, + enabled = session !is Session.Empty + ) { + Icon( + Icons.AutoMirrored.Filled.Logout, + contentDescription = "Ausloggen", + tint = MaterialTheme.colorScheme.primary + ) + } } } diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/login/LoginForm.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/login/LoginForm.kt index 8a312d98eefd50a573da479fcde4b21e7d47559e..dec7ea0e338d8930f59c5a4f595b1cf010bf59cc 100644 --- a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/login/LoginForm.kt +++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/login/LoginForm.kt @@ -122,7 +122,6 @@ fun LoginForm( verticalArrangement = Arrangement.spacedBy(8.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - val activityContext = LocalContext.current Icon( Icons.Rounded.Coffee, diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/login/UserSelection.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/login/UserSelection.kt index a4f3081482bd3de25a0c905281acdca578f87030..e1117c1b3e2bbe1ba573ce71ff468cd90a8d46aa 100644 --- a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/login/UserSelection.kt +++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/login/UserSelection.kt @@ -1,29 +1,50 @@ package net.novagamestudios.kaffeekasse.ui.login +import android.content.res.Configuration +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.detectVerticalDragGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Password import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.DividerDefaults import androidx.compose.material3.DockedSearchBar +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.SearchBarDefaults import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.pulltorefresh.PullToRefreshContainer import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -31,6 +52,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment @@ -39,68 +61,109 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +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.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.screenModelScope +import cafe.adriel.voyager.navigator.Navigator import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.launch -import net.novagamestudios.common_utils.compose.components.BoxCenter +import net.novagamestudios.common_utils.Logger import net.novagamestudios.common_utils.compose.components.RowCenter +import net.novagamestudios.common_utils.compose.state.rememberDerivedStateOf import net.novagamestudios.common_utils.toastShort import net.novagamestudios.common_utils.warn import net.novagamestudios.kaffeekasse.api.kaffeekasse.model.BasicUserInfo +import net.novagamestudios.kaffeekasse.model.kaffeekasse.UserAuthCredentials import net.novagamestudios.kaffeekasse.repositories.LoginRepository import net.novagamestudios.kaffeekasse.repositories.RepositoryProvider import net.novagamestudios.kaffeekasse.repositories.i11.KaffeekasseRepository -import net.novagamestudios.kaffeekasse.ui.navigation.LoginNavigation +import net.novagamestudios.kaffeekasse.ui.handleSession import net.novagamestudios.kaffeekasse.ui.kaffeekasse.components.FailureRetryScreen -import net.novagamestudios.kaffeekasse.ui.util.PullToRefreshBox +import net.novagamestudios.kaffeekasse.ui.navigation.LoginNavigation +import net.novagamestudios.kaffeekasse.ui.theme.disabled import net.novagamestudios.kaffeekasse.ui.util.RichDataContent import net.novagamestudios.kaffeekasse.ui.util.rememberPullToRefreshState import net.novagamestudios.kaffeekasse.util.richdata.asRichDataFlow import net.novagamestudios.kaffeekasse.util.richdata.combineRich import net.novagamestudios.kaffeekasse.util.richdata.dataOrNull -import net.novagamestudios.kaffeekasse.util.richdata.derivedRichDataSource import net.novagamestudios.kaffeekasse.util.richdata.stateIn +import net.novagamestudios.kaffeekasse.util.richdata.withFunctions class UserSelectionViewModel private constructor( private val loginRepository: LoginRepository, kaffeekasse: KaffeekasseRepository -) : ScreenModel { +) : ScreenModel, Logger { val users = kaffeekasse.basicUserInfoList var searchQuery by mutableStateOf("") - val filteredUsers = derivedRichDataSource( - users, - combineRich( + val filteredUsers = run { + val data = combineRich( snapshotFlow { searchQuery.lowercase() }.asRichDataFlow(), users, loadDuringTransform = true ) { query, users -> - val sorted = users.asSequence().sortedBy { it.lastName } + val sorted = users.asSequence().sortedBy { it.lastName.uppercase() } if (query.isBlank()) return@combineRich sorted.toList() sorted.filter { query in "${it.firstName} ${it.lastName}".lowercase() }.toList() } .flowOn(Dispatchers.IO) .stateIn(screenModelScope) - ) + data withFunctions users + } var error by mutableStateOf<String?>(null) - fun selectUser(user: BasicUserInfo) { - // TODO handle pin + var userAuthDialog by mutableStateOf<UserAuthDialogState?>(null) + + data class UserAuthDialogState( + val user: BasicUserInfo, + val pin: String = "" + // TODO + ) { + val auth get() = UserAuthCredentials(pin = pin.takeIf { it.isNotBlank() }) + } + + fun loginUser( + user: BasicUserInfo, + auth: UserAuthCredentials = UserAuthCredentials.Empty, + navigator: Navigator? = null + ) { + if (user.mayHavePin && auth.pin == null) { + userAuthDialog = UserAuthDialogState(user) + return + } screenModelScope.launch { - error = loginRepository.login(user) + when (val result = loginRepository.login(user, auth)) { + is LoginRepository.LoginResult.Success -> { + userAuthDialog = null + navigator?.handleSession(result.session) + } + is LoginRepository.LoginResult.Failure -> { + error = result.error + } + } } } + fun loginUser(dialog: UserAuthDialogState) { + loginUser(dialog.user, dialog.auth) + } companion object { context (RepositoryProvider) @@ -138,6 +201,37 @@ fun UserSelection( .nestedScroll(pullToRefreshState.nestedScrollConnection), contentAlignment = Alignment.TopCenter ) { + val focusRequester = remember { FocusRequester() } + // TODO + DockedSearchBar( + query = vm.searchQuery, + onQueryChange = { vm.searchQuery = it }, + onSearch = { + vm.filteredUsers.value + .dataOrNull + ?.singleOrNull() + ?.let { vm.loginUser(it) } + }, + active = false, + onActiveChange = { }, + Modifier + .padding(8.dp) + .align(Alignment.TopCenter) + .focusRequester(focusRequester), + placeholder = { Text("Search users") }, + leadingIcon = { Icon(Icons.Default.Search, "Search") }, + trailingIcon = { + if (vm.searchQuery.isNotEmpty()) IconButton( + onClick = { vm.searchQuery = "" } + ) { + Icon(Icons.Default.Close, "Clear search") + } + }, + content = { } + ) + + val searchBarOffset = SearchBarDefaults.InputFieldHeight + RichDataContent( data = vm.filteredUsers, errorContent = { @@ -150,68 +244,264 @@ fun UserSelection( modifier = Modifier.fillMaxSize() ) }, - loadingContent = { LinearProgressIndicator( - Modifier - .padding(top = 8.dp) - .align(Alignment.TopCenter) - .fillMaxWidth() - ) }, - dataContent = { users -> - val focusRequester = remember { FocusRequester() } - // TODO - DockedSearchBar( - query = vm.searchQuery, - onQueryChange = { vm.searchQuery = it }, - onSearch = { - vm.filteredUsers.value - .dataOrNull - ?.singleOrNull() - ?.let { vm.selectUser(it) } - }, - active = false, - onActiveChange = { }, + loadingContent = { + LinearProgressIndicator( Modifier - .padding(8.dp) - .align(Alignment.TopCenter) - .focusRequester(focusRequester), - placeholder = { Text("Search users") }, - leadingIcon = { Icon(Icons.Default.Search, "Search") }, - trailingIcon = { - if (vm.searchQuery.isNotEmpty()) IconButton( - onClick = { vm.searchQuery = "" } - ) { - Icon(Icons.Default.Close, "Clear search") + .padding(top = 8.dp) + .align(Alignment.BottomCenter) + .fillMaxWidth() + ) + }, + dataContent = { users -> + + val gridState = rememberLazyGridState() + val coroutineScope = rememberCoroutineScope() + + val currentStartChars by rememberDerivedStateOf { + val start = gridState.layoutInfo.viewportStartOffset + gridState.layoutInfo.beforeContentPadding + val end = gridState.layoutInfo.viewportEndOffset - gridState.layoutInfo.afterContentPadding + gridState.layoutInfo.visibleItemsInfo + .asSequence() + .filter { + val mid = it.offset.y + (it.size.height / 2) + mid in start..<end } + .mapNotNull { it.contentType as? Int } + .mapNotNullTo(mutableSetOf()) { users.getOrNull(it)?.charOrNull } + } + val userIndexAndCharIndexByChar: Map<Char, Pair<Int, Int>> by rememberDerivedStateOf { + users.asSequence() + .withIndex() + .mapNotNull { (userIndex, user) -> + user.charOrNull?.let { it to userIndex } + } + .distinctBy { (char, _) -> char } + .withIndex() + .associate { (charIndex, pair) -> + val (char, userIndex) = pair + char to Pair(userIndex, charIndex) + } + } + val chars = remember { ('A'..'Z').toList() } + + val onSelectIndex: (Int) -> Unit = block@{ index -> + val (userIndex, charIndex) = (userIndexAndCharIndexByChar[chars[index]] ?: return@block) + val sectionIndex = userIndex + charIndex + coroutineScope.launch { + gridState.scrollToItem(sectionIndex) } - ) { } - - LazyVerticalGrid( - columns = GridCells.Adaptive(minSize = 300.dp), - Modifier.fillMaxSize(), - contentPadding = PaddingValues( - start = 8.dp, - top = 12.dp + SearchBarDefaults.InputFieldHeight, - end = 8.dp, - bottom = 12.dp - ), - horizontalArrangement = Arrangement.Center - ) { - items(users) { user -> - UserItem( - user = user, - onClick = { vm.selectUser(user) }, - Modifier - .padding(6.dp) - .widthIn(max = 300.dp) + } + + val itemContent: @Composable (Int) -> Unit = { i -> + val char = chars[i] + AlphabetSelectionChar( + char, + highlighted = char in currentStartChars, + enabled = char in userIndexAndCharIndexByChar + ) + } + + @Composable + fun Grid(modifier: Modifier = Modifier) = UserGrid( + gridState = gridState, + users = users, + onClick = { vm.loginUser(it) }, + topOffset = searchBarOffset, + modifier + ) + + + when { + users.isEmpty() -> Text( + "Niemand hat diesen Namen 🤔", + Modifier.align(Alignment.Center) + ) + LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE -> Column { + Grid(Modifier.weight(1f)) + HorizontalDivider() + HorizontalSelectionBar( + itemCount = chars.size, + onSelect = onSelectIndex, + itemContent = itemContent ) } + else -> Row { + VerticalSelectionBar( + itemCount = chars.size, + onSelect = onSelectIndex, + Modifier.padding(top = searchBarOffset), + itemContent = itemContent + ) + Grid(Modifier.weight(1f)) + } } } ) PullToRefreshContainer(pullToRefreshState, Modifier.zIndex(2f)) } + vm.userAuthDialog?.let { state -> + UserAuthDialog( + state, + onChange = { vm.userAuthDialog = it }, + onDismiss = { vm.userAuthDialog = null }, + onSubmit = { vm.loginUser(state) } + ) + } +} + + +@Composable +fun VerticalSelectionBar( + itemCount: Int, + onSelect: (Int) -> Unit, + modifier: Modifier = Modifier, + itemContent: @Composable (Int) -> Unit +) { + fun selectAtRatio(ratio: Float) = onSelect((ratio * itemCount).toInt()) + Column( + modifier + .fillMaxHeight() + .pointerInput(Unit) { + detectTapGestures { offset -> + selectAtRatio(offset.y / size.height) + } + } + .pointerInput(Unit) { + detectVerticalDragGestures { change, _ -> + change.consume() + selectAtRatio(change.position.y / size.height) + } + } + .padding(4.dp), + verticalArrangement = Arrangement.SpaceAround, + horizontalAlignment = Alignment.CenterHorizontally + ) { + for (item in 0 until itemCount) key(item) { + itemContent(item) + } + } +} + + +@Composable +fun HorizontalSelectionBar( + itemCount: Int, + onSelect: (Int) -> Unit, + modifier: Modifier = Modifier, + itemContent: @Composable (Int) -> Unit +) { + fun selectAtRatio(ratio: Float) = onSelect((ratio * itemCount).toInt()) + Row( + modifier + .fillMaxWidth() + .pointerInput(Unit) { + detectTapGestures { offset -> + selectAtRatio(offset.x / size.width) + } + } + .pointerInput(Unit) { + detectVerticalDragGestures { change, _ -> + selectAtRatio(change.position.x / size.width) + } + } + .padding(4.dp), + horizontalArrangement = Arrangement.SpaceAround, + verticalAlignment = Alignment.CenterVertically + ) { + for (item in 0 until itemCount) key(item) { + itemContent(item) + } + } +} + + +@Composable +fun AlphabetSelectionChar( + char: Char, + modifier: Modifier = Modifier, + highlighted: Boolean = false, + enabled: Boolean = true, +) { + val scale by animateFloatAsState( + if (highlighted) 1f else 0.7f, + label = "CharScaleAnimation" + ) + Text( + "$char", + modifier.graphicsLayer { + scaleX = scale + scaleY = scale + }, + color = when { + enabled && highlighted -> MaterialTheme.colorScheme.primary + enabled -> LocalContentColor.current + else -> LocalContentColor.current.disabled() + }, + fontSize = 24.sp, + fontWeight = if (highlighted) FontWeight.Bold else FontWeight.Normal + ) +} + +@Composable +private fun UserGrid( + gridState: LazyGridState, + users: List<BasicUserInfo>, + onClick: (BasicUserInfo) -> Unit, + topOffset: Dp, + modifier: Modifier = Modifier +) = LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 250.dp), + modifier, + state = gridState, + contentPadding = PaddingValues( + start = 8.dp, + top = 12.dp + topOffset, + end = 8.dp, + bottom = 12.dp + ), + horizontalArrangement = Arrangement.Center +) { + var prevChar: Char? = null + users.forEachIndexed { i, user -> + val char = user.charOrNull + if (prevChar != char) { + item( + key = char, + span = { GridItemSpan(maxLineSpan) }, + contentType = char + ) { + Row( + Modifier.padding(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + "$char", + Modifier.padding(horizontal = 6.dp), + color = DividerDefaults.color, + fontSize = 24.sp, + fontWeight = FontWeight.Bold + ) + HorizontalDivider(thickness = 1.dp) + } + } + } + prevChar = char + item( + key = i, + contentType = i + ) { + UserItem( + user = user, + onClick = { onClick(user) }, + Modifier + .padding(6.dp) + .widthIn(max = 300.dp) + ) + } + } } + @Composable private fun UserItem( user: BasicUserInfo, @@ -222,8 +512,16 @@ private fun UserItem( onClick = onClick, modifier ) { - RowCenter(Modifier.padding(14.dp)) { - Text("${user.lastName}, ${user.firstName}") + RowCenter( + Modifier + .height(56.dp) + .padding(horizontal = 16.dp) + ) { + ProvideTextStyle(MaterialTheme.typography.titleMedium) { + Text("${user.lastName},") + Spacer(Modifier.width(8.dp)) + Text(user.firstName) + } Spacer(Modifier.weight(1f)) if (user.noPinSet == false) { Icon(Icons.Default.Lock, "No PIN set") @@ -233,3 +531,51 @@ private fun UserItem( } +@Composable +private fun UserAuthDialog( + state: UserSelectionViewModel.UserAuthDialogState, + onChange: (UserSelectionViewModel.UserAuthDialogState) -> Unit, + onDismiss: () -> Unit, + onSubmit: () -> Unit, + modifier: Modifier = Modifier +) = AlertDialog( + onDismissRequest = { onDismiss() }, + confirmButton = { + Button( + onClick = onSubmit, + enabled = state.pin.isNotBlank() + ) { Text("Einloggen") } + }, + modifier, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Abbrechen") } + }, + icon = { Icon(Icons.Default.Password, "PIN") }, + title = { Text("${state.user.firstName} ${state.user.lastName}") }, + text = { + val focusRequester = remember { FocusRequester() } + OutlinedTextField( + state.pin, + onValueChange = { onChange(state.copy(pin = it)) }, + Modifier.focusRequester(focusRequester), + label = { Text("PIN") }, + leadingIcon = { Icon(Icons.Default.Lock, "PIN") }, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { onSubmit() } + ), + singleLine = true + ) + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + } +) + + +private val BasicUserInfo.charOrNull get() = lastName.firstOrNull()?.uppercaseChar() + diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/navigation/AppScreens.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/navigation/AppScreens.kt index 0b0fb7fd61c503da82674767bdda6cb0c19621b2..fbd280e34a772b38740561ba3b9dd527cc4fe19d 100644 --- a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/navigation/AppScreens.kt +++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/navigation/AppScreens.kt @@ -1,7 +1,5 @@ package net.novagamestudios.kaffeekasse.ui.navigation -import androidx.activity.compose.BackHandler -import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.background import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.CircularProgressIndicator @@ -18,13 +16,14 @@ import net.novagamestudios.common_utils.Logger import net.novagamestudios.common_utils.compose.components.BoxCenter import net.novagamestudios.kaffeekasse.model.session.Device import net.novagamestudios.kaffeekasse.model.session.Session +import net.novagamestudios.kaffeekasse.model.session.deviceOrNull import net.novagamestudios.kaffeekasse.rememberScreenModelWithApp import net.novagamestudios.kaffeekasse.ui.AppModulesViewModel import net.novagamestudios.kaffeekasse.ui.hiwi_tracker.HiwiTrackerModuleViewModel -import net.novagamestudios.kaffeekasse.ui.hiwi_tracker.Overview -import net.novagamestudios.kaffeekasse.ui.hiwi_tracker.OverviewViewModel import net.novagamestudios.kaffeekasse.ui.hiwi_tracker.HiwiTrackerTopBarActions import net.novagamestudios.kaffeekasse.ui.hiwi_tracker.HiwiTrackerTopBarTitle +import net.novagamestudios.kaffeekasse.ui.hiwi_tracker.Overview +import net.novagamestudios.kaffeekasse.ui.hiwi_tracker.OverviewViewModel import net.novagamestudios.kaffeekasse.ui.kaffeekasse.Account import net.novagamestudios.kaffeekasse.ui.kaffeekasse.AccountViewModel import net.novagamestudios.kaffeekasse.ui.kaffeekasse.DynamicManualBill @@ -47,14 +46,12 @@ import net.novagamestudios.kaffeekasse.ui.login.UserSelectionViewModel import net.novagamestudios.kaffeekasse.util.nearestScreen - - sealed interface LoginNavigation { - companion object : Screen { + companion object : LoginNavigation, Screen { override val key = LoginNavigation::class.simpleName!! @Composable fun vm() = rememberScreenModelWithApp { LoginViewModel.create(this@Companion) } private val initialScreen: Screen - @Composable get() = vm().session.device + @Composable get() = vm().session.deviceOrNull ?.let { UserSelectionScreen(it) } ?: FormScreen @Composable override fun Content() = AppScaffoldNavigator( @@ -97,12 +94,12 @@ sealed interface LoginNavigation { sealed interface ModuleTab : Tab { - val session: Session + val session: Session.WithRealUser } data class AppModulesScreen( - val session: Session + val session: Session.WithRealUser ) : Screen { override val key = AppModulesScreen::class.simpleName!! @Composable fun vm() = rememberScreenModelWithApp { AppModulesViewModel.create(this@AppModulesScreen) } @@ -137,7 +134,7 @@ data class AppModulesScreen( sealed interface KaffeekasseNavigation { data class Tab( - override val session: Session + override val session: Session.WithRealUser ) : ModuleTab, KaffeekasseNavigation { override val key = KaffeekasseNavigation::class.simpleName!! @Composable fun vm() = rememberScreenModelWithApp { KaffeekasseModuleViewModel.create(this@Tab) } @@ -152,21 +149,21 @@ sealed interface KaffeekasseNavigation { override val options @Composable get() = remember { TabOptions(1u, "Kaffeekasse") } } data class ManualBillScreen( - val session: Session + val session: Session.WithRealUser ) : KaffeekasseNavigation, Screen, Logger { override val key = ManualBillScreen::class.simpleName!! @Composable fun vm() = rememberScreenModelWithApp { ManualBillViewModel.create(this@ManualBillScreen) } @Composable override fun Content() = DynamicManualBill(vm()) } data class AccountScreen( - val session: Session + val session: Session.WithRealUser ) : KaffeekasseNavigation, Screen { override val key = AccountScreen::class.simpleName!! @Composable fun vm() = rememberScreenModelWithApp { AccountViewModel.create(this@AccountScreen) } @Composable override fun Content() = Account(vm()) } data class TransactionsScreen( - val session: Session + val session: Session.WithRealUser ) : KaffeekasseNavigation, Screen { override val key = TransactionsScreen::class.simpleName!! @Composable fun vm() = rememberScreenModelWithApp { TransactionsViewModel.create(this@TransactionsScreen) } @@ -177,7 +174,7 @@ sealed interface KaffeekasseNavigation { sealed interface HiwiTrackerNavigation { data class Tab( - override val session: Session + override val session: Session.WithRealUser ) : ModuleTab, HiwiTrackerNavigation { override val key = HiwiTrackerNavigation::class.simpleName!! @Composable fun vm() = rememberScreenModelWithApp { HiwiTrackerModuleViewModel.create(this@Tab) } @@ -190,7 +187,7 @@ sealed interface HiwiTrackerNavigation { override val options @Composable get() = remember { TabOptions(2u, "Hiwi Tracker") } } data class OverviewScreen( - val session: Session + val session: Session.WithRealUser ) : HiwiTrackerNavigation, Screen { override val key = OverviewScreen::class.simpleName!! @Composable fun vm() = rememberScreenModelWithApp { OverviewViewModel.create(this@OverviewScreen) } diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/util/Helpers.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/util/Helpers.kt index a5e09589e82ac94f3c0b81b41be8283abb0ee9b3..3228432299b39b6318d0d4235b18c41e5d2cbd73 100644 --- a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/util/Helpers.kt +++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/util/Helpers.kt @@ -1,20 +1,26 @@ package net.novagamestudios.kaffeekasse.ui.util +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.TwoWayConverter +import androidx.compose.animation.core.VectorConverter +import androidx.compose.animation.core.animateValueAsState +import androidx.compose.animation.core.spring import androidx.compose.runtime.Composable +import androidx.compose.runtime.State import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorMatrix import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit import cafe.adriel.voyager.core.annotation.InternalVoyagerApi import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.Navigator import cafe.adriel.voyager.navigator.currentOrThrow -import kotlinx.coroutines.sync.Mutex -import net.novagamestudios.common_utils.compose.state.ReentrantActionState import net.novagamestudios.common_utils.debug import net.novagamestudios.common_utils.toastShort -import net.novagamestudios.kaffeekasse.util.withReentrantLock @Composable @@ -44,14 +50,6 @@ private fun ColorMatrix.setToTint(tint: Color) { -suspend fun <R> ReentrantActionState.trueWhileWithMutex(mutex: Mutex, block: suspend () -> R): R = trueWhile { - mutex.withReentrantLock { - block() - } -} - - - @OptIn(InternalVoyagerApi::class) @Composable fun debugNavigation() { @@ -71,3 +69,36 @@ fun debugNavigation() { } +@Composable +fun animateTextUnitAsState( + targetValue: TextUnit, + animationSpec: AnimationSpec<TextUnit> = spring(), + label: String = "TextUnitAnimation", + finishedListener: ((TextUnit) -> Unit)? = null +): State<TextUnit> { + val density = LocalDensity.current + return animateValueAsState( + targetValue = targetValue, + typeConverter = remember(density) { + val dpConverter = Dp.VectorConverter + with(density) { + TwoWayConverter( + convertToVector = { dpConverter.convertToVector( it.toDp()) }, + convertFromVector = { dpConverter.convertFromVector(it).toSp() } + ) + } + }, + animationSpec = animationSpec, + label = label, + finishedListener = finishedListener + ) +} + + +@OptIn(InternalVoyagerApi::class) +fun Navigator.requireWithKey(key: String): Navigator { + if (this.key == key) return this + val parent = parent ?: error("Navigator with key '$key' not found") + return parent.requireWithKey(key) +} + diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/util/richdata/KeyedMultiDataSource.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/util/richdata/KeyedMultiDataSource.kt new file mode 100644 index 0000000000000000000000000000000000000000..95a3fb891cd8a4674b9bd79a3b0df883d5a1cdfd --- /dev/null +++ b/app/src/main/java/net/novagamestudios/kaffeekasse/util/richdata/KeyedMultiDataSource.kt @@ -0,0 +1,33 @@ +package net.novagamestudios.kaffeekasse.util.richdata + +import kotlinx.coroutines.CoroutineScope + +class KeyedMultiDataSource<K, T : Any>( + private val coroutineScope: CoroutineScope, + private val producer: suspend RichDataCollector<T>.(K) -> RichData<T>, +) { + private val dataByKey = mutableMapOf<K, RichDataSource<T>>() + + operator fun get(key: K): RichDataSource<T> = dataByKey.getOrPut(key) { + with(coroutineScope) { + RichDataSource.RichDataSource { + producer(key) + } + } + } + + fun clear() { + dataByKey.clear() + } + + fun markDirty() { + dataByKey.values.forEach { it.markDirty() } + } + + companion object { + context (CoroutineScope) + operator fun <K, T : Any> invoke( + producer: suspend RichDataCollector<T>.(K) -> RichData<T> + ) = KeyedMultiDataSource(this@CoroutineScope, producer) + } +} \ No newline at end of file diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/util/richdata/RichDataFunctions.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/util/richdata/RichDataFunctions.kt new file mode 100644 index 0000000000000000000000000000000000000000..c06161256fe40b7432deecbaf8bb61da9003e249 --- /dev/null +++ b/app/src/main/java/net/novagamestudios/kaffeekasse/util/richdata/RichDataFunctions.kt @@ -0,0 +1,50 @@ +package net.novagamestudios.kaffeekasse.util.richdata + +import kotlinx.coroutines.launch +import kotlinx.coroutines.supervisorScope + +interface RichDataFunctions<in T : Any> { + fun markDirty() + suspend fun ensureCleanData() + suspend fun refresh() + + companion object { + @Suppress("unused") + val NOOP: RichDataFunctions<Any> = object : RichDataFunctions<Any> { + override fun markDirty() = Unit + override suspend fun ensureCleanData() = Unit + override suspend fun refresh() = Unit + } + } +} + +operator fun <T : Any> RichDataFunctions<T>.plus( + other: RichDataFunctions<T> +): RichDataFunctions<T> = object : RichDataFunctions<T> { + override fun markDirty() { + this@plus.markDirty() + other.markDirty() + } + override suspend fun ensureCleanData(): Unit = supervisorScope { + launch { this@plus.ensureCleanData() } + launch { other.ensureCleanData() } + } + override suspend fun refresh(): Unit = supervisorScope { + launch { this@plus.refresh() } + launch { other.refresh() } + } +} + + + +infix fun <T : Any> RichDataStateFlow<out T>.withFunctions( + functions: RichDataFunctions<T> +): RichDataSource<T> = object : RichDataSource<T>, + RichDataStateFlow<T> by this, + RichDataFunctions<T> by functions { } + +infix fun <T : Any> RichDataState<T>.withFunctions( + functions: RichDataFunctions<T> +): RichDataStateWithFunctions<T> = object : RichDataStateWithFunctions<T>, + RichDataState<T> by this, + RichDataFunctions<T> by functions { } diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/util/richdata/RichDataSource.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/util/richdata/RichDataSource.kt index 523756d53af76b102aed99e41832098e573162ad..4374a937e6089b9cfa50b8c0463ffee3917ee8c5 100644 --- a/app/src/main/java/net/novagamestudios/kaffeekasse/util/richdata/RichDataSource.kt +++ b/app/src/main/java/net/novagamestudios/kaffeekasse/util/richdata/RichDataSource.kt @@ -4,37 +4,15 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.FlowCollector -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.channelFlow -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import net.novagamestudios.common_utils.compose.components.Progress import kotlin.coroutines.suspendCoroutine -typealias RichDataFactory<T> = suspend FlowCollector<RichData<T>>.() -> RichData<T> +typealias RichDataCollector<T> = FlowCollector<RichData<T>> +typealias RichDataFactory<T> = suspend RichDataCollector<T>.() -> RichData<T> -private typealias Updater<T> = suspend FlowCollector<RichData<T>>.(RichData<T>) -> Unit - - -interface RichDataFunctions<in T : Any> { - suspend fun markDirty() - suspend fun ensureCleanData() - suspend fun refresh() - - companion object { - // no operation - val NOOP: RichDataFunctions<Any> = object : RichDataFunctions<Any> { - override suspend fun markDirty() = Unit - override suspend fun ensureCleanData() = Unit - override suspend fun refresh() = Unit - } - } -} interface RichDataSource<T : Any> : RichDataFunctions<T>, RichDataStateFlow<T> { companion object { @@ -45,6 +23,9 @@ interface RichDataSource<T : Any> : RichDataFunctions<T>, RichDataStateFlow<T> { } } + +private typealias Updater<T> = suspend RichDataCollector<T>.(RichData<T>) -> Unit + private class RichDataProducer<T : Any>( coroutineScope: CoroutineScope, producer: RichDataFactory<T> @@ -58,7 +39,7 @@ private class RichDataProducer<T : Any>( private val outFlow: StateFlow<RichData<T>> = channelFlow { var current: RichData<T> = RichData.None() - val collector = FlowCollector<RichData<T>> { + val collector = RichDataCollector<T> { current = it this.send(it) } @@ -74,7 +55,7 @@ private class RichDataProducer<T : Any>( }.stateIn(coroutineScope, SharingStarted.Lazily) override val replayCache get() = outFlow.replayCache - override suspend fun collect(collector: FlowCollector<RichData<T>>) = outFlow.collect(collector) + override suspend fun collect(collector: RichDataCollector<T>) = outFlow.collect(collector) override val value: RichData<T> get() = outFlow.value init { @@ -91,10 +72,13 @@ private class RichDataProducer<T : Any>( } } - override suspend fun markDirty() = awaitUpdater { current -> + override fun markDirty() = enqueueUpdater { current -> if (current is RichData.Data && !current.isDirty) { emit(current.copy(isDirty = true)) } +// if (current is RichData.Data) { +// emit(RichData.None()) +// } } override suspend fun ensureCleanData() = awaitUpdater { current -> @@ -111,46 +95,13 @@ private class RichDataProducer<T : Any>( } -context (FlowCollector<RichData<T>>) +context (RichDataCollector<T>) suspend fun <T : Any> progress(progress: Progress) { emit(RichData.Loading(progress)) } -context (FlowCollector<RichData<T>>) +context (RichDataCollector<T>) suspend fun <T : Any> progress(progress: Float) { emit(RichData.Loading(Progress.Determinate(progress))) } - -fun <T : Any> derivedRichDataSource( - functions: RichDataFunctions<T>, - stateFlow: RichDataStateFlow<out T> -): RichDataSource<T> = object : RichDataSource<T>, RichDataFunctions<T> by functions, RichDataStateFlow<T> by stateFlow { } - -fun <T : Any> RichDataStateFlow<T>.asRichDataSource(): RichDataSource<T> = derivedRichDataSource( - RichDataFunctions.NOOP, - this -) - -fun <T : Any> Flow<T>.asRichDataSourceIn( - coroutineScope: CoroutineScope -): RichDataSource<T> = derivedRichDataSource( - RichDataFunctions.NOOP, - richStateIn(coroutineScope) -) - -operator fun <T : Any> RichDataFunctions<T>.plus(other: RichDataFunctions<T>): RichDataFunctions<T> = object : RichDataFunctions<T> { - override suspend fun markDirty() { - this@plus.markDirty() - other.markDirty() - } - override suspend fun ensureCleanData() { - this@plus.ensureCleanData() - other.ensureCleanData() - } - override suspend fun refresh() { - this@plus.refresh() - other.refresh() - } -} - diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/util/richdata/RichDataState.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/util/richdata/RichDataState.kt index 7ecceeccae7787c2dacc538a35bf2909fe795985..b825afde68d9b0da260812278ecfdb1393271d22 100644 --- a/app/src/main/java/net/novagamestudios/kaffeekasse/util/richdata/RichDataState.kt +++ b/app/src/main/java/net/novagamestudios/kaffeekasse/util/richdata/RichDataState.kt @@ -13,14 +13,16 @@ import cafe.adriel.voyager.core.model.screenModelScope import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch -interface RichDataState<T : Any> : State<RichData<T>> { +interface RichDataState<out T : Any> : State<RichData<T>> { val isLoading: Boolean val isNone: Boolean - val isLoadingOrNone: Boolean get() = isLoading || isNone val dataOrNull: T? val errorOrNull: RichData.Error<T>? } +interface RichDataStateWithFunctions<T : Any> : RichDataState<T>, RichDataFunctions<T> + + class MutableRichDataState<T : Any>( private val initial: RichData<T> = RichData.None() ) : MutableState<RichData<T>> by mutableStateOf(initial), RichDataState<T> { @@ -30,7 +32,10 @@ class MutableRichDataState<T : Any>( override val errorOrNull by derivedStateOf { value.errorOrNull } } +val <T : Any> RichDataState<T>.isLoadingOrNone: Boolean get() = isLoading || isNone + fun <T : Any> MutableRichDataState<T>.asRichDataState(): RichDataState<T> = this + fun <T : Any> RichDataFlow<T>.collectAsRichStateIn(coroutineScope: CoroutineScope): RichDataState<T> { val mutableState = MutableRichDataState<T>() coroutineScope.launch { @@ -38,12 +43,19 @@ fun <T : Any> RichDataFlow<T>.collectAsRichStateIn(coroutineScope: CoroutineScop } return mutableState.asRichDataState() } +fun <T : Any> RichDataSource<T>.collectAsRichStateIn(coroutineScope: CoroutineScope): RichDataStateWithFunctions<T> { + return (this as RichDataFlow<T>).collectAsRichStateIn(coroutineScope) withFunctions this +} context (CoroutineScope) fun <T : Any> RichDataFlow<T>.collectAsRichStateHere() = collectAsRichStateIn(this@CoroutineScope) +context (CoroutineScope) +fun <T : Any> RichDataSource<T>.collectAsRichStateHere() = collectAsRichStateIn(this@CoroutineScope) context (ScreenModel) fun <T : Any> RichDataFlow<T>.collectAsRichStateHere() = collectAsRichStateIn(screenModelScope) +context (ScreenModel) +fun <T : Any> RichDataSource<T>.collectAsRichStateHere() = collectAsRichStateIn(screenModelScope) @Composable @@ -58,3 +70,5 @@ fun <T : Any> RichDataFlow<T>.collectAsRichState(): MutableRichDataState<T> { + +