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 (7)
Showing
with 349 additions and 200 deletions
......@@ -11,7 +11,7 @@ image: eclipse-temurin:19-jdk-jammy
variables:
APP_VERSION: "1.2.2"
APP_VERSION: "1.2.3"
APP_APK: "kaffeekasse-${APP_VERSION}.apk"
PACKAGE_REGISTRY_URL: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/kaffeekasse/${APP_VERSION}"
......
......@@ -18,7 +18,7 @@ android {
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "1.2.2"
versionName = "1.2.3"
// Check that the version in environment matches the version in build.gradle.kts
System.getenv()["APP_VERSION"]?.let { versionFromEnv ->
......
......@@ -121,7 +121,6 @@ class App : Application(), RepositoryProvider, CoroutineScope by MainScope() + C
scraper = HiwiTrackerScraper(portalClient)
)
override val portalRepository = PortalRepository(
coroutineScope = this,
portalClient,
kaffeekasseRepository,
hiwiTrackerRepository
......
......@@ -3,6 +3,7 @@ package net.novagamestudios.kaffeekasse
import android.app.Application
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.snapshotFlow
import cafe.adriel.voyager.navigator.Navigator
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
......@@ -63,7 +64,7 @@ object CrashHandling {
}
}
launch {
portalRepository.session.collectLatest {
snapshotFlow { portalRepository.session.value }.collectLatest {
ACRA.errorReporter.putCustomData("CurrentPortalSession", "$it")
}
}
......
package net.novagamestudios.kaffeekasse.api.portal
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
......@@ -25,8 +27,6 @@ import it.skrape.selects.Doc
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.newCoroutineContext
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
......@@ -76,7 +76,7 @@ class PortalClient(
private val portal = object : Module(this, "portal") {
val currentSession = MutableStateFlow(Session())
val currentSession = mutableStateOf(Session())
suspend fun updateSession(restore: String? = null): Unit = withContext(computationScope) {
if (restore != null) {
debug { "Restoring cookie: $restore" }
......@@ -104,7 +104,7 @@ class PortalClient(
private val SessionCookieName = "PORTALSESSID"
}
val session get() = portal.currentSession.asStateFlow()
val session: State<Session> get() = portal.currentSession
data class Session(
val response: PortalAPIResponse.Session = PortalAPIResponse.Session(),
......
......@@ -3,7 +3,83 @@ package net.novagamestudios.kaffeekasse.data
import net.novagamestudios.kaffeekasse.R
import net.novagamestudios.kaffeekasse.model.kaffeekasse.ItemCategory
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.*
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.Almdudler
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.Apple
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.BaguetteBistro2
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.Balisto
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.Bier330
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.Bier500
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.Bionade
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.CafeAuLait
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.Cappuccino
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.ChioTortillaChips
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.ChocolateCreamCocoa
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.ChouffeBier
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.Clementine
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.ClubMate
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.CocaCola
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.CocaCola05Glass
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.CocaCola330
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.CoffeeLarge
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.CoffeeSmall
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.CokeZero
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.CokeZeroCaffeineFree
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.CujaMaraSplit
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.DuplexBlackWhite
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.DuplexColor
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.Duplo
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.EiflerLandbier
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.EngelbertApple
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.EngelbertNatural
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.EngelbertSprudel
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.ErdingerAlcoholFree330
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.ErdingerAlcoholFree500
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.Erdnuesse
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.Espresso
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.Euglueh
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.Fanta
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.Fassbrause
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.FritzCola
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.Gerolsteiner
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.Hanuta
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.HariboBag
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.IceBounty
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.IceCornetto
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.IceFlutschfinger
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.IceMars
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.IceNogger
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.IceSnickers
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.IceTwix
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.Katjes
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.KinderChocolate
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.Knoppers
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.KnoppersBar
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.LatteMacchiato
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.Leffe
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.LindtHase
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.Magnum
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.MilkMachine
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.Milka
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.MioMioMate
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.Moccachoc
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.Monster
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.NUII
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.NicNacs
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.NutsRoyalNuts
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.OreoCookies
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.PickUp
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.Pizza
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.RheinfelsSprudel
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.Rockstar
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.SimplexBlackWhite
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.SimplexColor
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.TUC
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.Tangerine
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.ThreeDPrintingPerGram
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.Tissues
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.Trolli
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.Twix
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.Yogurette
import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItemGroup
......@@ -76,6 +152,9 @@ val KnownItem.category get() = when (this) {
KinderChocolate -> ItemCategory.Snack
NutsRoyalNuts -> ItemCategory.Snack
Katjes -> ItemCategory.Snack
TUC -> ItemCategory.Snack
NicNacs -> ItemCategory.Snack
ChioTortillaChips -> ItemCategory.Snack
CujaMaraSplit -> ItemCategory.IceCream
Magnum -> ItemCategory.IceCream
NUII -> ItemCategory.IceCream
......@@ -85,7 +164,9 @@ val KnownItem.category get() = when (this) {
IceSnickers -> ItemCategory.IceCream
IceTwix -> ItemCategory.IceCream
IceBounty -> ItemCategory.IceCream
IceFlutschfinger -> ItemCategory.IceCream
Pizza -> ItemCategory.Food
BaguetteBistro2 -> ItemCategory.Food
Apple -> ItemCategory.Fruit
Tangerine -> ItemCategory.Fruit
Clementine -> ItemCategory.Fruit
......@@ -159,7 +240,9 @@ val KnownItem.cleanProductName get() = when (this) {
IceSnickers -> "Snickers Ice Cream"
IceTwix -> "Twix Ice Cream"
IceBounty -> "Bounty Ice Cream"
IceFlutschfinger -> "Flutschfinger"
Pizza -> "Pizza"
BaguetteBistro2 -> "Bistro Baguette"
Apple -> "Apfel"
Tangerine -> "Mandarine"
Clementine -> "Clementine"
......@@ -171,6 +254,9 @@ val KnownItem.cleanProductName get() = when (this) {
ThreeDPrintingPerGram -> "3D-Druck"
Euglueh -> "Euglüh"
Katjes -> "Katjes"
TUC -> "TUC"
NicNacs -> "NicNac's"
ChioTortillaChips -> "Chio Tortillas"
}
val KnownItem.cleanVariantName get() = when (this) {
Bier330 -> "0,33L"
......@@ -191,6 +277,7 @@ val KnownItem.cleanVariantName get() = when (this) {
HariboBag -> "Beutel"
Katjes -> "Beutel"
LindtHase -> "Hase"
BaguetteBistro2 -> "2 Stück"
DuplexColor -> "Farbe"
DuplexBlackWhite -> "S/W"
SimplexColor -> "Farbe"
......@@ -259,7 +346,9 @@ val KnownItem.estimatedPrice get() = when (this) {
IceSnickers -> 0.68
IceTwix -> 0.68
IceBounty -> 0.68
IceFlutschfinger -> 0.49
Pizza -> 2.99
BaguetteBistro2 -> 2.00
Apple -> 0.30
Tangerine -> null
Clementine -> null
......@@ -271,6 +360,9 @@ val KnownItem.estimatedPrice get() = when (this) {
ThreeDPrintingPerGram -> 0.02
Euglueh -> null
Katjes -> 0.99
TUC -> 1.25
NicNacs -> 1.40
ChioTortillaChips -> 1.35
}
val KnownItem.drawableResource get() = when (this) {
......
......@@ -49,6 +49,7 @@ enum class KnownItem(
Milka (152, "Milka"),
Moccachoc (162, "Moccachoc"),
Pizza (243, "Pizza"),
BaguetteBistro2 (268, "Baguette Bistro 2Stk"),
Rockstar (187, "Rockstar/Monster (inkl. Pfand)"),
Monster (187, "Rockstar/Monster (inkl. Pfand)"),
ChocolateCreamCocoa (164, "Schoko-Creme (Kakao)"),
......@@ -66,6 +67,7 @@ enum class KnownItem(
IceSnickers (136, "Eis: Mars/Snickers/Twix/Bounty"),
IceTwix (136, "Eis: Mars/Snickers/Twix/Bounty"),
IceBounty (136, "Eis: Mars/Snickers/Twix/Bounty"),
IceFlutschfinger (167, "Eis: Flutschfinger"),
OreoCookies (235, "Oreo Kekse"),
CocaCola05Glass (265, "CocaCola 0,5l GLAS"),
Trolli (249, "Trolli"),
......@@ -80,6 +82,9 @@ enum class KnownItem(
ThreeDPrintingPerGram (256, "3D-Druck pro gramm"),
Euglueh (173, "Euglüh"),
Katjes (254, "Katjes"),
TUC (270, "TUC"),
NicNacs (269, "NicNacs"),
ChioTortillaChips (255, "Chio Tortilla Chips"),
;
companion object {
val byId by lazy { entries.groupBy { it.id } }
......
......@@ -49,6 +49,6 @@ data class ManualBillDetails(
}
}
sealed interface ScraperPurchaseAccount : PurchaseAccount {
val isDefault: Boolean
sealed interface ScraperPurchaseAccount : PurchaseAccount, PurchaseAccount.WithDefault {
override val isDefault: Boolean
}
......@@ -3,5 +3,8 @@ package net.novagamestudios.kaffeekasse.model.kaffeekasse
interface PurchaseAccount {
val id: Int
val name: Name
}
interface WithDefault : PurchaseAccount {
val isDefault: Boolean
}
}
......@@ -4,13 +4,13 @@ import android.content.Context
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex
import net.novagamestudios.common_utils.core.toastShort
import net.novagamestudios.common_utils.core.collection.mapState
import net.novagamestudios.common_utils.core.logging.Logger
import net.novagamestudios.common_utils.core.logging.debug
import net.novagamestudios.common_utils.core.logging.info
import net.novagamestudios.common_utils.core.logging.verbose
import net.novagamestudios.common_utils.core.logging.warn
import net.novagamestudios.common_utils.core.toastShort
import net.novagamestudios.common_utils.core.withReentrantLock
import net.novagamestudios.kaffeekasse.api.kaffeekasse.KaffeekasseAPI
import net.novagamestudios.kaffeekasse.api.kaffeekasse.model.BasicUserInfo
......@@ -47,7 +47,7 @@ class LoginRepository(
private val _errors = MutableStateFlow<List<String>?>(null)
private val _errors = MutableStateFlow<List<Exception>?>(null)
val errors = _errors.asStateFlow()
......@@ -68,7 +68,7 @@ class LoginRepository(
true
} catch (e: Exception) {
warn(e) { "Failed to login" }
_errors.value = listOf(e.message ?: e::class.simpleName ?: "Unknown error")
_errors.value = listOf(e)
false
}
autoLoginAttemptAvailable = success
......
package net.novagamestudios.kaffeekasse.repositories
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import kotlinx.coroutines.flow.StateFlow
import net.novagamestudios.common_utils.core.collection.mapState
import net.novagamestudios.common_utils.compose.state.MutableDataStoreState
import net.novagamestudios.common_utils.core.collection.mapState
import net.novagamestudios.kaffeekasse.model.session.realUserOrNull
class SettingsRepository(
repositoryProvider: RepositoryProvider,
settingsStore: MutableSettingsStore
private val repositoryProvider: RepositoryProvider,
private val settingsStore: MutableSettingsStore
) : MutableSettingsStore by settingsStore {
val userSettings: StateFlow<MutableDataStoreState<UserSettings>?> by lazy {
repositoryProvider.portalRepository.session.mapState { session ->
val key = session.realUserOrNull?.user ?: return@mapState null
UserSettingsStore(key, settingsStore)
}
private val userSettingsStores = mutableMapOf<String, UserSettingsStore>()
val userSettings: State<MutableDataStoreState<UserSettings>?> = derivedStateOf {
val session = repositoryProvider.portalRepository.session.value
val key = session.realUserOrNull?.user ?: return@derivedStateOf null
userSettingsStores.getOrPut(key) { UserSettingsStore(key, settingsStore) }
}
}
......
package net.novagamestudios.kaffeekasse.repositories.i11
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
......@@ -42,8 +47,8 @@ class KaffeekasseRepository(
) : PortalRepositoryModule(), CoroutineScope by coroutineScope, Logger {
private val client get() = api.client
private val mutableCurrentDevice = MutableStateFlow<Device?>(null)
internal val currentDevice = mutableCurrentDevice.asStateFlow()
private val mutableCurrentDevice = mutableStateOf<Device?>(null)
internal val currentDevice: State<Device?> get() = mutableCurrentDevice
......
package net.novagamestudios.kaffeekasse.repositories.i11
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import net.novagamestudios.common_utils.core.logging.Logger
import net.novagamestudios.common_utils.core.logging.debug
import net.novagamestudios.common_utils.core.logging.info
......@@ -17,7 +15,6 @@ import net.novagamestudios.kaffeekasse.model.session.realUserOrNull
class PortalRepository(
coroutineScope: CoroutineScope,
private val client: PortalClient,
internal val kaffeekasse: KaffeekasseRepository,
vararg otherModules: PortalRepositoryModule
......@@ -28,17 +25,16 @@ class PortalRepository(
modules.forEach { it.portal = this }
}
public override val session = combine(
client.session,
kaffeekasse.currentDevice
) { session, device ->
public override val session: State<Session> = derivedStateOf {
val session = client.session.value
val device = kaffeekasse.currentDevice.value
val response = session.response
Session(
device = device,
user = if (response.isLoggedIn) User(response.username, response.displayName) else null,
data = session.data
)
}.stateIn(coroutineScope, SharingStarted.Eagerly, Session.Empty)
}
suspend fun forceUpdateSession(restore: String? = null) {
info { "Updating session" }
......
......@@ -23,7 +23,6 @@ import net.novagamestudios.common_utils.core.logging.LoggerForFun
import net.novagamestudios.common_utils.core.logging.debug
import net.novagamestudios.common_utils.voyager.model.GlobalScreenModelFactory
import net.novagamestudios.common_utils.voyager.model.ScreenModelProvider
import net.novagamestudios.common_utils.voyager.model.collectAsStateHere
import net.novagamestudios.common_utils.voyager.model.getValue
import net.novagamestudios.common_utils.voyager.requireWithKey
import net.novagamestudios.kaffeekasse.App
......@@ -40,7 +39,7 @@ class AppScreenModel private constructor(
private val portal: PortalRepository
) : ScreenModel, Logger {
val session by portal.session.collectAsStateHere()
val session by portal.session
companion object : GlobalScreenModelFactory<RepositoryProvider, App, AppScreenModel>, ScreenModelProvider<AppScreenModel> {
context (RepositoryProvider)
......
......@@ -29,7 +29,6 @@ import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.core.model.ScreenModel
import cafe.adriel.voyager.navigator.tab.LocalTabNavigator
import net.novagamestudios.common_utils.voyager.model.ScreenModelFactory
import net.novagamestudios.common_utils.voyager.model.collectAsStateHere
import net.novagamestudios.common_utils.voyager.nearestScreen
import net.novagamestudios.kaffeekasse.AppModule
import net.novagamestudios.kaffeekasse.AppModules
......@@ -51,7 +50,7 @@ class AppModulesScreenModel private constructor(
) : ScreenModel {
private val userSettings by settingsRepository.userSettings.collectAsStateHere()
private val userSettings by settingsRepository.userSettings
val modules: AppModules get() = when {
settingsRepository.value.developerMode -> allModules
session is Session.WithDevice -> AppModules(allModules.filterIsInstance<KaffeekasseModule>())
......
......@@ -115,6 +115,7 @@ import net.novagamestudios.kaffeekasse.ui.util.synchronizePagerState
import net.novagamestudios.kaffeekasse.util.richdata.RichData
import net.novagamestudios.kaffeekasse.util.richdata.RichDataState
import net.novagamestudios.kaffeekasse.util.richdata.collectAsRichStateHere
import net.novagamestudios.kaffeekasse.util.richdata.exceptions
import java.time.format.DateTimeFormatter
import kotlin.math.absoluteValue
import kotlin.math.max
......@@ -236,72 +237,82 @@ class OverviewContent(provider: ScreenModelProvider<OverviewScreenModel>) : AppS
fun Overview(
model: OverviewScreenModel,
modifier: Modifier = Modifier
) = PullToRefreshBox(
isRefreshing = false,
onRefresh = { model.refreshMonth(model.currentMonth) },
modifier.fillMaxSize()
) {
LaunchedEffect(Unit) {
model.keepMonthDataUpToDate()
}
Column {
MonthSelection(
model,
Modifier
.align(Alignment.CenterHorizontally)
.padding(horizontal = 16.dp, vertical = 8.dp)
)
when (val data = model.dataForMonthState(model.currentMonth).value) {
is RichData.Loading -> BoxCenter(Modifier.fillMaxSize()) {
CircularProgressIndicator(data.progress)
}
is RichData.Data -> Column {
synchronizePagerState(
pagerState = model.pagerState,
property = model::currentMonth
)
HorizontalKeyedPager(state = model.pagerState) { key ->
BoxCenter(Modifier.fillMaxWidth()) {
val pagerData = model.dataForMonthState(key).dataOrNull
if (pagerData != null) CalendarForMonth(
pagerData,
onClick = model::onClickDate,
Modifier
.padding(horizontal = 32.dp)
.padding(bottom = 8.dp)
)
// Bad fix because "isRefreshing = false" results in indicator not disappearing
var isRefreshingQuickFix by remember { mutableStateOf(false) }
PullToRefreshBox(
isRefreshing = isRefreshingQuickFix.also { isRefreshingQuickFix = false },
onRefresh = {
isRefreshingQuickFix = true
model.refreshMonth(model.currentMonth)
},
modifier.fillMaxSize()
) {
LaunchedEffect(Unit) {
model.keepMonthDataUpToDate()
}
Column {
MonthSelection(
model,
Modifier
.align(Alignment.CenterHorizontally)
.padding(horizontal = 16.dp, vertical = 8.dp)
)
when (val data = model.dataForMonthState(model.currentMonth).value) {
is RichData.Loading -> BoxCenter(Modifier.fillMaxSize()) {
CircularProgressIndicator(data.progress)
}
is RichData.Data -> Column {
synchronizePagerState(
pagerState = model.pagerState,
property = model::currentMonth
)
HorizontalKeyedPager(state = model.pagerState) { key ->
BoxCenter(Modifier.fillMaxWidth()) {
val pagerData = model.dataForMonthState(key).dataOrNull
if (pagerData != null) CalendarForMonth(
pagerData,
onClick = model::onClickDate,
Modifier
.padding(horizontal = 32.dp)
.padding(bottom = 8.dp)
)
}
}
HorizontalDivider()
val scrollState = rememberScrollState()
MonthContent(
model,
Modifier
.weight(1f)
.verticalScroll(scrollState)
)
LaunchedEffect(model.currentMonth) {
scrollState.animateScrollTo(0)
}
}
HorizontalDivider()
val scrollState = rememberScrollState()
MonthContent(
model,
Modifier
.weight(1f)
.verticalScroll(scrollState)
is RichData.Error -> FailureRetryScreen(
message = "Failed to fetch data",
Modifier.fillMaxSize(),
exceptions = data.exceptions,
onRetry = { model.refreshMonth(model.currentMonth) }
)
LaunchedEffect(model.currentMonth) {
scrollState.animateScrollTo(0)
}
else -> {}
}
is RichData.Error -> FailureRetryScreen(
message = "Failed to fetch data",
errors = listOf(data.toString()),
Modifier.fillMaxSize(),
onRetry = { model.refreshMonth(model.currentMonth) }
)
else -> { }
}
}
model.enterWorkingHoursState?.let { subVM ->
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
onDismissRequest = { model.enterWorkingHoursState = null },
sheetState = sheetState
) {
EnterWorkingHoursForm(subVM)
if (model.enterDone) LaunchedEffect(Unit) {
launch { sheetState.hide() }.invokeOnCompletion { model.enterWorkingHoursState = null }
model.enterWorkingHoursState?.let { subVM ->
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
onDismissRequest = { model.enterWorkingHoursState = null },
sheetState = sheetState
) {
EnterWorkingHoursForm(subVM)
if (model.enterDone) LaunchedEffect(Unit) {
launch { sheetState.hide() }.invokeOnCompletion { model.enterWorkingHoursState = null }
}
}
}
}
......
package net.novagamestudios.kaffeekasse.ui.kaffeekasse
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
......@@ -25,13 +24,14 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.core.model.ScreenModel
import cafe.adriel.voyager.navigator.Navigator
import net.novagamestudios.common_utils.compose.components.BoxCenter
import net.novagamestudios.common_utils.compose.components.CircularProgressIndicator
import net.novagamestudios.common_utils.compose.components.ColumnCenter
import net.novagamestudios.common_utils.core.format
import net.novagamestudios.common_utils.core.logging.Logger
import net.novagamestudios.common_utils.voyager.BackNavigationHandler
import net.novagamestudios.common_utils.voyager.model.ScreenModelFactory
import net.novagamestudios.common_utils.voyager.model.ScreenModelProvider
import net.novagamestudios.kaffeekasse.App
import net.novagamestudios.kaffeekasse.model.kaffeekasse.Account
import net.novagamestudios.kaffeekasse.model.session.Session
import net.novagamestudios.kaffeekasse.repositories.RepositoryProvider
......@@ -41,10 +41,10 @@ import net.novagamestudios.kaffeekasse.ui.login.LogoutTopBarAction
import net.novagamestudios.kaffeekasse.ui.navigation.AppScaffoldContentWithModel
import net.novagamestudios.kaffeekasse.ui.navigation.AppSubpageTitle
import net.novagamestudios.kaffeekasse.ui.navigation.KaffeekasseNavigation
import net.novagamestudios.kaffeekasse.ui.util.DebugDataText
import net.novagamestudios.kaffeekasse.ui.util.FailureRetryScreen
import net.novagamestudios.kaffeekasse.ui.util.PullToRefreshBox
import net.novagamestudios.kaffeekasse.ui.util.RichDataContent
import net.novagamestudios.common_utils.voyager.BackNavigationHandler
import net.novagamestudios.kaffeekasse.util.richdata.collectAsRichStateHere
class AccountScreenModel private constructor(
......@@ -90,22 +90,32 @@ fun Account(
data = model.account,
modifier.fillMaxSize()
) {
RichDataContent(
state = model.account,
errorContent = { error ->
FailureRetryScreen(
error = error,
message = "Failed to fetch account",
Modifier.fillMaxSize()
ColumnCenter(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {
RichDataContent(
state = model.account,
errorContent = { error ->
FailureRetryScreen(
error = error,
message = "Failed to fetch account",
Modifier.fillMaxSize()
)
},
loadingContent = { progress ->
CircularProgressIndicator(progress, Modifier.align(Alignment.CenterHorizontally))
}
) { account ->
AccountDetails(
account = account,
Modifier.align(Alignment.CenterHorizontally)
)
},
loadingContent = { progress ->
CircularProgressIndicator(progress, Modifier.align(Alignment.Center))
}
) { account ->
AccountDetails(
account = account
)
if (App.developerMode) ColumnCenter(
Modifier.padding(16.dp)
) {
Text("Session", style = MaterialTheme.typography.headlineSmall)
DebugDataText("${model.session.realUser}")
DebugDataText("${model.session.data}")
}
}
}
......@@ -113,79 +123,72 @@ fun Account(
private fun AccountDetails(
account: Account,
modifier: Modifier = Modifier
) = BoxCenter(
modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())) {
Column(
Modifier.width(OutlinedTextFieldDefaults.MinWidth),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = Icons.Default.Person,
contentDescription = "Name",
Modifier.size(48.dp)
) = ColumnCenter(
modifier.width(OutlinedTextFieldDefaults.MinWidth)
) {
Icon(
imageVector = Icons.Default.Person,
contentDescription = "Name",
Modifier.size(48.dp)
)
Spacer(Modifier.height(16.dp))
Text(
"${account.firstName} ${account.lastName}",
textAlign = TextAlign.Center,
style = MaterialTheme.typography.headlineMedium
)
Spacer(Modifier.height(64.dp))
ColumnCenter {
Text(
"Kontostand".uppercase(),
style = MaterialTheme.typography.labelMedium
)
Spacer(Modifier.height(16.dp))
Text(
"${account.firstName} ${account.lastName}",
textAlign = TextAlign.Center,
"${account.total.format("%.2f")} €",
Modifier.padding(8.dp),
color = when {
account.total > 0 -> Color.Green
account.total < 0 -> Color.Red
else -> Color.Unspecified
},
fontFamily = FontFamily.Monospace,
style = MaterialTheme.typography.headlineMedium
)
}
Spacer(Modifier.height(64.dp))
Spacer(Modifier.height(16.dp))
ColumnCenter {
Row {
ColumnCenter(Modifier.weight(1f)) {
Text(
"Kontostand".uppercase(),
"Verzehrt".uppercase(),
style = MaterialTheme.typography.labelMedium
)
Text(
"${account.total.format("%.2f")} €",
"${account.paid.format("%.2f")} €",
Modifier.padding(8.dp),
color = when {
account.total > 0 -> Color.Green
account.total < 0 -> Color.Red
else -> Color.Unspecified
},
fontFamily = FontFamily.Monospace,
style = MaterialTheme.typography.headlineMedium
style = MaterialTheme.typography.titleLarge
)
}
Spacer(Modifier.height(16.dp))
Row {
ColumnCenter(Modifier.weight(1f)) {
Text(
"Verzehrt".uppercase(),
style = MaterialTheme.typography.labelMedium
)
Text(
"${account.paid.format("%.2f")} €",
Modifier.padding(8.dp),
fontFamily = FontFamily.Monospace,
style = MaterialTheme.typography.titleLarge
)
}
Spacer(Modifier.width(16.dp))
Spacer(Modifier.width(16.dp))
ColumnCenter(Modifier.weight(1f)) {
Text(
"Eingezahlt".uppercase(),
style = MaterialTheme.typography.labelMedium
)
Text(
"${account.deposited.format("%.2f")} €",
Modifier.padding(8.dp),
fontFamily = FontFamily.Monospace,
style = MaterialTheme.typography.titleLarge
)
}
ColumnCenter(Modifier.weight(1f)) {
Text(
"Eingezahlt".uppercase(),
style = MaterialTheme.typography.labelMedium
)
Text(
"${account.deposited.format("%.2f")} €",
Modifier.padding(8.dp),
fontFamily = FontFamily.Monospace,
style = MaterialTheme.typography.titleLarge
)
}
Spacer(Modifier.height(64.dp))
}
Spacer(Modifier.height(64.dp))
}
......@@ -11,18 +11,20 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import net.novagamestudios.kaffeekasse.App
import net.novagamestudios.kaffeekasse.model.kaffeekasse.PurchaseAccount
class AccountSelectionState<out PA : PurchaseAccount>(
val accounts: List<PA>,
initialIndex: Int = -1
initialIndex: Int
) {
var selectedIndex by mutableStateOf(initialIndex)
var selectedIndex by mutableIntStateOf(initialIndex)
val selectedAccount: PA? get() = accounts.getOrNull(selectedIndex)
}
......@@ -54,7 +56,20 @@ fun AccountSelection(
) {
state.accounts.forEachIndexed { index, account ->
DropdownMenuItem(
text = { Text("${account.name}") },
text = {
val str = listOfNotNull(
"${account.name}",
if (App.developerMode) listOfNotNull(
"${account.id}",
when {
account is PurchaseAccount.WithDefault && account.isDefault -> "default"
account is PurchaseAccount.WithDefault && !account.isDefault -> "not default"
else -> "unknown default"
}
).joinToString(";", "(", ")") else null
).joinToString(" ")
Text(str)
},
onClick = {
state.selectedIndex = index
expanded = false
......
......@@ -10,7 +10,6 @@ import androidx.compose.animation.scaleOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
......@@ -20,8 +19,9 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import kotlinx.coroutines.launch
import net.novagamestudios.common_utils.core.toastShort
import net.novagamestudios.common_utils.compose.state.MutableDataStoreState
import net.novagamestudios.common_utils.core.toastShort
import net.novagamestudios.common_utils.voyager.BackNavigationHandler
import net.novagamestudios.kaffeekasse.App.Companion.settings
import net.novagamestudios.kaffeekasse.model.kaffeekasse.Cart
import net.novagamestudios.kaffeekasse.model.kaffeekasse.Item
......@@ -32,7 +32,6 @@ import net.novagamestudios.kaffeekasse.ui.kaffeekasse.components.cards.BasicCard
import net.novagamestudios.kaffeekasse.ui.kaffeekasse.components.cards.CategoryCard
import net.novagamestudios.kaffeekasse.ui.kaffeekasse.components.cards.LazyBasicCardGrid
import net.novagamestudios.kaffeekasse.ui.theme.ifAnimationsEnabled
import net.novagamestudios.common_utils.voyager.BackNavigationHandler
class CategorizedItemsState(
items: Iterable<Item>
......@@ -179,7 +178,7 @@ private fun ItemCard(
cart: MutableCart
) {
val coroutineScope = rememberCoroutineScope()
val userSettings by settings().userSettings.collectAsState()
val userSettings by settings().userSettings
val context = LocalContext.current
net.novagamestudios.kaffeekasse.ui.kaffeekasse.components.cards.ItemCard(
item = item,
......
......@@ -56,6 +56,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.core.logging.Logger
import net.novagamestudios.common_utils.core.logging.info
import net.novagamestudios.common_utils.core.logging.warn
import net.novagamestudios.kaffeekasse.KaffeekasseModule.Companion.kaffeekasseCartProvider
import net.novagamestudios.kaffeekasse.api.kaffeekasse.model.APIPurchaseAccount
......@@ -70,11 +71,12 @@ 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.PurchaseController.WrappedAccount
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.collectAsRichStateIn
import net.novagamestudios.kaffeekasse.util.richdata.awaitDataOrNull
import net.novagamestudios.kaffeekasse.util.richdata.collectAsRichStateHere
import net.novagamestudios.kaffeekasse.util.richdata.combineRich
import net.novagamestudios.kaffeekasse.util.richdata.dataOrNull
import net.novagamestudios.kaffeekasse.util.richdata.mapRich
......@@ -102,19 +104,24 @@ class CheckoutState<PA : PurchaseAccount> private constructor(
var indicateSuccess by mutableStateOf(false)
val accountSelectionState: RichDataState<AccountSelectionState<PA>> by lazy {
private val wrappedAccountSelectionFlow: RichDataFlow<AccountSelectionState<WrappedAccount<PA>>> by lazy {
purchaseController
.purchaseAccounts
.mapRich { accounts -> accounts.sortedBy { it.name } }
.mapRich { accounts ->
val sorted = accounts.sortedBy { it.account.name }
val selected = sorted.withIndex().firstOrNull { it.value.isDefault }?.index
?: sorted.withIndex().firstOrNull { it.value.account.name.firstLast == session.realUser.displayName }?.index
?: -1
AccountSelectionState(
accounts = accounts.map { it.account },
initialIndex = accounts.indexOfFirst { it.isDefault }
accounts = sorted,
initialIndex = selected
)
}
.collectAsRichStateIn(this)
}
val accountSelectionState: RichDataState<AccountSelectionState<PurchaseAccount>> get() = wrappedAccountSelectionFlow
.collectAsRichStateHere()
fun refreshPurchaseAccounts() {
launch {
loadingMutex.trueWhile {
......@@ -124,13 +131,23 @@ class CheckoutState<PA : PurchaseAccount> private constructor(
}
fun submitCart(logoutAfter: Boolean = false) {
if (isCheckoutLoading) return
if (cart.isEmpty()) return
val purchaseAccount = accountSelectionState.dataOrNull?.selectedAccount ?: return
if (isCheckoutLoading) {
warn { "Checkout already in progress, not submitting" }
return
}
if (cart.isEmpty()) {
warn { "Cart is empty, not submitting" }
return
}
launch {
val success = loadingMutex.trueWhile {
val purchaseAccount = wrappedAccountSelectionFlow.awaitDataOrNull()?.selectedAccount ?: run {
warn { "No purchase account loaded or selected, not aborting" }
toasts.short("Bitte Konto wählen")
return@trueWhile false
}
try {
purchaseController.performPurchase(purchaseAccount, cart)
purchaseController.performPurchase(purchaseAccount.account, cart)
true
} catch (e: Exception) {
warn(e) { "Failed to submit cart" }
......@@ -179,14 +196,14 @@ class CheckoutState<PA : PurchaseAccount> private constructor(
}
private interface PurchaseController<PA : PurchaseAccount> {
val purchaseAccounts: RichDataFlow<List<Account<PA>>>
val purchaseAccounts: RichDataFlow<List<WrappedAccount<PA>>>
suspend fun refreshPurchaseAccountsIfNeeded()
suspend fun performPurchase(purchaseAccount: PA, cart: Cart)
data class Account<out PA : PurchaseAccount>(
data class WrappedAccount<out PA : PurchaseAccount>(
val account: PA,
val isDefault: Boolean
) : PurchaseAccount by account
override val isDefault: Boolean
) : PurchaseAccount by account, PurchaseAccount.WithDefault
}
private class ScraperPurchaseController(
......@@ -194,9 +211,9 @@ private class ScraperPurchaseController(
private val kaffeekasse: KaffeekasseRepository
) : PurchaseController<ScraperPurchaseAccount> {
private val manualBillAccounts = kaffeekasse.manualBillAccounts[user]
override val purchaseAccounts: RichDataFlow<List<Account<ScraperPurchaseAccount>>> = manualBillAccounts.mapRich { accounts ->
override val purchaseAccounts: RichDataFlow<List<WrappedAccount<ScraperPurchaseAccount>>> = manualBillAccounts.mapRich { accounts ->
accounts.map { account ->
Account(
WrappedAccount(
account = account,
isDefault = account.isDefault
)
......@@ -253,7 +270,7 @@ private class APIPurchaseController(
) { scraperAccounts, apiAccounts ->
scraperAccounts.mapNotNull { scraperAccount ->
apiAccounts.firstOrNull { it.name == scraperAccount.name }?.let { apiAccount ->
Account(
WrappedAccount(
account = apiAccount,
isDefault = scraperAccount.isDefault
)
......@@ -294,7 +311,10 @@ fun Checkout(
itemCount = state.cart.itemCount,
onCheckout = { state.showModal = true },
onInstantCheckout = if (state.hasInstantCheckout) {
{ state.submitCart(logoutAfter = true) }
{
state.info { "User clicked instant checkout" }
state.submitCart(logoutAfter = true)
}
} else null
)
}
......@@ -304,7 +324,7 @@ fun Checkout(
state.refreshPurchaseAccounts()
}
CheckoutModal(
accountSelectionState = state.accountSelectionState.dataOrNull ?: AccountSelectionState(emptyList()),
accountSelectionState = state.accountSelectionState.dataOrNull ?: AccountSelectionState(emptyList(), -1),
cart = state.cart,
onSubmit = { state.submitCart() },
onDismissRequest = { state.showModal = false },
......