Skip to content
Snippets Groups Projects
Commit eb4d82f1 authored by Jonas Broeckmann's avatar Jonas Broeckmann
Browse files

Merge branch 'official-api' into 'master'

Official api

See merge request !3
parents ecba6baf 0a9817f5
No related branches found
No related tags found
1 merge request!3Official api
Pipeline #1328199 passed
......@@ -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}")
}
......@@ -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
}
......@@ -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]
)
}
}
......
......@@ -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
......
......@@ -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)
}
......
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)) {
......
......@@ -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)
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment