Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found
Select Git revision

Target

Select target project
  • jonas.broeckmann/kaffeekasse
1 result
Select Git revision
Show changes
Commits on Source (4)
......@@ -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)
......