diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/AppModule.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/AppModule.kt index c3ad6c1810afd9b73e073c4c3789ab986478b8e9..98f343a6e098e482b6f637dbf306a5c48b665531 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.model.session.User import net.novagamestudios.kaffeekasse.repositories.RepositoryProvider @@ -15,7 +16,32 @@ sealed interface AppModule { class KaffeekasseModule : AppModule { override val id = KaffeekasseModule::class.simpleName!! - val cart: MutableCart = MutableCartImpl() + val cartProvider = CartProvider() + + companion object { + val RepositoryProvider.kaffeekasseCartProvider get() = modules.require<KaffeekasseModule>().cartProvider + } +} + +class HiwiTrackerModule : AppModule { + override val id = HiwiTrackerModule::class.simpleName!! +} + + +class AppModules( + modules: List<AppModule> +) : List<AppModule> by modules { + constructor(vararg modules: AppModule) : this(listOf(*modules)) + + inline fun <reified T : AppModule> get() = filterIsInstance<T>().singleOrNull() + inline fun <reified T : AppModule> require() = get<T>() ?: error("No single module of type ${T::class.simpleName}") +} + +class CartProvider { + + private val cartByUser = mutableMapOf<User, MutableCart>() + + operator fun get(user: User): MutableCart = cartByUser.getOrPut(user) { MutableCartImpl() } private class MutableCartImpl : MutableCart { private val content = mutableStateMapOf<Item, Int>() @@ -44,24 +70,6 @@ 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 { - override val id = HiwiTrackerModule::class.simpleName!! -} - - -class AppModules( - modules: List<AppModule> -) : List<AppModule> by modules { - constructor(vararg modules: AppModule) : this(listOf(*modules)) - - inline fun <reified T : AppModule> get() = filterIsInstance<T>().singleOrNull() - inline fun <reified T : AppModule> require() = get<T>() ?: error("No single module of type ${T::class.simpleName}") } diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/model/kaffeekasse/ManualBillDetails.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/model/kaffeekasse/ManualBillDetails.kt index c7bdbab2f5e96876a3e8d2306841586f30cb61d6..0084fd1d5a073c3b12ba8aa870d5e803c6715300 100644 --- a/app/src/main/java/net/novagamestudios/kaffeekasse/model/kaffeekasse/ManualBillDetails.kt +++ b/app/src/main/java/net/novagamestudios/kaffeekasse/model/kaffeekasse/ManualBillDetails.kt @@ -18,7 +18,7 @@ data class ManualBillDetails( data class PurchaseAccount( override val name: Name, override val id: Int, - val isDefault: Boolean = false + override val isDefault: Boolean = false ) : ScraperPurchaseAccount @Serializable data class ItemGroup( @@ -49,4 +49,6 @@ data class ManualBillDetails( } } -sealed interface ScraperPurchaseAccount : PurchaseAccount +sealed interface ScraperPurchaseAccount : PurchaseAccount { + val isDefault: Boolean +} 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 4fb597fc7c3da91e554aa0eaec2a545706886b51..7d7d88c85947a26a4868eff9ea324dfb6295e77d 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,7 +11,7 @@ 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.Companion.kaffeekasseCart +import net.novagamestudios.kaffeekasse.KaffeekasseModule.Companion.kaffeekasseCartProvider import net.novagamestudios.kaffeekasse.model.kaffeekasse.MutableCart import net.novagamestudios.kaffeekasse.model.kaffeekasse.isNotEmpty import net.novagamestudios.kaffeekasse.model.session.Session @@ -39,7 +39,7 @@ class KaffeekasseModuleScreenModel private constructor( context (RepositoryProvider) override fun create(screen: KaffeekasseNavigation.Tab) = KaffeekasseModuleScreenModel( session = screen.session, - cart = kaffeekasseCart + cart = kaffeekasseCartProvider[screen.session.realUser] ) } } 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 3da7ef89e24ecf23245cef4f7a5f1a4086c83544..38e8d93f7fd09f191051317ce31ead47990d6b3c 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 @@ -41,7 +41,7 @@ import cafe.adriel.voyager.navigator.currentOrThrow import net.novagamestudios.common_utils.Logger import net.novagamestudios.common_utils.compose.components.CircularProgressIndicator import net.novagamestudios.common_utils.compose.tabIndicatorOffset -import net.novagamestudios.kaffeekasse.KaffeekasseModule.Companion.kaffeekasseCart +import net.novagamestudios.kaffeekasse.KaffeekasseModule.Companion.kaffeekasseCartProvider import net.novagamestudios.kaffeekasse.model.kaffeekasse.MutableCart import net.novagamestudios.kaffeekasse.model.kaffeekasse.isEmpty import net.novagamestudios.kaffeekasse.model.session.Session @@ -122,7 +122,7 @@ class ManualBillScreenModel private constructor( repositoryProvider = this@RepositoryProvider, session = screen.session, kaffeekasse = kaffeekasseRepository, - cart = kaffeekasseCart + cart = kaffeekasseCartProvider[screen.session.realUser] ) @Composable diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/components/AccountSelection.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/components/AccountSelection.kt index 30f34dc16e6d0d17341bbe1948ee60fa1dd7f63a..00023a5068809a8249e1ec1929c0392fa8822b12 100644 --- a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/components/AccountSelection.kt +++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/components/AccountSelection.kt @@ -19,9 +19,10 @@ import net.novagamestudios.kaffeekasse.model.kaffeekasse.PurchaseAccount class AccountSelectionState<out PA : PurchaseAccount>( - val accounts: List<PA> + val accounts: List<PA>, + initialIndex: Int = -1 ) { - var selectedIndex by mutableStateOf(0) + var selectedIndex by mutableStateOf(initialIndex) val selectedAccount: PA? get() = accounts.getOrNull(selectedIndex) } 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 502ea5f338297de9fff26de5790cbbcfe7845ee2..0820ff42793eeef8326f525a4f11cc3945b44602 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 @@ -1,6 +1,7 @@ package net.novagamestudios.kaffeekasse.ui.kaffeekasse.components import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -17,7 +18,9 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.icons.filled.Remove +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButtonDefaults @@ -43,6 +46,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.launch import net.novagamestudios.common_utils.Logger import net.novagamestudios.common_utils.compose.components.BoxCenter @@ -51,7 +55,7 @@ import net.novagamestudios.common_utils.compose.components.TransparentListItem import net.novagamestudios.common_utils.compose.maskedCircleIcon import net.novagamestudios.common_utils.compose.state.ReentrantActionState import net.novagamestudios.common_utils.warn -import net.novagamestudios.kaffeekasse.KaffeekasseModule.Companion.kaffeekasseCart +import net.novagamestudios.kaffeekasse.KaffeekasseModule.Companion.kaffeekasseCartProvider import net.novagamestudios.kaffeekasse.api.kaffeekasse.model.APIPurchaseAccount import net.novagamestudios.kaffeekasse.model.kaffeekasse.Cart import net.novagamestudios.kaffeekasse.model.kaffeekasse.MutableCart @@ -61,13 +65,14 @@ 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.LoginRepository import net.novagamestudios.kaffeekasse.repositories.RepositoryProvider import net.novagamestudios.kaffeekasse.repositories.i11.KaffeekasseRepository +import net.novagamestudios.kaffeekasse.ui.kaffeekasse.components.PurchaseController.Account import net.novagamestudios.kaffeekasse.ui.kaffeekasse.components.cards.CategoryIcon import net.novagamestudios.kaffeekasse.ui.util.Toasts import net.novagamestudios.kaffeekasse.ui.util.ToastsState import net.novagamestudios.kaffeekasse.util.richdata.RichDataFlow -import net.novagamestudios.kaffeekasse.util.richdata.RichDataSource import net.novagamestudios.kaffeekasse.util.richdata.RichDataState import net.novagamestudios.kaffeekasse.util.richdata.collectAsRichStateIn import net.novagamestudios.kaffeekasse.util.richdata.combineRich @@ -78,83 +83,55 @@ import net.novagamestudios.kaffeekasse.util.richdata.stateIn import kotlin.time.Duration.Companion.seconds -interface CheckoutState { - val cart: MutableCart - var showModal: Boolean - val isCheckoutLoading: Boolean - val toasts: ToastsState - var indicateSuccess: Boolean - val accountSelectionState: RichDataState<AccountSelectionState<*>> - fun refreshPurchaseAccounts() - fun submitCart() - - companion object { - context (RepositoryProvider) - fun create( - session: Session.WithRealUser, - coroutineScope: CoroutineScope, - onSubmitted: suspend () -> Unit - ): CheckoutState = if (session is Session.WithDevice) { - APICheckoutState( - session.realUser, - coroutineScope, - kaffeekasseRepository, - kaffeekasseCart, - onSubmitted - ) - } else { - ScraperCheckoutState( - session.realUser, - coroutineScope, - kaffeekasseRepository, - kaffeekasseCart, - onSubmitted - ) - } - } -} - -private sealed class CheckoutStateBase<PA : PurchaseAccount>( - private val coroutineScope: CoroutineScope, - protected val kaffeekasse: KaffeekasseRepository, - override val cart: MutableCart, +class CheckoutState<PA : PurchaseAccount> private constructor( + coroutineScope: CoroutineScope, + private val session: Session.WithRealUser, + val cart: MutableCart, + private val purchaseController: PurchaseController<PA>, + private val loginRepository: LoginRepository, private val onSubmitted: suspend () -> Unit -) : Logger, CheckoutState { +) : Logger, CoroutineScope by coroutineScope { - override var showModal by mutableStateOf(false) + var showModal by mutableStateOf(false) - private val loadingMutex = ReentrantActionState() - override val isCheckoutLoading by loadingMutex + val hasInstantCheckout: Boolean = session is Session.WithDevice - override val toasts = ToastsState() + private val loadingMutex = ReentrantActionState() + val isCheckoutLoading by loadingMutex - override var indicateSuccess by mutableStateOf(false) + val toasts = ToastsState() - protected abstract val purchaseAccounts: RichDataFlow<List<PA>> + var indicateSuccess by mutableStateOf(false) - override val accountSelectionState: RichDataState<AccountSelectionState<PA>> by lazy { - purchaseAccounts - .mapRich { accounts -> AccountSelectionState(accounts) } - .collectAsRichStateIn(coroutineScope) + val accountSelectionState: RichDataState<AccountSelectionState<PA>> by lazy { + purchaseController + .purchaseAccounts + .mapRich { accounts -> accounts.sortedBy { it.name } } + .mapRich { accounts -> + AccountSelectionState( + accounts = accounts.map { it.account }, + initialIndex = accounts.indexOfFirst { it.isDefault } + ) + } + .collectAsRichStateIn(this) } - protected abstract suspend fun refreshPurchaseAccountsIfNeeded() - override fun refreshPurchaseAccounts() { - coroutineScope.launch { + fun refreshPurchaseAccounts() { + launch { loadingMutex.trueWhile { - refreshPurchaseAccountsIfNeeded() + purchaseController.refreshPurchaseAccountsIfNeeded() } } } - protected abstract suspend fun performSubmitCart(purchaseAccount: PA) - override fun submitCart() { + fun submitCart(logoutAfter: Boolean = false) { + if (isCheckoutLoading) return if (cart.isEmpty()) return val purchaseAccount = accountSelectionState.dataOrNull?.selectedAccount ?: return - coroutineScope.launch { + launch { val success = loadingMutex.trueWhile { try { - performSubmitCart(purchaseAccount) + purchaseController.performPurchase(purchaseAccount, cart) true } catch (e: Exception) { warn(e) { "Failed to submit cart" } @@ -168,29 +145,70 @@ private sealed class CheckoutStateBase<PA : PurchaseAccount>( cart.clear() onSubmitted() indicateSuccess = false + if (logoutAfter) { + loginRepository.logoutUser() + } } } } + + companion object { + context (RepositoryProvider) + fun create( + session: Session.WithRealUser, + coroutineScope: CoroutineScope, + onSubmitted: suspend () -> Unit + ): CheckoutState<*> = CheckoutState( + coroutineScope = coroutineScope, + session = session, + cart = kaffeekasseCartProvider[session.realUser], + purchaseController = when (session) { + is Session.WithDevice -> APIPurchaseController( + user = session.realUser, + coroutineScope = coroutineScope, + kaffeekasse = kaffeekasseRepository + ) + else -> ScraperPurchaseController( + user = session.realUser, + kaffeekasse = kaffeekasseRepository + ) + }, + loginRepository = loginRepository, + onSubmitted = onSubmitted + ) + } } -private class ScraperCheckoutState( +private interface PurchaseController<PA : PurchaseAccount> { + val purchaseAccounts: RichDataFlow<List<Account<PA>>> + suspend fun refreshPurchaseAccountsIfNeeded() + suspend fun performPurchase(purchaseAccount: PA, cart: Cart) + + data class Account<out PA : PurchaseAccount>( + val account: PA, + val isDefault: Boolean + ) : PurchaseAccount by account +} + +private class ScraperPurchaseController( private val user: User, - coroutineScope: CoroutineScope, - kaffeekasse: KaffeekasseRepository, - cart: MutableCart, - onSubmitted: suspend () -> Unit -) : CheckoutStateBase<ScraperPurchaseAccount>( - coroutineScope, - kaffeekasse, - cart, - onSubmitted -) { - override val purchaseAccounts: RichDataSource<List<ScraperPurchaseAccount>> = kaffeekasse.manualBillAccounts[user] + private val kaffeekasse: KaffeekasseRepository +) : PurchaseController<ScraperPurchaseAccount> { + private val manualBillAccounts = kaffeekasse.manualBillAccounts[user] + override val purchaseAccounts: RichDataFlow<List<Account<ScraperPurchaseAccount>>> = manualBillAccounts.mapRich { accounts -> + accounts.map { account -> + Account( + account = account, + isDefault = account.isDefault + ) + } + } override suspend fun refreshPurchaseAccountsIfNeeded() { - purchaseAccounts.ensureCleanData() + manualBillAccounts.ensureCleanData() } - override suspend fun performSubmitCart(purchaseAccount: ScraperPurchaseAccount) { + + override suspend fun performPurchase(purchaseAccount: ScraperPurchaseAccount, cart: Cart) { kaffeekasse.purchase( asUser = user, cart = cart, @@ -199,18 +217,17 @@ private class ScraperCheckoutState( } } -private class APICheckoutState( +private class APIPurchaseController( private val user: User, coroutineScope: CoroutineScope, - kaffeekasse: KaffeekasseRepository, - cart: MutableCart, - onSubmitted: suspend () -> Unit -) : CheckoutStateBase<APIPurchaseAccount>(coroutineScope, kaffeekasse, cart, onSubmitted) { + private val kaffeekasse: KaffeekasseRepository +) : PurchaseController<APIPurchaseAccount> { private val selfUserBasic = kaffeekasse.basicUserInfoList.mapRich { userList -> val selfDisplayName = user.displayName ?: return@mapRich null userList.firstOrNull { it.name.firstLast == selfDisplayName } - }.stateIn(coroutineScope) + }.stateIn(coroutineScope, SharingStarted.Eagerly) + private val selfId get() = selfUserBasic.value.dataOrNull?.id private val selfUserExtended = selfUserBasic.flatMapLatestRich { kaffeekasse.getExtendedUserInfo(it.id) @@ -235,22 +252,27 @@ private class APICheckoutState( kaffeekasse.manualBillAccounts[user], kaffeekasse.basicUserInfoList ) { scraperAccounts, apiAccounts -> - scraperAccounts.mapNotNull { account -> apiAccounts.firstOrNull { it.name == account.name } } + scraperAccounts.mapNotNull { scraperAccount -> + apiAccounts.firstOrNull { it.name == scraperAccount.name }?.let { apiAccount -> + Account( + account = apiAccount, + isDefault = scraperAccount.isDefault + ) + } + } } override suspend fun refreshPurchaseAccountsIfNeeded() { kaffeekasse.basicUserInfoList.ensureCleanData() - selfUserBasic.value.dataOrNull?.let { - kaffeekasse.getExtendedUserInfo(it.id).ensureCleanData() + selfId?.let { + kaffeekasse.getExtendedUserInfo(it).ensureCleanData() } } - override suspend fun performSubmitCart(purchaseAccount: APIPurchaseAccount) { + override suspend fun performPurchase(purchaseAccount: APIPurchaseAccount, cart: Cart) { kaffeekasse.purchase( asUser = user, cart = cart, - targetAccount = purchaseAccount.takeUnless { - it.id == selfUserBasic.value.dataOrNull?.id - } + targetAccount = purchaseAccount.takeUnless { it.id == selfId } ) } } @@ -258,14 +280,26 @@ private class APICheckoutState( @Composable fun Checkout( - state: CheckoutState, + state: CheckoutState<*>, modifier: Modifier = Modifier ) { - if (state.cart.isNotEmpty()) CheckoutFAB( - itemCount = state.cart.itemCount, - onClick = { state.showModal = true }, - modifier - ) + if (state.cart.isNotEmpty() && !state.showModal) Box(modifier.padding(16.dp)) { + if (state.indicateSuccess) { + BoxCenter(Modifier.size(56.dp)) { + Icon(Icons.Default.Check, "Success", tint = Color.Green) + } + } else if (state.isCheckoutLoading) { + CircularProgressIndicator(Modifier) + } else { + CheckoutFABs( + itemCount = state.cart.itemCount, + onCheckout = { state.showModal = true }, + onInstantCheckout = if (state.hasInstantCheckout) { + { state.submitCart(logoutAfter = true) } + } else null + ) + } + } if (state.showModal) { LaunchedEffect(Unit) { state.refreshPurchaseAccounts() @@ -283,20 +317,35 @@ fun Checkout( } @Composable -private fun CheckoutFAB( +private fun CheckoutFABs( itemCount: Int, - onClick: () -> Unit, + onCheckout: () -> Unit, + onInstantCheckout: (() -> Unit)?, modifier: Modifier = Modifier -) = ExtendedFloatingActionButton( - text = { Text("Buchen") }, - icon = { - BoxCenter(Modifier.maskedCircleIcon(LocalContentColor.current)) { - Text("$itemCount", fontWeight = FontWeight.Bold) - } - }, - onClick = onClick, - modifier.padding(16.dp) -) +) = Column( + modifier, + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.End +) { + FloatingActionButton( + onClick = onCheckout + ) { + Icon(Icons.Default.KeyboardArrowUp, null) + } + if (onInstantCheckout != null) ExtendedFloatingActionButton( + text = { Text("Schnell buchen") }, + icon = { CountIcon(itemCount) }, + onClick = onInstantCheckout + ) +} + +@Composable +fun CountIcon( + itemCount: Int, + modifier: Modifier = Modifier +) = BoxCenter(modifier.maskedCircleIcon(LocalContentColor.current)) { + Text("$itemCount", fontWeight = FontWeight.Bold) +} @Composable private fun CheckoutModal( @@ -357,7 +406,10 @@ private fun CheckoutDetails( // Account AccountSelection(accountSelectionState) - Spacer(Modifier.weight(1f).width(16.dp)) + Spacer( + Modifier + .weight(1f) + .width(16.dp)) // Submit if (indicateSuccess) BoxCenter(Modifier.size(56.dp)) { 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 138e61e2a68549db46fed56688c1189054cb7518..51b42431e424fb987bf97e27569714f11c81db6b 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 @@ -323,7 +323,7 @@ private fun UserGridWithSelectionBar( ) val onSelectIndex: (Int) -> Unit = block@{ index -> - val (userIndex, charIndex) = (userIndexAndCharIndexByChar[chars[index]] ?: return@block) + val (userIndex, charIndex) = (userIndexAndCharIndexByChar[chars[index.coerceIn(chars.indices)]] ?: return@block) val sectionIndex = userIndex + charIndex coroutineScope.launch { gridState.scrollToItem(sectionIndex)