diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index d77bfe3e2e6c1541f2b4f90311a34e8a392542b6..1390fc958e7876ead85d2f9ad864b2dd363ad7b6 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -7,11 +7,11 @@
 # Read more about this script on this blog post https://about.gitlab.com/2018/10/24/setting-up-gitlab-ci-for-android-projects/, by Jason Lenny
 # If you are interested in using Android with FastLane for publishing take a look at the Android-Fastlane template.
 
-image: eclipse-temurin:17-jdk-jammy
+image: eclipse-temurin:19-jdk-jammy
 
 variables:
 
-  APP_VERSION: "1.1.1"
+  APP_VERSION: "1.2.0"
   APP_APK: "kaffeekasse-${APP_VERSION}.apk"
   PACKAGE_REGISTRY_URL: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/kaffeekasse/${APP_VERSION}"
 
diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml
index 7643783a82f60b3b876fe58a9314fb50520df486..f2cb48a1df0b9c6a3f10bef0ffef5b4d1154c888 100644
--- a/.idea/codeStyles/Project.xml
+++ b/.idea/codeStyles/Project.xml
@@ -1,5 +1,6 @@
 <component name="ProjectCodeStyleConfiguration">
   <code_scheme name="Project" version="173">
+    <option name="RIGHT_MARGIN" value="130" />
     <JetCodeStyleSettings>
       <option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
     </JetCodeStyleSettings>
diff --git a/.idea/compiler.xml b/.idea/compiler.xml
index b589d56e9f285d8cfdc6c270853a5d439021a278..e58d3e423fd6ba1f41f8b449410b7a5c9b2a5695 100644
--- a/.idea/compiler.xml
+++ b/.idea/compiler.xml
@@ -1,6 +1,6 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <project version="4">
   <component name="CompilerConfiguration">
-    <bytecodeTargetLevel target="17" />
+    <bytecodeTargetLevel target="19" />
   </component>
 </project>
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index 0897082f7512e48e89310db81b5455d997417505..8f1b960d1647c16b49204dafeda373864f7f7c7c 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -5,7 +5,7 @@
     <option name="linkedExternalProjectsSettings">
       <GradleProjectSettings>
         <option name="externalProjectPath" value="$PROJECT_DIR$" />
-        <option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
+        <option name="gradleJvm" value="19" />
         <option name="modules">
           <set>
             <option value="$PROJECT_DIR$" />
diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml
index f8467b458e43862c587a34f34c06af8fbbf30d0f..e805548aaa85edd33b0785865442784b18e6b3cb 100644
--- a/.idea/kotlinc.xml
+++ b/.idea/kotlinc.xml
@@ -1,6 +1,6 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <project version="4">
   <component name="KotlinJpsPluginSettings">
-    <option name="version" value="1.9.10" />
+    <option name="version" value="1.9.20" />
   </component>
 </project>
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
index 2188b52ba32001c9c693512cc725ce6c1d331efd..5f04613c84047e3fcff7491e61f09c4ebe5c1245 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -5,7 +5,7 @@
     </list>
   </component>
   <component name="ExternalStorageConfigurationManager" enabled="true" />
-  <component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="jbr-17" project-jdk-type="JavaSDK">
+  <component name="ProjectRootManager" version="2" languageLevel="JDK_19" default="true" project-jdk-name="19" project-jdk-type="JavaSDK">
     <output url="file://$PROJECT_DIR$/build/classes" />
   </component>
   <component name="ProjectType">
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 4c46ec60914ea8174a89c9f7b7b368c2ceafef10..8028776344108a9f7f09896f70e6dab541dc69bb 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,9 +1,11 @@
-import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties
+import com.android.build.api.dsl.ApplicationBuildType
 
 plugins {
+    alias(libs.plugins.gradle.versions)
     alias(libs.plugins.android.application)
     alias(libs.plugins.kotlin.android)
     alias(libs.plugins.kotlinx.serialization)
+    alias(libs.plugins.dokka)
 }
 
 android {
@@ -16,7 +18,7 @@ android {
         minSdk = 26
         targetSdk = 34
         versionCode = 1
-        versionName = "1.1.1"
+        versionName = "1.2.0"
 
         // Check that the version in environment matches the version in build.gradle.kts
         System.getenv()["APP_VERSION"]?.let { versionFromEnv ->
@@ -32,55 +34,79 @@ android {
         }
     }
     buildTypes {
-        release {
+        fun ApplicationBuildType.debugCredentials(
+            portalUsername: Any?,
+            portalPassword: Any?,
+            kaffeekasseDeviceId: Any?,
+            kaffeekasseApiKey: Any?
+        ) {
+            fun Any?.toStringField() = this?.let { "\"$it\"" } ?: "null"
+            buildConfigField("String", "I11_PORTAL_DEBUG_USERNAME", portalUsername.toStringField())
+            buildConfigField("String", "I11_PORTAL_DEBUG_PASSWORD", portalPassword.toStringField())
+            buildConfigField("String", "I11_KAFFEEKASSE_DEBUG_DEVICEID", kaffeekasseDeviceId.toStringField())
+            buildConfigField("String", "I11_KAFFEEKASSE_DEBUG_APIKEY", kaffeekasseApiKey.toStringField())
+        }
+
+        fun ApplicationBuildType.releaseConfig() {
+//            isMinifyEnabled = true
+//            isShrinkResources = true
             proguardFiles(
                 getDefaultProguardFile("proguard-android-optimize.txt"),
                 "proguard-rules.pro"
             )
-            buildConfigField("String", "I11PORTAL_DEBUG_USERNAME", "null")
-            buildConfigField("String", "I11PORTAL_DEBUG_PASSWORD", "null")
+            debugCredentials(null, null, null, null)
         }
-        debug {
-            applicationIdSuffix = ".debug"
+
+        fun ApplicationBuildType.debugConfig() {
             proguardFiles(
                 "proguard-rules.pro"
             )
-            signingConfig = signingConfigs.getByName("debug")
-            gradleLocalProperties(rootDir).let { properties ->
-                var username = properties["i11portal.debug.username"]
-                var password = properties["i11portal.debug.password"]
-                if (username == null || password == null) {
-                    username = null
-                    password = null
-                }
-                buildConfigField("String", "I11PORTAL_DEBUG_USERNAME", username?.let { "\"$it\"" } ?: "null")
-                buildConfigField("String", "I11PORTAL_DEBUG_PASSWORD", password?.let { "\"$it\"" } ?: "null")
+            com.android.build.gradle.internal.cxx.configure.gradleLocalProperties(rootDir).let { properties ->
+                debugCredentials(
+                    properties["i11.portal.debug.username"],
+                    properties["i11.portal.debug.password"],
+                    properties["i11.kaffeekasse.debug.deviceid"],
+                    properties["i11.kaffeekasse.debug.apikey"]
+                )
             }
         }
+
+        release {
+            releaseConfig()
+        }
+        create("releasePreview") {
+            releaseConfig()
+            applicationIdSuffix = ".debug"
+            signingConfig = signingConfigs.getByName("debug")
+        }
+        debug {
+            debugConfig()
+            applicationIdSuffix = ".debug"
+            signingConfig = signingConfigs.getByName("debug")
+        }
     }
     compileOptions {
-        sourceCompatibility = JavaVersion.VERSION_17
-        targetCompatibility = JavaVersion.VERSION_17
+        sourceCompatibility = JavaVersion.VERSION_19
+        targetCompatibility = JavaVersion.VERSION_19
     }
     kotlinOptions {
-        jvmTarget = "17"
+        jvmTarget = "19"
         freeCompilerArgs += "-Xcontext-receivers"
 //        freeCompilerArgs += "-opt-in=kotlin.ExperimentalStdlibApi"
 //        freeCompilerArgs += "-opt-in=kotlin.time.ExperimentalTime"
-        freeCompilerArgs += "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi"
+//        freeCompilerArgs += "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi"
         freeCompilerArgs += "-opt-in=androidx.compose.ui.ExperimentalComposeUiApi"
         freeCompilerArgs += "-opt-in=androidx.compose.foundation.ExperimentalFoundationApi"
 //        freeCompilerArgs += "-opt-in=androidx.compose.foundation.layout.ExperimentalLayoutApi"
 //        freeCompilerArgs += "-opt-in=androidx.compose.animation.ExperimentalAnimationApi"
         freeCompilerArgs += "-opt-in=androidx.compose.material3.ExperimentalMaterial3Api"
-        freeCompilerArgs += "-opt-in=com.google.accompanist.navigation.material.ExperimentalMaterialNavigationApi"
     }
     buildFeatures {
         compose = true
         buildConfig = true
     }
     composeOptions {
-        kotlinCompilerExtensionVersion = "1.5.3"
+        kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get()
     }
     packaging {
         resources {
@@ -130,14 +156,31 @@ dependencies {
     implementation(libs.voyager.tabnavigator)
     implementation(libs.voyager.transitions)
 
+    implementation(libs.coil)
+    implementation(libs.coil.compose)
+
+    implementation(libs.acra.mail)
+    implementation(libs.acra.limiter)
+
     implementation(libs.commonutils)
 
 
-//    testImplementation("junit:junit:4.13.2")
+    dokkaPlugin(libs.dokka.android)
+
+    testImplementation("junit:junit:4.13.2")
 //    androidTestImplementation("androidx.test.ext:junit:1.1.5")
 //    androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
 //    androidTestImplementation(platform("androidx.compose:compose-bom:2023.10.01"))
 //    androidTestImplementation("androidx.compose.ui:ui-test-junit4")
-//    debugImplementation("androidx.compose.ui:ui-tooling")
-//    debugImplementation("androidx.compose.ui:ui-test-manifest")
-}
\ No newline at end of file
+    debugImplementation("androidx.compose.ui:ui-tooling")
+    debugImplementation("androidx.compose.ui:ui-test-manifest")
+}
+
+tasks.withType<com.github.benmanes.gradle.versions.updates.DependencyUpdatesTask> {
+    rejectVersionIf { isUnstable(candidate.version) }
+}
+
+fun isUnstable(version: String): Boolean {
+    val normalizedVersion = version.lowercase()
+    return listOf("snapshot", "alpha", "beta", "rc", "m", "dev").any { it in normalizedVersion }
+}
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/App.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/App.kt
index bea2a3cb18faa797d140f338da70806a300fd829..1eb22a529fdbad99e6a8565de183fe6ec53df7ec 100644
--- a/app/src/main/java/net/novagamestudios/kaffeekasse/App.kt
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/App.kt
@@ -1,102 +1,147 @@
 package net.novagamestudios.kaffeekasse
 
+import android.app.Activity
 import android.app.Application
+import android.content.Context
 import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisallowComposableCalls
-import androidx.compose.runtime.remember
 import androidx.credentials.CredentialManager
-import androidx.lifecycle.ViewModelProvider
-import androidx.lifecycle.viewmodel.CreationExtras
-import cafe.adriel.voyager.core.concurrent.ThreadSafeMap
-import cafe.adriel.voyager.core.model.ScreenModel
-import cafe.adriel.voyager.core.model.rememberNavigatorScreenModel
-import cafe.adriel.voyager.core.platform.multiplatformName
-import cafe.adriel.voyager.navigator.LocalNavigator
-import cafe.adriel.voyager.navigator.currentOrThrow
 import kotlinx.coroutines.CoroutineName
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.MainScope
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.plus
+import kotlinx.coroutines.runBlocking
 import net.novagamestudios.common_utils.Logger
 import net.novagamestudios.common_utils.compose.application
-import net.novagamestudios.common_utils.compose.state.loadInitialBlocking
-import net.novagamestudios.common_utils.debug
+import net.novagamestudios.common_utils.error
 import net.novagamestudios.common_utils.info
 import net.novagamestudios.common_utils.toastShort
-import net.novagamestudios.kaffeekasse.repositories.UpdateController
+import net.novagamestudios.common_utils.warn
+import net.novagamestudios.kaffeekasse.api.hiwi_tracker.HiwiTrackerAPI
+import net.novagamestudios.kaffeekasse.api.hiwi_tracker.HiwiTrackerScraper
+import net.novagamestudios.kaffeekasse.api.kaffeekasse.KaffeekasseAPI
+import net.novagamestudios.kaffeekasse.api.kaffeekasse.KaffeekasseScraper
+import net.novagamestudios.kaffeekasse.api.portal.PortalClient
 import net.novagamestudios.kaffeekasse.gitlab.GitLab
-import net.novagamestudios.kaffeekasse.repositories.GitLabReleases
-import net.novagamestudios.kaffeekasse.repositories.I11Client
-import net.novagamestudios.kaffeekasse.repositories.LoginCredentials
+import net.novagamestudios.kaffeekasse.repositories.Credentials
+import net.novagamestudios.kaffeekasse.repositories.LoginRepository
+import net.novagamestudios.kaffeekasse.repositories.RepositoryProvider
+import net.novagamestudios.kaffeekasse.repositories.SettingsRepository
+import net.novagamestudios.kaffeekasse.repositories.UpdateController
+import net.novagamestudios.kaffeekasse.repositories.i11.HiwiTrackerRepository
+import net.novagamestudios.kaffeekasse.repositories.i11.KaffeekasseRepository
+import net.novagamestudios.kaffeekasse.repositories.i11.PortalRepository
 import net.novagamestudios.kaffeekasse.repositories.newSettingsStore
+import net.novagamestudios.kaffeekasse.repositories.releases.GitLabReleases
+import net.novagamestudios.kaffeekasse.ui.util.derived
+
 
+class App : Application(), RepositoryProvider, CoroutineScope by MainScope() + CoroutineName(App::class.simpleName!!), Logger {
 
-class App : Application(), CoroutineScope by MainScope() + CoroutineName(App::class.simpleName!!), Logger {
+    override fun attachBaseContext(base: Context?) {
+        super.attachBaseContext(base)
+        CrashHandling.init()
+    }
 
     override fun onCreate() {
         super.onCreate()
-        instanceOrNull = this
         info { "App instance available" }
-        settingsStore.loadInitialBlocking()
-        launch {
-            try {
-                releases.fetchNewerReleases()
-            } catch (e: Throwable) {
-                debug(e) { "Failed to fetch newer releases" }
-                toastShort("Failed to check for updates: ${e.message}")
-            }
+        runBlocking { loadInitialSettings() }
+        launch { CrashHandling.syncInformation() }
+        launch { tryAutoLoginDevice() }
+        launch { fetchNewerReleases() }
+    }
+
+    private suspend fun loadInitialSettings() {
+        try {
+            settingsRepository.loadInitial()
+        } catch (e: Throwable) {
+            error(e) { "Failed to load initial settings" }
+            toastShort("Failed to load settings: ${e.message}")
+            throw e
         }
-//        ScreenRegistry {
-//            modules.forEach { it.applyScreenModule() }
-//        }
     }
 
+    private suspend fun tryAutoLoginDevice() {
+        try {
+            loginRepository.tryAutoLoginDevice()
+        } catch (e: Throwable) {
+            warn(e) { "Failed to auto-login device" }
+            toastShort("Failed to auto-login device: ${e.message}")
+        }
+    }
 
+    private suspend fun fetchNewerReleases() {
+        try {
+            releases.fetchNewerReleases()
+        } catch (e: Throwable) {
+            warn(e) { "Failed to fetch newer releases" }
+            toastShort("Failed to check for updates: ${e.message}")
+        }
+    }
 
-    companion object : Logger {
-        var instanceOrNull: App? = null
-            private set
+    companion object {
+        @Composable fun settings(): SettingsRepository = app().settingsRepository
 
-        val globalScreenModels = ThreadSafeMap<String, ScreenModel>()
-        @Composable
-        inline fun <reified T : ScreenModel> globalScreenModel(
-            tag: String? = null,
-            crossinline factory: @DisallowComposableCalls App.() -> T
-        ): T = with(app()) {
-            val key = "${T::class.multiplatformName}:${tag ?: "default"}"
-            remember(key) {
-                globalScreenModels.getOrPut(key) { factory() } as T
-            }
-        }
-        @Composable
-        inline fun <reified T : ScreenModel> navigatorScreenModel(
-            crossinline factory: @DisallowComposableCalls App.() -> T
-        ): T = with(app()) {
-            LocalNavigator.currentOrThrow.rememberNavigatorScreenModel { factory() }
-        }
+        val developerMode: Boolean @Composable get() = settings().derived { developerMode }.value
     }
 
-    val settingsStore = newSettingsStore(this)
-    val loginCredentials by lazy { LoginCredentials(CredentialManager.create(this)) }
+
+    // Settings stuff
+    override val settingsRepository = SettingsRepository(
+        repositoryProvider = this,
+        settingsStore = newSettingsStore(this)
+    )
+    override val credentials by lazy {
+        Credentials(
+            credentialManager = CredentialManager.create(this),
+            settingsStore = settingsRepository
+        )
+    }
+
+    // Update stuff
     val gitLab = GitLab(
         instanceUrl = "https://git.rwth-aachen.de",
         projectPath = "jonas.broeckmann/kaffeekasse",
         projectId = 95637
     )
-    val releases = GitLabReleases(gitLab)
-    val updateController = UpdateController(this)
-    val i11Client = I11Client(this)
+    override val releases = GitLabReleases(gitLab)
+    override val updateController = UpdateController(this)
 
-    val modules = AppModules(
-        KaffeekasseModule,
-        HiwiTrackerModule
+    // I11 stuff
+    private val portalClient = PortalClient(this)
+    override val kaffeekasseRepository = KaffeekasseRepository(
+        coroutineScope = this,
+        api = KaffeekasseAPI(portalClient),
+        scraper = KaffeekasseScraper(portalClient)
+    )
+    override val hiwiTrackerRepository = HiwiTrackerRepository(
+        coroutineScope = this,
+        api = HiwiTrackerAPI(portalClient),
+        scraper = HiwiTrackerScraper(portalClient)
+    )
+    override val portalRepository = PortalRepository(
+        coroutineScope = this,
+        portalClient,
+        kaffeekasseRepository,
+        hiwiTrackerRepository
+    )
+    override val loginRepository = LoginRepository(
+        credentialsRepository = credentials,
+        settings = settingsRepository,
+        portal = portalRepository,
+        kaffeekasse = kaffeekasseRepository
+    )
+
+    // Active app modules
+    override val modules = AppModules(
+        KaffeekasseModule(),
+        HiwiTrackerModule()
     )
 }
 
+val Activity.app get() = application as App
 
 @Composable
 fun app() = application<App>()
 
-
-
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/AppModule.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/AppModule.kt
index 9f76141691452c4cc5a4f3d12546825c56041700..c3ad6c1810afd9b73e073c4c3789ab986478b8e9 100644
--- a/app/src/main/java/net/novagamestudios/kaffeekasse/AppModule.kt
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/AppModule.kt
@@ -1,28 +1,24 @@
 package net.novagamestudios.kaffeekasse
 
 import androidx.compose.runtime.mutableStateMapOf
-import cafe.adriel.voyager.navigator.tab.Tab
 import net.novagamestudios.kaffeekasse.model.kaffeekasse.Cart
-import net.novagamestudios.kaffeekasse.model.kaffeekasse.ManualBillDetails
+import net.novagamestudios.kaffeekasse.model.kaffeekasse.Item
 import net.novagamestudios.kaffeekasse.model.kaffeekasse.MutableCart
-import net.novagamestudios.kaffeekasse.ui.HiwiTrackerNavigation
-import net.novagamestudios.kaffeekasse.ui.KaffeekasseNavigation
+import net.novagamestudios.kaffeekasse.repositories.RepositoryProvider
 
 
 sealed interface AppModule {
     val id: String
-    val navigationTab: Tab
 }
 
 
-data object KaffeekasseModule : AppModule {
+class KaffeekasseModule : AppModule {
     override val id = KaffeekasseModule::class.simpleName!!
-    override val navigationTab = KaffeekasseNavigation
 
     val cart: MutableCart = MutableCartImpl()
 
     private class MutableCartImpl : MutableCart {
-        private val content = mutableStateMapOf<ManualBillDetails.Item, Int>()
+        private val content = mutableStateMapOf<Item, Int>()
         override fun iterator(): Iterator<Cart.Entry> = object : Iterator<Cart.Entry> {
             private val iterator = content.iterator()
             override fun hasNext() = iterator.hasNext()
@@ -30,35 +26,40 @@ data object KaffeekasseModule : AppModule {
                 Cart.Entry(item, count)
             }
         }
-        override operator fun get(item: ManualBillDetails.Item) = content[item] ?: 0
-        override operator fun plusAssign(item: ManualBillDetails.Item) {
+        override operator fun get(item: Item) = content[item] ?: 0
+        override operator fun plusAssign(item: Item) {
             content[item] = (content[item] ?: 0) + 1
         }
-        override operator fun minusAssign(item: ManualBillDetails.Item) {
+        override operator fun minusAssign(item: Item) {
             val count = (content[item] ?: 0) - 1
             if (count < 1) content -= item
             else content[item] = count
         }
-        override fun removeAll(item: ManualBillDetails.Item) {
+        override fun removeAll(item: Item) {
             content -= item
         }
         override fun clear() {
             content.clear()
         }
-        override operator fun contains(item: ManualBillDetails.Item) = item in content
+        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
+    }
 }
 
-data object HiwiTrackerModule : AppModule {
+class HiwiTrackerModule : AppModule {
     override val id = HiwiTrackerModule::class.simpleName!!
-    override val navigationTab = HiwiTrackerNavigation
 }
 
 
 class AppModules(
-    vararg modules: AppModule
-) : List<AppModule> by modules.toList() {
+    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/CrashHandling.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/CrashHandling.kt
new file mode 100644
index 0000000000000000000000000000000000000000..9ca1598aeeea0ffbfb2552584d22a329a8ae996b
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/CrashHandling.kt
@@ -0,0 +1,78 @@
+package net.novagamestudios.kaffeekasse
+
+import android.app.Application
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import cafe.adriel.voyager.navigator.Navigator
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.supervisorScope
+import kotlinx.serialization.ExperimentalSerializationApi
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import net.novagamestudios.kaffeekasse.repositories.RepositoryProvider
+import net.novagamestudios.kaffeekasse.repositories.Settings
+import org.acra.ACRA
+import org.acra.config.limiter
+import org.acra.config.mailSender
+import org.acra.data.StringFormat
+import org.acra.ktx.initAcra
+
+
+object CrashHandling {
+    private const val ReportEmail = "broeckmann@embedded.rwth-aachen.de"
+
+    context (Application)
+    fun init() {
+        if (BuildConfig.DEBUG) return
+        initAcra {
+            buildConfigClass = BuildConfig::class.java
+            reportFormat = StringFormat.JSON
+            mailSender {
+                val appName = getString(R.string.app_name)
+                mailTo = ReportEmail
+
+                reportAsFile = true
+                reportFileName = "${appName.uppercase()}-CRASH-REPORT.json"
+
+                subject = "$appName Crash Report"
+                body = """
+                    Whoops! It seems like $appName crashed.
+                    Please help us to improve the app by sending this crash report.
+                """.trimIndent()
+            }
+            limiter {
+                enabled = true
+            }
+        }
+    }
+
+    @OptIn(ExperimentalSerializationApi::class)
+    private val jsonFormat = Json {
+        allowStructuredMapKeys = true
+        ignoreUnknownKeys = true
+        explicitNulls = false
+        serializersModule = Settings.serializersModule
+    }
+
+    context (RepositoryProvider)
+    suspend fun syncInformation() = supervisorScope {
+        launch {
+            settingsRepository.values.collectLatest {
+                ACRA.errorReporter.putCustomData("CurrentSettings", jsonFormat.encodeToString(it))
+            }
+        }
+        launch {
+            portalRepository.session.collectLatest {
+                ACRA.errorReporter.putCustomData("CurrentPortalSession", "$it")
+            }
+        }
+    }
+
+    @Composable
+    fun updateNavigation(navigator: Navigator) {
+        LaunchedEffect(navigator.lastItem) {
+            ACRA.errorReporter.putCustomData("CurrentNavigationScreen", "${navigator.lastItem::class.qualifiedName}")
+        }
+    }
+}
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/MainActivity.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/MainActivity.kt
index 798eca549c96192f9e135514e7a5e895d80a65a1..eea4033c523d7321680c6790fb64a36c77c3c7ce 100644
--- a/app/src/main/java/net/novagamestudios/kaffeekasse/MainActivity.kt
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/MainActivity.kt
@@ -1,56 +1,64 @@
 package net.novagamestudios.kaffeekasse
 
+import android.annotation.SuppressLint
+import android.os.Build
 import android.os.Bundle
+import android.view.WindowInsets
 import androidx.activity.ComponentActivity
 import androidx.activity.compose.setContent
-import androidx.activity.viewModels
-import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.runtime.CompositionLocalProvider
-import androidx.compose.ui.Modifier
-import androidx.lifecycle.ViewModelStoreOwner
-import androidx.lifecycle.viewmodel.CreationExtras
-import androidx.lifecycle.viewmodel.MutableCreationExtras
-import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
-import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsControllerCompat
+import androidx.lifecycle.lifecycleScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.launch
 import net.novagamestudios.common_utils.LocalLogger
-import net.novagamestudios.common_utils.Logger
-import net.novagamestudios.common_utils.error
-import net.novagamestudios.common_utils.info
-import net.novagamestudios.kaffeekasse.repositories.LocalSettingsStore
+import net.novagamestudios.kaffeekasse.repositories.SettingsRepository
 import net.novagamestudios.kaffeekasse.ui.App
-import net.novagamestudios.kaffeekasse.ui.AppViewModel
-import net.novagamestudios.kaffeekasse.ui.theme.KaffeekasseTheme
-import kotlin.time.measureTimedValue
+import net.novagamestudios.kaffeekasse.ui.util.removeScrollableTabRowMinimumTabWidth
 
 class MainActivity : ComponentActivity() {
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
+        syncFullscreenWithSettings(app.settingsRepository)
         removeScrollableTabRowMinimumTabWidth()
         setContent {
-            val app = app()
-            CompositionLocalProvider(
-                LocalLogger provides app,
-                LocalSettingsStore provides app.settingsStore
-            ) {
-                KaffeekasseTheme {
-                    App()
-                }
+            CompositionLocalProvider(LocalLogger provides app()) {
+                App()
             }
         }
     }
 
-    private companion object : Logger {
-        // See https://issuetracker.google.com/issues/226665301
-        private fun removeScrollableTabRowMinimumTabWidth() {
-            try {
-                Class
-                    .forName("androidx.compose.material3.TabRowKt")
-                    .getDeclaredField("ScrollableTabRowMinimumTabWidth").apply {
-                        isAccessible = true
-                    }.set(this, 0f)
-            } catch (e: Exception) {
-                error(e) { "Failed to remove ScrollableTabRowMinimumTabWidth" }
+    @SuppressLint("WrongConstant")
+    private fun syncFullscreenWithSettings(settingsRepository: SettingsRepository): Job {
+        val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView)
+        windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
+
+        fun hideSystemUI() {
+            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+                windowInsetsController.hide(WindowInsets.Type.systemBars())
+            }
+        }
+
+        fun showSystemUI() {
+            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+                windowInsetsController.show(WindowInsets.Type.systemBars())
             }
         }
+
+        return lifecycleScope.launch {
+            settingsRepository.values
+                .map { it.fullscreen }
+                .distinctUntilChanged()
+                .collect { fullscreen ->
+                    if (fullscreen) {
+                        hideSystemUI()
+                    } else {
+                        showSystemUI()
+                    }
+                }
+        }
     }
 }
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/UpdateReciever.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/UpdateReciever.kt
index 3da5af65bdc15febc1ff549d1212a00ba994e7cb..46ad143eccb57661a96b92ff1863e586451e9eab 100644
--- a/app/src/main/java/net/novagamestudios/kaffeekasse/UpdateReciever.kt
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/UpdateReciever.kt
@@ -10,7 +10,7 @@ import net.novagamestudios.common_utils.info
 class UpdateReceiver : BroadcastReceiver(), Logger {
     override fun onReceive(context: Context, intent: Intent) {
         info { "Received update intent: $intent" }
-        val application = context.applicationContext as App
-        application.updateController.handleInstallStatus(intent)
+        val app = context.applicationContext as App
+        app.updateController.handleInstallStatus(intent)
     }
 }
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/api/hiwi_tracker/HiwiTrackerAPI.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/api/hiwi_tracker/HiwiTrackerAPI.kt
new file mode 100644
index 0000000000000000000000000000000000000000..be4b62b1a3a49b38d72322f6dfd43c32c536eefc
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/api/hiwi_tracker/HiwiTrackerAPI.kt
@@ -0,0 +1,21 @@
+package net.novagamestudios.kaffeekasse.api.hiwi_tracker
+
+import io.ktor.http.parameters
+import kotlinx.datetime.format
+import net.novagamestudios.common_utils.Logger
+import net.novagamestudios.kaffeekasse.api.portal.PortalClient
+import net.novagamestudios.kaffeekasse.api.hiwi_tracker.model.MonthDataResponse
+import net.novagamestudios.kaffeekasse.model.hiwi_tracker.MonthKey
+
+
+class HiwiTrackerAPI(
+    client: PortalClient
+) : PortalClient.Module(client, "hiwi"), Logger {
+    suspend fun fetchDataForMonth(month: MonthKey): MonthDataResponse = with(client) {
+        requestAPI(
+            urlParameters = parameters {
+                append("selectdate", month.toLocalDate().format(HiwiTrackerFormats.DateFormat))
+            }
+        ).decodeAs<MonthDataResponse>()
+    }
+}
\ No newline at end of file
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/api/hiwi_tracker/HiwiTrackerFormats.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/api/hiwi_tracker/HiwiTrackerFormats.kt
new file mode 100644
index 0000000000000000000000000000000000000000..dc945da3be0663599c64cf619dc0616ad68d9b4d
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/api/hiwi_tracker/HiwiTrackerFormats.kt
@@ -0,0 +1,27 @@
+package net.novagamestudios.kaffeekasse.api.hiwi_tracker
+
+import kotlinx.datetime.LocalDate
+import kotlinx.datetime.LocalTime
+import kotlinx.datetime.format.char
+import kotlin.time.Duration
+
+
+internal object HiwiTrackerFormats {
+    val DateFormat = LocalDate.Format {
+        year()
+        char('-')
+        monthNumber()
+        char('-')
+        dayOfMonth()
+    }
+    val TimeFormat = LocalTime.Format {
+        hour()
+        char(':')
+        minute()
+    }
+    fun Duration.apiFormat() = toComponents { hours, minutes, _, _ ->
+        "%02d:%02d".format(hours, minutes)
+    }
+}
+
+
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/api/hiwi_tracker/HiwiTrackerScraper.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/api/hiwi_tracker/HiwiTrackerScraper.kt
new file mode 100644
index 0000000000000000000000000000000000000000..8fb656fdc607e86b049f0ea5cbc2621f02528170
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/api/hiwi_tracker/HiwiTrackerScraper.kt
@@ -0,0 +1,33 @@
+package net.novagamestudios.kaffeekasse.api.hiwi_tracker
+
+import io.ktor.http.URLBuilder
+import io.ktor.http.parameters
+import kotlinx.datetime.format
+import net.novagamestudios.kaffeekasse.api.portal.PortalClient
+import net.novagamestudios.kaffeekasse.model.hiwi_tracker.WorkEntry
+
+class HiwiTrackerScraper(
+    client: PortalClient
+) : PortalClient.Module(client, "hiwi") {
+    suspend fun submitWorkEntry(workEntry: WorkEntry): Unit = with(client) {
+        post(
+            moduleUrl,
+            formParameters = parameters {
+                append("hw_date", workEntry.date.format(HiwiTrackerFormats.DateFormat))
+                append("hw_begin", workEntry.begin.format(HiwiTrackerFormats.TimeFormat))
+                append("hw_end", workEntry.end.format(HiwiTrackerFormats.TimeFormat))
+                append("hw_breaktime", with(HiwiTrackerFormats) { workEntry.breakDurationOrNull?.apiFormat() ?: "" })
+                append("hw_note", workEntry.note)
+                append("savetimes", "")
+            }
+        )
+    }
+
+    suspend fun deleteWorkEntry(workEntryId: Int): Unit = with(client) {
+        post(
+            URLBuilder(moduleUrl).apply {
+                parameters.append("deleteentry", "$workEntryId")
+            }.build(),
+        )
+    }
+}
\ No newline at end of file
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/model/i11_portal/api/HiwiTrackerMonthData.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/api/hiwi_tracker/model/MonthDataResponse.kt
similarity index 91%
rename from app/src/main/java/net/novagamestudios/kaffeekasse/model/i11_portal/api/HiwiTrackerMonthData.kt
rename to app/src/main/java/net/novagamestudios/kaffeekasse/api/hiwi_tracker/model/MonthDataResponse.kt
index ea07693f48ce06de25e68409a18e9858f2a58478..3b735e22345f0b4044e8c1a43fe601fc6293de0a 100644
--- a/app/src/main/java/net/novagamestudios/kaffeekasse/model/i11_portal/api/HiwiTrackerMonthData.kt
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/api/hiwi_tracker/model/MonthDataResponse.kt
@@ -1,4 +1,4 @@
-package net.novagamestudios.kaffeekasse.model.i11_portal.api
+package net.novagamestudios.kaffeekasse.api.hiwi_tracker.model
 
 import kotlinx.serialization.KSerializer
 import kotlinx.serialization.SerialName
@@ -8,9 +8,9 @@ import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
 import kotlinx.serialization.descriptors.SerialDescriptor
 import kotlinx.serialization.encoding.Decoder
 import kotlinx.serialization.encoding.Encoder
-import kotlinx.serialization.json.JsonElement
-import net.novagamestudios.kaffeekasse.model.ISODate
-import net.novagamestudios.kaffeekasse.model.ISOTime
+import net.novagamestudios.kaffeekasse.api.portal.model.PortalAPIResponse
+import net.novagamestudios.kaffeekasse.model.date_time.ISODate
+import net.novagamestudios.kaffeekasse.model.date_time.ISOTime
 import net.novagamestudios.kaffeekasse.model.hiwi_tracker.WorkEntry
 import kotlin.math.absoluteValue
 import kotlin.time.Duration
@@ -21,14 +21,9 @@ import kotlin.time.Duration.Companion.seconds
 import kotlin.time.times
 
 @Serializable
-data class HiwiTrackerMonthData(
-    override val session: I11PortalData.Session,
-    override val infos: List<String>,
-    override val warnings: List<String>,
-    override val errors: List<String>,
-    override val navigation: JsonElement,
-    @SerialName("ajaxui")
-    override val ajaxUI: I11PortalData.AjaxUI? = null,
+data class MonthDataResponse(
+    @SerialName("session")
+    val session: PortalAPIResponse.Session,
     val calendar: Map<ISODate, CalendarEntry>,
     @SerialName("monthname")
     val monthName: String,
@@ -51,7 +46,7 @@ data class HiwiTrackerMonthData(
     val reset: Boolean,
     @SerialName("hidedatecaption")
     val hideDateCaption: Boolean
-) : I11PortalData {
+) {
     @Serializable
     data class CalendarEntry(
         val disabled: Boolean,
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/api/kaffeekasse/KaffeekasseAPI.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/api/kaffeekasse/KaffeekasseAPI.kt
new file mode 100644
index 0000000000000000000000000000000000000000..8e8d1767ed7315d71e32616eef0c59a7b902aa4e
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/api/kaffeekasse/KaffeekasseAPI.kt
@@ -0,0 +1,210 @@
+package net.novagamestudios.kaffeekasse.api.kaffeekasse
+
+import io.ktor.http.Parameters
+import io.ktor.http.parameters
+import io.ktor.util.sha1
+import net.novagamestudios.common_utils.Logger
+import net.novagamestudios.common_utils.debug
+import net.novagamestudios.common_utils.error
+import net.novagamestudios.kaffeekasse.api.portal.PortalClient
+import net.novagamestudios.kaffeekasse.api.kaffeekasse.model.ItemListResponse
+import net.novagamestudios.kaffeekasse.api.kaffeekasse.model.KaffeekasseAPIResponse
+import net.novagamestudios.kaffeekasse.api.kaffeekasse.model.LoggedInUserResponse
+import net.novagamestudios.kaffeekasse.api.kaffeekasse.model.LoginDeviceResponse
+import net.novagamestudios.kaffeekasse.api.kaffeekasse.model.LoginUserResponse
+import net.novagamestudios.kaffeekasse.api.kaffeekasse.model.LogoutUserResponse
+import net.novagamestudios.kaffeekasse.api.kaffeekasse.model.PurchaseResponse
+import net.novagamestudios.kaffeekasse.api.kaffeekasse.model.UserInfoResponse
+import net.novagamestudios.kaffeekasse.api.kaffeekasse.model.UserListResponse
+import net.novagamestudios.kaffeekasse.api.kaffeekasse.model.KaffeekasseAPIResponse.Error.Code as ErrorCode
+
+class KaffeekasseAPI(
+    client: PortalClient
+) : PortalClient.Module(client, "kaffeekasse"), Logger {
+
+    private suspend inline fun <reified R> invokeFunction(
+        name: String?,
+        arguments: List<Pair<String, String>>,
+        updateSession: Boolean = false,
+        validateLoggedIn: Boolean = true
+    ): Result<R> = with(client) {
+        debug { """Invoking ${name?.let { "\"$it\"" }} with arguments $arguments""" }
+        val raw = requestAPI(
+            urlParameters = parameters { if (name != null) append(name, "") },
+            formParameters = Parameters.build { arguments.forEach { (key, value) -> append(key, value) } },
+            updateSession = updateSession
+        )
+        val response = raw.decodeAs<KaffeekasseAPIResponse>()
+        with(response) {
+            error?.takeIf { it.isError }?.let { error { "Error code ${it.code}: ${it.string}" } }
+        }
+        return when {
+            response.hasError -> Result.Error(response)
+            validateLoggedIn && !response.session.isLoggedIn -> {
+                debug { "Not logged in" }
+                Result.NotLoggedIn(response)
+            }
+            else -> {
+                val result = try {
+                    raw.decodeAs<R>()
+                } catch (e: Exception) {
+                    error(e) { "Failed to decode response as ${R::class.qualifiedName}" }
+                    throw e
+                }
+                Result.Success(response, result)
+            }
+        }
+    }
+
+    sealed interface Result<T> {
+        val response: KaffeekasseAPIResponse
+        val errorCode: ErrorCode? get() = response.error?.code
+
+        data class Error<T>(override val response: KaffeekasseAPIResponse) : Result<T>
+        data class NotLoggedIn<T>(override val response: KaffeekasseAPIResponse) : Result<T>
+        data class Success<T>(override val response: KaffeekasseAPIResponse, val result: T) : Result<T>
+    }
+
+    suspend fun loggedInUser() = invokeFunction<LoggedInUserResponse>(
+        name = null,
+        emptyList()
+    )
+
+
+    suspend fun userList() = invokeFunction<UserListResponse>(
+        name = "user_list",
+        emptyList()
+    )
+
+    suspend fun userInfo(userId: Int) = invokeFunction<UserInfoResponse>(
+        name = "user_info",
+        listOf("user_id" to "$userId")
+    )
+
+    suspend fun itemList(itemTypeId: Int? = null) = invokeFunction<ItemListResponse>(
+        name = "item_list",
+        listOfNotNull(itemTypeId?.let { "item_type_id" to "$it" })
+    )
+
+
+    private suspend fun loginDevice(deviceId: String, challengeResponse: String? = null) = invokeFunction<LoginDeviceResponse>(
+        name = "login_device",
+        listOfNotNull(
+            "id" to deviceId,
+            challengeResponse?.let { "response" to it }
+        ),
+        updateSession = true,
+        validateLoggedIn = false
+    )
+
+    @OptIn(ExperimentalStdlibApi::class)
+    suspend fun performDeviceLogin(deviceId: String, apiKey: String): DeviceLoginResult {
+        // Phase 1
+        val challenge = when (val response = loginDevice(deviceId)) {
+            is Result.Error -> when (response.errorCode) {
+                ErrorCode.InvalidFieldValue -> return DeviceLoginResult.Failure.InvalidDeviceId
+                ErrorCode.AccessDenied -> return DeviceLoginResult.Failure.AccessDenied
+                else -> return DeviceLoginResult.Failure.UnknownError(response.response)
+            }
+            is Result.NotLoggedIn -> throw AssertionError("Not logged in")
+            is Result.Success -> response.result.challenge
+                ?: return DeviceLoginResult.Failure.UnknownError(response.response)
+        }
+
+        // Phase 2 (useless, but yolo)
+        val hash = sha1((challenge + apiKey).encodeToByteArray())
+            .toHexString(HexFormat { upperCase = false })
+        return when (val response = loginDevice(deviceId, hash)) {
+            is Result.Error -> when (response.errorCode) {
+                ErrorCode.AuthenticationFailure -> DeviceLoginResult.Failure.AuthenticationFailure
+                else -> DeviceLoginResult.Failure.UnknownError(response.response)
+            }
+            is Result.NotLoggedIn -> throw AssertionError("Not logged in")
+            is Result.Success -> DeviceLoginResult.LoggedIn(response.result)
+        }
+    }
+
+    sealed interface DeviceLoginResult {
+        sealed interface Failure : DeviceLoginResult {
+            data object InvalidDeviceId : Failure
+            data object AccessDenied : Failure
+            data object AuthenticationFailure : Failure
+            data class UnknownError(val response: KaffeekasseAPIResponse) : Failure
+        }
+        data class LoggedIn(val response: LoginDeviceResponse) : DeviceLoginResult
+    }
+
+
+    private suspend fun loginUser(
+        userId: Int,
+        pin: String? = null,
+        rwthId: String? = null,
+        key: String? = null
+    ): Result<LoginUserResponse> {
+        val authList = listOfNotNull(
+            pin?.let { "pin" to it },
+            rwthId?.let { "rwth_id" to it },
+            key?.let { "key" to it }
+        )
+        require(authList.size <= 1) { "More than one auth type specified" }
+        val auth = authList.firstOrNull()
+        return invokeFunction<LoginUserResponse>(
+            name = "login_user",
+            listOfNotNull(
+                "user_id" to "$userId",
+                auth?.let { "auth_type" to it.first },
+                auth?.let { it.first to it.second }
+            ),
+            updateSession = true,
+            validateLoggedIn = false
+        )
+    }
+
+    suspend fun performUserLogin(
+        userId: Int,
+        pin: String? = null,
+        rwthId: String? = null,
+        key: String? = null
+    ): UserLoginResult {
+        return when (val response = loginUser(userId, pin, rwthId, key)) {
+            is Result.Error -> when (response.errorCode) {
+                ErrorCode.InvalidAction -> UserLoginResult.Failure.PrivateDevice
+                ErrorCode.AuthenticationFailure -> UserLoginResult.Failure.UserAuthenticationFailure
+                else -> UserLoginResult.Failure.UnknownError(response.response)
+            }
+            is Result.NotLoggedIn -> throw AssertionError("Not logged in")
+            is Result.Success -> UserLoginResult.LoggedIn(response.result)
+        }
+    }
+
+    sealed interface UserLoginResult {
+        sealed interface Failure : UserLoginResult {
+            data object PrivateDevice : Failure
+            data object UserAuthenticationFailure : Failure
+            data class UnknownError(val response: KaffeekasseAPIResponse) : Failure
+        }
+        data class LoggedIn(val response: LoginUserResponse) : UserLoginResult
+    }
+
+
+    suspend fun logoutUser() = invokeFunction<LogoutUserResponse>(
+        name = "logout_user",
+        emptyList(),
+        updateSession = true,
+        validateLoggedIn = false
+    )
+
+
+    suspend fun purchase(
+        itemId: Int,
+        count: Int,
+        targetUserId: Int? = null
+    ): Result<PurchaseResponse> = invokeFunction<PurchaseResponse>(
+        name = "purchase",
+        listOfNotNull(
+            "item_id" to "$itemId",
+            "item_count" to "$count",
+            targetUserId?.let { "other_user_id" to "$it" }
+        )
+    )
+}
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/api/kaffeekasse/KaffeekasseScraper.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/api/kaffeekasse/KaffeekasseScraper.kt
new file mode 100644
index 0000000000000000000000000000000000000000..76b51fb1264977914fa6ea4245b18263d3721a5d
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/api/kaffeekasse/KaffeekasseScraper.kt
@@ -0,0 +1,190 @@
+package net.novagamestudios.kaffeekasse.api.kaffeekasse
+
+import io.ktor.http.URLBuilder
+import io.ktor.http.parameters
+import it.skrape.selects.Doc
+import it.skrape.selects.DocElement
+import it.skrape.selects.attribute
+import it.skrape.selects.html5.a
+import it.skrape.selects.html5.form
+import it.skrape.selects.html5.option
+import it.skrape.selects.html5.select
+import it.skrape.selects.html5.table
+import it.skrape.selects.html5.td
+import it.skrape.selects.html5.tr
+import net.novagamestudios.common_utils.info
+import net.novagamestudios.kaffeekasse.api.portal.PortalClient
+import net.novagamestudios.kaffeekasse.model.kaffeekasse.Cart
+import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem
+import net.novagamestudios.kaffeekasse.model.kaffeekasse.ManualBillDetails
+import net.novagamestudios.kaffeekasse.model.kaffeekasse.Name.Companion.toNameOrNull
+import net.novagamestudios.kaffeekasse.model.kaffeekasse.ScraperPurchaseAccount
+import net.novagamestudios.kaffeekasse.model.kaffeekasse.Transaction
+import net.novagamestudios.kaffeekasse.model.kaffeekasse.isEmpty
+import java.time.LocalDateTime
+import java.time.format.DateTimeFormatter
+
+class KaffeekasseScraper(
+    client: PortalClient
+) : PortalClient.Module(client, "kaffeekasse") {
+
+    private val manualBillUrl = URLBuilder(moduleUrl).apply {
+        parameters.append("manualbill", "")
+    }.build()
+    val transactionsUrl = URLBuilder(moduleUrl).apply {
+        parameters.append("balance", "")
+    }.build()
+
+    suspend fun manualBillDetails(): ManualBillDetails = with(client) {
+        scrapePage(manualBillUrl) {
+            scrapeManualBillDetails()
+        }
+    }
+
+    suspend fun transactions(): List<Transaction> = with(client) {
+        scrapePage(transactionsUrl) {
+            scrapeTransactions()
+        }
+    }
+
+    suspend fun submitCart(account: ScraperPurchaseAccount, cart: Cart): Unit = with(client) {
+        if (cart.isEmpty()) return
+        post(
+            manualBillUrl,
+            formParameters = parameters {
+                cart.forEach { (item, count) ->
+                    info { "adding @ $account : $item x $count" }
+                    append("tdata[account_id][]", "${account.id}")
+                    append("tdata[item_id][]", "${item.id}")
+                    append("tdata[item_count][]", "$count")
+                    append("manualbill", "")
+                    append("lockedfrom", "1")
+                }
+            }
+        )
+    }
+}
+
+private fun Doc.scrapeManualBillDetails(): ManualBillDetails = form {
+    table {
+        val accounts = select {
+            withId = "manual_account_id"
+            option {
+                findAll {
+                    mapNotNull { option ->
+                        if (option.hasAttribute("disabled")) null
+                        else if (option.attribute("value").isBlank()) null
+                        else ManualBillDetails.PurchaseAccount(
+                            name = option.text.let {
+                                requireNotNull(it.toNameOrNull()) { "Invalid account name: $it" }
+                            },
+                            id = option.attribute("value").let {
+                                requireNotNull(it.toIntOrNull()) { "Invalid account id: $it" }
+                            },
+                            isDefault = option.attribute("selected") == "selected"
+                        )
+                    }
+                }
+            }
+        }
+        val groups = select {
+            withId = "manual_item_id"
+            option {
+                findAll {
+                    val groups = mutableListOf<ManualBillDetails.ItemGroup>()
+                    var currentGroup: String? = null
+                    var currentItems = mutableListOf<ManualBillDetails.Item>()
+                    for (option in this) {
+                        if (option.hasAttribute("disabled")) {
+                            if (currentGroup != null) {
+                                groups += ManualBillDetails.ItemGroup(
+                                    currentGroup,
+                                    currentItems
+                                )
+                            }
+                            currentGroup = option.text
+                            currentItems = mutableListOf()
+                        } else if (option.attribute("value").isBlank()) continue
+                        else {
+                            val id = option.attribute("value").let {
+                                requireNotNull(it.toIntOrNull()) { "Invalid item id: $it" }
+                            }
+                            val name = option.text
+                            val knownItems = KnownItem.byId[id]
+                            if (knownItems != null) knownItems.forEach {
+                                currentItems += ManualBillDetails.Item(
+                                    originalName = name,
+                                    id = id,
+                                    knownItem = it
+                                )
+                            } else {
+                                currentItems += ManualBillDetails.Item(
+                                    originalName = option.text,
+                                    id = id
+                                )
+                            }
+                        }
+                    }
+                    if (currentGroup != null) {
+                        groups += ManualBillDetails.ItemGroup(
+                            currentGroup,
+                            currentItems
+                        )
+                    }
+                    groups
+                }
+            }
+        }
+        ManualBillDetails(accounts, groups)
+    }
+}
+
+private fun Doc.scrapeTransactions(): List<Transaction> = table {
+    withClass = "transactions"
+    tr {
+        findAll {
+            mapNotNull { row ->
+                if (row.children.any { it.tagName == "th" }) return@mapNotNull null
+                row.scrapeTransactionRow()
+            }
+        }
+    }
+}
+private fun String.euros() = removeSuffix("€").toDouble()
+private fun DocElement.scrapeTransactionRow(): Transaction? = td {
+    findAll {
+        if (size != 7) return@findAll null
+        // Example: 2024-02-26 13:05:21
+        val dateTime = LocalDateTime.parse(
+            this[0].text.trim(),
+            DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
+        )
+        val payer = this[1].text.trim().takeUnless { it == "-" }
+        val payee = this[2].text.trim()
+        val purpose = this[3].text.parseTransactionPurpose()
+        val total = this[4].text.trim().euros()
+        val newBalance = this[5].text.trim().euros()
+        val refundId = this[6].a { attribute("href") }.substringAfterLast("=")
+        Transaction(
+            date = dateTime,
+            payer = payer,
+            payee = payee,
+            purpose = purpose,
+            total = total,
+            newBalance = newBalance,
+            refundId = refundId
+        )
+    }
+}
+private fun String.parseTransactionPurpose(): Transaction.Purpose {
+    val trimmed = trim()
+    return when (trimmed.lowercase()) {
+        "einzahlung" -> Transaction.Purpose.Deposit
+        else -> {
+            val match = "([0-9]+)x (.+) à (-?[0-9.]+€?)".toRegex().matchEntire(trimmed)
+                ?: return Transaction.Purpose.Other(trimmed)
+            val (count, itemName, unitPrice) = match.destructured
+            Transaction.Purpose.Purchase(itemName, count, unitPrice.euros())
+        }
+    }
+}
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/api/kaffeekasse/model/APIPurchaseAccount.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/api/kaffeekasse/model/APIPurchaseAccount.kt
new file mode 100644
index 0000000000000000000000000000000000000000..d1a4905a827a08678b7de54575997f1235d8d3ec
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/api/kaffeekasse/model/APIPurchaseAccount.kt
@@ -0,0 +1,5 @@
+package net.novagamestudios.kaffeekasse.api.kaffeekasse.model
+
+import net.novagamestudios.kaffeekasse.model.kaffeekasse.PurchaseAccount
+
+sealed interface APIPurchaseAccount : PurchaseAccount
\ No newline at end of file
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/api/kaffeekasse/model/BasicUserInfo.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/api/kaffeekasse/model/BasicUserInfo.kt
new file mode 100644
index 0000000000000000000000000000000000000000..3e2b94425645f533a870493706a34dd241d342b3
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/api/kaffeekasse/model/BasicUserInfo.kt
@@ -0,0 +1,18 @@
+package net.novagamestudios.kaffeekasse.api.kaffeekasse.model
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import net.novagamestudios.kaffeekasse.model.kaffeekasse.Name
+import net.novagamestudios.kaffeekasse.util.IntAsBooleanSerializer
+
+@Serializable
+data class BasicUserInfo(
+    @SerialName("id")
+    override val id: Int,
+    @SerialName("name")
+    override val name: Name,
+    @SerialName("empty_pin")
+    val noPinSet: @Serializable(with = IntAsBooleanSerializer::class) Boolean? = null
+) : APIPurchaseAccount {
+    val mayHavePin get() = noPinSet == null || !noPinSet
+}
\ No newline at end of file
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/api/kaffeekasse/model/BlackWhiteListUserInfo.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/api/kaffeekasse/model/BlackWhiteListUserInfo.kt
new file mode 100644
index 0000000000000000000000000000000000000000..af64c210b240e8dbeae7abf46d10533a81ec949e
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/api/kaffeekasse/model/BlackWhiteListUserInfo.kt
@@ -0,0 +1,13 @@
+package net.novagamestudios.kaffeekasse.api.kaffeekasse.model
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import net.novagamestudios.kaffeekasse.model.kaffeekasse.Name
+
+@Serializable
+data class BlackWhiteListUserInfo(
+    @SerialName("target_id")
+    override val id: Int,
+    @SerialName("name")
+    override val name: Name
+) : APIPurchaseAccount
\ No newline at end of file
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/api/kaffeekasse/model/ItemListResponse.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/api/kaffeekasse/model/ItemListResponse.kt
new file mode 100644
index 0000000000000000000000000000000000000000..6960eb3b2a2b9624719b51d7ddf9c36e683a3891
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/api/kaffeekasse/model/ItemListResponse.kt
@@ -0,0 +1,32 @@
+package net.novagamestudios.kaffeekasse.api.kaffeekasse.model
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.JsonElement
+import net.novagamestudios.kaffeekasse.util.IntAsBooleanSerializer
+
+@Serializable
+data class ItemListResponse(
+    @SerialName("item_list")
+    val itemList: List<Item>
+) {
+    @Serializable
+    data class Item(
+        @SerialName("item_id")
+        val id: Int,
+        @SerialName("name")
+        val originalName: String,
+        val gtin: String? = null,
+        val price: Double,
+        @SerialName("image_url")
+        val imageUrl: String? = null,
+        val sort: Int,
+        @SerialName("sort_p")
+        val sortP: Double,
+        @SerialName("itemtype_id")
+        val itemTypeId: Int,
+        val enabled: @Serializable(with = IntAsBooleanSerializer::class) Boolean,
+        @SerialName("has_condition_reports")
+        val hasConditionReports: JsonElement
+    )
+}
\ No newline at end of file
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/api/kaffeekasse/model/KaffeekasseAPIResponse.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/api/kaffeekasse/model/KaffeekasseAPIResponse.kt
new file mode 100644
index 0000000000000000000000000000000000000000..ec300f9d0f65a4f085c931623ccbf38bdca89eda
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/api/kaffeekasse/model/KaffeekasseAPIResponse.kt
@@ -0,0 +1,42 @@
+package net.novagamestudios.kaffeekasse.api.kaffeekasse.model
+
+import kotlinx.serialization.Serializable
+import net.novagamestudios.kaffeekasse.api.portal.model.PortalAPIResponse
+
+
+@Serializable
+data class KaffeekasseAPIResponse(
+    val error: Error? = null
+) : PortalAPIResponse() {
+    @Serializable
+    data class Error(
+        val code: Code,
+        val string: String
+    ) {
+        @Serializable
+        @JvmInline
+        value class Code(val value: Long) {
+            companion object {
+                val NoError = Code(0)
+                val Unknown = Code(1)
+                val AccessDenied = Code(3)
+                val UnsupportedAuthMethod = Code(4)
+                val AuthenticationFailure = Code(5)
+                val MethodDisabled = Code(6)
+                val AuthenticationRequired = Code(7)
+                val InvalidFieldValue = Code(207)
+                val MissingFieldValue = Code(208)
+                val NotFound = Code(209)
+                val InvalidAction = Code(210)
+                val NotImplemented = Code(0xDEADBEEF)
+                val NoCodeYet = Code(0xEA7DEADBEEF)
+            }
+        }
+        val isError get() = code != Code.NoError
+    }
+    val hasError get() = error?.takeIf { it.isError } != null || errors.isNotEmpty()
+}
+
+
+
+
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/api/kaffeekasse/model/LoggedInUserResponse.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/api/kaffeekasse/model/LoggedInUserResponse.kt
new file mode 100644
index 0000000000000000000000000000000000000000..fd24ec875f6c7c375eab3bf442d0a8a8a62932a0
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/api/kaffeekasse/model/LoggedInUserResponse.kt
@@ -0,0 +1,37 @@
+package net.novagamestudios.kaffeekasse.api.kaffeekasse.model
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import net.novagamestudios.kaffeekasse.api.portal.model.PortalAPIResponse
+
+
+@Serializable
+data class LoggedInUserResponse(
+    @SerialName("session")
+    val session: PortalAPIResponse.Session,
+    @SerialName("Kontostand")
+    val balance: Balance
+) {
+    @Serializable
+    data class Balance(
+        @SerialName("mybalance")
+        val myBalance: MyBalance
+    ) {
+        @Serializable
+        data class MyBalance(
+            @SerialName("user_id")
+            val userId: Int,
+            val user: String,
+            val outgoing: Double,
+            val incoming: Double,
+            val paid: Double,
+            val refunded: Double,
+            val deposited: Double,
+            val withdrawn: Double,
+            val total: Double
+        ) {
+            val firstName by lazy { user.split(", ")[1] }
+            val lastName by lazy { user.split(", ")[0] }
+        }
+    }
+}
\ No newline at end of file
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/api/kaffeekasse/model/LoginDeviceResponse.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/api/kaffeekasse/model/LoginDeviceResponse.kt
new file mode 100644
index 0000000000000000000000000000000000000000..b1fc40956494a25a05226e4ce35adb78ac45de0c
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/api/kaffeekasse/model/LoginDeviceResponse.kt
@@ -0,0 +1,18 @@
+package net.novagamestudios.kaffeekasse.api.kaffeekasse.model
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class LoginDeviceResponse(
+    val challenge: String? = null,
+    val name: String? = null,
+    @SerialName("item_type_id")
+    val itemTypeId: Int? = null
+)
+
+
+
+
+
+
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/api/kaffeekasse/model/LoginUserResponse.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/api/kaffeekasse/model/LoginUserResponse.kt
new file mode 100644
index 0000000000000000000000000000000000000000..19e763274d09e122016c716a58d86d360595f38b
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/api/kaffeekasse/model/LoginUserResponse.kt
@@ -0,0 +1,12 @@
+package net.novagamestudios.kaffeekasse.api.kaffeekasse.model
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+
+@Serializable
+data class LoginUserResponse(
+    @SerialName("user_id")
+    val userId: Int
+)
+
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/api/kaffeekasse/model/LogoutUserResponse.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/api/kaffeekasse/model/LogoutUserResponse.kt
new file mode 100644
index 0000000000000000000000000000000000000000..6dfc8e632bf98c69bd06ccf7ec6f5a5d7d605420
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/api/kaffeekasse/model/LogoutUserResponse.kt
@@ -0,0 +1,6 @@
+package net.novagamestudios.kaffeekasse.api.kaffeekasse.model
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+class LogoutUserResponse
\ No newline at end of file
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/api/kaffeekasse/model/PurchaseResponse.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/api/kaffeekasse/model/PurchaseResponse.kt
new file mode 100644
index 0000000000000000000000000000000000000000..803abc8d9349456be1e70aad5b4dc4ca729d01aa
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/api/kaffeekasse/model/PurchaseResponse.kt
@@ -0,0 +1,8 @@
+package net.novagamestudios.kaffeekasse.api.kaffeekasse.model
+
+import kotlinx.serialization.Serializable
+
+
+@Serializable
+class PurchaseResponse
+
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/api/kaffeekasse/model/UserInfoResponse.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/api/kaffeekasse/model/UserInfoResponse.kt
new file mode 100644
index 0000000000000000000000000000000000000000..ac1e31fc7b302c0d34a1854d6e5962d63ee170c8
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/api/kaffeekasse/model/UserInfoResponse.kt
@@ -0,0 +1,32 @@
+package net.novagamestudios.kaffeekasse.api.kaffeekasse.model
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.JsonArray
+import net.novagamestudios.kaffeekasse.model.kaffeekasse.Name
+
+@Serializable
+data class UserInfoResponse(
+    @SerialName("user_id")
+    override val id: Int,
+    @SerialName("name")
+    override val name: Name,
+    @SerialName("balance")
+    override val balance: Double,
+    @SerialName("favorites")
+    override val favorites: JsonArray = JsonArray(emptyList()),
+    @SerialName("blacklist")
+    override val blacklist: List<BlackWhiteListUserInfo>? = null,
+    @SerialName("whitelist")
+    override val whitelist: List<BlackWhiteListUserInfo>? = null
+) : ExtendedUserInfo
+
+sealed interface ExtendedUserInfo : APIPurchaseAccount {
+    override val id: Int
+    override val name: Name
+    val balance: Double
+    val favorites: JsonArray
+    val blacklist: List<BlackWhiteListUserInfo>?
+    val whitelist: List<BlackWhiteListUserInfo>?
+}
+
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/api/kaffeekasse/model/UserListResponse.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/api/kaffeekasse/model/UserListResponse.kt
new file mode 100644
index 0000000000000000000000000000000000000000..61a49462c5db46ea7f8b59135f951adb8109a642
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/api/kaffeekasse/model/UserListResponse.kt
@@ -0,0 +1,10 @@
+package net.novagamestudios.kaffeekasse.api.kaffeekasse.model
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class UserListResponse(
+    @SerialName("user_list")
+    val userList: List<BasicUserInfo>
+)
\ No newline at end of file
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/api/portal/PortalClient.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/api/portal/PortalClient.kt
new file mode 100644
index 0000000000000000000000000000000000000000..4922e8c8c86e285552ce73f1c32e6ac867988b7c
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/api/portal/PortalClient.kt
@@ -0,0 +1,184 @@
+package net.novagamestudios.kaffeekasse.api.portal
+
+import androidx.compose.runtime.mutableStateListOf
+import io.ktor.client.HttpClient
+import io.ktor.client.call.body
+import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
+import io.ktor.client.plugins.cookies.HttpCookies
+import io.ktor.client.request.HttpRequestBuilder
+import io.ktor.client.request.forms.FormDataContent
+import io.ktor.client.request.request
+import io.ktor.client.request.setBody
+import io.ktor.client.statement.HttpResponse
+import io.ktor.client.statement.bodyAsText
+import io.ktor.http.ContentType
+import io.ktor.http.HttpMethod
+import io.ktor.http.Parameters
+import io.ktor.http.URLBuilder
+import io.ktor.http.Url
+import io.ktor.http.parameters
+import io.ktor.serialization.kotlinx.json.json
+import it.skrape.core.htmlDocument
+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
+import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.decodeFromJsonElement
+import net.novagamestudios.common_utils.Logger
+import net.novagamestudios.common_utils.debug
+import net.novagamestudios.common_utils.error
+import net.novagamestudios.common_utils.info
+import net.novagamestudios.common_utils.verbose
+import net.novagamestudios.common_utils.warn
+import net.novagamestudios.kaffeekasse.api.portal.model.PortalAPIResponse
+import net.novagamestudios.kaffeekasse.model.credentials.Login
+import net.novagamestudios.kaffeekasse.model.credentials.isValid
+import net.novagamestudios.kaffeekasse.util.MutableCookiesStorage
+
+class PortalClient(
+    coroutineScope: CoroutineScope
+) : Logger {
+    private val baseUrl = Url("https://embedded.rwth-aachen.de")
+
+    @OptIn(ExperimentalCoroutinesApi::class)
+    private val computationScope = coroutineScope.newCoroutineContext(Dispatchers.IO)
+
+    private val jsonFormat by lazy {
+        Json {
+            prettyPrint = true
+            isLenient = true
+            ignoreUnknownKeys = true
+        }
+    }
+    private val cookiesStorage by lazy { MutableCookiesStorage(mutableStateListOf()) }
+    private val client = HttpClient {
+        install(HttpCookies) {
+            storage = cookiesStorage
+        }
+        install(ContentNegotiation) {
+            json(
+                jsonFormat,
+                contentType = ContentType.Text.Any // Uses "text/json"
+            )
+        }
+    }
+
+    internal inline fun <reified T> JsonElement.decodeAs() = jsonFormat.decodeFromJsonElement<T>(this)
+
+
+    private val portal = object : Module(this, "portal") {
+        val currentSession = MutableStateFlow(PortalAPIResponse.Session())
+        suspend fun updateSession(): Unit = withContext(computationScope) {
+            debug { "Updating session" }
+            val response = requestAPI(updateSession = false).decodeAs<PortalAPIResponse>()
+            debug { "Session: ${response.session}" }
+            currentSession.value = response.session
+        }
+    }
+
+    val session get() = portal.currentSession.asStateFlow()
+
+    suspend fun login(login: Login) {
+        require(login.isValid) { "Invalid login" }
+        if (session.value.username == login.username) return
+        debug { "Logging in as ${login.username}" }
+        post(
+            portal.moduleUrl,
+            parameters {
+                append("username", login.username)
+                append("password", login.password)
+                append("login", "")
+            },
+            updateSession = true
+        )
+        debug { "Logged in: ${session.value.isLoggedIn}" }
+    }
+
+    suspend fun logoutAll() {
+        cookiesStorage.clear(baseUrl)
+        portal.updateSession()
+    }
+
+
+    private suspend fun request(
+        url: Url,
+        updateSession: Boolean = false,
+        block: HttpRequestBuilder.() -> Unit = {}
+    ): HttpResponse = withContext(computationScope) {
+        debug { """Making request to "$url"""" }
+        val httpResponse = client.request(url, block)
+        debug { """Response: ${httpResponse.status}""" }
+        if (updateSession) portal.updateSession()
+        httpResponse
+    }
+
+    context (Module)
+    internal suspend fun requestAPI(
+        urlParameters: Parameters = Parameters.Empty,
+        formParameters: Parameters? = null,
+        method: HttpMethod = HttpMethod.Post,
+        updateSession: Boolean = false,
+        block: HttpRequestBuilder.() -> Unit = {}
+    ): JsonObject {
+        val url = URLBuilder(moduleUrl).apply {
+            parameters.append("json", "")
+            parameters.appendAll(urlParameters)
+        }.build()
+        val httpResponse = request(url, updateSession) {
+            this.method = method
+            if (formParameters != null) {
+                if (method == HttpMethod.Get) throw IllegalArgumentException("Cannot send form parameters with GET request")
+                setBody(FormDataContent(formParameters))
+            }
+            block()
+        }
+
+        val rawJson = httpResponse.body<JsonObject>()
+        val apiResponse = rawJson.decodeAs<PortalAPIResponse>()
+        with(apiResponse) {
+            errors.forEach { error { it } }
+            warnings.forEach { warn { it } }
+            infos.forEach { info { it } }
+        }
+        verbose { "Raw json: $rawJson" }
+        return rawJson
+    }
+
+    internal suspend fun post(
+        url: Url,
+        formParameters: Parameters = Parameters.Empty,
+        updateSession: Boolean = false,
+        block: HttpRequestBuilder.() -> Unit = {}
+    ): HttpResponse = request(url, updateSession) {
+        method = HttpMethod.Post
+        setBody(FormDataContent(formParameters))
+        block()
+    }
+
+    internal suspend fun <R> scrapePage(
+        url: Url,
+        block: HttpRequestBuilder.() -> Unit = {},
+        scrape: Doc.() -> R
+    ): R = withContext(computationScope) {
+        val response = request(url) {
+            method = HttpMethod.Get
+            block()
+        }
+        htmlDocument(response.bodyAsText()) { scrape() }
+    }
+
+    abstract class Module internal constructor(
+        val client: PortalClient,
+        protected val name: String
+    ) {
+        internal val moduleUrl by lazy { URLBuilder(client.baseUrl).apply { host = "$name.$host" }.build() }
+        protected val computationScope get() = client.computationScope
+    }
+}
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/api/portal/model/PortalAPIResponse.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/api/portal/model/PortalAPIResponse.kt
new file mode 100644
index 0000000000000000000000000000000000000000..e7c8ebb00863b1dbb0a4d60bf3537f534a38fdb8
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/api/portal/model/PortalAPIResponse.kt
@@ -0,0 +1,34 @@
+package net.novagamestudios.kaffeekasse.api.portal.model
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.JsonNull
+import net.novagamestudios.kaffeekasse.model.session.User
+
+@Serializable
+open class PortalAPIResponse {
+    @SerialName("session")
+    val session: Session = Session()
+    val infos: List<String> = emptyList()
+    val warnings: List<String> = emptyList()
+    val errors: List<String> = emptyList()
+    @Suppress("unused")
+    val navigation: JsonElement = JsonNull
+    @Suppress("unused")
+    @SerialName("ajaxui")
+    val ajaxUI: AjaxUI? = null
+    @Serializable
+    data class Session(
+        @SerialName("login")
+        val isLoggedIn: Boolean = false,
+        @SerialName("user")
+        val username: String? = null,
+        @SerialName("displayname")
+        val displayName: String? = null
+    )
+    @Serializable
+    data class AjaxUI(
+        val reload: Boolean?
+    )
+}
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/data/KnownItems.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/data/KnownItems.kt
index a9ba73864e6ed080e826df68a3af44d47acee9b2..e8614e72cb7012d3ceb972e14d8ecc5493442c7c 100644
--- a/app/src/main/java/net/novagamestudios/kaffeekasse/data/KnownItems.kt
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/data/KnownItems.kt
@@ -1,84 +1,98 @@
 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.KnownItemGroup
 
 
-val Category.cleanName get() = when (this) {
-    Category.ColdBeverage -> "Kaltgetränke"
-    Category.HotBeverage -> "Heißgetränke"
-    Category.Snack -> "Snacks"
-    Category.IceCream -> "Eis"
-    Category.Food -> "Essen"
-    Category.Fruit -> "Obst"
-    Category.Other -> "Sonstiges"
+val KnownItemGroup.cleanName get() = when (this) {
+    KnownItemGroup.Kueche -> "Küche"
+    KnownItemGroup.Getraenkeraum -> "Getränkeraum"
+    KnownItemGroup.Farbdrucker -> "Farbdrucker"
+}
+
+val ItemCategory.cleanName get() = when (this) {
+    ItemCategory.ColdBeverage -> "Kaltgetränke"
+    ItemCategory.HotBeverage -> "Heißgetränke"
+    ItemCategory.Snack -> "Snacks"
+    ItemCategory.IceCream -> "Eis"
+    ItemCategory.Food -> "Essen"
+    ItemCategory.Fruit -> "Obst"
+    ItemCategory.Other -> "Sonstiges"
 }
 
 val KnownItem.category get() = when (this) {
-    Almdudler -> Category.ColdBeverage
-    Bier330 -> Category.ColdBeverage
-    Bier500 -> Category.ColdBeverage
-    ClubMate -> Category.ColdBeverage
-    CocaCola -> Category.ColdBeverage
-    CokeZero -> Category.ColdBeverage
-    CokeZeroCaffeineFree -> Category.ColdBeverage
-    ChouffeBier -> Category.ColdBeverage
-    EiflerLandbier -> Category.ColdBeverage
-    EngelbertApple -> Category.ColdBeverage
-    EngelbertNatural -> Category.ColdBeverage
-    EngelbertSprudel -> Category.ColdBeverage
-    RheinfelsSprudel -> Category.ColdBeverage
-    ErdingerAlcoholFree500 -> Category.ColdBeverage
-    Fanta -> Category.ColdBeverage
-    Fassbrause -> Category.ColdBeverage
-    Bionade -> Category.ColdBeverage
-    Gerolsteiner -> Category.ColdBeverage
-    ErdingerAlcoholFree330 -> Category.ColdBeverage
-    Leffe -> Category.ColdBeverage
-    Rockstar -> Category.ColdBeverage
-    Monster -> Category.ColdBeverage
-    CocaCola05Glass -> Category.ColdBeverage
-    MioMioMate -> Category.ColdBeverage
-    MilkMachine -> Category.HotBeverage
-    CafeAuLait -> Category.HotBeverage
-    Cappuccino -> Category.HotBeverage
-    Espresso -> Category.HotBeverage
-    CoffeeSmall -> Category.HotBeverage
-    CoffeeLarge -> Category.HotBeverage
-    LatteMacchiato -> Category.HotBeverage
-    Moccachoc -> Category.HotBeverage
-    ChocolateCreamCocoa -> Category.HotBeverage
-    Erdnuesse -> Category.Snack
-    Hanuta -> Category.Snack
-    HariboBag -> Category.Snack
-    KnoppersBar -> Category.Snack
-    Milka -> Category.Snack
-    Balisto -> Category.Snack
-    Duplo -> Category.Snack
-    OreoCookies -> Category.Snack
-    Trolli -> Category.Snack
-    Twix -> Category.Snack
-    Yogurette -> Category.Snack
-    KinderChocolate -> Category.Snack
-    NutsRoyalNuts -> Category.Snack
-    Katjes -> Category.Snack
-    CujaMaraSplit -> Category.IceCream
-    Magnum -> Category.IceCream
-    NUII -> Category.IceCream
-    IceCornetto -> Category.IceCream
-    IceNogger -> Category.IceCream
-    Pizza -> Category.Food
-    Apple -> Category.Fruit
-    Tangerine -> Category.Fruit
-    Clementine -> Category.Fruit
-    DuplexColor -> Category.Other
-    DuplexBlackWhite -> Category.Other
-    SimplexColor -> Category.Other
-    SimplexBlackWhite -> Category.Other
-    Tissues -> Category.Other
-    ThreeDPrintingPerGram -> Category.Other
-    Euglueh -> Category.HotBeverage
+    Almdudler -> ItemCategory.ColdBeverage
+    Bier330 -> ItemCategory.ColdBeverage
+    Bier500 -> ItemCategory.ColdBeverage
+    ClubMate -> ItemCategory.ColdBeverage
+    CocaCola -> ItemCategory.ColdBeverage
+    CocaCola330 -> ItemCategory.ColdBeverage
+    CokeZero -> ItemCategory.ColdBeverage
+    CokeZeroCaffeineFree -> ItemCategory.ColdBeverage
+    ChouffeBier -> ItemCategory.ColdBeverage
+    EiflerLandbier -> ItemCategory.ColdBeverage
+    EngelbertApple -> ItemCategory.ColdBeverage
+    EngelbertNatural -> ItemCategory.ColdBeverage
+    EngelbertSprudel -> ItemCategory.ColdBeverage
+    RheinfelsSprudel -> ItemCategory.ColdBeverage
+    ErdingerAlcoholFree500 -> ItemCategory.ColdBeverage
+    Fanta -> ItemCategory.ColdBeverage
+    Fassbrause -> ItemCategory.ColdBeverage
+    Bionade -> ItemCategory.ColdBeverage
+    Gerolsteiner -> ItemCategory.ColdBeverage
+    ErdingerAlcoholFree330 -> ItemCategory.ColdBeverage
+    Leffe -> ItemCategory.ColdBeverage
+    Rockstar -> ItemCategory.ColdBeverage
+    Monster -> ItemCategory.ColdBeverage
+    CocaCola05Glass -> ItemCategory.ColdBeverage
+    MioMioMate -> ItemCategory.ColdBeverage
+    MilkMachine -> ItemCategory.HotBeverage
+    CafeAuLait -> ItemCategory.HotBeverage
+    Cappuccino -> ItemCategory.HotBeverage
+    Espresso -> ItemCategory.HotBeverage
+    CoffeeSmall -> ItemCategory.HotBeverage
+    CoffeeLarge -> ItemCategory.HotBeverage
+    LatteMacchiato -> ItemCategory.HotBeverage
+    Moccachoc -> ItemCategory.HotBeverage
+    ChocolateCreamCocoa -> ItemCategory.HotBeverage
+    Erdnuesse -> ItemCategory.Snack
+    Hanuta -> ItemCategory.Snack
+    HariboBag -> ItemCategory.Snack
+    Knoppers -> ItemCategory.Snack
+    KnoppersBar -> ItemCategory.Snack
+    Milka -> ItemCategory.Snack
+    Balisto -> ItemCategory.Snack
+    Duplo -> ItemCategory.Snack
+    OreoCookies -> ItemCategory.Snack
+    Trolli -> ItemCategory.Snack
+    Twix -> ItemCategory.Snack
+    Yogurette -> ItemCategory.Snack
+    KinderChocolate -> ItemCategory.Snack
+    NutsRoyalNuts -> ItemCategory.Snack
+    Katjes -> ItemCategory.Snack
+    CujaMaraSplit -> ItemCategory.IceCream
+    Magnum -> ItemCategory.IceCream
+    NUII -> ItemCategory.IceCream
+    IceCornetto -> ItemCategory.IceCream
+    IceNogger -> ItemCategory.IceCream
+    IceMars -> ItemCategory.IceCream
+    IceSnickers -> ItemCategory.IceCream
+    IceTwix -> ItemCategory.IceCream
+    IceBounty -> ItemCategory.IceCream
+    Pizza -> ItemCategory.Food
+    Apple -> ItemCategory.Fruit
+    Tangerine -> ItemCategory.Fruit
+    Clementine -> ItemCategory.Fruit
+    DuplexColor -> ItemCategory.Other
+    DuplexBlackWhite -> ItemCategory.Other
+    SimplexColor -> ItemCategory.Other
+    SimplexBlackWhite -> ItemCategory.Other
+    Tissues -> ItemCategory.Other
+    ThreeDPrintingPerGram -> ItemCategory.Other
+    Euglueh -> ItemCategory.HotBeverage
 }
 val KnownItem.cleanFullName get() = cleanProductName + cleanVariantName?.let { " ($it)" }.orEmpty()
 val KnownItem.cleanProductName get() = when (this) {
@@ -92,6 +106,7 @@ val KnownItem.cleanProductName get() = when (this) {
     Leffe -> "Leffe"
     ClubMate -> "Club-Mate"
     CocaCola -> "Coca-Cola"
+    CocaCola330 -> "Coca-Cola"
     CocaCola05Glass -> "Coca-Cola"
     CokeZero -> "Coca-Cola Zero"
     CokeZeroCaffeineFree -> "Coca-Cola Zero"
@@ -118,6 +133,7 @@ val KnownItem.cleanProductName get() = when (this) {
     Erdnuesse -> "Erdnüsse"
     Hanuta -> "Hanuta"
     HariboBag -> "Haribo"
+    Knoppers -> "Knoppers"
     KnoppersBar -> "Knoppers Riegel"
     Milka -> "Milka"
     Balisto -> "Balisto"
@@ -133,6 +149,10 @@ val KnownItem.cleanProductName get() = when (this) {
     NUII -> "NUII"
     IceCornetto -> "Cornetto"
     IceNogger -> "Nogger"
+    IceMars -> "Mars Ice Cream"
+    IceSnickers -> "Snickers Ice Cream"
+    IceTwix -> "Twix Ice Cream"
+    IceBounty -> "Bounty Ice Cream"
     Pizza -> "Pizza"
     Apple -> "Apfel"
     Tangerine -> "Mandarine"
@@ -154,6 +174,8 @@ val KnownItem.cleanVariantName get() = when (this) {
     Fanta -> "Flasche"
     CokeZero -> "Flasche"
     CokeZeroCaffeineFree -> "koffeinfrei"
+    CocaCola -> "1L"
+    CocaCola330 -> "0,33L"
     CocaCola05Glass -> "0,5L Glas"
     Rockstar -> "inkl. Pfand"
     Monster -> "inkl. Pfand"
@@ -177,6 +199,7 @@ val KnownItem.estimatedPrice get() = when (this) {
     ChouffeBier -> null
     ClubMate -> null
     CocaCola -> null
+    CocaCola330 -> null
     CocaCola05Glass -> null
     CokeZero -> 1.66
     CokeZeroCaffeineFree -> null
@@ -206,6 +229,7 @@ val KnownItem.estimatedPrice get() = when (this) {
     RheinfelsSprudel -> null
     Hanuta -> 0.28
     HariboBag -> 1.15
+    Knoppers -> 0.32
     KnoppersBar -> 0.45
     Milka -> null
     Balisto -> 0.25
@@ -221,6 +245,10 @@ val KnownItem.estimatedPrice get() = when (this) {
     NUII -> 0.99
     IceCornetto -> 0.50
     IceNogger -> null
+    IceMars -> null
+    IceSnickers -> null
+    IceTwix -> null
+    IceBounty -> null
     Pizza -> 2.99
     Apple -> null
     Tangerine -> null
@@ -243,6 +271,7 @@ val KnownItem.drawableResource get() = when (this) {
     ChouffeBier -> R.drawable.la_chouffe_logo
     ClubMate -> R.drawable.club_mate
     CocaCola -> R.drawable.coca_cola_square
+    CocaCola330 -> R.drawable.coca_cola_square
     CocaCola05Glass -> R.drawable.coca_cola_square
     CokeZero -> R.drawable.coca_cola_zero_logo_300dpi
     CokeZeroCaffeineFree -> R.drawable.coca_cola_zero_logo_300dpi
@@ -280,6 +309,7 @@ val KnownItem.drawableResource get() = when (this) {
     Hanuta -> R.drawable.hanuta_29
     HariboBag -> R.drawable.haribo_logo_svg
     KinderChocolate -> R.drawable.kinder_schokolade_svg
+    Knoppers -> null
     KnoppersBar -> R.drawable.knopper_riegel_broken // TODO
     Milka -> R.drawable._048px_milka_logo_svg
     OreoCookies -> R.drawable.oreo_logo
@@ -294,6 +324,10 @@ val KnownItem.drawableResource get() = when (this) {
     Magnum -> null
     NUII -> R.drawable.nuii
     IceNogger -> null
+    IceMars -> null
+    IceSnickers -> null
+    IceTwix -> null
+    IceBounty -> null
 
     // Food
     Pizza -> R.drawable.pizza3
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/gitlab/GitLabPackage.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/gitlab/GitLabPackage.kt
index 0c4163c11cc0b0ebadd1293220f9f360d5ca42ce..45c037a8b8ad6e10baea06482784391e29883139 100644
--- a/app/src/main/java/net/novagamestudios/kaffeekasse/gitlab/GitLabPackage.kt
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/gitlab/GitLabPackage.kt
@@ -2,8 +2,8 @@ package net.novagamestudios.kaffeekasse.gitlab
 
 import kotlinx.serialization.SerialName
 import kotlinx.serialization.Serializable
-import net.novagamestudios.kaffeekasse.model.AppVersion
-import net.novagamestudios.kaffeekasse.model.ISODateTime
+import net.novagamestudios.kaffeekasse.model.app.AppVersion
+import net.novagamestudios.kaffeekasse.model.date_time.ISODateTime
 
 @Serializable
 data class GitLabPackage(
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/gitlab/GitLabPackageFile.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/gitlab/GitLabPackageFile.kt
index ae0f0106a6d663219c04c404e060ba78d3cec54c..0a61324dd15b4e7a76e81479beae3ed26fa9099d 100644
--- a/app/src/main/java/net/novagamestudios/kaffeekasse/gitlab/GitLabPackageFile.kt
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/gitlab/GitLabPackageFile.kt
@@ -2,7 +2,7 @@ package net.novagamestudios.kaffeekasse.gitlab
 
 import kotlinx.serialization.SerialName
 import kotlinx.serialization.Serializable
-import net.novagamestudios.kaffeekasse.model.ISODateTime
+import net.novagamestudios.kaffeekasse.model.date_time.ISODateTime
 
 @Serializable
 data class GitLabPackageFile(
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/gitlab/GitLabRelease.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/gitlab/GitLabRelease.kt
index e411c6688472b839a49c9b9e8f96106a1a8f83f0..40aea1f5f07a7ae236f747e84ae11928656b4826 100644
--- a/app/src/main/java/net/novagamestudios/kaffeekasse/gitlab/GitLabRelease.kt
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/gitlab/GitLabRelease.kt
@@ -2,8 +2,8 @@ package net.novagamestudios.kaffeekasse.gitlab
 
 import kotlinx.serialization.SerialName
 import kotlinx.serialization.Serializable
-import net.novagamestudios.kaffeekasse.model.AppVersion
-import net.novagamestudios.kaffeekasse.model.ISODateTime
+import net.novagamestudios.kaffeekasse.model.app.AppVersion
+import net.novagamestudios.kaffeekasse.model.date_time.ISODateTime
 
 @Serializable
 data class GitLabRelease(
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/model/AppVersion.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/model/AppVersion.kt
deleted file mode 100644
index f361c6d7f95052c07f8fd1d99554c7b12cb85966..0000000000000000000000000000000000000000
--- a/app/src/main/java/net/novagamestudios/kaffeekasse/model/AppVersion.kt
+++ /dev/null
@@ -1,39 +0,0 @@
-package net.novagamestudios.kaffeekasse.model
-
-import kotlinx.serialization.Serializable
-import net.novagamestudios.kaffeekasse.BuildConfig
-
-@Serializable
-@JvmInline
-value class AppVersion(private val string: String) : Comparable<AppVersion> {
-    val major get() = string.split(".")[0].toInt()
-    val minor get() = string.split(".")[1].toInt()
-    val patch get() = string.split(".")[2].toInt()
-
-    operator fun component0() = major
-    operator fun component1() = minor
-    operator fun component2() = patch
-
-    init {
-        require(string.matches(Regex("""\d+\.\d+\.\d+"""))) { "Invalid version string: $string" }
-    }
-
-    override fun compareTo(other: AppVersion): Int = when {
-        major > other.major -> 1
-        major < other.major -> -1
-        minor > other.minor -> 1
-        minor < other.minor -> -1
-        patch > other.patch -> 1
-        patch < other.patch -> -1
-        else -> 0
-    }
-
-    override fun toString(): String = string
-
-    companion object {
-        private val Regex by lazy { """\d+\.\d+\.\d+""".toRegex() }
-
-        val Current = AppVersion(BuildConfig.VERSION_NAME)
-        fun findIn(string: String) = Regex.find(string)?.value?.let { AppVersion(it) }
-    }
-}
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/model/ISODateTime.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/model/ISODateTime.kt
deleted file mode 100644
index 9453fb24a1a5d5253218ad969a6ece135d0691f6..0000000000000000000000000000000000000000
--- a/app/src/main/java/net/novagamestudios/kaffeekasse/model/ISODateTime.kt
+++ /dev/null
@@ -1,72 +0,0 @@
-package net.novagamestudios.kaffeekasse.model
-
-import kotlinx.datetime.LocalDate
-import kotlinx.datetime.LocalDateTime
-import kotlinx.datetime.LocalTime
-import kotlinx.datetime.toJavaLocalDate
-import kotlinx.datetime.toJavaLocalDateTime
-import kotlinx.datetime.toJavaLocalTime
-import kotlinx.datetime.toKotlinLocalDate
-import kotlinx.datetime.toKotlinLocalDateTime
-import kotlinx.datetime.toKotlinLocalTime
-import kotlinx.serialization.KSerializer
-import kotlinx.serialization.Serializable
-import kotlinx.serialization.descriptors.PrimitiveKind
-import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
-import kotlinx.serialization.descriptors.SerialDescriptor
-import kotlinx.serialization.encoding.Decoder
-import kotlinx.serialization.encoding.Encoder
-import java.time.format.DateTimeFormatter
-import java.time.LocalDate as JavaLocalDate
-import java.time.LocalDateTime as JavaLocalDateTime
-import java.time.LocalTime as JavaLocalTime
-
-typealias ISODateTime = @Serializable(with = ISODateTimeSerializer::class) LocalDateTime
-class ISODateTimeSerializer : KSerializer<LocalDateTime> {
-    private val formatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME!!
-    override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ISODateTime", PrimitiveKind.STRING)
-    override fun serialize(encoder: Encoder, value: LocalDateTime) {
-        encoder.encodeString(value.format(formatter))
-    }
-    override fun deserialize(decoder: Decoder): LocalDateTime {
-        return LocalDateTime.parse(decoder.decodeString(), formatter)
-    }
-}
-
-typealias ISODate = @Serializable(with = ISODateSerializer::class) LocalDate
-class ISODateSerializer : KSerializer<LocalDate> {
-    private val formatter = DateTimeFormatter.ISO_DATE!!
-    override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ISODate", PrimitiveKind.STRING)
-    override fun serialize(encoder: Encoder, value: LocalDate) {
-        encoder.encodeString(value.format(formatter))
-    }
-    override fun deserialize(decoder: Decoder): LocalDate {
-        return LocalDate.parse(decoder.decodeString(), formatter)
-    }
-}
-
-typealias ISOTime = @Serializable(with = ISOTimeSerializer::class) LocalTime
-class ISOTimeSerializer : KSerializer<LocalTime> {
-    private val formatter = DateTimeFormatter.ISO_TIME!!
-    override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ISOTime", PrimitiveKind.STRING)
-    override fun serialize(encoder: Encoder, value: LocalTime) {
-        encoder.encodeString(value.format(formatter))
-    }
-    override fun deserialize(decoder: Decoder): LocalTime {
-        val string = decoder.decodeString()
-        if (string.startsWith("24:")) return JavaLocalTime.MAX.toKotlinLocalTime()
-        return LocalTime.parse(string, formatter)
-    }
-}
-
-
-fun LocalDateTime.format(formatter: DateTimeFormatter) = formatter.format(toJavaLocalDateTime())!!
-fun LocalDateTime.Companion.parse(input: CharSequence, formatter: DateTimeFormatter) = JavaLocalDateTime.parse(input, formatter).toKotlinLocalDateTime()
-
-fun LocalDate.format(formatter: DateTimeFormatter) = formatter.format(toJavaLocalDate())!!
-fun LocalDate.Companion.parse(input: CharSequence, formatter: DateTimeFormatter) = JavaLocalDate.parse(input, formatter).toKotlinLocalDate()
-
-fun LocalTime.format(formatter: DateTimeFormatter) = formatter.format(toJavaLocalTime())!!
-fun LocalTime.Companion.parse(input: CharSequence, formatter: DateTimeFormatter) = JavaLocalTime.parse(input, formatter).toKotlinLocalTime()
-
-
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/model/AppRelease.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/model/app/AppRelease.kt
similarity index 79%
rename from app/src/main/java/net/novagamestudios/kaffeekasse/model/AppRelease.kt
rename to app/src/main/java/net/novagamestudios/kaffeekasse/model/app/AppRelease.kt
index 3cd0de2be8b683006afaf4e2d9159e6f33c2e68b..838384ee9bb33c99dec7b1eadecd79f0b5528cc3 100644
--- a/app/src/main/java/net/novagamestudios/kaffeekasse/model/AppRelease.kt
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/model/app/AppRelease.kt
@@ -1,4 +1,4 @@
-package net.novagamestudios.kaffeekasse.model
+package net.novagamestudios.kaffeekasse.model.app
 
 import kotlinx.datetime.LocalDateTime
 
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/model/app/AppVersion.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/model/app/AppVersion.kt
new file mode 100644
index 0000000000000000000000000000000000000000..aaa951c0ac14d6fa232cf8541a004350cc8ec296
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/model/app/AppVersion.kt
@@ -0,0 +1,85 @@
+package net.novagamestudios.kaffeekasse.model.app
+
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.SerializationException
+import kotlinx.serialization.descriptors.PrimitiveKind
+import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
+import kotlinx.serialization.descriptors.SerialDescriptor
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+import net.novagamestudios.common_utils.format
+import net.novagamestudios.kaffeekasse.BuildConfig
+
+
+/**
+ * A version of the app in the format `{major}.{minor}.{patch}-{type}{iteration}`.
+ */
+@Serializable(with = AppVersionSerializer::class)
+data class AppVersion(
+    val major: Int,
+    val minor: Int,
+    val patch: Int,
+    val type: Type,
+    val iteration: Int,
+) : Comparable<AppVersion> {
+
+    override fun compareTo(other: AppVersion): Int = when {
+        major > other.major -> 1
+        major < other.major -> -1
+        minor > other.minor -> 1
+        minor < other.minor -> -1
+        patch > other.patch -> 1
+        patch < other.patch -> -1
+        type != other.type -> type.compareTo(other.type)
+        iteration > other.iteration -> 1
+        iteration < other.iteration -> -1
+        else -> 0
+    }
+
+    override fun toString(): String = listOfNotNull(
+        "$major.$minor.$patch",
+        if (type == Type.Stable) null else "$type${iteration format "%02d"}"
+    ).joinToString("-")
+
+    companion object {
+        private val Regex by lazy { """(\d+)\.(\d+)\.(\d+)(-([A-Za-z]+)(\d+)?)?""".toRegex() }
+
+        operator fun invoke(string: String): AppVersion? {
+            val match = Regex.matchEntire(string) ?: return null
+            val (major, minor, patch, _, type, iteration) = match.destructured
+            return AppVersion(
+                major = major.toInt(),
+                minor = minor.toInt(),
+                patch = patch.toInt(),
+                type = if (type.isBlank()) Type.Stable else (Type[type] ?: return null),
+                iteration = iteration.toIntOrNull() ?: 0
+            )
+        }
+
+        fun findIn(string: String) = Regex.find(string)?.value?.let { AppVersion(it) }
+
+        val Current = AppVersion(BuildConfig.VERSION_NAME) ?: throw AssertionError("Invalid version: ${BuildConfig.VERSION_NAME}")
+    }
+
+    enum class Type(val label: String) {
+        Alpha("alpha"),
+        Beta("beta"),
+        Stable("stable");
+
+        override fun toString() = label
+
+        companion object {
+            operator fun get(label: String) = entries.firstOrNull { it.label == label }
+        }
+    }
+}
+
+object AppVersionSerializer : KSerializer<AppVersion> {
+    override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor(AppVersion::class.simpleName!!, PrimitiveKind.STRING)
+    override fun serialize(encoder: Encoder, value: AppVersion) = encoder.encodeString(value.toString())
+    override fun deserialize(decoder: Decoder): AppVersion {
+        val string = decoder.decodeString()
+        return AppVersion(string) ?: throw SerializationException("Invalid version: $string")
+    }
+}
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/model/credentials/DeviceCredentials.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/model/credentials/DeviceCredentials.kt
new file mode 100644
index 0000000000000000000000000000000000000000..9cda0b69708fb799c84733739ae926550553a579
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/model/credentials/DeviceCredentials.kt
@@ -0,0 +1,14 @@
+package net.novagamestudios.kaffeekasse.model.credentials
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class DeviceCredentials(
+    val deviceId: String,
+    val apiKey: String
+) {
+    companion object {
+        val Empty = DeviceCredentials("", "")
+    }
+}
+
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/model/credentials/Extensions.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/model/credentials/Extensions.kt
new file mode 100644
index 0000000000000000000000000000000000000000..17cc21832a8c7b0197aca179b3ea3182c11c9c09
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/model/credentials/Extensions.kt
@@ -0,0 +1,5 @@
+package net.novagamestudios.kaffeekasse.model.credentials
+
+val Login.isValid get() = username.isNotBlank() && password.isNotBlank()
+
+val DeviceCredentials.isValid get() = deviceId.isNotBlank() && apiKey.isNotBlank()
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/model/i11_portal/Login.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/model/credentials/Login.kt
similarity index 60%
rename from app/src/main/java/net/novagamestudios/kaffeekasse/model/i11_portal/Login.kt
rename to app/src/main/java/net/novagamestudios/kaffeekasse/model/credentials/Login.kt
index 0c1489314dd7d87f2613cbe6e9a1dc71574a460f..d80b1e088f01d968ab7b2993e0902698b0047b8f 100644
--- a/app/src/main/java/net/novagamestudios/kaffeekasse/model/i11_portal/Login.kt
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/model/credentials/Login.kt
@@ -1,4 +1,4 @@
-package net.novagamestudios.kaffeekasse.model.i11_portal
+package net.novagamestudios.kaffeekasse.model.credentials
 
 import kotlinx.serialization.Serializable
 
@@ -12,4 +12,3 @@ data class Login(
     }
 }
 
-val Login.isValid get() = username.isNotBlank() && password.isNotBlank()
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/model/date_time/Extensions.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/model/date_time/Extensions.kt
new file mode 100644
index 0000000000000000000000000000000000000000..ff09f5eaa799b8a7404d93b914c55684030088de
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/model/date_time/Extensions.kt
@@ -0,0 +1,32 @@
+package net.novagamestudios.kaffeekasse.model.date_time
+
+import kotlinx.datetime.LocalDate
+import kotlinx.datetime.LocalDateTime
+import kotlinx.datetime.LocalTime
+import kotlinx.datetime.toJavaLocalDate
+import kotlinx.datetime.toJavaLocalDateTime
+import kotlinx.datetime.toJavaLocalTime
+import kotlinx.datetime.toKotlinLocalDate
+import kotlinx.datetime.toKotlinLocalDateTime
+import kotlinx.datetime.toKotlinLocalTime
+import java.time.format.DateTimeFormatter
+import java.time.LocalDate as JavaLocalDate
+import java.time.LocalDateTime as JavaLocalDateTime
+import java.time.LocalTime as JavaLocalTime
+
+fun LocalDateTime.format(formatter: DateTimeFormatter) = formatter.format(toJavaLocalDateTime())!!
+fun LocalDateTime.Companion.parse(input: CharSequence, formatter: DateTimeFormatter) = JavaLocalDateTime.parse(
+    input,
+    formatter
+).toKotlinLocalDateTime()
+fun LocalDate.format(formatter: DateTimeFormatter) = formatter.format(toJavaLocalDate())!!
+fun LocalDate.Companion.parse(input: CharSequence, formatter: DateTimeFormatter) = JavaLocalDate.parse(
+    input,
+    formatter
+).toKotlinLocalDate()
+
+fun LocalTime.format(formatter: DateTimeFormatter) = formatter.format(toJavaLocalTime())!!
+fun LocalTime.Companion.parse(input: CharSequence, formatter: DateTimeFormatter) = JavaLocalTime.parse(
+    input,
+    formatter
+).toKotlinLocalTime()
\ No newline at end of file
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/model/date_time/ISODate.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/model/date_time/ISODate.kt
new file mode 100644
index 0000000000000000000000000000000000000000..d1ac06f590f4731869586b7d2184cc8b8cfda63e
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/model/date_time/ISODate.kt
@@ -0,0 +1,7 @@
+package net.novagamestudios.kaffeekasse.model.date_time
+
+import kotlinx.datetime.LocalDate
+import kotlinx.serialization.Serializable
+import net.novagamestudios.kaffeekasse.model.date_time.serializers.ISODateSerializer
+
+typealias ISODate = @Serializable(with = ISODateSerializer::class) LocalDate
\ No newline at end of file
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/model/date_time/ISODateTime.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/model/date_time/ISODateTime.kt
new file mode 100644
index 0000000000000000000000000000000000000000..b1b601e751d74617deb9f2ccefe7e004644370ba
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/model/date_time/ISODateTime.kt
@@ -0,0 +1,7 @@
+package net.novagamestudios.kaffeekasse.model.date_time
+
+import kotlinx.datetime.LocalDateTime
+import kotlinx.serialization.Serializable
+import net.novagamestudios.kaffeekasse.model.date_time.serializers.ISODateTimeSerializer
+
+typealias ISODateTime = @Serializable(with = ISODateTimeSerializer::class) LocalDateTime
\ No newline at end of file
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/model/date_time/ISOTime.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/model/date_time/ISOTime.kt
new file mode 100644
index 0000000000000000000000000000000000000000..8973362d4cdbb77726f9031df708e364b49292cf
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/model/date_time/ISOTime.kt
@@ -0,0 +1,7 @@
+package net.novagamestudios.kaffeekasse.model.date_time
+
+import kotlinx.datetime.LocalTime
+import kotlinx.serialization.Serializable
+import net.novagamestudios.kaffeekasse.model.date_time.serializers.ISOTimeSerializer
+
+typealias ISOTime = @Serializable(with = ISOTimeSerializer::class) LocalTime
\ No newline at end of file
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/model/date_time/serializers/ISODateSerializer.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/model/date_time/serializers/ISODateSerializer.kt
new file mode 100644
index 0000000000000000000000000000000000000000..121066f973f8bfe307ad978e27af63284f53179b
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/model/date_time/serializers/ISODateSerializer.kt
@@ -0,0 +1,24 @@
+package net.novagamestudios.kaffeekasse.model.date_time.serializers
+
+import kotlinx.datetime.LocalDate
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.descriptors.PrimitiveKind
+import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
+import kotlinx.serialization.descriptors.SerialDescriptor
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+import net.novagamestudios.kaffeekasse.model.date_time.format
+import net.novagamestudios.kaffeekasse.model.date_time.parse
+import java.time.format.DateTimeFormatter
+
+class ISODateSerializer : KSerializer<LocalDate> {
+    private val formatter = DateTimeFormatter.ISO_DATE!!
+    override val descriptor: SerialDescriptor =
+        PrimitiveSerialDescriptor("ISODate", PrimitiveKind.STRING)
+    override fun serialize(encoder: Encoder, value: LocalDate) {
+        encoder.encodeString(value.format(formatter))
+    }
+    override fun deserialize(decoder: Decoder): LocalDate {
+        return LocalDate.parse(decoder.decodeString(), formatter)
+    }
+}
\ No newline at end of file
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/model/date_time/serializers/ISODateTimeSerializer.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/model/date_time/serializers/ISODateTimeSerializer.kt
new file mode 100644
index 0000000000000000000000000000000000000000..5e73613acbf18137149f11552d71afa8ce8c818b
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/model/date_time/serializers/ISODateTimeSerializer.kt
@@ -0,0 +1,24 @@
+package net.novagamestudios.kaffeekasse.model.date_time.serializers
+
+import kotlinx.datetime.LocalDateTime
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.descriptors.PrimitiveKind
+import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
+import kotlinx.serialization.descriptors.SerialDescriptor
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+import net.novagamestudios.kaffeekasse.model.date_time.format
+import net.novagamestudios.kaffeekasse.model.date_time.parse
+import java.time.format.DateTimeFormatter
+
+class ISODateTimeSerializer : KSerializer<LocalDateTime> {
+    private val formatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME!!
+    override val descriptor: SerialDescriptor =
+        PrimitiveSerialDescriptor("ISODateTime", PrimitiveKind.STRING)
+    override fun serialize(encoder: Encoder, value: LocalDateTime) {
+        encoder.encodeString(value.format(formatter))
+    }
+    override fun deserialize(decoder: Decoder): LocalDateTime {
+        return LocalDateTime.parse(decoder.decodeString(), formatter)
+    }
+}
\ No newline at end of file
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/model/date_time/serializers/ISOTimeSerializer.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/model/date_time/serializers/ISOTimeSerializer.kt
new file mode 100644
index 0000000000000000000000000000000000000000..f48b7e374d1caffe403164a1cf1632f823d8d870
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/model/date_time/serializers/ISOTimeSerializer.kt
@@ -0,0 +1,28 @@
+package net.novagamestudios.kaffeekasse.model.date_time.serializers
+
+import kotlinx.datetime.LocalTime
+import kotlinx.datetime.toKotlinLocalTime
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.descriptors.PrimitiveKind
+import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
+import kotlinx.serialization.descriptors.SerialDescriptor
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+import net.novagamestudios.kaffeekasse.model.date_time.format
+import net.novagamestudios.kaffeekasse.model.date_time.parse
+import java.time.format.DateTimeFormatter
+import java.time.LocalTime as JavaLocalTime
+
+class ISOTimeSerializer : KSerializer<LocalTime> {
+    private val formatter = DateTimeFormatter.ISO_TIME!!
+    override val descriptor: SerialDescriptor =
+        PrimitiveSerialDescriptor("ISOTime", PrimitiveKind.STRING)
+    override fun serialize(encoder: Encoder, value: LocalTime) {
+        encoder.encodeString(value.format(formatter))
+    }
+    override fun deserialize(decoder: Decoder): LocalTime {
+        val string = decoder.decodeString()
+        if (string.startsWith("24:")) return JavaLocalTime.MAX.toKotlinLocalTime()
+        return LocalTime.parse(string, formatter)
+    }
+}
\ No newline at end of file
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/model/i11_portal/api/I11PortalData.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/model/i11_portal/api/I11PortalData.kt
deleted file mode 100644
index c68d11c26466d8ff371a0327d9319516ed69f7c9..0000000000000000000000000000000000000000
--- a/app/src/main/java/net/novagamestudios/kaffeekasse/model/i11_portal/api/I11PortalData.kt
+++ /dev/null
@@ -1,353 +0,0 @@
-package net.novagamestudios.kaffeekasse.model.i11_portal.api
-
-import kotlinx.serialization.SerialName
-import kotlinx.serialization.Serializable
-import kotlinx.serialization.json.JsonElement
-
-interface I11PortalData {
-    val session: Session
-    val infos: List<String>
-    val warnings: List<String>
-    val errors: List<String>
-    val navigation: JsonElement
-    @SerialName("ajaxui")
-    val ajaxUI: AjaxUI?
-    @Serializable
-    data class Session(
-        val login: Boolean,
-        val user: String? = null,
-        @SerialName("displayname")
-        val displayName: String? = null
-    )
-    @Serializable
-    data class AjaxUI(
-        val reload: Boolean?
-    )
-}
-
-
-/*
-{
-    "session": {
-        "login": true,
-        "user": "broeckmann",
-        "displayname": "Jonas Broeckmann"
-    },
-    "calendar": {
-        "2024-02-26": {
-            "disabled": true,
-            "day": 26,
-            "hasentry": false,
-            "vacation": false,
-            "holiday": false
-        },
-        "2024-02-27": {
-            "disabled": true,
-            "day": 27,
-            "hasentry": false,
-            "vacation": false,
-            "holiday": false
-        },
-        "2024-02-28": {
-            "disabled": true,
-            "day": 28,
-            "hasentry": false,
-            "vacation": false,
-            "holiday": false
-        },
-        "2024-02-29": {
-            "disabled": true,
-            "day": 29,
-            "hasentry": false,
-            "vacation": false,
-            "holiday": false
-        },
-        "2024-03-01": {
-            "disabled": false,
-            "day": 1,
-            "hasentry": false,
-            "vacation": false,
-            "holiday": false
-        },
-        "2024-03-02": {
-            "disabled": false,
-            "day": 2,
-            "hasentry": false,
-            "vacation": false,
-            "holiday": false
-        },
-        "2024-03-03": {
-            "disabled": false,
-            "day": 3,
-            "hasentry": false,
-            "vacation": false,
-            "holiday": false
-        },
-        "2024-03-04": {
-            "disabled": false,
-            "day": 4,
-            "hasentry": false,
-            "vacation": false,
-            "holiday": false
-        },
-        "2024-03-05": {
-            "disabled": false,
-            "day": 5,
-            "hasentry": false,
-            "vacation": false,
-            "holiday": false
-        },
-        "2024-03-06": {
-            "disabled": false,
-            "day": 6,
-            "hasentry": false,
-            "vacation": false,
-            "holiday": false
-        },
-        "2024-03-07": {
-            "disabled": false,
-            "day": 7,
-            "hasentry": true,
-            "vacation": false,
-            "holiday": false
-        },
-        "2024-03-08": {
-            "disabled": false,
-            "day": 8,
-            "hasentry": false,
-            "vacation": false,
-            "holiday": false
-        },
-        "2024-03-09": {
-            "disabled": false,
-            "day": 9,
-            "hasentry": false,
-            "vacation": false,
-            "holiday": false
-        },
-        "2024-03-10": {
-            "disabled": false,
-            "day": 10,
-            "hasentry": false,
-            "vacation": false,
-            "holiday": false
-        },
-        "2024-03-11": {
-            "disabled": false,
-            "day": 11,
-            "hasentry": false,
-            "vacation": false,
-            "holiday": false
-        },
-        "2024-03-12": {
-            "disabled": false,
-            "day": 12,
-            "hasentry": false,
-            "vacation": false,
-            "holiday": false
-        },
-        "2024-03-13": {
-            "disabled": false,
-            "day": 13,
-            "hasentry": false,
-            "vacation": false,
-            "holiday": false
-        },
-        "2024-03-14": {
-            "disabled": false,
-            "day": 14,
-            "hasentry": false,
-            "vacation": false,
-            "holiday": false
-        },
-        "2024-03-15": {
-            "disabled": false,
-            "day": 15,
-            "hasentry": false,
-            "vacation": false,
-            "holiday": false
-        },
-        "2024-03-16": {
-            "disabled": false,
-            "day": 16,
-            "hasentry": false,
-            "vacation": false,
-            "holiday": false
-        },
-        "2024-03-17": {
-            "disabled": false,
-            "day": 17,
-            "hasentry": false,
-            "vacation": false,
-            "holiday": false
-        },
-        "2024-03-18": {
-            "disabled": false,
-            "day": 18,
-            "hasentry": false,
-            "vacation": false,
-            "holiday": false
-        },
-        "2024-03-19": {
-            "disabled": false,
-            "day": 19,
-            "hasentry": false,
-            "vacation": false,
-            "holiday": false
-        },
-        "2024-03-20": {
-            "disabled": false,
-            "day": 20,
-            "hasentry": false,
-            "vacation": false,
-            "holiday": false
-        },
-        "2024-03-21": {
-            "disabled": false,
-            "day": 21,
-            "hasentry": false,
-            "vacation": false,
-            "holiday": false
-        },
-        "2024-03-22": {
-            "disabled": false,
-            "day": 22,
-            "hasentry": false,
-            "vacation": false,
-            "holiday": false
-        },
-        "2024-03-23": {
-            "disabled": false,
-            "day": 23,
-            "hasentry": false,
-            "vacation": false,
-            "holiday": false
-        },
-        "2024-03-24": {
-            "disabled": false,
-            "day": 24,
-            "hasentry": false,
-            "vacation": false,
-            "holiday": false
-        },
-        "2024-03-25": {
-            "disabled": false,
-            "day": 25,
-            "hasentry": false,
-            "vacation": false,
-            "holiday": false
-        },
-        "2024-03-26": {
-            "disabled": false,
-            "day": 26,
-            "hasentry": false,
-            "vacation": false,
-            "holiday": false
-        },
-        "2024-03-27": {
-            "disabled": false,
-            "day": 27,
-            "hasentry": false,
-            "vacation": false,
-            "holiday": false
-        },
-        "2024-03-28": {
-            "disabled": false,
-            "day": 28,
-            "hasentry": false,
-            "vacation": false,
-            "holiday": false
-        },
-        "2024-03-29": {
-            "disabled": false,
-            "day": 29,
-            "hasentry": false,
-            "vacation": false,
-            "holiday": "Karfreitag"
-        },
-        "2024-03-30": {
-            "disabled": false,
-            "day": 30,
-            "hasentry": false,
-            "vacation": false,
-            "holiday": false
-        },
-        "2024-03-31": {
-            "disabled": false,
-            "day": 31,
-            "hasentry": false,
-            "vacation": false,
-            "holiday": false
-        }
-    },
-    "monthname": "März",
-    "today": "2024-03-08",
-    "nextmonthdate": "2024-04-01",
-    "prevmonthdate": "2024-02-01",
-    "selecteddate": null,
-    "entries": [
-        {
-            "timetableentry_id": 71889,
-            "timetable_id": 463,
-            "date": "2024-03-07",
-            "begin": "10:00:00",
-            "end": "17:00:00",
-            "breaktime": "00:30:00",
-            "note": "psp_course_materials",
-            "hoursperday": "01:54:00.0000",
-            "name": "Broeckmann, Jonas",
-            "hrsweek": "09:30:00",
-            "hours_worked": "06:30:00.000000"
-        }
-    ],
-    "total_month": {
-        "timetable_id": 463,
-        "name": "Broeckmann, Jonas",
-        "date": "2024-03-01",
-        "hoursperweek": "09:30:00",
-        "hoursperday": "01:54:00.0000",
-        "workdays": 20,
-        "seconds_worked": 23400,
-        "holidays": 1,
-        "vacation_days": null,
-        "hours_worked": "06:30:00",
-        "hoursthismonth": "38:00:00.000000",
-        "hours_balance": "-31:30:00.000000",
-        "hours_balance_percent": -0.828947368,
-        "hours_balance_total_percent": -0.83
-    },
-    "total": {
-        "timetable_id": 463,
-        "hoursperweek": "08:30:00",
-        "hoursperday": "01:42:00.0000",
-        "workdays": 692,
-        "seconds_worked": 4492800,
-        "holidays": 26,
-        "last_reset": "03/2021",
-        "vacation_days": 43,
-        "date": "2024-02-29",
-        "hours_worked": "838:59:59",
-        "hours_balance": "04:48:00",
-        "hours_balance_percent": 1.403921568
-    },
-    "last_reset": {
-        "date": "2021-04-01"
-    },
-    "overtime_expire": "00:00:00",
-    "holiday": false,
-    "reset": false,
-    "hidedatecaption": false,
-    "infos": [],
-    "warnings": [],
-    "errors": [],
-    "navigation": {
-        "Urlaub eintragen": {
-            "type": "link",
-            "action": "?vacations",
-            "subitems": []
-        }
-    }
-}
- */
-
-
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/model/i11_portal/api/KaffeekasseData.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/model/i11_portal/api/KaffeekasseData.kt
deleted file mode 100644
index 34a8dc79fcc43a90d66dcae69e517f9d133ed417..0000000000000000000000000000000000000000
--- a/app/src/main/java/net/novagamestudios/kaffeekasse/model/i11_portal/api/KaffeekasseData.kt
+++ /dev/null
@@ -1,63 +0,0 @@
-package net.novagamestudios.kaffeekasse.model.i11_portal.api
-
-import kotlinx.serialization.SerialName
-import kotlinx.serialization.Serializable
-import kotlinx.serialization.json.JsonElement
-
-/*
-{"session":{"login":false},"error":{"code":0,"string":"No error"},"infos":[],"warnings":[],"errors":["Login fehlgeschlagen."],"navigation":[]}
-
-{
-"ajaxui":{"reload":true},
-"session":{"login":true,"user":"broeckmann","displayname":"Jonas Broeckmann"},
-"error":{"code":0,"string":"No error"},
-"Kontostand":{"mybalance":{"user_id":487074,"user":"Broeckmann, Jonas","outgoing":0,"incoming":15,"paid":4.87,"refunded":0,"deposited":15,"withdrawn":0,"total":10.13}},
-"infos":["Login erfolgreich."],
-"warnings":[],
-"errors":[],
-"navigation":{
-    "Transaktionen":{"type":"placeholder","action":null,"subitems":{"Manuell buchen":{"type":"link","action":"?manualbill"},"Überweisung tätigen":{"type":"link","action":"?u2utransaction"}}},
-    "Mein Konto":{"type":"placeholder","action":null,"subitems":{"Benutzerübersicht":{"type":"link","action":"?overview"},"Kontoübersicht":{"type":"link","action":"?balance"}}}}
-}
-*/
-@Serializable
-data class KaffeekasseData(
-    override val session: I11PortalData.Session,
-    override val infos: List<String>,
-    override val warnings: List<String>,
-    override val errors: List<String>,
-    override val navigation: JsonElement,
-    @SerialName("ajaxui")
-    override val ajaxUI: I11PortalData.AjaxUI? = null,
-    val error: Error? = null,
-    @SerialName("Kontostand")
-    val balance: Balance? = null
-) : I11PortalData {
-    @Serializable
-    data class Error(
-        val code: Int,
-        val string: String
-    )
-    @Serializable
-    data class Balance(
-        @SerialName("mybalance")
-        val myBalance: MyBalance
-    ) {
-        @Serializable
-        data class MyBalance(
-            @SerialName("user_id")
-            val userId: Int,
-            val user: String,
-            val outgoing: Double,
-            val incoming: Double,
-            val paid: Double,
-            val refunded: Double,
-            val deposited: Double,
-            val withdrawn: Double,
-            val total: Double
-        ) {
-            val firstName by lazy { user.split(", ")[1] }
-            val lastName by lazy { user.split(", ")[0] }
-        }
-    }
-}
\ No newline at end of file
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/model/kaffeekasse/Account.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/model/kaffeekasse/Account.kt
new file mode 100644
index 0000000000000000000000000000000000000000..4c350c095e0d61e941c3525c73a4717027fea3a1
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/model/kaffeekasse/Account.kt
@@ -0,0 +1,12 @@
+package net.novagamestudios.kaffeekasse.model.kaffeekasse
+
+
+data class Account(
+    val firstName: String,
+    val lastName: String,
+    val total: Double,
+    val paid: Double,
+    val deposited: Double
+)
+
+
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/model/kaffeekasse/Cart.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/model/kaffeekasse/Cart.kt
index b92a65f2db902a4f84c10170964a571fd656cff6..164fc33570d6d8876bf619e73ca57bc525a68400 100644
--- a/app/src/main/java/net/novagamestudios/kaffeekasse/model/kaffeekasse/Cart.kt
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/model/kaffeekasse/Cart.kt
@@ -3,15 +3,15 @@ package net.novagamestudios.kaffeekasse.model.kaffeekasse
 interface Cart : Iterable<Cart.Entry> {
     val itemCount: Int
     override fun iterator(): Iterator<Entry>
-    operator fun get(item: ManualBillDetails.Item): Int
-    operator fun contains(item: ManualBillDetails.Item): Boolean
-    data class Entry(val item: ManualBillDetails.Item, val count: Int)
+    operator fun get(item: Item): Int
+    operator fun contains(item: Item): Boolean
+    data class Entry(val item: Item, val count: Int)
 }
 
 interface MutableCart : Cart {
-    operator fun plusAssign(item: ManualBillDetails.Item)
-    operator fun minusAssign(item: ManualBillDetails.Item)
-    fun removeAll(item: ManualBillDetails.Item)
+    operator fun plusAssign(item: Item)
+    operator fun minusAssign(item: Item)
+    fun removeAll(item: Item)
     fun clear()
 }
 
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/model/kaffeekasse/Item.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/model/kaffeekasse/Item.kt
new file mode 100644
index 0000000000000000000000000000000000000000..1fe9f976eb048b7234c8038ba34448e1fcf03702
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/model/kaffeekasse/Item.kt
@@ -0,0 +1,20 @@
+package net.novagamestudios.kaffeekasse.model.kaffeekasse
+
+
+interface Item {
+    val id: Int
+    val originalName: String
+
+    val category: ItemCategory
+
+    val cleanProductName: String
+    val cleanVariantName: String?
+    val cleanFullName get() = cleanProductName + cleanVariantName?.let { " ($it)" }.orEmpty()
+
+    val price: Double?
+    val estimatedPrice: Double?
+
+
+    val imageDrawable: Int?
+    val imageUrl: String?
+}
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/model/kaffeekasse/ItemCategory.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/model/kaffeekasse/ItemCategory.kt
new file mode 100644
index 0000000000000000000000000000000000000000..87c888c40759c86987b03fb4b20144d701651711
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/model/kaffeekasse/ItemCategory.kt
@@ -0,0 +1,11 @@
+package net.novagamestudios.kaffeekasse.model.kaffeekasse
+
+enum class ItemCategory {
+    ColdBeverage,
+    HotBeverage,
+    Snack,
+    IceCream,
+    Food,
+    Fruit,
+    Other
+}
\ No newline at end of file
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/model/kaffeekasse/ItemGroup.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/model/kaffeekasse/ItemGroup.kt
new file mode 100644
index 0000000000000000000000000000000000000000..06b72db8920a7e63586e54b1166ca9f6796b2a6d
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/model/kaffeekasse/ItemGroup.kt
@@ -0,0 +1,9 @@
+package net.novagamestudios.kaffeekasse.model.kaffeekasse
+
+interface ItemGroup {
+    val id: Int?
+    val originalName: String?
+
+    val name: String
+    val items: List<Item>
+}
\ No newline at end of file
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/model/kaffeekasse/KnownItem.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/model/kaffeekasse/KnownItem.kt
index be3a637111ae8176df501c92450a89850771f4bd..35f502543207f0ecd9b4c48b601e8be21e5ef990 100644
--- a/app/src/main/java/net/novagamestudios/kaffeekasse/model/kaffeekasse/KnownItem.kt
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/model/kaffeekasse/KnownItem.kt
@@ -3,168 +3,84 @@ package net.novagamestudios.kaffeekasse.model.kaffeekasse
 import kotlinx.serialization.Serializable
 
 
-/*
-KaffeekasseManualTransactionDetails(
-    accounts=[
-        Account(name=Broeckmann, Jonas, id=339, isDefault=true)
-    ],
-    itemGroups=[
-        ItemGroup(
-            name=Küche,
-            items=[
-                Item(name=Almdudler, id=221),
-                Item(name=Bier (0,33L auch alk.frei–außer Leffe/Erdinger), id=110),
-                Item(name=Bier (0,5 L) auch alk.frei - kein Erdinger-, id=80),
-                Item(name=Café au lait, id=163),
-                Item(name=Cappuccino, id=159),
-                Item(name=Club-Mate, id=108),
-                Item(name=Coca Cola (hi-carb), id=77),
-                Item(name=Coke ZERO, id=78),
-                Item(name=Coke Zero koffeinfrei, id=246),
-                Item(name=Duplex Farbe, id=155),
-                Item(name=Chouffe Bier, id=262),
-                Item(name=Duplex S/W, id=91),
-                Item(name=Eifler Landbier, id=192),
-                Item(name=Eis: Cuja Mara Split, id=170),
-                Item(name=Eis: Magnum / NUII, id=124),
-                Item(name=Engelbert Apfel, id=127),
-                Item(name=Engelbert Naturell, id=240),
-                Item(name=Engelbert/Rheinfels Sprudel, id=79),
-                Item(name=Erdinger Alkoholfrei (0,5l), id=242),
-                Item(name=Erdnüsse, id=87),
-                Item(name=Espresso, id=160),
-                Item(name=Fanta, id=81),
-                Item(name=Fassbrause/Bionade, id=199),
-                Item(name=Gerolsteiner, id=83),
-                Item(name=Hanuta, id=209),
-                Item(name=Haribo-Beutel, id=149),
-                Item(name=Kaffee klein, id=84),
-                Item(name=Kaffee groß, id=165),
-                Item(name=Erdinger Alkoholfrei (0.33l), id=261),
-                Item(name=Knoppers Riegel, id=214),
-                Item(name=Latte Macchiato, id=161),
-                Item(name=Leffe, id=191),
-                Item(name=Milka, id=152),
-                Item(name=Moccachoc, id=162),
-                Item(name=Pizza, id=243),
-                Item(name=Rockstar/Monster (inkl. Pfand), id=187),
-                Item(name=Schoko-Creme (Kakao), id=164),
-                Item(name=Simplex Farbe, id=140),
-                Item(name=Simplex S/W, id=90),
-                Item(name=Taschentücher, id=158),
-                Item(name=Apfel, id=195),
-                Item(name=Balisto, id=105),
-                Item(name=Duplo, id=231),
-                Item(name=Eis: Cornetto, id=128),
-                Item(name=Eis: Nogger, id=101),
-                Item(name=Oreo Kekse, id=235),
-                Item(name=CocaCola 0,5l GLAS, id=265),
-                Item(name=Trolli, id=249),
-                Item(name=Twix, id=93),
-                Item(name=Yogurette, id=217),
-                Item(name=Kinder Schokolade, id=220),
-                Item(name=Nuts Royal Nüsse, id=227),
-                Item(name=Mandarine/Clementine, id=213),
-                Item(name=Milch (Maschine), id=263)
-            ]
-        ),
-        ItemGroup(
-            name=Getränkeraum,
-            items=[
-                Item(name=MioMio Mate, id=259)
-            ]
-        ), ItemGroup(
-            name=Farbdrucker,
-            items=[
-                Item(name=3D-Druck pro gramm, id=256),
-                Item(name=Euglüh, id=173)
-            ]
-        )
-    ]
-)
-*/
 @Serializable
 enum class KnownItem(
-    val id: String,
+    val id: Int,
     val originalName: String,
 ) {
-    Almdudler               ("221", "Almdudler"),
-    Bier330                 ("110", "Bier (0,33L auch alk.frei–außer Leffe/Erdinger)"),
-    Bier500                 ( "80", "Bier (0,5 L) auch alk.frei - kein Erdinger-"),
-    CafeAuLait              ("163", "Café au lait"),
-    Cappuccino              ("159", "Cappuccino"),
-    ClubMate                ("108", "Club-Mate"),
-    CocaCola                ( "77", "Coca Cola (hi-carb)"),
-    CokeZero                ( "78", "Coke ZERO"),
-    CokeZeroCaffeineFree    ("246", "Coke Zero koffeinfrei"),
-    DuplexColor             ("155", "Duplex Farbe"),
-    ChouffeBier             ("262", "Chouffe Bier"),
-    DuplexBlackWhite        ( "91", "Duplex S/W"),
-    EiflerLandbier          ("192", "Eifler Landbier"),
-    CujaMaraSplit           ("170", "Eis: Cuja Mara Split"),
-    Magnum                  ("124", "Eis: Magnum / NUII"),
-    NUII                    ("124", "Eis: Magnum / NUII"),
-    EngelbertApple          ("127", "Engelbert Apfel"),
-    EngelbertNatural        ("240", "Engelbert Naturell"),
-    EngelbertSprudel        ( "79", "Engelbert/Rheinfels Sprudel"),
-    RheinfelsSprudel        ( "79", "Engelbert/Rheinfels Sprudel"),
-    ErdingerAlcoholFree500  ("242", "Erdinger Alkoholfrei (0,5l)"),
-    Erdnuesse               ( "87", "Erdnüsse"),
-    Espresso                ("160", "Espresso"),
-    Fanta                   ( "81", "Fanta"),
-    Fassbrause              ("199", "Fassbrause/Bionade"),
-    Bionade                 ("199", "Fassbrause/Bionade"),
-    Gerolsteiner            ( "83", "Gerolsteiner"),
-    Hanuta                  ("209", "Hanuta"),
-    HariboBag               ("149", "Haribo-Beutel"),
-    CoffeeSmall             ( "84", "Kaffee klein"),
-    CoffeeLarge             ("165", "Kaffee groß"),
-    ErdingerAlcoholFree330  ("261", "Erdinger Alkoholfrei (0.33l)"),
-    KnoppersBar             ("214", "Knoppers Riegel"),
-    LatteMacchiato          ("161", "Latte Macchiato"),
-    Leffe                   ("191", "Leffe"),
-    Milka                   ("152", "Milka"),
-    Moccachoc               ("162", "Moccachoc"),
-    Pizza                   ("243", "Pizza"),
-    Rockstar                ("187", "Rockstar/Monster (inkl. Pfand)"),
-    Monster                 ("187", "Rockstar/Monster (inkl. Pfand)"),
-    ChocolateCreamCocoa     ("164", "Schoko-Creme (Kakao)"),
-    SimplexColor            ("140", "Simplex Farbe"),
-    SimplexBlackWhite       ( "90", "Simplex S/W"),
-    Tissues                 ("158", "Taschentücher"),
-    Apple                   ("195", "Apfel"),
-    Balisto                 ("105", "Balisto"),
-    Duplo                   ("231", "Duplo"),
-    IceCornetto             ("128", "Eis: Cornetto"),
-    IceNogger               ("101", "Eis: Nogger"),
-    OreoCookies             ("235", "Oreo Kekse"),
-    CocaCola05Glass         ("265", "CocaCola 0,5l GLAS"),
-    Trolli                  ("249", "Trolli"),
-    Twix                    ( "93", "Twix"),
-    Yogurette               ("217", "Yogurette"),
-    KinderChocolate         ("220", "Kinder Schokolade"),
-    NutsRoyalNuts           ("227", "Nuts Royal Nüsse"),
-    Tangerine               ("213", "Mandarine/Clementine"),
-    Clementine              ("213", "Mandarine/Clementine"),
-    MilkMachine             ("263", "Milch (Maschine)"),
-    MioMioMate              ("259", "MioMio Mate"),
-    ThreeDPrintingPerGram   ("256", "3D-Druck pro gramm"),
-    Euglueh                 ("173", "Euglüh"),
-    Katjes                  ("254", "Katjes"),
+    Almdudler               (221, "Almdudler"),
+    Bier330                 (110, "Bier (0,33L auch alk.frei–außer Leffe/Erdinger)"),
+    Bier500                 ( 80, "Bier (0,5 L) auch alk.frei - kein Erdinger-"),
+    CafeAuLait              (163, "Café au lait"),
+    Cappuccino              (159, "Cappuccino"),
+    ClubMate                (108, "Club-Mate"),
+    CocaCola                ( 77, "Coca Cola (hi-carb)"),
+    CocaCola330             (266, "Coca Cola 0,33l"),
+    CokeZero                ( 78, "Coke ZERO"),
+    CokeZeroCaffeineFree    (246, "Coke Zero koffeinfrei"),
+    DuplexColor             (155, "Duplex Farbe"),
+    ChouffeBier             (262, "Chouffe Bier"),
+    DuplexBlackWhite        ( 91, "Duplex S/W"),
+    EiflerLandbier          (192, "Eifler Landbier"),
+    CujaMaraSplit           (170, "Eis: Cuja Mara Split"),
+    Magnum                  (124, "Eis: Magnum / NUII"),
+    NUII                    (124, "Eis: Magnum / NUII"),
+    EngelbertApple          (127, "Engelbert Apfel"),
+    EngelbertNatural        (240, "Engelbert Naturell"),
+    EngelbertSprudel        ( 79, "Engelbert/Rheinfels Sprudel"),
+    RheinfelsSprudel        ( 79, "Engelbert/Rheinfels Sprudel"),
+    ErdingerAlcoholFree500  (242, "Erdinger Alkoholfrei (0,5l)"),
+    Erdnuesse               ( 87, "Erdnüsse"),
+    Espresso                (160, "Espresso"),
+    Fanta                   ( 81, "Fanta"),
+    Fassbrause              (199, "Fassbrause/Bionade"),
+    Bionade                 (199, "Fassbrause/Bionade"),
+    Gerolsteiner            ( 83, "Gerolsteiner"),
+    Hanuta                  (209, "Hanuta"),
+    HariboBag               (149, "Haribo-Beutel"),
+    CoffeeSmall             ( 84, "Kaffee klein"),
+    CoffeeLarge             (165, "Kaffee groß"),
+    ErdingerAlcoholFree330  (261, "Erdinger Alkoholfrei (0.33l)"),
+    Knoppers                (117, "Knoppers"),
+    KnoppersBar             (214, "Knoppers Riegel"),
+    LatteMacchiato          (161, "Latte Macchiato"),
+    Leffe                   (191, "Leffe"),
+    Milka                   (152, "Milka"),
+    Moccachoc               (162, "Moccachoc"),
+    Pizza                   (243, "Pizza"),
+    Rockstar                (187, "Rockstar/Monster (inkl. Pfand)"),
+    Monster                 (187, "Rockstar/Monster (inkl. Pfand)"),
+    ChocolateCreamCocoa     (164, "Schoko-Creme (Kakao)"),
+    SimplexColor            (140, "Simplex Farbe"),
+    SimplexBlackWhite       ( 90, "Simplex S/W"),
+    Tissues                 (158, "Taschentücher"),
+    Apple                   (195, "Apfel"),
+    Balisto                 (105, "Balisto"),
+    Duplo                   (231, "Duplo"),
+    IceCornetto             (128, "Eis: Cornetto"),
+    IceNogger               (101, "Eis: Nogger"),
+    IceMars                 (136, "Eis: Mars/Snickers/Twix/Bounty"),
+    IceSnickers             (136, "Eis: Mars/Snickers/Twix/Bounty"),
+    IceTwix                 (136, "Eis: Mars/Snickers/Twix/Bounty"),
+    IceBounty               (136, "Eis: Mars/Snickers/Twix/Bounty"),
+    OreoCookies             (235, "Oreo Kekse"),
+    CocaCola05Glass         (265, "CocaCola 0,5l GLAS"),
+    Trolli                  (249, "Trolli"),
+    Twix                    ( 93, "Twix"),
+    Yogurette               (217, "Yogurette"),
+    KinderChocolate         (220, "Kinder Schokolade"),
+    NutsRoyalNuts           (227, "Nuts Royal Nüsse"),
+    Tangerine               (213, "Mandarine/Clementine"),
+    Clementine              (213, "Mandarine/Clementine"),
+    MilkMachine             (263, "Milch (Maschine)"),
+    MioMioMate              (259, "MioMio Mate"),
+    ThreeDPrintingPerGram   (256, "3D-Druck pro gramm"),
+    Euglueh                 (173, "Euglüh"),
+    Katjes                  (254, "Katjes"),
     ;
-    enum class Category {
-        ColdBeverage,
-        HotBeverage,
-        Snack,
-        IceCream,
-        Food,
-        Fruit,
-        Other
-    }
     companion object {
         val byId by lazy { entries.groupBy { it.id } }
         val idByOriginalName by lazy { entries.associate { it.originalName to it.id } }
     }
 }
 
-
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/model/kaffeekasse/KnownItemGroup.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/model/kaffeekasse/KnownItemGroup.kt
new file mode 100644
index 0000000000000000000000000000000000000000..a82fa956552905808f638a490716b5274e63434e
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/model/kaffeekasse/KnownItemGroup.kt
@@ -0,0 +1,15 @@
+package net.novagamestudios.kaffeekasse.model.kaffeekasse
+
+enum class KnownItemGroup(
+    val id: Int,
+    val originalName: String
+) {
+    Kueche       (5, "Küche"),
+    Getraenkeraum(6, "Getränkeraum"),
+    Farbdrucker  (7, "Farbdrucker"),
+    ;
+    companion object {
+        val byId by lazy { entries.associateBy { it.id } }
+        val byOriginalName by lazy { entries.associateBy { it.originalName } }
+    }
+}
\ No newline at end of file
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 8b0dcc850df37ac39ad1b99b4974acb8729d9c6f..c7bdbab2f5e96876a3e8d2306841586f30cb61d6 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
@@ -3,34 +3,50 @@ package net.novagamestudios.kaffeekasse.model.kaffeekasse
 import kotlinx.serialization.Serializable
 import net.novagamestudios.kaffeekasse.data.category
 import net.novagamestudios.kaffeekasse.data.cleanFullName
+import net.novagamestudios.kaffeekasse.data.cleanName
 import net.novagamestudios.kaffeekasse.data.cleanProductName
 import net.novagamestudios.kaffeekasse.data.cleanVariantName
+import net.novagamestudios.kaffeekasse.data.drawableResource
+import net.novagamestudios.kaffeekasse.data.estimatedPrice
 
 @Serializable
 data class ManualBillDetails(
-    val accounts: List<Account>,
+    val accounts: List<PurchaseAccount>,
     val itemGroups: List<ItemGroup>
 ) {
     @Serializable
-    data class Account(
-        val name: String,
-        val id: String,
+    data class PurchaseAccount(
+        override val name: Name,
+        override val id: Int,
         val isDefault: Boolean = false
-    )
+    ) : ScraperPurchaseAccount
     @Serializable
     data class ItemGroup(
-        val name: String,
-        val items: List<Item>
-    )
+        override val originalName: String,
+        override val items: List<Item>,
+        private val knownItemGroup: KnownItemGroup? = KnownItemGroup.byOriginalName[originalName]
+    ) : net.novagamestudios.kaffeekasse.model.kaffeekasse.ItemGroup {
+        override val id: Int? get() = knownItemGroup?.id
+        override val name: String get() = knownItemGroup?.cleanName ?: originalName
+    }
     @Serializable
     data class Item(
-        val name: String,
-        val id: String,
-        val knownItem: KnownItem? = null
-    ) {
-        val category get() = knownItem?.category ?: KnownItem.Category.Other
-        val cleanFullName get() = knownItem?.cleanFullName ?: name
-        val cleanProductName get() = knownItem?.cleanProductName ?: name
-        val cleanVariantName get() = knownItem?.cleanVariantName
+        override val originalName: String,
+        override val id: Int,
+        private val knownItem: KnownItem? = null
+    ) : net.novagamestudios.kaffeekasse.model.kaffeekasse.Item {
+        override val category get() = knownItem?.category ?: ItemCategory.Other
+
+        override val cleanFullName get() = knownItem?.cleanFullName ?: originalName
+        override val cleanProductName get() = knownItem?.cleanProductName ?: originalName
+        override val cleanVariantName get() = knownItem?.cleanVariantName
+
+        override val price: Double? get() = null
+        override val estimatedPrice: Double? get() = knownItem?.estimatedPrice
+
+        override val imageDrawable: Int? get() = knownItem?.drawableResource
+        override val imageUrl: String? get() = null
     }
-}
\ No newline at end of file
+}
+
+sealed interface ScraperPurchaseAccount : PurchaseAccount
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/model/kaffeekasse/Name.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/model/kaffeekasse/Name.kt
new file mode 100644
index 0000000000000000000000000000000000000000..84309c8530d13adf66e30c6afe386b2d6d1ca2d0
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/model/kaffeekasse/Name.kt
@@ -0,0 +1,47 @@
+package net.novagamestudios.kaffeekasse.model.kaffeekasse
+
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.SerializationException
+import kotlinx.serialization.builtins.serializer
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+import net.novagamestudios.kaffeekasse.model.kaffeekasse.Name.Companion.toNameOrNull
+
+
+@Serializable(with = NameWithCommaSerializer::class)
+data class Name(
+    val last: String,
+    val first: String?
+) : Comparable<Name> {
+    val firstLast by lazy { listOfNotNull(first, last).joinToString(" ") }
+    val char by lazy { last.first().uppercaseChar() }
+
+    private val string by lazy { listOfNotNull(last, first).joinToString(Separator) }
+    private val compareString by lazy { string.uppercase() }
+
+    override fun toString() = string
+
+    override fun compareTo(other: Name) = compareString.compareTo(other.compareString)
+
+    companion object {
+        const val Separator = ", "
+
+        fun String.toNameOrNull(): Name? = this
+            .split(Separator)
+            .takeIf { it.size <= 2 }
+            ?.let { Name(it[0], it.getOrNull(1)) }
+    }
+}
+
+
+object NameWithCommaSerializer : KSerializer<Name> {
+    override val descriptor = String.serializer().descriptor
+    override fun serialize(encoder: Encoder, value: Name) {
+        encoder.encodeString(value.toString())
+    }
+    override fun deserialize(decoder: Decoder): Name {
+        val raw = decoder.decodeString()
+        return raw.toNameOrNull() ?: throw SerializationException("Invalid name: $raw")
+    }
+}
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/model/kaffeekasse/PurchaseAccount.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/model/kaffeekasse/PurchaseAccount.kt
new file mode 100644
index 0000000000000000000000000000000000000000..594d805c5cb3f2ca23657e6bf3f9547953f20f62
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/model/kaffeekasse/PurchaseAccount.kt
@@ -0,0 +1,7 @@
+package net.novagamestudios.kaffeekasse.model.kaffeekasse
+
+interface PurchaseAccount {
+    val id: Int
+    val name: Name
+}
+
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/model/kaffeekasse/Stock.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/model/kaffeekasse/Stock.kt
new file mode 100644
index 0000000000000000000000000000000000000000..c27179bcf395bbc2071b671f19084bf93c3bfc9b
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/model/kaffeekasse/Stock.kt
@@ -0,0 +1,5 @@
+package net.novagamestudios.kaffeekasse.model.kaffeekasse
+
+interface Stock {
+    val itemGroups: List<ItemGroup>
+}
\ No newline at end of file
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/model/kaffeekasse/UserAuthCredentials.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/model/kaffeekasse/UserAuthCredentials.kt
new file mode 100644
index 0000000000000000000000000000000000000000..028c611667043607c0e4d76a1853d23451eb6195
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/model/kaffeekasse/UserAuthCredentials.kt
@@ -0,0 +1,11 @@
+package net.novagamestudios.kaffeekasse.model.kaffeekasse
+
+data class UserAuthCredentials(
+    val pin: String? = null,
+    val rwthId: String? = null,
+    val key: String? = null
+) {
+    companion object {
+        val Empty = UserAuthCredentials()
+    }
+}
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/model/session/Device.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/model/session/Device.kt
new file mode 100644
index 0000000000000000000000000000000000000000..1334017d40d6bcba7a720b830b1ae742002668d3
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/model/session/Device.kt
@@ -0,0 +1,12 @@
+package net.novagamestudios.kaffeekasse.model.session
+
+import kotlinx.serialization.Serializable
+import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItemGroup
+
+@Serializable
+data class Device(
+    val name: String?,
+    val itemTypeId: Int?
+) : java.io.Serializable {
+    val knownItemGroup by lazy { itemTypeId?.let { KnownItemGroup.byId[it] } }
+}
\ No newline at end of file
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/model/session/Session.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/model/session/Session.kt
new file mode 100644
index 0000000000000000000000000000000000000000..a44768c517814df3e5711974541cd247981d7b42
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/model/session/Session.kt
@@ -0,0 +1,50 @@
+package net.novagamestudios.kaffeekasse.model.session
+
+
+sealed interface Session : java.io.Serializable {
+    data object Empty : Session
+    interface WithDevice : Session {
+        val device: Device
+    }
+    interface WithRealUser : Session {
+        val realUser: User
+    }
+    interface WithDeviceAndUser : Session, WithDevice, WithRealUser
+
+    companion object {
+        operator fun invoke(
+            device: Device?,
+            user: User?
+        ): Session {
+            val realUser = user?.takeIf { it.isRealUser }
+            return when {
+                device != null && realUser != null -> SessionWithDeviceAndRealUser(device, realUser)
+                device != null -> SessionWithDevice(device)
+                realUser != null -> SessionWithRealUser(realUser)
+                else -> Empty
+            }
+        }
+    }
+}
+
+
+val Session.deviceOrNull: Device? get() = (this as? Session.WithDevice)?.device
+val Session.realUserOrNull: User? get() = (this as? Session.WithRealUser)?.realUser
+
+
+private val User.isKaffeekasse get() = user == "kaffeekasse"
+private val User.isRealUser get() = !isKaffeekasse
+
+
+private data class SessionWithDevice(
+    override val device: Device
+) : Session.WithDevice
+
+private data class SessionWithRealUser(
+    override val realUser: User
+) : Session.WithRealUser
+
+private data class SessionWithDeviceAndRealUser(
+    override val device: Device,
+    override val realUser: User
+) : Session.WithDeviceAndUser
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/model/session/User.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/model/session/User.kt
new file mode 100644
index 0000000000000000000000000000000000000000..19799feeb0925c3677a5b1123076703cbe7d053a
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/model/session/User.kt
@@ -0,0 +1,10 @@
+package net.novagamestudios.kaffeekasse.model.session
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class User(
+    val user: String? = null,
+    val displayName: String? = null
+) : java.io.Serializable
+
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/LoginCredentials.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/Credentials.kt
similarity index 58%
rename from app/src/main/java/net/novagamestudios/kaffeekasse/repositories/LoginCredentials.kt
rename to app/src/main/java/net/novagamestudios/kaffeekasse/repositories/Credentials.kt
index b1d36cf0b93f7ab5ac0dd04a88f5e66bd2bb19d0..6784cff22ca57949a5d01d4e1a9021dc96a0357e 100644
--- a/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/LoginCredentials.kt
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/Credentials.kt
@@ -16,24 +16,50 @@ import net.novagamestudios.common_utils.debug
 import net.novagamestudios.common_utils.error
 import net.novagamestudios.common_utils.info
 import net.novagamestudios.common_utils.toastShort
+import net.novagamestudios.common_utils.verbose
 import net.novagamestudios.kaffeekasse.BuildConfig
-import net.novagamestudios.kaffeekasse.model.i11_portal.Login
+import net.novagamestudios.kaffeekasse.model.credentials.DeviceCredentials
+import net.novagamestudios.kaffeekasse.model.credentials.Login
 
-class LoginCredentials(
-    private val credentialManager: CredentialManager
+class Credentials(
+    private val credentialManager: CredentialManager,
+    private val settingsStore: MutableSettingsStore
 ) : Logger {
 
-    private fun getDebugCredentials(): Login? {
-        if (!BuildConfig.DEBUG || !EnableDebugCredentials) return null
-        @Suppress("USELESS_ELVIS") val username = BuildConfig.I11PORTAL_DEBUG_USERNAME ?: return null
-        @Suppress("USELESS_ELVIS") val password = BuildConfig.I11PORTAL_DEBUG_PASSWORD ?: return null
+    @Suppress("RedundantIf")
+    private fun verifyDebug(): Boolean {
+        if (!BuildConfig.DEBUG) return false
+        return true
+    }
+
+    private fun getDebugUserLogin(): Login? {
+        if (!verifyDebug()) return null
+        if (!EnableDebugUserCredentials) return null
+        verbose { "Searching for debug user credentials" }
+        @Suppress("USELESS_ELVIS")
+        val username = BuildConfig.I11_PORTAL_DEBUG_USERNAME ?: return null
+        @Suppress("USELESS_ELVIS")
+        val password = BuildConfig.I11_PORTAL_DEBUG_PASSWORD ?: return null
         info { "Using debug credentials for $username" }
         return Login(username, password)
     }
 
+    private fun getDebugDeviceCredentials(): DeviceCredentials? {
+        if (!verifyDebug()) return null
+        if (!EnableDebugDeviceCredentials) return null
+        verbose { "Searching for debug device credentials" }
+        @Suppress("USELESS_ELVIS")
+        val deviceId = BuildConfig.I11_KAFFEEKASSE_DEBUG_DEVICEID ?: return null
+        @Suppress("USELESS_ELVIS")
+        val apiKey = BuildConfig.I11_KAFFEEKASSE_DEBUG_APIKEY ?: return null
+        info { "Using debug credentials for $deviceId" }
+        return DeviceCredentials(deviceId, apiKey)
+    }
+
+
     context (Context)
-    suspend fun get(): Login? {
-        getDebugCredentials()?.let { return it }
+    suspend fun userLogin(): Login? {
+        getDebugUserLogin()?.let { return it }
         val request = GetCredentialRequest(
             listOf(GetPasswordOption())
         )
@@ -52,7 +78,7 @@ class LoginCredentials(
     }
 
     context (Context)
-    suspend fun store(login: Login): StoreResult {
+    suspend fun storeUserLogin(login: Login): StoreResult {
         val passwordRequest = CreatePasswordRequest(login.username, login.password)
         val response = try {
             credentialManager.createCredential(this@Context, passwordRequest)
@@ -76,7 +102,18 @@ class LoginCredentials(
         data class Error(val message: String?) : StoreResult
     }
 
+
+    suspend fun deviceCredentials(): DeviceCredentials? {
+        getDebugDeviceCredentials()?.let { return it }
+        return settingsStore.value.deviceCredentials
+    }
+
+    suspend fun storeDeviceCredentials(credentials: DeviceCredentials?) {
+        settingsStore.update { it.copy(deviceCredentials = credentials) }
+    }
+
     companion object {
-        private const val EnableDebugCredentials = true
+        private const val EnableDebugUserCredentials = true
+        private const val EnableDebugDeviceCredentials = true
     }
 }
\ No newline at end of file
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/GitLabReleases.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/GitLabReleases.kt
deleted file mode 100644
index ed18a6e68e4c4e291bf6998b3de09ad9215ffd0d..0000000000000000000000000000000000000000
--- a/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/GitLabReleases.kt
+++ /dev/null
@@ -1,124 +0,0 @@
-package net.novagamestudios.kaffeekasse.repositories
-
-import kotlinx.coroutines.flow.MutableStateFlow
-import net.novagamestudios.common_utils.Logger
-import net.novagamestudios.common_utils.debug
-import net.novagamestudios.common_utils.info
-import net.novagamestudios.kaffeekasse.gitlab.GitLab
-import net.novagamestudios.kaffeekasse.model.AppRelease
-import net.novagamestudios.kaffeekasse.model.AppVersion
-
-class GitLabReleases(
-    private val gitLab: GitLab
-) : Releases, Logger {
-
-    override val newerReleases: MutableStateFlow<List<AppRelease>?> = MutableStateFlow(null)
-
-    override suspend fun fetchNewerReleases() {
-        info { "Checking for newer releases ..." }
-        newerReleases.value = gitLab.releases.list()
-            .also { debug { "Found releases: $it" } }
-            .asSequence()
-            .filter { !it.upcomingRelease }
-            .mapNotNull { release -> release.version?.let { it to release } }
-            .filter { (version, _) -> version > AppVersion.Current }
-            .sortedByDescending { (version, _) -> version }
-            .mapNotNull { (version, release) ->
-                val link = release.assets.links.let { links ->
-                    links.firstOrNull { it.name.endsWith(".apk") } ?: links.firstOrNull()
-                } ?: return@mapNotNull null
-                AppRelease(version, link.url, release.createdAt, release.description)
-            }
-            .toList()
-            .also { debug { "Found newer releases than ${AppVersion.Current}: $it" } }
-    }
-}
-
-
-/*
-
-    {
-        "name": "1.0.0",
-        "tag_name": "v1.0.0",
-        "description": null,
-        "created_at": "2024-03-03T22:38:29.368+01:00",
-        "released_at": "2024-03-03T22:38:29.368+01:00",
-        "upcoming_release": false,
-        "author": {
-            "id": 13529,
-            "username": "jonas.broeckmann",
-            "name": "Jonas Broeckmann",
-            "state": "active",
-            "locked": false,
-            "avatar_url": "https://git.rwth-aachen.de/uploads/-/system/user/avatar/13529/avatar.png",
-            "web_url": "https://git.rwth-aachen.de/jonas.broeckmann"
-        },
-        "commit": {
-            "id": "0f3ed979a3aa506e5fbbe37ac2b25929dcea5718",
-            "short_id": "0f3ed979",
-            "created_at": "2024-03-03T22:32:51.000+01:00",
-            "parent_ids": [
-                "4a566100b8648a1a452966e9bdb97c6ae37b47f1"
-            ],
-            "title": "CI use package registry",
-            "message": "CI use package registry\n",
-            "author_name": "JojoIV",
-            "author_email": "jonas.broeckmann@gmx.de",
-            "authored_date": "2024-03-03T22:32:51.000+01:00",
-            "committer_name": "JojoIV",
-            "committer_email": "jonas.broeckmann@gmx.de",
-            "committed_date": "2024-03-03T22:32:51.000+01:00",
-            "trailers": {},
-            "extended_trailers": {},
-            "web_url": "https://git.rwth-aachen.de/jonas.broeckmann/kaffeekasse/-/commit/0f3ed979a3aa506e5fbbe37ac2b25929dcea5718"
-        },
-        "commit_path": "/jonas.broeckmann/kaffeekasse/-/commit/0f3ed979a3aa506e5fbbe37ac2b25929dcea5718",
-        "tag_path": "/jonas.broeckmann/kaffeekasse/-/tags/v1.0.0",
-        "assets": {
-            "count": 5,
-            "sources": [
-                {
-                    "format": "zip",
-                    "url": "https://git.rwth-aachen.de/jonas.broeckmann/kaffeekasse/-/archive/v1.0.0/kaffeekasse-v1.0.0.zip"
-                },
-                {
-                    "format": "tar.gz",
-                    "url": "https://git.rwth-aachen.de/jonas.broeckmann/kaffeekasse/-/archive/v1.0.0/kaffeekasse-v1.0.0.tar.gz"
-                },
-                {
-                    "format": "tar.bz2",
-                    "url": "https://git.rwth-aachen.de/jonas.broeckmann/kaffeekasse/-/archive/v1.0.0/kaffeekasse-v1.0.0.tar.bz2"
-                },
-                {
-                    "format": "tar",
-                    "url": "https://git.rwth-aachen.de/jonas.broeckmann/kaffeekasse/-/archive/v1.0.0/kaffeekasse-v1.0.0.tar"
-                }
-            ],
-            "links": [
-                {
-                    "id": 802,
-                    "name": "kaffeekasse-1.0.0.apk",
-                    "url": "https://git.rwth-aachen.de/api/v4/projects/95637/packages/generic/kaffeekasse/1.0.0/kaffeekasse-1.0.0.apk",
-                    "direct_asset_url": "https://git.rwth-aachen.de/api/v4/projects/95637/packages/generic/kaffeekasse/1.0.0/kaffeekasse-1.0.0.apk",
-                    "link_type": "other"
-                }
-            ]
-        },
-        "evidences": [
-            {
-                "sha": "e93469e2ef6de10312003fd88eca1f021342d1c20eed",
-                "filepath": "https://git.rwth-aachen.de/jonas.broeckmann/kaffeekasse/-/releases/v1.0.0/evidences/5410.json",
-                "collected_at": "2024-03-03T22:38:29.729+01:00"
-            }
-        ],
-        "_links": {
-            "closed_issues_url": "https://git.rwth-aachen.de/jonas.broeckmann/kaffeekasse/-/issues?release_tag=v1.0.0&scope=all&state=closed",
-            "closed_merge_requests_url": "https://git.rwth-aachen.de/jonas.broeckmann/kaffeekasse/-/merge_requests?release_tag=v1.0.0&scope=all&state=closed",
-            "merged_merge_requests_url": "https://git.rwth-aachen.de/jonas.broeckmann/kaffeekasse/-/merge_requests?release_tag=v1.0.0&scope=all&state=merged",
-            "opened_issues_url": "https://git.rwth-aachen.de/jonas.broeckmann/kaffeekasse/-/issues?release_tag=v1.0.0&scope=all&state=opened",
-            "opened_merge_requests_url": "https://git.rwth-aachen.de/jonas.broeckmann/kaffeekasse/-/merge_requests?release_tag=v1.0.0&scope=all&state=opened",
-            "self": "https://git.rwth-aachen.de/jonas.broeckmann/kaffeekasse/-/releases/v1.0.0"
-        }
-    }
- */
-
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/I11Client.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/I11Client.kt
deleted file mode 100644
index b4d8f26271c50d27cf380e314d3afb0d0ec1ba43..0000000000000000000000000000000000000000
--- a/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/I11Client.kt
+++ /dev/null
@@ -1,611 +0,0 @@
-package net.novagamestudios.kaffeekasse.repositories
-
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateMapOf
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.setValue
-import io.ktor.client.HttpClient
-import io.ktor.client.call.body
-import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
-import io.ktor.client.plugins.cookies.CookiesStorage
-import io.ktor.client.plugins.cookies.HttpCookies
-import io.ktor.client.plugins.cookies.cookies
-import io.ktor.client.request.HttpRequestBuilder
-import io.ktor.client.request.forms.submitForm
-import io.ktor.client.request.get
-import io.ktor.client.request.parameter
-import io.ktor.client.statement.HttpResponse
-import io.ktor.client.statement.bodyAsText
-import io.ktor.http.ContentType
-import io.ktor.http.Cookie
-import io.ktor.http.Parameters
-import io.ktor.http.URLBuilder
-import io.ktor.http.Url
-import io.ktor.http.hostIsIp
-import io.ktor.http.isSecure
-import io.ktor.http.parameters
-import io.ktor.http.renderCookieHeader
-import io.ktor.serialization.kotlinx.json.json
-import io.ktor.util.date.GMTDate
-import io.ktor.util.date.getTimeMillis
-import io.ktor.util.toLowerCasePreservingASCIIRules
-import it.skrape.core.htmlDocument
-import it.skrape.selects.Doc
-import it.skrape.selects.DocElement
-import it.skrape.selects.attribute
-import it.skrape.selects.html5.a
-import it.skrape.selects.html5.form
-import it.skrape.selects.html5.option
-import it.skrape.selects.html5.select
-import it.skrape.selects.html5.table
-import it.skrape.selects.html5.td
-import it.skrape.selects.html5.tr
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.newCoroutineContext
-import kotlinx.coroutines.sync.Mutex
-import kotlinx.coroutines.sync.withLock
-import kotlinx.coroutines.withContext
-import kotlinx.datetime.LocalDate
-import kotlinx.datetime.LocalTime
-import kotlinx.datetime.format
-import kotlinx.datetime.format.char
-import kotlinx.serialization.SerialName
-import kotlinx.serialization.Serializable
-import kotlinx.serialization.json.Json
-import kotlinx.serialization.json.JsonElement
-import net.novagamestudios.common_utils.Logger
-import net.novagamestudios.common_utils.debug
-import net.novagamestudios.common_utils.info
-import net.novagamestudios.common_utils.warn
-import net.novagamestudios.kaffeekasse.model.kaffeekasse.Cart
-import net.novagamestudios.kaffeekasse.model.i11_portal.api.I11PortalData
-import net.novagamestudios.kaffeekasse.model.i11_portal.api.HiwiTrackerMonthData
-import net.novagamestudios.kaffeekasse.model.i11_portal.api.KaffeekasseData
-import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem
-import net.novagamestudios.kaffeekasse.model.i11_portal.Login
-import net.novagamestudios.kaffeekasse.model.hiwi_tracker.MonthKey
-import net.novagamestudios.kaffeekasse.model.hiwi_tracker.MonthKey.Companion.toMonthKey
-import net.novagamestudios.kaffeekasse.model.hiwi_tracker.WorkEntry
-import net.novagamestudios.kaffeekasse.model.hiwi_tracker.WorkEntry.Companion.isValid
-import net.novagamestudios.kaffeekasse.model.kaffeekasse.ManualBillDetails
-import net.novagamestudios.kaffeekasse.model.kaffeekasse.ManualBillDetails.Account
-import net.novagamestudios.kaffeekasse.model.kaffeekasse.ManualBillDetails.Item
-import net.novagamestudios.kaffeekasse.model.kaffeekasse.ManualBillDetails.ItemGroup
-import net.novagamestudios.kaffeekasse.model.kaffeekasse.Transaction
-import net.novagamestudios.kaffeekasse.model.kaffeekasse.isEmpty
-import net.novagamestudios.kaffeekasse.model.i11_portal.isValid
-import net.novagamestudios.kaffeekasse.ui.hiwi_tracker.formatHiwiTracker
-import net.novagamestudios.kaffeekasse.util.RichData
-import net.novagamestudios.kaffeekasse.util.RichDataState
-import java.time.LocalDateTime
-import java.time.format.DateTimeFormatter
-import kotlin.math.min
-import kotlin.time.Duration
-
-
-class I11Client(
-    private val coroutineScope: CoroutineScope // TODO use
-) : Logger {
-
-    private val computationScope = coroutineScope.newCoroutineContext(Dispatchers.IO)
-
-    private val cookiesStorage by lazy { MutableCookiesStorage() }
-    private val client = HttpClient {
-        install(HttpCookies) {
-            storage = cookiesStorage
-        }
-        install(ContentNegotiation) {
-            json(
-                Json {
-                    prettyPrint = true
-                    isLenient = true
-                    ignoreUnknownKeys = true
-                },
-                contentType = ContentType.Text.Any // Uses "text/json"
-            )
-        }
-    }
-
-
-    val portal = Portal()
-    val kaffeekasse = Kaffeekasse()
-    val hiwiTracker = HiwiTracker()
-
-    private val modules: List<Module<*>> = listOf(portal, kaffeekasse, hiwiTracker)
-
-
-    private var lastTriedLogin: Login? = null
-    private var session by mutableStateOf<Session?>(null)
-    var loginErrors by mutableStateOf<List<String>?>(null)
-        private set
-    val isLoggedIn get() = session != null
-
-    private suspend fun performLogin(login: Login): Session {
-        debug { "performing login" }
-        if (!login.isValid) throw IllegalArgumentException("Invalid login")
-        lastTriedLogin = login
-        session = null
-        loginErrors = null
-        modules.forEach { it.invalidateData() }
-        info { "logging in as ${login.username}" }
-        portal.fetchData(parameters {
-            append("username", login.username)
-            append("password", login.password)
-            append("login", "")
-        })
-        val response = portal.jsonData.value
-        val sessionCookie = client.cookies(portal.baseUrl)
-            .firstOrNull { it.name == SessionCookieName && !it.isExpired }
-        if (response == null || !response.session.login || sessionCookie == null) {
-            debug { "login failed" }
-            modules.forEach { it.invalidateData() }
-            loginErrors = response?.errors ?: emptyList()
-            throw IllegalStateException("Login failed")
-        }
-        debug { "login successful" }
-        return Session(sessionCookie).also { session = it }
-    }
-
-    suspend fun login(login: Login) {
-        debug { "login" }
-        if (!login.isValid) throw IllegalArgumentException("Invalid login")
-        if (login == lastTriedLogin && isLoggedIn) return
-        performLogin(login)
-    }
-    suspend fun logout() {
-        debug { "logout" }
-        lastTriedLogin = null
-        session = null
-        loginErrors = null
-        modules.forEach { it.invalidateData() }
-    }
-    private suspend fun requireSession(): Session = session?.takeIf { it.isValid } ?: performLogin(
-        lastTriedLogin ?: throw IllegalStateException("Not logged in and no login credentials provided")
-    )
-    private data class Session(
-        private val sessionCookie: Cookie
-    ) {
-        val isValid get() = !sessionCookie.isExpired
-        context(HttpRequestBuilder)
-        fun apply() {
-            headers["Cookie"] = renderCookieHeader(sessionCookie)
-        }
-    }
-
-
-    companion object {
-        private const val SessionCookieName = "PORTALSESSID"
-    }
-
-
-    abstract inner class Module<T : I11PortalData>(
-        val baseUrl: Url
-    ) {
-        val isLoggedIn get() = this@I11Client.isLoggedIn
-
-        protected val jsonUrl = URLBuilder(baseUrl).apply {
-            parameters["json"] = ""
-        }.build()
-
-        val jsonData = MutableStateFlow<T?>(null)
-        var jsonDataDirty by mutableStateOf(false)
-            protected set
-
-        suspend fun fetchData(formParameters: Parameters = Parameters.Empty) {
-            val session = if ("login" in formParameters) null else requireSession()
-            val response = client.submitForm(
-                url = "$jsonUrl",
-                formParameters = formParameters
-            ) {
-                session?.apply()
-            }
-            jsonData.value = response.toData()
-            jsonDataDirty = false
-        }
-        protected abstract suspend fun HttpResponse.toData(): T
-
-        open suspend fun invalidateData() {
-            cookiesStorage.clear(baseUrl)
-            jsonData.value = null
-        }
-    }
-
-    inner class Portal : Module<I11PortalData>(Url("https://portal.embedded.rwth-aachen.de")) {
-        override suspend fun HttpResponse.toData(): I11PortalData {
-            return body<I11PortalDataImpl>()
-        }
-    }
-    @Serializable
-    private data class I11PortalDataImpl(
-        override val session: I11PortalData.Session,
-        override val infos: List<String>,
-        override val warnings: List<String>,
-        override val errors: List<String>,
-        override val navigation: JsonElement,
-        @SerialName("ajaxui")
-        override val ajaxUI: I11PortalData.AjaxUI? = null
-    ) : I11PortalData
-
-    inner class Kaffeekasse : Module<KaffeekasseData>(Url("https://kaffeekasse.embedded.rwth-aachen.de")) {
-        val manuaBillUrl = URLBuilder(baseUrl).apply {
-            parameters["manualbill"] = ""
-        }.build()
-        val transactionsUrl = URLBuilder(baseUrl).apply {
-            parameters["balance"] = ""
-        }.build()
-
-        val manualBillDetails = MutableStateFlow<ManualBillDetails?>(null)
-        val transactions = MutableStateFlow<List<Transaction>?>(null)
-
-        var manualBillDetailsDirty by mutableStateOf(false)
-            private set
-        var transactionsDirty by mutableStateOf(false)
-            private set
-
-        override suspend fun HttpResponse.toData(): KaffeekasseData {
-            return body<KaffeekasseData>()
-        }
-
-        override suspend fun invalidateData() {
-            super.invalidateData()
-            manualBillDetails.value = null
-            transactions.value = null
-        }
-
-        suspend fun fetchManualBillDetails() = withContext(computationScope) {
-            val session = requireSession()
-            val response = client.get(manuaBillUrl) {
-                session.apply()
-            }
-            manualBillDetails.value = htmlDocument(response.bodyAsText()) {
-                scrapeManualBillDetails()
-            }
-            manualBillDetailsDirty = false
-        }
-
-        suspend fun fetchTransactions() {
-            val session = requireSession()
-            val response = client.get(transactionsUrl) {
-                session.apply()
-            }
-            transactions.value = htmlDocument(response.bodyAsText()) {
-                scrapeTransactions()
-            }
-            transactionsDirty = false
-        }
-
-        suspend fun submitCart(account: Account, cart: Cart) {
-            if (cart.isEmpty()) return
-            val session = requireSession()
-            client.submitForm(
-                url = "$manuaBillUrl",
-                formParameters = parameters {
-                    cart.forEach { (item, count) ->
-                        info { "adding @ $account : $item x $count" }
-                        append("tdata[account_id][]", account.id)
-                        append("tdata[item_id][]", item.id)
-                        append("tdata[item_count][]", "$count")
-                        append("manualbill", "")
-                        append("lockedfrom", "1")
-                    }
-                }
-            ) {
-                session.apply()
-            }
-            jsonDataDirty = true
-            manualBillDetailsDirty = true
-            transactionsDirty = true
-        }
-
-        private fun Doc.scrapeManualBillDetails(): ManualBillDetails = form {
-            table {
-                val accounts = select {
-                    withId = "manual_account_id"
-                    option {
-                        findAll {
-                            mapNotNull { option ->
-                                if (option.hasAttribute("disabled")) null
-                                else if (option.attribute("value").isBlank()) null
-                                else Account(
-                                    name = option.text,
-                                    id = option.attribute("value"),
-                                    isDefault = option.attribute("selected") == "selected"
-                                )
-                            }
-                        }
-                    }
-                }
-                val groups = select {
-                    withId = "manual_item_id"
-                    option {
-                        findAll {
-                            val groups = mutableListOf<ItemGroup>()
-                            var currentGroup: String? = null
-                            var currentItems = mutableListOf<Item>()
-                            for (option in this) {
-                                if (option.hasAttribute("disabled")) {
-                                    if (currentGroup != null) {
-                                        groups += ItemGroup(
-                                            currentGroup,
-                                            currentItems
-                                        )
-                                    }
-                                    currentGroup = option.text
-                                    currentItems = mutableListOf()
-                                } else if (option.attribute("value").isBlank()) continue
-                                else {
-                                    val id = option.attribute("value")
-                                    val name = option.text
-                                    val knownItems = KnownItem.byId[id]
-                                    if (knownItems != null) knownItems.forEach {
-                                        currentItems += Item(
-                                            name = name,
-                                            id = id,
-                                            knownItem = it
-                                        )
-                                    } else {
-                                        currentItems += Item(
-                                            name = option.text,
-                                            id = id
-                                        )
-                                    }
-                                }
-                            }
-                            if (currentGroup != null) {
-                                groups += ItemGroup(
-                                    currentGroup,
-                                    currentItems
-                                )
-                            }
-                            groups
-                        }
-                    }
-                }
-                ManualBillDetails(accounts, groups)
-            }
-        }
-
-        private fun Doc.scrapeTransactions(): List<Transaction> = table {
-            withClass = "transactions"
-            tr {
-                findAll {
-                    mapNotNull { row ->
-                        if (row.children.any { it.tagName == "th" }) return@mapNotNull null
-                        row.scrapeTransactionRow()
-                    }
-                }
-            }
-        }
-        private fun String.euros() = removeSuffix("€").toDouble()
-        private fun DocElement.scrapeTransactionRow(): Transaction? = td {
-            findAll {
-                if (size != 7) return@findAll null
-                // Example: 2024-02-26 13:05:21
-                val dateTime = LocalDateTime.parse(
-                    this[0].text.trim(),
-                    DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
-                )
-                val payer = this[1].text.trim().takeUnless { it == "-" }
-                val payee = this[2].text.trim()
-                val purpose = this[3].text.parseTransactionPurpose()
-                val total = this[4].text.trim().euros()
-                val newBalance = this[5].text.trim().euros()
-                val refundId = this[6].a { attribute("href") }.substringAfterLast("=")
-                Transaction(
-                    date = dateTime,
-                    payer = payer,
-                    payee = payee,
-                    purpose = purpose,
-                    total = total,
-                    newBalance = newBalance,
-                    refundId = refundId
-                )
-            }
-        }
-        private fun String.parseTransactionPurpose(): Transaction.Purpose {
-            val trimmed = trim()
-            return when (trimmed.lowercase()) {
-                "einzahlung" -> Transaction.Purpose.Deposit
-                else -> {
-                    val match = "([0-9]+)x (.+) à (-?[0-9.]+€?)".toRegex().matchEntire(trimmed)
-                        ?: return Transaction.Purpose.Other(trimmed)
-                    val (count, itemName, unitPrice) = match.destructured
-                    Transaction.Purpose.Purchase(itemName, count, unitPrice.euros())
-                }
-            }
-        }
-    }
-
-
-    inner class HiwiTracker : Module<HiwiTrackerMonthData>(Url("https://hiwi.embedded.rwth-aachen.de")) {
-
-        val dataByMonth = mutableStateMapOf<MonthKey, RichDataState<HiwiTrackerMonthData, List<String>>>()
-
-        private val apiDateFormat = LocalDate.Format {
-            year()
-            char('-')
-            monthNumber()
-            char('-')
-            dayOfMonth()
-        }
-        private val apiTimeFormat = LocalTime.Format {
-            hour()
-            char(':')
-            minute()
-        }
-        private fun Duration.apiFormat() = toComponents { hours, minutes, _, _ ->
-            "%02d:%02d".format(hours, minutes)
-        }
-
-        suspend fun fetchDataForMonth(month: MonthKey) {
-            val richDataState = dataByMonth.getOrPut(month) { RichDataState() }
-            richDataState.calculate(
-                onError = {
-                    warn(it) { "Error fetching data for $month" }
-                    RichData.Error(emptyList())
-                }
-            ) {
-                val session = requireSession()
-                val response = client.get(jsonUrl) {
-                    session.apply()
-                    parameter("selectdate", month.toLocalDate().format(apiDateFormat))
-                }
-                RichData.Data(response.toData())
-            }
-        }
-
-        override suspend fun HttpResponse.toData(): HiwiTrackerMonthData {
-            try {
-                return body<HiwiTrackerMonthData>()
-            } catch (e: Throwable) {
-                val text = bodyAsText()
-                debug { text }
-                throw e
-            }
-        }
-        override suspend fun invalidateData() {
-            super.invalidateData()
-            dataByMonth.clear()
-        }
-
-        suspend fun submitWorkEntry(workEntry: WorkEntry) {
-            if (!workEntry.isValid) return
-            val monthKey = workEntry.date.toMonthKey()
-            val session = requireSession()
-            client.submitForm(
-                url = "$baseUrl",
-                formParameters = parameters {
-                    append("hw_date", workEntry.date.format(apiDateFormat))
-                    append("hw_begin", workEntry.begin.format(apiTimeFormat))
-                    append("hw_end", workEntry.end.format(apiTimeFormat))
-                    append("hw_breaktime", workEntry.breakDurationOrNull?.apiFormat() ?: "")
-                    append("hw_note", workEntry.note)
-                    append("savetimes", "")
-                }
-            ) {
-                session.apply()
-            }
-            jsonDataDirty = true
-            dataByMonth[monthKey]?.invalidate()
-        }
-
-        suspend fun deleteWorkEntry(workEntryId: Int) {
-            val session = requireSession()
-            client.submitForm(
-                url = "$baseUrl",
-                formParameters = parameters {
-                    append("deleteentry", "$workEntryId")
-                },
-                encodeInQuery = true
-            ) {
-                session.apply()
-            }
-            jsonDataDirty = true
-            dataByMonth.values.forEach { it.invalidate() }
-        }
-    }
-}
-
-
-val Cookie.isExpired get() = expires?.let { it < GMTDate() } ?: false
-
-
-private class MutableCookiesStorage : CookiesStorage {
-    private val container: MutableList<Cookie> = mutableListOf()
-    private var oldestCookie: Long = 0L
-    private val mutex = Mutex()
-
-    override suspend fun get(requestUrl: Url): List<Cookie> = mutex.withLock {
-        val now = getTimeMillis()
-        if (now >= oldestCookie) cleanup(now)
-
-        return@withLock container.filter { it.matches(requestUrl) }
-    }
-
-    override suspend fun addCookie(requestUrl: Url, cookie: Cookie): Unit = mutex.withLock {
-        with(cookie) {
-            if (name.isBlank()) return@withLock
-        }
-
-        container.removeAll { it.name == cookie.name && it.matches(requestUrl) }
-        container.add(cookie.fillDefaults(requestUrl))
-        cookie.expires?.timestamp?.let { expires ->
-            if (oldestCookie > expires) {
-                oldestCookie = expires
-            }
-        }
-    }
-
-    @Suppress("unused")
-    suspend fun removeCookie(requestUrl: Url, cookie: Cookie): Unit = mutex.withLock {
-        container.removeAll { it.name == cookie.name && it.matches(requestUrl) }
-    }
-    suspend fun clear(requestUrl: Url): Unit = mutex.withLock {
-        container.removeAll { it.matches(requestUrl) }
-    }
-
-    override fun close() {
-    }
-
-    private fun cleanup(timestamp: Long) {
-        container.removeAll { cookie ->
-            val expires = cookie.expires?.timestamp ?: return@removeAll false
-            expires < timestamp
-        }
-
-        val newOldest = container.fold(Long.MAX_VALUE) { acc, cookie ->
-            cookie.expires?.timestamp?.let { min(acc, it) } ?: acc
-        }
-
-        oldestCookie = newOldest
-    }
-}
-
-private fun Cookie.matches(requestUrl: Url): Boolean {
-    val domain = domain?.toLowerCasePreservingASCIIRules()?.trimStart('.')
-        ?: error("Domain field should have the default value")
-
-    val path = with(path) {
-        val current = path ?: error("Path field should have the default value")
-        if (current.endsWith('/')) current else "$path/"
-    }
-
-    val host = requestUrl.host.toLowerCasePreservingASCIIRules()
-    val requestPath = let {
-        val pathInRequest = requestUrl.encodedPath
-        if (pathInRequest.endsWith('/')) pathInRequest else "$pathInRequest/"
-    }
-
-    if (host != domain && (hostIsIp(host) || !host.endsWith(".$domain"))) {
-        return false
-    }
-
-    if (path != "/" &&
-        requestPath != path &&
-        !requestPath.startsWith(path)
-    ) {
-        return false
-    }
-
-    return !(secure && !requestUrl.protocol.isSecure())
-}
-
-private fun Cookie.fillDefaults(requestUrl: Url): Cookie {
-    var result = this
-
-    if (result.path?.startsWith("/") != true) {
-        result = result.copy(path = requestUrl.encodedPath)
-    }
-
-    if (result.domain.isNullOrBlank()) {
-        result = result.copy(domain = requestUrl.host)
-    }
-
-    return result
-}
-
-
-
-
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/LoginRepository.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/LoginRepository.kt
new file mode 100644
index 0000000000000000000000000000000000000000..1cc778ed77aadf60f4e1a7cfcb4c7b58f23f3af8
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/LoginRepository.kt
@@ -0,0 +1,187 @@
+package net.novagamestudios.kaffeekasse.repositories
+
+import android.content.Context
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.sync.Mutex
+import net.novagamestudios.common_utils.Logger
+import net.novagamestudios.common_utils.debug
+import net.novagamestudios.common_utils.info
+import net.novagamestudios.common_utils.toastShort
+import net.novagamestudios.common_utils.verbose
+import net.novagamestudios.common_utils.warn
+import net.novagamestudios.kaffeekasse.api.kaffeekasse.KaffeekasseAPI
+import net.novagamestudios.kaffeekasse.api.kaffeekasse.model.BasicUserInfo
+import net.novagamestudios.kaffeekasse.model.credentials.DeviceCredentials
+import net.novagamestudios.kaffeekasse.model.credentials.Login
+import net.novagamestudios.kaffeekasse.model.credentials.isValid
+import net.novagamestudios.kaffeekasse.model.kaffeekasse.UserAuthCredentials
+import net.novagamestudios.kaffeekasse.model.session.Session
+import net.novagamestudios.kaffeekasse.repositories.i11.KaffeekasseRepository
+import net.novagamestudios.kaffeekasse.repositories.i11.PortalRepository
+import net.novagamestudios.kaffeekasse.util.mapState
+import net.novagamestudios.kaffeekasse.util.withReentrantLock
+
+class LoginRepository(
+    private val credentialsRepository: Credentials,
+    private val settings: SettingsRepository,
+    private val portal: PortalRepository,
+    private val kaffeekasse: KaffeekasseRepository
+) : Logger {
+
+    private val mutex = Mutex()
+    private val _isPerformingAction = MutableStateFlow(false)
+    val isPerformingAction = _isPerformingAction.asStateFlow()
+
+    private suspend fun <R> action(block: suspend () -> R) = mutex.withReentrantLock {
+        val prev = _isPerformingAction.value
+        verbose { "Locking action: ${!prev}" }
+        _isPerformingAction.value = true
+        try {
+            block()
+        } finally {
+            verbose { "Unlocking action: ${!prev}" }
+            _isPerformingAction.value = prev
+        }
+    }
+
+
+
+    private val _errors = MutableStateFlow<List<String>?>(null)
+    val errors = _errors.asStateFlow()
+
+
+
+    val autoLogin by lazy { settings.values.mapState { it.autoLogin } }
+    private var autoLoginAttemptAvailable = true
+
+    private suspend fun performUserLogin(login: Login): Boolean = action {
+        _errors.value = null
+        info { "Logging in user: ${login.username}" }
+        val success = try {
+            portal.loginUser(login)
+            debug { "User logged in" }
+            true
+        } catch (e: Exception) {
+            warn(e) { "Failed to login" }
+            _errors.value = listOf(e.message ?: e::class.simpleName ?: "Unknown error")
+            false
+        }
+        autoLoginAttemptAvailable = success
+        return@action success
+    }
+
+    suspend fun tryAutoLogin(activityContext: Context): Login? = action {
+        if (portal.session.value !is Session.Empty) return@action null
+        if (!autoLoginAttemptAvailable) return@action null
+
+        debug { "Auto login enabled: ${autoLogin.value}" }
+        if (!autoLogin.value) return@action null
+        val login = with(activityContext) {
+            credentialsRepository.userLogin()
+                ?.takeIf { it.isValid }
+                ?: run {
+                    autoLoginAttemptAvailable = false
+                    return@action null
+                }
+        }
+        autoLoginAttemptAvailable = false
+        info { "Trying to auto login user: ${login.username}" }
+        performUserLogin(login)
+        return@action login
+    }
+
+    suspend fun login(login: Login, autoLogin: Boolean, activityContext: Context) {
+        val cleanedLogin = login.copy(username = login.username.trim())
+        if (!performUserLogin(cleanedLogin)) return
+        with(activityContext) {
+            updateAutoLogin(cleanedLogin, autoLogin)
+        }
+    }
+
+
+    context (Context)
+    private suspend fun updateAutoLogin(login: Login, autoLogin: Boolean) {
+        settings.update { it.copy(autoLogin = autoLogin) }
+        if (autoLogin) when (val result = credentialsRepository.storeUserLogin(login)) {
+            Credentials.StoreResult.Success -> { }
+            Credentials.StoreResult.Cancelled -> { }
+            Credentials.StoreResult.Unsupported -> {
+                toastShort("Credential storage not supported")
+                settings.tryUpdate { it.copy(autoLogin = false) }
+            }
+            is Credentials.StoreResult.Error -> {
+                toastShort("Failed to store credentials: ${result.message}")
+            }
+        }
+    }
+
+
+
+
+
+
+
+
+    suspend fun login(user: BasicUserInfo, auth: UserAuthCredentials = UserAuthCredentials.Empty): LoginResult = action {
+        try {
+            when (portal.loginUser(
+                user.id,
+                pin = auth.pin,
+                rwthId = auth.rwthId,
+                key = auth.key
+            )) {
+                KaffeekasseAPI.UserLoginResult.Failure.PrivateDevice -> LoginResult.Failure("Private device")
+                is KaffeekasseAPI.UserLoginResult.Failure.UnknownError -> LoginResult.Failure("Unknown error")
+                KaffeekasseAPI.UserLoginResult.Failure.UserAuthenticationFailure -> LoginResult.Failure("User authentication failure")
+                is KaffeekasseAPI.UserLoginResult.LoggedIn -> LoginResult.Success(portal.session.value)
+            }
+        } catch (e: Exception) {
+            warn(e) { "Failed to login user" }
+            LoginResult.Failure(e.message ?: e::class.simpleName ?: "Unknown error")
+        }
+    }
+
+    sealed interface LoginResult {
+        data class Success(val session: Session) : LoginResult
+        data class Failure(val error: String) : LoginResult
+    }
+
+
+    suspend fun logoutUser() = action {
+        portal.logoutUser()
+    }
+
+
+
+    private suspend fun performDeviceLogin(credentials: DeviceCredentials): Boolean = action {
+        info { "Logging in device: ${credentials.deviceId}" }
+        val success = kaffeekasse.loginDevice(credentials)
+        debug { "Device logged in" }
+        return@action success
+    }
+
+    suspend fun tryAutoLoginDevice(): Unit = action {
+        if (portal.session.value !is Session.Empty) return@action
+        info { "Trying to auto login device" }
+        val credentials = credentialsRepository.deviceCredentials()
+            ?.takeIf { it.isValid }
+            ?: return@action
+        performDeviceLogin(credentials)
+    }
+
+    suspend fun loginDevice(credentials: DeviceCredentials): Unit = action {
+        if (!credentials.isValid) return@action
+        val cleanedCredentials = credentials.copy(
+            deviceId = credentials.deviceId.trim(),
+            apiKey = credentials.apiKey.trim()
+        )
+        if (!performDeviceLogin(cleanedCredentials)) return@action
+        credentialsRepository.storeDeviceCredentials(cleanedCredentials)
+    }
+
+    suspend fun logoutDevice(): Unit = action {
+        credentialsRepository.storeDeviceCredentials(null)
+        kaffeekasse.logoutDevice()
+    }
+}
\ No newline at end of file
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/RepositoryProvider.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/RepositoryProvider.kt
new file mode 100644
index 0000000000000000000000000000000000000000..7f35ced67789863be530d5c15d70fb20ba958f7d
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/RepositoryProvider.kt
@@ -0,0 +1,28 @@
+package net.novagamestudios.kaffeekasse.repositories
+
+import net.novagamestudios.kaffeekasse.AppModules
+import net.novagamestudios.kaffeekasse.repositories.i11.HiwiTrackerRepository
+import net.novagamestudios.kaffeekasse.repositories.i11.KaffeekasseRepository
+import net.novagamestudios.kaffeekasse.repositories.i11.PortalRepository
+import net.novagamestudios.kaffeekasse.repositories.releases.GitLabReleases
+
+interface RepositoryProvider {
+
+    // Settings stuff
+    val settingsRepository: SettingsRepository
+    val credentials: Credentials
+
+    // Update stuff
+    val releases: GitLabReleases
+    val updateController: UpdateController
+
+    // I11 stuff
+    val kaffeekasseRepository: KaffeekasseRepository
+    val hiwiTrackerRepository: HiwiTrackerRepository
+    val portalRepository: PortalRepository
+    val loginRepository: LoginRepository
+
+    // Active app modules
+    val modules: AppModules
+
+}
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/Settings.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/Settings.kt
index 27868fb32552aff483407321fdb3974ec43c2d9b..6ca7957e5ed99e6a6da986750c5999332e650bd8 100644
--- a/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/Settings.kt
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/Settings.kt
@@ -3,7 +3,7 @@ package net.novagamestudios.kaffeekasse.repositories
 import android.content.Context
 import androidx.compose.foundation.isSystemInDarkTheme
 import androidx.compose.runtime.Composable
-import androidx.compose.runtime.staticCompositionLocalOf
+import androidx.compose.runtime.State
 import androidx.datastore.core.MultiProcessDataStoreFactory
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.serialization.ExperimentalSerializationApi
@@ -14,17 +14,20 @@ import kotlinx.serialization.serializer
 import net.novagamestudios.common_utils.JsonToDataStore
 import net.novagamestudios.common_utils.compose.state.DataStoreState
 import net.novagamestudios.common_utils.compose.state.MutableDataStoreState
-import net.novagamestudios.common_utils.compose.state.rememberMockedDataStoreState
 import net.novagamestudios.common_utils.compose.state.stateIn
+import net.novagamestudios.kaffeekasse.model.credentials.DeviceCredentials
+import net.novagamestudios.kaffeekasse.ui.util.derived
 import java.io.File
 
 @Serializable
 data class Settings(
     val themeMode: ThemeMode = ThemeMode.Dark,
+    val fullscreen: Boolean = false,
+    val animationsEnabled: Boolean = true,
     val autoLogin: Boolean = false,
-    val favoriteItems: List<String> = emptyList(),
-    val lastSelectedModule: String? = null,
-    val developerMode: Boolean = false
+    val deviceCredentials: DeviceCredentials? = null,
+    val developerMode: Boolean = false,
+    val userSettings: Map<String, UserSettings> = emptyMap()
 ) {
 
     enum class ThemeMode {
@@ -32,30 +35,30 @@ data class Settings(
     }
 
     companion object {
-        val Settings.isDarkMode @Composable get() = when(themeMode) {
+        val State<Settings>.isDarkMode: Boolean @Composable get() = when(derived { themeMode }.value) {
             ThemeMode.Unspecified -> isSystemInDarkTheme()
             ThemeMode.Dark -> true
             ThemeMode.Light -> false
         }
 
-        internal val serializersModule = SerializersModule {
-
-        }
+        internal val serializersModule = SerializersModule { }
+    }
+}
 
+@Serializable
+data class UserSettings(
+    val favoriteItemIds: List<Int> = emptyList(),
+    val lastSelectedModule: String? = null
+) {
+    companion object {
+        val Empty = UserSettings()
     }
 }
 
+
 typealias MutableSettingsStore = MutableDataStoreState<Settings>
 typealias SettingsStore = DataStoreState<Settings>
 
-val LocalSettingsStore = staticCompositionLocalOf<MutableSettingsStore> {
-    throw NoSuchElementException()
-}
-
-private val settingsValidator: Settings.(Settings?) -> Settings = {
-    this
-}
-
 @OptIn(ExperimentalSerializationApi::class)
 fun Context.newSettingsStore(
     coroutineScope: CoroutineScope
@@ -75,8 +78,6 @@ fun Context.newSettingsStore(
     }
 ).stateIn(coroutineScope, settingsValidator)
 
-@Composable
-fun rememberMockedSettingsStore(initial: Settings = Settings()) = rememberMockedDataStoreState(
-    initial = initial,
-    validator = settingsValidator
-)
+private val settingsValidator: Settings.(Settings?) -> Settings = {
+    this
+}
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/SettingsRepository.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/SettingsRepository.kt
new file mode 100644
index 0000000000000000000000000000000000000000..b586c6cad1827c6407dda022f7d7d502c4ec02b9
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/SettingsRepository.kt
@@ -0,0 +1,45 @@
+package net.novagamestudios.kaffeekasse.repositories
+
+import kotlinx.coroutines.flow.StateFlow
+import net.novagamestudios.common_utils.compose.state.MutableDataStoreState
+import net.novagamestudios.kaffeekasse.model.session.realUserOrNull
+import net.novagamestudios.kaffeekasse.util.mapState
+
+class SettingsRepository(
+    repositoryProvider: RepositoryProvider,
+    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 class UserSettingsStore(
+    private val userKey: String,
+    private val settingsStore: MutableSettingsStore
+) : MutableDataStoreState<UserSettings> {
+    private fun Settings.map() = userSettings.getOrElse(userKey) { UserSettings.Empty }
+    private fun Updater<UserSettings>.wrapped(): Updater<Settings> = { settings ->
+        val new = this(settings.map())
+        if (new == UserSettings.Empty) {
+            settings.copy(userSettings = settings.userSettings - userKey)
+        } else {
+            settings.copy(userSettings = settings.userSettings + (userKey to new))
+        }
+    }
+
+    override val value: UserSettings get() = settingsStore.value.map()
+    override val values: StateFlow<UserSettings> get() = settingsStore.values.mapState { settings -> settings.map() }
+
+    override suspend fun loadInitial() = throw UnsupportedOperationException()
+    override fun provideInitial(value: UserSettings) = throw UnsupportedOperationException()
+
+    override fun tryUpdate(updater: suspend (UserSettings) -> UserSettings) = settingsStore.tryUpdate(updater.wrapped())
+    override suspend fun update(updater: suspend (UserSettings) -> UserSettings) = settingsStore.update(updater.wrapped())
+}
+
+private typealias Updater<T> = suspend (T) -> T
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/UpdateController.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/UpdateController.kt
index 3f550fc612a20359b277665341071329385a1d72..0579bc8ae27b32b52ff14a23ab20c707469bee40 100644
--- a/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/UpdateController.kt
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/UpdateController.kt
@@ -1,5 +1,6 @@
 package net.novagamestudios.kaffeekasse.repositories
 
+import android.app.Application
 import android.app.PendingIntent
 import android.content.Intent
 import android.content.pm.PackageInstaller
@@ -15,15 +16,14 @@ import net.novagamestudios.common_utils.compose.components.Progress
 import net.novagamestudios.common_utils.debug
 import net.novagamestudios.common_utils.error
 import net.novagamestudios.common_utils.info
-import net.novagamestudios.kaffeekasse.App
 import net.novagamestudios.kaffeekasse.MainActivity
 import net.novagamestudios.kaffeekasse.UpdateReceiver
-import net.novagamestudios.kaffeekasse.model.AppRelease
+import net.novagamestudios.kaffeekasse.model.app.AppRelease
 import java.io.File
 import java.io.FileInputStream
 
 class UpdateController(
-    private val application: App
+    private val application: Application
 ) : Logger {
     private val client by lazy { HttpClient() }
 
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/i11/HiwiTrackerRepository.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/i11/HiwiTrackerRepository.kt
new file mode 100644
index 0000000000000000000000000000000000000000..7f16d4635523b1fc44d56e847183248ace4f2ae5
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/i11/HiwiTrackerRepository.kt
@@ -0,0 +1,55 @@
+package net.novagamestudios.kaffeekasse.repositories.i11
+
+import kotlinx.coroutines.CoroutineScope
+import net.novagamestudios.common_utils.Logger
+import net.novagamestudios.common_utils.debug
+import net.novagamestudios.kaffeekasse.api.hiwi_tracker.HiwiTrackerAPI
+import net.novagamestudios.kaffeekasse.api.hiwi_tracker.HiwiTrackerScraper
+import net.novagamestudios.kaffeekasse.api.hiwi_tracker.model.MonthDataResponse
+import net.novagamestudios.kaffeekasse.model.hiwi_tracker.MonthKey
+import net.novagamestudios.kaffeekasse.model.hiwi_tracker.MonthKey.Companion.toMonthKey
+import net.novagamestudios.kaffeekasse.model.hiwi_tracker.WorkEntry
+import net.novagamestudios.kaffeekasse.model.hiwi_tracker.WorkEntry.Companion.isValid
+import net.novagamestudios.kaffeekasse.util.richdata.RichData
+import net.novagamestudios.kaffeekasse.util.richdata.RichDataSource
+import net.novagamestudios.kaffeekasse.util.richdata.RichDataSource.Companion.RichDataSource
+
+class HiwiTrackerRepository(
+    coroutineScope: CoroutineScope,
+    private val api: HiwiTrackerAPI,
+    private val scraper: HiwiTrackerScraper
+) : PortalRepositoryModule(), CoroutineScope by coroutineScope, Logger {
+
+
+    private val dataByMonth = mutableMapOf<MonthKey, RichDataSource<MonthDataResponse>>()
+
+
+    fun getDataForMonth(month: MonthKey) = dataByMonth.getOrPut(month) {
+        RichDataSource {
+            debug { "Fetching data for month $month" }
+            val data = api.fetchDataForMonth(month)
+            RichData.Data(data)
+        }
+    }
+
+    override fun onLoggedInUserChanged() {
+        dataByMonth.clear()
+    }
+
+    suspend fun submitWorkEntry(workEntry: WorkEntry) {
+        if (!workEntry.isValid) return
+        scraper.submitWorkEntry(workEntry)
+        markFutureMonthsDirty(workEntry.date.toMonthKey())
+    }
+
+    suspend fun deleteWorkEntry(workEntryId: Int) {
+        scraper.deleteWorkEntry(workEntryId)
+        dataByMonth.values.forEach { it.markDirty() }
+    }
+
+    private fun markFutureMonthsDirty(month: MonthKey) {
+        dataByMonth.entries.forEach { (key, data) ->
+            if (key >= month) data.markDirty()
+        }
+    }
+}
\ No newline at end of file
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/i11/KaffeekasseRepository.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/i11/KaffeekasseRepository.kt
new file mode 100644
index 0000000000000000000000000000000000000000..451c832b7c617ff3e3925efe8fadfc7d9832da8a
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/i11/KaffeekasseRepository.kt
@@ -0,0 +1,258 @@
+package net.novagamestudios.kaffeekasse.repositories.i11
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import net.novagamestudios.common_utils.Logger
+import net.novagamestudios.kaffeekasse.api.kaffeekasse.KaffeekasseAPI
+import net.novagamestudios.kaffeekasse.api.kaffeekasse.KaffeekasseScraper
+import net.novagamestudios.kaffeekasse.api.kaffeekasse.model.APIPurchaseAccount
+import net.novagamestudios.kaffeekasse.api.kaffeekasse.model.ExtendedUserInfo
+import net.novagamestudios.kaffeekasse.data.category
+import net.novagamestudios.kaffeekasse.data.cleanName
+import net.novagamestudios.kaffeekasse.data.cleanProductName
+import net.novagamestudios.kaffeekasse.data.cleanVariantName
+import net.novagamestudios.kaffeekasse.data.drawableResource
+import net.novagamestudios.kaffeekasse.model.credentials.DeviceCredentials
+import net.novagamestudios.kaffeekasse.model.kaffeekasse.Account
+import net.novagamestudios.kaffeekasse.model.kaffeekasse.Cart
+import net.novagamestudios.kaffeekasse.model.kaffeekasse.Item
+import net.novagamestudios.kaffeekasse.model.kaffeekasse.ItemCategory
+import net.novagamestudios.kaffeekasse.model.kaffeekasse.ItemGroup
+import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem
+import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItemGroup
+import net.novagamestudios.kaffeekasse.model.kaffeekasse.ManualBillDetails
+import net.novagamestudios.kaffeekasse.model.kaffeekasse.ScraperPurchaseAccount
+import net.novagamestudios.kaffeekasse.model.kaffeekasse.Stock
+import net.novagamestudios.kaffeekasse.model.kaffeekasse.Transaction
+import net.novagamestudios.kaffeekasse.model.kaffeekasse.isEmpty
+import net.novagamestudios.kaffeekasse.model.session.Device
+import net.novagamestudios.kaffeekasse.model.session.Session
+import net.novagamestudios.kaffeekasse.model.session.User
+import net.novagamestudios.kaffeekasse.util.richdata.KeyedMultiDataSource
+import net.novagamestudios.kaffeekasse.util.richdata.RichData
+import net.novagamestudios.kaffeekasse.util.richdata.RichDataSource
+import net.novagamestudios.kaffeekasse.util.richdata.RichDataSource.Companion.RichDataSource
+import net.novagamestudios.kaffeekasse.util.richdata.progress
+
+class KaffeekasseRepository(
+    coroutineScope: CoroutineScope,
+    internal val api: KaffeekasseAPI,
+    internal val scraper: KaffeekasseScraper
+) : PortalRepositoryModule(), CoroutineScope by coroutineScope, Logger {
+    private val client get() = api.client
+
+    private val mutableCurrentDevice = MutableStateFlow<Device?>(null)
+    internal val currentDevice = mutableCurrentDevice.asStateFlow()
+
+
+
+    suspend fun loginDevice(credentials: DeviceCredentials): Boolean {
+        val result = api.performDeviceLogin(credentials.deviceId.uppercase(), credentials.apiKey)
+        if (result is KaffeekasseAPI.DeviceLoginResult.LoggedIn) {
+            mutableCurrentDevice.value = Device(
+                name = result.response.name,
+                itemTypeId = result.response.itemTypeId
+            )
+            return true
+        }
+        return false
+    }
+
+    suspend fun logoutDevice() {
+        if (session.value !is Session.WithDevice) return
+        mutableCurrentDevice.value = null
+        client.logoutAll()
+    }
+
+
+    val account: KeyedMultiDataSource<User, Account> = KeyedMultiDataSource { user ->
+        requireLoggedIn(user)
+        api.loggedInUser().mapToRichDataState {
+            Account(
+                firstName = balance.myBalance.firstName,
+                lastName = balance.myBalance.lastName,
+                total = balance.myBalance.total,
+                paid = balance.myBalance.paid,
+                deposited = balance.myBalance.deposited
+            )
+        }
+    }
+
+    val transactions: KeyedMultiDataSource<User, List<Transaction>> = KeyedMultiDataSource { user ->
+        requireLoggedIn(user)
+        scraper.transactions().let {
+            RichData.Data(it)
+        }
+    }
+
+    val manualBillAccounts: KeyedMultiDataSource<User, List<ManualBillDetails.PurchaseAccount>> = KeyedMultiDataSource { user ->
+        requireLoggedIn(user)
+        scraper.manualBillDetails().accounts.let {
+            RichData.Data(it)
+        }
+    }
+
+
+
+    val stock: RichDataSource<Stock> = RichDataSource {
+        requireAnyLoggedInUser()
+        progress(0f / 3f)
+
+        val baseItemGroups = scraper.manualBillDetails().itemGroups
+        progress(1f / 3f)
+
+        if (session.value !is Session.WithDevice) return@RichDataSource RichData.Data(
+            StockImpl(itemGroups = baseItemGroups)
+        )
+
+        val itemGroupsById: Map<Int?, MutableItemGroup> = baseItemGroups.map {
+            MutableItemGroup(
+                knownItemGroup = KnownItemGroup.byOriginalName[it.originalName],
+                items = mutableListOf(),
+                id = it.id,
+                originalName = it.originalName
+            )
+        }.associateBy { it.id }
+
+        api.itemList().mapToRichDataState {
+            progress(2f / 3f)
+            itemList.forEach { item ->
+                val knownItems = KnownItem.byId[item.id]?.takeUnless { it.isEmpty() }
+                if (knownItems == null) {
+                    // Unknown item
+                    itemGroupsById[item.itemTypeId]?.items?.add(
+                        ItemImpl(
+                            id = item.id,
+                            originalName = item.originalName,
+                            category = ItemCategory.Other,
+                            cleanProductName = item.originalName,
+                            cleanVariantName = null,
+                            price = item.price,
+                            estimatedPrice = item.price,
+                            imageDrawable = null,
+                            imageUrl = item.imageUrl
+                        )
+                    )
+                } else knownItems.forEach { known ->
+                    // Combine with known values
+                    itemGroupsById[item.itemTypeId]?.items?.add(
+                        ItemImpl(
+                            id = item.id,
+                            originalName = item.originalName,
+                            category = known.category,
+                            cleanProductName = known.cleanProductName,
+                            cleanVariantName = known.cleanVariantName,
+                            price = item.price,
+                            estimatedPrice = item.price,
+                            imageDrawable = known.drawableResource,
+                            imageUrl = item.imageUrl
+                        )
+                    )
+                }
+            }
+            progress(3f / 3f)
+            StockImpl(
+                itemGroups = itemGroupsById.values.toList()
+            )
+        }
+    }
+
+
+
+    val basicUserInfoList = RichDataSource {
+        requireLoggedInDevice()
+        api.userList().mapToRichDataState { userList }
+    }
+
+    private val extendedUserInfoById = mutableMapOf<Int, RichDataSource<ExtendedUserInfo>>()
+    fun getExtendedUserInfo(userId: Int): RichDataSource<ExtendedUserInfo> = extendedUserInfoById.getOrPut(userId) {
+        RichDataSource {
+            requireLoggedInDevice()
+            api.userInfo(userId).mapToRichDataState { this }
+        }
+    }
+
+
+    suspend fun purchase(asUser: User, cart: Cart, account: ScraperPurchaseAccount) {
+        if (cart.isEmpty()) return
+        requireLoggedIn(asUser)
+        scraper.submitCart(account, cart)
+        markPurchaseDataDirty(asUser)
+    }
+
+    suspend fun purchase(asUser: User, cart: Cart, targetAccount: APIPurchaseAccount? = null) {
+        if (cart.isEmpty()) return
+        requireLoggedIn(asUser)
+        requireLoggedInDevice()
+        cart.forEach { (item, count) ->
+            val result = api.purchase(
+                itemId = item.id,
+                count = count,
+                targetUserId = targetAccount?.id
+            ).mapToRichDataState { this }
+            if (result is RichData.Error) throw IllegalStateException(result.messages.joinToString("\n"))
+        }
+        markPurchaseDataDirty(asUser)
+    }
+
+
+    private fun markPurchaseDataDirty(asUser: User) {
+        stock.markDirty()
+        account.getOrNull(asUser)?.markDirty()
+        transactions.getOrNull(asUser)?.markDirty()
+        manualBillAccounts.getOrNull(asUser)?.markDirty()
+        extendedUserInfoById.values.forEach { it.markDirty() }
+    }
+
+    override fun onLoggedInUserChanged() {
+//        extendedUserInfoById.values.forEach { it.markDirty() }
+    }
+
+    private inline fun <A, T : Any>  KaffeekasseAPI.Result<A>.mapToRichDataState(
+        transform: A.() -> T
+    ) : RichData<T> = when (this) {
+        is KaffeekasseAPI.Result.Error -> KaffeekasseRichDataError(
+            listOfNotNull(response.error?.string) + response.errors
+        )
+        is KaffeekasseAPI.Result.NotLoggedIn -> KaffeekasseRichDataError(listOf("Not logged in"))
+        is KaffeekasseAPI.Result.Success -> RichData.Data(result.transform())
+    }
+}
+
+data class KaffeekasseRichDataError<T : Any>(
+    val errors: List<String>
+) : RichData.Error<T> {
+    override val messages: List<String> get() = errors
+}
+
+
+private data class ItemImpl(
+    override val id: Int,
+    override val originalName: String,
+    override val category: ItemCategory,
+    override val cleanProductName: String,
+    override val cleanVariantName: String?,
+    override val price: Double?,
+    override val estimatedPrice: Double?,
+    override val imageDrawable: Int?,
+    override val imageUrl: String?
+) : Item
+
+
+private class MutableItemGroup(
+    knownItemGroup: KnownItemGroup?,
+    override val items: MutableList<Item>,
+    id: Int? = null,
+    originalName: String? = null
+) : ItemGroup {
+    override val id: Int? = knownItemGroup?.id ?: id
+    override val originalName: String? = knownItemGroup?.originalName ?: originalName
+    override val name: String = knownItemGroup?.cleanName ?: this.originalName ?: "$id"
+}
+
+private data class StockImpl(
+    override val itemGroups: List<ItemGroup>
+) : Stock
+
+
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/i11/PortalRepository.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/i11/PortalRepository.kt
new file mode 100644
index 0000000000000000000000000000000000000000..b217d1cf924c0a0474466d5b8789a7e4533ec9df
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/i11/PortalRepository.kt
@@ -0,0 +1,81 @@
+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 net.novagamestudios.common_utils.Logger
+import net.novagamestudios.common_utils.info
+import net.novagamestudios.common_utils.warn
+import net.novagamestudios.kaffeekasse.api.kaffeekasse.KaffeekasseAPI
+import net.novagamestudios.kaffeekasse.api.portal.PortalClient
+import net.novagamestudios.kaffeekasse.model.credentials.Login
+import net.novagamestudios.kaffeekasse.model.session.Session
+import net.novagamestudios.kaffeekasse.model.session.User
+import net.novagamestudios.kaffeekasse.model.session.realUserOrNull
+
+
+class PortalRepository(
+    coroutineScope: CoroutineScope,
+    private val client: PortalClient,
+    internal val kaffeekasse: KaffeekasseRepository,
+    vararg otherModules: PortalRepositoryModule
+) : PortalRepositoryModule(), Logger {
+    private val modules = listOf(this, kaffeekasse, *otherModules)
+
+    init {
+        modules.forEach { it.portal = this }
+    }
+
+    public override val session = combine(
+        client.session,
+        kaffeekasse.currentDevice
+    ) { session, device ->
+        Session(
+            device = device,
+            user = if (session.isLoggedIn) User(session.username, session.displayName) else null
+        )
+    }.stateIn(coroutineScope, SharingStarted.Eagerly, Session.Empty)
+
+    suspend fun loginUser(login: Login) {
+        if (session.value is Session.WithRealUser) throw IllegalStateException("User already logged in")
+        client.login(login)
+        onLoggedInUserChanged()
+    }
+
+    suspend fun loginUser(
+        userId: Int,
+        pin: String? = null,
+        rwthId: String? = null,
+        key: String? = null
+    ): KaffeekasseAPI.UserLoginResult {
+        if (session.value is Session.WithRealUser) throw IllegalStateException("User already logged in")
+        if (session.value !is Session.WithDevice) throw IllegalStateException("No permissions to login user")
+        val result = kaffeekasse.api.performUserLogin(userId, pin, rwthId, key)
+        info { "User login result: $result" }
+        onLoggedInUserChanged()
+        return result
+    }
+
+    suspend fun logoutUser() {
+//        requireAnyLoggedInUser()
+        if (session.value !is Session.WithRealUser) warn { "Trying to log out user without user session" }
+        if (session.value is Session.WithDevice) kaffeekasse.api.logoutUser()
+        else client.logoutAll()
+        onLoggedInUserChanged()
+    }
+
+    override fun onLoggedInUserChanged() {
+        modules.forEach { if (it != this) it.onLoggedInUserChanged() }
+    }
+}
+
+abstract class PortalRepositoryModule internal constructor() {
+    internal lateinit var portal: PortalRepository
+    protected open val session get() = portal.session
+    protected fun requireAnyLoggedInUser() = require(session.value is Session.WithRealUser) { "Logged in user required" }
+    protected fun requireLoggedIn(user: User) = require(session.value.realUserOrNull == user) { "User \"${user.user}\" not logged in" }
+    protected fun requireLoggedInDevice() = require(session.value is Session.WithDevice) { "Logged in device required" }
+    internal abstract fun onLoggedInUserChanged()
+}
+
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/GitLabPackageReleases.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/releases/GitLabPackageReleases.kt
similarity index 89%
rename from app/src/main/java/net/novagamestudios/kaffeekasse/repositories/GitLabPackageReleases.kt
rename to app/src/main/java/net/novagamestudios/kaffeekasse/repositories/releases/GitLabPackageReleases.kt
index cdfc38418fb0089c57390ce949aa060c7d9555ee..0c4189ebc1d4599d823b2313b392efa5015d1591 100644
--- a/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/GitLabPackageReleases.kt
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/releases/GitLabPackageReleases.kt
@@ -1,4 +1,4 @@
-package net.novagamestudios.kaffeekasse.repositories
+package net.novagamestudios.kaffeekasse.repositories.releases
 
 import kotlinx.coroutines.flow.MutableStateFlow
 import net.novagamestudios.common_utils.Logger
@@ -7,8 +7,8 @@ import net.novagamestudios.common_utils.info
 import net.novagamestudios.kaffeekasse.gitlab.GitLab
 import net.novagamestudios.kaffeekasse.gitlab.GitLabPackage
 import net.novagamestudios.kaffeekasse.gitlab.GitLabPackageFile
-import net.novagamestudios.kaffeekasse.model.AppRelease
-import net.novagamestudios.kaffeekasse.model.AppVersion
+import net.novagamestudios.kaffeekasse.model.app.AppRelease
+import net.novagamestudios.kaffeekasse.model.app.AppVersion
 
 
 class GitLabPackageReleases(
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/releases/GitLabReleases.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/releases/GitLabReleases.kt
new file mode 100644
index 0000000000000000000000000000000000000000..d6751153b695f91b6bf644f3beb130b89227513a
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/releases/GitLabReleases.kt
@@ -0,0 +1,38 @@
+package net.novagamestudios.kaffeekasse.repositories.releases
+
+import kotlinx.coroutines.flow.MutableStateFlow
+import net.novagamestudios.common_utils.Logger
+import net.novagamestudios.common_utils.debug
+import net.novagamestudios.common_utils.info
+import net.novagamestudios.kaffeekasse.gitlab.GitLab
+import net.novagamestudios.kaffeekasse.model.app.AppRelease
+import net.novagamestudios.kaffeekasse.model.app.AppVersion
+
+class GitLabReleases(
+    private val gitLab: GitLab
+) : Releases, Logger {
+
+    override val newerReleases: MutableStateFlow<List<AppRelease>?> = MutableStateFlow(null)
+
+    override suspend fun fetchNewerReleases() {
+        info { "Checking for newer releases ..." }
+        newerReleases.value = gitLab.releases.list()
+            .also { debug { "Found releases: $it" } }
+            .asSequence()
+            .filter { !it.upcomingRelease }
+            .mapNotNull { release -> release.version?.let { it to release } }
+            .filter { (version, _) -> version > AppVersion.Current && version.type == AppVersion.Type.Stable }
+            .sortedByDescending { (version, _) -> version }
+            .mapNotNull { (version, release) ->
+                val links = release.assets.links
+                val link = links.firstOrNull { it.name.endsWith(".apk") }
+                    ?: links.firstOrNull()
+                    ?: return@mapNotNull null
+                AppRelease(version, link.url, release.createdAt, release.description)
+            }
+            .toList()
+            .also { debug { "Found newer releases than ${AppVersion.Current}: $it" } }
+    }
+}
+
+
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/Releases.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/releases/Releases.kt
similarity index 58%
rename from app/src/main/java/net/novagamestudios/kaffeekasse/repositories/Releases.kt
rename to app/src/main/java/net/novagamestudios/kaffeekasse/repositories/releases/Releases.kt
index ca5ba59be71b0ad375af3e464d19dca9abf2c2ab..8698e298d367bf95e809d6398bf665f30aa8ef95 100644
--- a/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/Releases.kt
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/repositories/releases/Releases.kt
@@ -1,7 +1,7 @@
-package net.novagamestudios.kaffeekasse.repositories
+package net.novagamestudios.kaffeekasse.repositories.releases
 
 import kotlinx.coroutines.flow.MutableStateFlow
-import net.novagamestudios.kaffeekasse.model.AppRelease
+import net.novagamestudios.kaffeekasse.model.app.AppRelease
 
 interface Releases {
     val newerReleases: MutableStateFlow<List<AppRelease>?>
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/App.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/App.kt
index fa8e5d7094c5f3765c8377834456508f88644931..baf6cfc28713b8a57b3b60bff4ad9e6080888718 100644
--- a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/App.kt
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/App.kt
@@ -1,310 +1,136 @@
 package net.novagamestudios.kaffeekasse.ui
 
-import androidx.compose.animation.AnimatedContent
-import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.ContentTransform
-import androidx.compose.animation.core.animateFloatAsState
-import androidx.compose.animation.core.tween
-import androidx.compose.animation.expandHorizontally
-import androidx.compose.animation.fadeIn
-import androidx.compose.animation.fadeOut
-import androidx.compose.animation.scaleIn
-import androidx.compose.animation.shrinkHorizontally
 import androidx.compose.foundation.background
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.filled.ArrowBack
-import androidx.compose.material.icons.filled.ArrowDropDown
-import androidx.compose.material3.DropdownMenu
-import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material.icons.filled.CloseFullscreen
+import androidx.compose.material.icons.filled.OpenInFull
+import androidx.compose.material3.CircularProgressIndicator
 import androidx.compose.material3.Icon
 import androidx.compose.material3.IconButton
-import androidx.compose.material3.LocalTextStyle
 import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Text
-import androidx.compose.material3.TopAppBar
-import androidx.compose.material3.TopAppBarDefaults
-import androidx.compose.material3.TopAppBarScrollBehavior
+import androidx.compose.material3.Surface
 import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.derivedStateOf
 import androidx.compose.runtime.getValue
-import androidx.compose.runtime.key
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
-import androidx.compose.ui.graphics.graphicsLayer
-import androidx.compose.ui.input.nestedscroll.nestedScroll
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.text.TextStyle
-import androidx.compose.ui.unit.DpOffset
-import androidx.compose.ui.unit.dp
 import cafe.adriel.voyager.core.model.ScreenModel
-import cafe.adriel.voyager.navigator.LocalNavigator
+import cafe.adriel.voyager.core.screen.Screen
 import cafe.adriel.voyager.navigator.Navigator
-import cafe.adriel.voyager.navigator.currentOrThrow
-import cafe.adriel.voyager.navigator.tab.LocalTabNavigator
-import cafe.adriel.voyager.navigator.tab.Tab
-import cafe.adriel.voyager.navigator.tab.TabNavigator
-import cafe.adriel.voyager.transitions.ScreenTransition
-import cafe.adriel.voyager.transitions.ScreenTransitionContent
 import net.novagamestudios.common_utils.Logger
-import net.novagamestudios.common_utils.ProvideLogger
-import net.novagamestudios.common_utils.compose.isInPreview
+import net.novagamestudios.common_utils.LoggerForFun
+import net.novagamestudios.common_utils.compose.components.BoxCenter
+import net.novagamestudios.common_utils.debug
 import net.novagamestudios.common_utils.verbose
 import net.novagamestudios.kaffeekasse.App
-import net.novagamestudios.kaffeekasse.AppModule
-import net.novagamestudios.kaffeekasse.AppModules
-import net.novagamestudios.kaffeekasse.HiwiTrackerModule
-import net.novagamestudios.kaffeekasse.KaffeekasseModule
-import net.novagamestudios.kaffeekasse.repositories.I11Client
-import net.novagamestudios.kaffeekasse.repositories.MutableSettingsStore
-import net.novagamestudios.kaffeekasse.ui.hiwi_tracker.HiwiTrackerTopBarActions
-import net.novagamestudios.kaffeekasse.ui.hiwi_tracker.HiwiTrackerTopBarTitle
-import net.novagamestudios.kaffeekasse.ui.kaffeekasse.KaffeekasseTopBarActions
-import net.novagamestudios.kaffeekasse.ui.kaffeekasse.KaffeekasseTopBarNavigation
-import net.novagamestudios.kaffeekasse.ui.kaffeekasse.KaffeekasseTopBarTitle
-
-
-class AppViewModel private constructor(
-    val mutableSettingsStore: MutableSettingsStore,
-    private val i11Client: I11Client,
-    val modules: AppModules
+import net.novagamestudios.kaffeekasse.model.session.Session
+import net.novagamestudios.kaffeekasse.repositories.RepositoryProvider
+import net.novagamestudios.kaffeekasse.repositories.i11.PortalRepository
+import net.novagamestudios.kaffeekasse.ui.navigation.AppModulesScreen
+import net.novagamestudios.kaffeekasse.ui.navigation.AppScreenTransition
+import net.novagamestudios.kaffeekasse.ui.navigation.LoginNavigation
+import net.novagamestudios.kaffeekasse.ui.theme.KaffeekasseTheme
+import net.novagamestudios.kaffeekasse.ui.util.navigation.requireWithKey
+import net.novagamestudios.kaffeekasse.ui.util.screenmodel.GlobalScreenModelFactory
+import net.novagamestudios.kaffeekasse.ui.util.screenmodel.ScreenModelProvider
+import net.novagamestudios.kaffeekasse.ui.util.screenmodel.collectAsStateHere
+
+
+class AppScreenModel private constructor(
+    private val portal: PortalRepository
 ) : ScreenModel, Logger {
 
-    val initialModule get() = modules
-        .find { it.id == mutableSettingsStore.value.lastSelectedModule }
-        ?: modules.first()
-
-    val isLoggedIn get() = i11Client.isLoggedIn
+    val session by portal.session.collectAsStateHere()
 
-    fun Tab.moduleOrNull() = modules.singleOrNull { it.navigationTab == this }
-
-    companion object {
-        @Composable fun vm() = App.globalScreenModel {
-            AppViewModel(
-                mutableSettingsStore = settingsStore,
-                i11Client = i11Client,
-                modules = modules
-            )
-        }
+    companion object : GlobalScreenModelFactory<App, AppScreenModel>, ScreenModelProvider<AppScreenModel> {
+        context (RepositoryProvider)
+        override fun create(app: App) = AppScreenModel(
+            portal = portalRepository
+        )
+        @get:Composable override val model by this
     }
 }
 
 
 @Composable
 fun App(
-    vm: AppViewModel = AppViewModel.vm()
-) = ProvideLogger {
+    model: AppScreenModel = AppScreenModel.model
+) = KaffeekasseTheme {
     verbose { "recompose app" }
-    TabNavigator(LoginScreen) { tabNavigator ->
-        AnimatedContent(
-            targetState = tabNavigator.current,
-            Modifier
-                .background(MaterialTheme.colorScheme.background)
-                .fillMaxSize(),
-            transitionSpec = { appTransitionSpec() },
-            label = "tab"
-        ) { tab ->
-            tabNavigator.saveableState("tab-transition", tab) {
-                tab.Content()
-            }
-        }
-        key(Unit) {
-            LaunchedEffect(vm.isLoggedIn) {
-                if (vm.isLoggedIn) {
-                    tabNavigator.current = vm.initialModule.navigationTab
-                } else {
-                    tabNavigator.current = LoginScreen
-                }
-            }
-            LaunchedEffect(tabNavigator.current) {
-                val module = with(vm) { tabNavigator.current.moduleOrNull() }
-                if (module != null) vm.mutableSettingsStore.tryUpdate {
-                    it.copy(lastSelectedModule = module.id)
+    Navigator(
+        LoginNavigation,
+        key = NavigatorKey
+    ) { navigator ->
+        if (navigator.lastItem matches model.session) {
+            AppScreenTransition(
+                navigator = navigator,
+                Modifier
+                    .background(MaterialTheme.colorScheme.background)
+                    .fillMaxSize(),
+            )
+        } else {
+            autoNavigateLogin(navigator, model)
+            Surface {
+                BoxCenter(Modifier.fillMaxSize()) {
+                    CircularProgressIndicator()
                 }
             }
         }
     }
-    AppToasts(vm)
-    Updates()
+    UpdateDialogs()
 }
 
-@Composable
-private fun AppToasts(@Suppress("UNUSED_PARAMETER") vm: AppViewModel) {
-    if (isInPreview.value) return
-    @Suppress("UNUSED_VARIABLE") val context = LocalContext.current
+private infix fun Screen.matches(session: Session) = when (this) {
+    is LoginNavigation -> session !is Session.WithRealUser
+    is AppModulesScreen -> session is Session.WithRealUser
+    else -> false
 }
 
-@Composable
-fun AppScaffold(
-    modifier: Modifier = Modifier,
-    vm: AppViewModel = AppViewModel.vm()
-) {
-    val topAppBarScrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior()
-    Scaffold(
-        modifier.nestedScroll(topAppBarScrollBehavior.nestedScrollConnection),
-        topBar = { AppTopBar(vm, topAppBarScrollBehavior) }
-    ) { paddingValues ->
-        verbose { "recompose Scaffold" }
-        Box(
-            Modifier
-                .padding(paddingValues)
-                .fillMaxSize(),
-            propagateMinConstraints = true
-        ) {
-            AppScreenTransition(LocalNavigator.currentOrThrow) { screen ->
-                screen.Content()
-            }
-        }
-    }
-}
+private const val NavigatorKey = "app-navigator"
 
 @Composable
-private fun AppTopBar(
-    vm: AppViewModel,
-    scrollBehavior: TopAppBarScrollBehavior
-) = TopAppBar(
-    title = {
-        when (LocalTabNavigator.current.current) {
-            LoginScreen -> { }
-            KaffeekasseNavigation -> KaffeekasseTopBarTitle()
-            HiwiTrackerNavigation -> HiwiTrackerTopBarTitle()
-        }
-    },
-    navigationIcon = {
-        when (LocalTabNavigator.current.current) {
-            LoginScreen -> { }
-            KaffeekasseNavigation -> KaffeekasseTopBarNavigation()
-            HiwiTrackerNavigation -> { }
-        }
-    },
-    actions = {
-        when (LocalTabNavigator.current.current) {
-            LoginScreen -> AppInfoTopBarAction()
-            KaffeekasseNavigation -> KaffeekasseTopBarActions()
-            HiwiTrackerNavigation -> HiwiTrackerTopBarActions()
-        }
-    },
-    colors = TopAppBarDefaults.topAppBarColors(
-        scrolledContainerColor = MaterialTheme.colorScheme.surface
-    ),
-    scrollBehavior = if (vm.isLoggedIn) scrollBehavior else null
-)
-
-@Composable
-fun AppModuleSelection(
-    modifier: Modifier = Modifier,
-    vm: AppViewModel = AppViewModel.vm()
+private fun autoNavigateLogin(
+    navigator: Navigator,
+    model: AppScreenModel
 ) {
-    val tabNavigator = LocalTabNavigator.current
-    val currentModule by remember(tabNavigator) { derivedStateOf { with(vm) { tabNavigator.current.moduleOrNull() } } }
+    navigator.handleSession(model.session)
+}
 
-    val hasMultiple = vm.modules.size > 1
 
-    var showModuleDropdown by remember { mutableStateOf(false) }
-    Row(
-        modifier
-            .clip(RoundedCornerShape(20.dp))
-            .clickable(enabled = hasMultiple) { showModuleDropdown = !showModuleDropdown }
-            .padding(start = 8.dp, end = 6.dp)
-            .height(40.dp),
-        verticalAlignment = Alignment.CenterVertically,
-        horizontalArrangement = Arrangement.spacedBy(8.dp)
-    ) {
-        currentModule?.let { AppModuleLabel(it) }
-        if (hasMultiple) {
-            val rotation by animateFloatAsState(
-                if (showModuleDropdown) 180f else 0f,
-                label = "rotation"
-            )
-            Icon(
-                Icons.Default.ArrowDropDown,
-                contentDescription = null,
-                Modifier.graphicsLayer { rotationZ = rotation }
-            )
+fun Navigator.handleSession(session: Session) {
+    val logger = LoggerForFun()
+    val appNavigator = requireWithKey(NavigatorKey)
+    when (appNavigator.lastItem) {
+        is LoginNavigation -> {
+            if (session is Session.WithRealUser) {
+                logger.debug { "Auto-navigate to AppModulesScreen because $session" }
+                appNavigator.replaceAll(AppModulesScreen(session))
+            }
         }
-    }
-    val textStyle = LocalTextStyle.current
-    DropdownMenu(
-        expanded = showModuleDropdown,
-        onDismissRequest = { showModuleDropdown = false },
-        Modifier.clip(RoundedCornerShape(20.dp)),
-        offset = DpOffset(0.dp, 8.dp)
-    ) {
-        vm.modules.filter { it != currentModule }.forEach { module ->
-            DropdownMenuItem(
-                text = { AppModuleLabel(module, style = textStyle.copy(fontSize = textStyle.fontSize * 0.8)) },
-                onClick = {
-                    tabNavigator.current = module.navigationTab
-                    showModuleDropdown = false
-                }
-            )
+        is AppModulesScreen -> {
+            if (session !is Session.WithRealUser) {
+                logger.debug { "Auto-navigate to LoginNavigation because $session" }
+                appNavigator.replaceAll(LoginNavigation)
+            }
         }
     }
 }
 
-@Composable
-private fun AppModuleLabel(
-    module: AppModule,
-    modifier: Modifier = Modifier,
-    style: TextStyle = LocalTextStyle.current
-) = when (module) {
-    KaffeekasseModule -> Text("Kaffeekasse", modifier, style = style)
-    HiwiTrackerModule -> Text("Hiwi Tracker (beta)", modifier, style = style)
-}
-
-@Composable
-fun AppSubpageTitle(text: String) = Text(text, Modifier.padding(start = 8.dp))
 
 @Composable
-fun AppBackNavigation(
-    show: Boolean,
-    onBack: () -> Unit
+fun FullscreenIconButton(
+    modifier: Modifier = Modifier
 ) {
-    AnimatedVisibility(
-        visible = show,
-        enter = expandHorizontally(),
-        exit = shrinkHorizontally()
+    val settings = App.settings()
+    IconButton(
+        onClick = { settings.tryUpdate { it.copy(fullscreen = !it.fullscreen) } },
+        modifier
     ) {
-        IconButton(onClick = onBack) {
-            Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
+        if (!settings.value.fullscreen) {
+            Icon(Icons.Default.OpenInFull, "Vollbild")
+        } else {
+            Icon(Icons.Default.CloseFullscreen, "Vollbild verlassen")
         }
     }
 }
 
-private fun appTransitionSpec() = ContentTransform(
-    targetContentEnter = fadeIn(
-        tween(durationMillis = 220, delayMillis = 90)
-    ) + scaleIn(
-        tween(durationMillis = 220, delayMillis = 90),
-        initialScale = 0.92f
-    ),
-    initialContentExit = fadeOut(
-        tween(durationMillis = 90)
-    )
-)
-
-@Composable
-private fun AppScreenTransition(
-    navigator: Navigator,
-    modifier: Modifier = Modifier,
-    content: ScreenTransitionContent = { it.Content() }
-) = ScreenTransition(
-    navigator = navigator,
-    transition = { appTransitionSpec() },
-    modifier = modifier,
-    content = content
-)
 
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/AppModules.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/AppModules.kt
new file mode 100644
index 0000000000000000000000000000000000000000..6c4752c81c9264c8c9bf1a45de79e6ffac424972
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/AppModules.kt
@@ -0,0 +1,159 @@
+package net.novagamestudios.kaffeekasse.ui
+
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ArrowDropDown
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.Icon
+import androidx.compose.material3.LocalTextStyle
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.unit.DpOffset
+import androidx.compose.ui.unit.dp
+import cafe.adriel.voyager.core.model.ScreenModel
+import cafe.adriel.voyager.navigator.tab.LocalTabNavigator
+import net.novagamestudios.kaffeekasse.AppModule
+import net.novagamestudios.kaffeekasse.AppModules
+import net.novagamestudios.kaffeekasse.HiwiTrackerModule
+import net.novagamestudios.kaffeekasse.KaffeekasseModule
+import net.novagamestudios.kaffeekasse.model.session.Session
+import net.novagamestudios.kaffeekasse.repositories.RepositoryProvider
+import net.novagamestudios.kaffeekasse.repositories.SettingsRepository
+import net.novagamestudios.kaffeekasse.ui.navigation.AppModulesScreen
+import net.novagamestudios.kaffeekasse.ui.navigation.HiwiTrackerNavigation
+import net.novagamestudios.kaffeekasse.ui.navigation.KaffeekasseNavigation
+import net.novagamestudios.kaffeekasse.ui.navigation.ModuleTab
+import net.novagamestudios.kaffeekasse.ui.util.navigation.nearestScreen
+import net.novagamestudios.kaffeekasse.ui.util.screenmodel.ScreenModelFactory
+import net.novagamestudios.kaffeekasse.ui.util.screenmodel.collectAsStateHere
+
+
+class AppModulesScreenModel private constructor(
+    val session: Session.WithRealUser,
+    private val settingsRepository: SettingsRepository,
+    val allModules: AppModules
+) : ScreenModel {
+
+
+    private val userSettings by settingsRepository.userSettings.collectAsStateHere()
+    val modules: AppModules get() = when {
+        settingsRepository.value.developerMode -> allModules
+        session is Session.WithDevice -> AppModules(allModules.filterIsInstance<KaffeekasseModule>())
+        else -> allModules
+    }
+    private val initialModule get() = modules
+        .find { it.id == userSettings?.value?.lastSelectedModule }
+        ?: modules.first()
+
+
+    val AppModule.moduleTab: ModuleTab get() = when (this) {
+        is KaffeekasseModule -> KaffeekasseNavigation.Tab(session)
+        is HiwiTrackerModule -> HiwiTrackerNavigation.Tab(session)
+    }
+    val ModuleTab.module: AppModule get() = when (this) {
+        is KaffeekasseNavigation.Tab -> allModules.require<KaffeekasseModule>()
+        is HiwiTrackerNavigation.Tab -> allModules.require<HiwiTrackerModule>()
+    }
+
+    fun initialModuleTab() = initialModule.moduleTab
+
+    fun onNavigateModuleTab(tab: ModuleTab) {
+        userSettings?.tryUpdate {
+            it.copy(lastSelectedModule = tab.module.id)
+        }
+    }
+
+    companion object : ScreenModelFactory<AppModulesScreen, AppModulesScreenModel> {
+        context (RepositoryProvider)
+        override fun create(screen: AppModulesScreen) = AppModulesScreenModel(
+            session = screen.session,
+            settingsRepository = settingsRepository,
+            allModules = modules
+        )
+    }
+}
+
+
+
+@Composable
+fun AppModuleSelection(
+    modifier: Modifier = Modifier,
+    model: AppModulesScreenModel = nearestScreen<AppModulesScreen>().model
+) {
+    val tabNavigator = LocalTabNavigator.current
+
+    val currentModule = with(model) { (tabNavigator.current as? ModuleTab)?.module }
+    val hasMultiple = model.modules.size > 1
+
+    var showModuleDropdown by remember { mutableStateOf(false) }
+
+    Row(
+        modifier
+            .clip(RoundedCornerShape(20.dp))
+            .clickable(enabled = hasMultiple) { showModuleDropdown = !showModuleDropdown }
+            .padding(start = 8.dp, end = 6.dp)
+            .height(40.dp),
+        verticalAlignment = Alignment.CenterVertically,
+        horizontalArrangement = Arrangement.spacedBy(8.dp)
+    ) {
+        if (currentModule != null) AppModuleLabel(currentModule)
+        else Text("Select Module")
+
+        if (hasMultiple) {
+            val rotation by animateFloatAsState(
+                if (showModuleDropdown) 180f else 0f,
+                label = "rotation"
+            )
+            Icon(
+                Icons.Default.ArrowDropDown,
+                contentDescription = null,
+                Modifier.graphicsLayer { rotationZ = rotation }
+            )
+        }
+    }
+
+    val dropdownItemStyle = LocalTextStyle.current // Take text style from here
+    DropdownMenu(
+        expanded = showModuleDropdown,
+        onDismissRequest = { showModuleDropdown = false },
+        Modifier.clip(RoundedCornerShape(20.dp)),
+        offset = DpOffset(0.dp, 8.dp)
+    ) {
+        model.modules.filter { it != currentModule }.forEach { module ->
+            DropdownMenuItem(
+                text = { AppModuleLabel(module, style = dropdownItemStyle.copy(fontSize = dropdownItemStyle.fontSize * 0.8)) },
+                onClick = {
+                    tabNavigator.current = with(model) { module.moduleTab }
+                    showModuleDropdown = false
+                }
+            )
+        }
+    }
+}
+
+@Composable
+private fun AppModuleLabel(
+    module: AppModule,
+    modifier: Modifier = Modifier,
+    style: TextStyle = LocalTextStyle.current
+) = when (module) {
+    is KaffeekasseModule -> Text("Kaffeekasse", modifier, style = style)
+    is HiwiTrackerModule -> Text("Hiwi Tracker (beta)", modifier, style = style)
+}
+
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/AppScreens.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/AppScreens.kt
deleted file mode 100644
index 182704088859eac3ce3b4579f8675c32ac6d082e..0000000000000000000000000000000000000000
--- a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/AppScreens.kt
+++ /dev/null
@@ -1,72 +0,0 @@
-package net.novagamestudios.kaffeekasse.ui
-
-import androidx.activity.compose.BackHandler
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.remember
-import cafe.adriel.voyager.core.screen.Screen
-import cafe.adriel.voyager.navigator.Navigator
-import cafe.adriel.voyager.navigator.tab.Tab
-import cafe.adriel.voyager.navigator.tab.TabOptions
-import net.novagamestudios.kaffeekasse.ui.hiwi_tracker.HiwiTrackerContent
-import net.novagamestudios.kaffeekasse.ui.kaffeekasse.Account
-import net.novagamestudios.kaffeekasse.ui.kaffeekasse.DynamicManualBill
-import net.novagamestudios.kaffeekasse.ui.kaffeekasse.KaffeekasseBackHandler
-import net.novagamestudios.kaffeekasse.ui.kaffeekasse.Transactions
-
-
-sealed interface LoginScreen {
-    data object FormScreen : LoginScreen, Screen {
-        @Composable override fun Content() = Login()
-    }
-    companion object : Tab {
-        @Composable override fun Content() = Navigator(
-            FormScreen,
-            onBackPressed = null
-        ) {
-            AppScaffold()
-            BackHandler { }
-        }
-        override val options: TabOptions
-            @Composable get() = remember { TabOptions(0u, "Login") }
-    }
-}
-
-sealed interface KaffeekasseNavigation {
-    data object ManualBillScreen : KaffeekasseNavigation, Screen {
-        @Composable override fun Content() = DynamicManualBill()
-    }
-    data object AccountScreen : KaffeekasseNavigation, Screen {
-        @Composable override fun Content() = Account()
-    }
-    data object TransactionsScreen : KaffeekasseNavigation, Screen {
-        @Composable override fun Content() = Transactions()
-    }
-    companion object : Tab {
-        @Composable override fun Content() = Navigator(
-            ManualBillScreen,
-            onBackPressed = null
-        ) {
-            AppScaffold()
-            KaffeekasseBackHandler()
-        }
-        override val options: TabOptions
-            @Composable get() = remember { TabOptions(1u, "Kaffeekasse") }
-    }
-}
-
-sealed interface HiwiTrackerNavigation {
-    data object OverviewScreen : HiwiTrackerNavigation, Screen {
-        @Composable override fun Content() = HiwiTrackerContent()
-    }
-    companion object : Tab {
-        @Composable override fun Content() = Navigator(
-            OverviewScreen,
-            onBackPressed = null
-        ) {
-            AppScaffold()
-            BackHandler { }
-        }
-        override val options: TabOptions
-            @Composable get() = remember { TabOptions(2u, "Hiwi Tracker") }
-    }
-}
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/Login.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/Login.kt
deleted file mode 100644
index ad3abfc164176c1c190ab4352f7f5ab8a9364dfa..0000000000000000000000000000000000000000
--- a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/Login.kt
+++ /dev/null
@@ -1,311 +0,0 @@
-package net.novagamestudios.kaffeekasse.ui
-
-import android.content.Context
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.IntrinsicSize
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.text.KeyboardActions
-import androidx.compose.foundation.text.KeyboardOptions
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.filled.Logout
-import androidx.compose.material.icons.filled.Password
-import androidx.compose.material.icons.filled.Person
-import androidx.compose.material.icons.filled.Visibility
-import androidx.compose.material.icons.filled.VisibilityOff
-import androidx.compose.material.icons.rounded.Coffee
-import androidx.compose.material3.Button
-import androidx.compose.material3.CircularProgressIndicator
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.ListItem
-import androidx.compose.material3.LocalContentColor
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedTextField
-import androidx.compose.material3.Switch
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.derivedStateOf
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.autofill.AutofillNode
-import androidx.compose.ui.autofill.AutofillType
-import androidx.compose.ui.composed
-import androidx.compose.ui.focus.FocusDirection
-import androidx.compose.ui.focus.onFocusChanged
-import androidx.compose.ui.layout.boundsInWindow
-import androidx.compose.ui.layout.onGloballyPositioned
-import androidx.compose.ui.platform.LocalAutofill
-import androidx.compose.ui.platform.LocalAutofillTree
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.platform.LocalFocusManager
-import androidx.compose.ui.text.input.ImeAction
-import androidx.compose.ui.text.input.KeyboardType
-import androidx.compose.ui.text.input.PasswordVisualTransformation
-import androidx.compose.ui.text.input.VisualTransformation
-import androidx.compose.ui.unit.dp
-import cafe.adriel.voyager.core.model.ScreenModel
-import cafe.adriel.voyager.core.model.screenModelScope
-import cafe.adriel.voyager.navigator.tab.LocalTabNavigator
-import kotlinx.coroutines.launch
-import net.novagamestudios.common_utils.Logger
-import net.novagamestudios.common_utils.compose.components.BoxCenter
-import net.novagamestudios.common_utils.compose.state.ReentrantActionState
-import net.novagamestudios.common_utils.debug
-import net.novagamestudios.common_utils.toastShort
-import net.novagamestudios.common_utils.warn
-import net.novagamestudios.kaffeekasse.model.i11_portal.Login
-import net.novagamestudios.kaffeekasse.model.i11_portal.isValid
-import net.novagamestudios.kaffeekasse.repositories.I11Client
-import net.novagamestudios.kaffeekasse.repositories.LoginCredentials
-import net.novagamestudios.kaffeekasse.repositories.MutableSettingsStore
-import net.novagamestudios.kaffeekasse.ui.kaffeekasse.components.FailureRetryScreen
-
-
-class LoginViewModel private constructor(
-    private val mutableSettingsStore: MutableSettingsStore,
-    private val loginCredentials: LoginCredentials,
-    private val i11Client: I11Client
-) : ScreenModel, Logger {
-    private val settings by mutableSettingsStore
-
-    val autoLogin by derivedStateOf { settings.autoLogin }
-    var initialFormInput by mutableStateOf(Login.Empty)
-
-    val isLoggedIn get() = i11Client.isLoggedIn
-
-    private val loadingMutex = ReentrantActionState()
-    val isLoading by loadingMutex
-    val loginErrors get() = i11Client.loginErrors
-
-    private var autoLoginAttemptAvailable = true
-
-
-    private suspend fun login(login: Login): Boolean {
-        val success = loadingMutex.trueWhile {
-            try {
-                i11Client.login(login)
-                true
-            } catch (e: Exception) {
-                warn(e) { "Failed to login" }
-                false
-            }
-        }
-        autoLoginAttemptAvailable = success
-        return success
-    }
-
-    fun tryAutoLogin(activityContext: Context) {
-        if (isLoggedIn) return
-        debug { "Auto login enabled: $autoLogin" }
-        if (!autoLogin || !autoLoginAttemptAvailable) return
-        screenModelScope.launch {
-            val login = with(activityContext) { loginCredentials.get() }
-            if (login == null) {
-                autoLoginAttemptAvailable = false
-                return@launch
-            }
-            autoLoginAttemptAvailable = false
-            initialFormInput = login
-            login(login)
-        }
-    }
-
-    fun login(login: Login, autoLogin: Boolean, activityContext: Context) {
-        val cleanedLogin = login.copy(username = login.username.trim())
-        screenModelScope.launch {
-            val success = login(cleanedLogin)
-            if (!success) return@launch
-            mutableSettingsStore.update { it.copy(autoLogin = autoLogin) }
-            if (autoLogin) with(activityContext) {
-                when (val result = loginCredentials.store(cleanedLogin)) {
-                    LoginCredentials.StoreResult.Success -> { }
-                    LoginCredentials.StoreResult.Cancelled -> { }
-                    LoginCredentials.StoreResult.Unsupported -> {
-                        toastShort("Credential storage not supported")
-                        mutableSettingsStore.tryUpdate { it.copy(autoLogin = false) }
-                    }
-                    is LoginCredentials.StoreResult.Error -> {
-                        toastShort("Failed to store credentials: ${result.message}")
-                    }
-                }
-            }
-        }
-    }
-
-    fun logout() {
-        screenModelScope.launch {
-            loadingMutex.trueWhile {
-                i11Client.logout()
-            }
-        }
-    }
-
-    companion object {
-        @Composable fun vm() = net.novagamestudios.kaffeekasse.App.navigatorScreenModel {
-            LoginViewModel(
-                mutableSettingsStore = settingsStore,
-                loginCredentials = loginCredentials,
-                i11Client = i11Client
-            )
-        }
-    }
-}
-
-@Composable
-fun Login(
-    modifier: Modifier = Modifier,
-    vm: LoginViewModel = LoginViewModel.vm()
-) = BoxCenter(modifier) {
-    val activityContext = LocalContext.current
-    LaunchedEffect(Unit) {
-        vm.initialFormInput = Login.Empty
-        vm.tryAutoLogin(activityContext)
-    }
-    if (vm.isLoading || vm.isLoggedIn) CircularProgressIndicator()
-    else LoginForm(vm)
-}
-
-@Composable
-private fun LoginForm(
-    vm: LoginViewModel,
-    modifier: Modifier = Modifier
-) = Column(
-    modifier.width(IntrinsicSize.Min),
-    verticalArrangement = Arrangement.spacedBy(8.dp),
-    horizontalAlignment = Alignment.CenterHorizontally
-) {
-    val activityContext = LocalContext.current
-
-    Icon(
-        Icons.Rounded.Coffee,
-        "Kaffeekasse",
-        Modifier
-            .padding(16.dp)
-            .size(48.dp),
-        tint = LocalContentColor.current.copy(alpha = 0.5f)
-    )
-
-    val focusManager = LocalFocusManager.current
-    var currentLogin by remember(vm.initialFormInput) { mutableStateOf(vm.initialFormInput) }
-    var autoLogin by remember(vm.autoLogin) { mutableStateOf(vm.autoLogin) }
-    OutlinedTextField(
-        value = currentLogin.username,
-        onValueChange = { currentLogin = currentLogin.copy(username = it) },
-        Modifier.autofill(
-            listOf(AutofillType.Username),
-            onFill = { currentLogin = currentLogin.copy(username = it) }
-        ),
-        label = { Text("Nutzername") },
-        leadingIcon = { Icon(Icons.Default.Person, "Nutzername") },
-        keyboardOptions = KeyboardOptions(
-            keyboardType = KeyboardType.Text,
-            imeAction = ImeAction.Next
-        ),
-        keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) }),
-        singleLine = true
-    )
-    var showPassword by remember { mutableStateOf(false) }
-    OutlinedTextField(
-        value = currentLogin.password,
-        onValueChange = { currentLogin = currentLogin.copy(password = it) },
-        Modifier.autofill(
-            listOf(AutofillType.Password),
-            onFill = { currentLogin = currentLogin.copy(password = it) }
-        ),
-        label = { Text("Passwort") },
-        leadingIcon = { Icon(Icons.Default.Password, "Passwort") },
-        trailingIcon = {
-            IconButton(onClick = { showPassword = !showPassword }) {
-                Icon(
-                    if (showPassword) Icons.Default.Visibility else Icons.Default.VisibilityOff,
-                    if (showPassword) "Hide password" else "Show password"
-                )
-            }
-        },
-        visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
-        keyboardOptions = KeyboardOptions(
-            keyboardType = KeyboardType.Password,
-            imeAction = ImeAction.Done
-        ),
-        keyboardActions = KeyboardActions(onDone = { vm.login(currentLogin, autoLogin, activityContext) }),
-        singleLine = true
-    )
-    ListItem(
-        headlineContent = {
-            Text(
-                "Automatisch einloggen",
-                style = MaterialTheme.typography.labelLarge
-            )
-        },
-        trailingContent = {
-            Switch(
-                checked = autoLogin,
-                onCheckedChange = { autoLogin = it }
-            )
-        }
-    )
-
-    Button(
-        onClick = { vm.login(currentLogin, autoLogin, activityContext) },
-        Modifier.align(Alignment.End),
-        enabled = currentLogin.isValid
-    ) {
-        Text("Einloggen")
-    }
-
-    val errors = vm.loginErrors
-    if (errors != null) FailureRetryScreen(
-        message = "Failed to login",
-        errors = errors,
-        Modifier.padding(top = 16.dp),
-        onRetry = null
-    ) else Spacer(Modifier.height(48.dp + 16.dp))
-}
-
-@Composable
-fun LogoutTopBarAction(
-    vm: LoginViewModel = LoginViewModel.vm()
-) {
-    val tabNavigator = LocalTabNavigator.current
-    IconButton(
-        onClick = {
-            vm.logout()
-            tabNavigator.current = LoginScreen
-        },
-        enabled = vm.isLoggedIn
-    ) {
-        Icon(Icons.AutoMirrored.Filled.Logout, "Ausloggen")
-    }
-}
-
-
-fun Modifier.autofill(
-    autofillTypes: List<AutofillType>,
-    onFill: (String) -> Unit
-) = composed {
-    val autofill = LocalAutofill.current
-    val autofillNode = AutofillNode(
-        autofillTypes = autofillTypes,
-        onFill = onFill
-    )
-    LocalAutofillTree.current += autofillNode
-    this
-        .onGloballyPositioned { autofillNode.boundingBox = it.boundsInWindow() }
-        .onFocusChanged {
-            autofill?.run {
-                if (it.isFocused) requestAutofillForNode(autofillNode)
-                else cancelAutofillForNode(autofillNode)
-            }
-        }
-}
-
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/ScreenModels.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/ScreenModels.kt
new file mode 100644
index 0000000000000000000000000000000000000000..6554230242abf07cc96834950092ed1741ba91e0
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/ScreenModels.kt
@@ -0,0 +1,35 @@
+package net.novagamestudios.kaffeekasse.ui
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import cafe.adriel.voyager.core.concurrent.ThreadSafeMap
+import cafe.adriel.voyager.core.model.ScreenModel
+import cafe.adriel.voyager.core.model.rememberScreenModel
+import cafe.adriel.voyager.core.platform.multiplatformName
+import cafe.adriel.voyager.core.screen.Screen
+import net.novagamestudios.kaffeekasse.App
+import net.novagamestudios.kaffeekasse.app
+import net.novagamestudios.kaffeekasse.ui.util.screenmodel.GlobalScreenModelFactory
+import net.novagamestudios.kaffeekasse.ui.util.screenmodel.ScreenModelFactory
+import net.novagamestudios.kaffeekasse.util.context
+
+
+val globalScreenModels = ThreadSafeMap<String, ScreenModel>()
+
+@Composable
+inline operator fun <reified T : ScreenModel> GlobalScreenModelFactory<App, T>.getValue(
+    thisRef: Any?,
+    property: Any?
+) = with(app()) {
+    val key = "${T::class.multiplatformName}:default"
+    remember(key) { globalScreenModels.getOrPut(key) { create(app = this) } as T }
+}
+
+context (S)
+@Composable
+inline operator fun <S : Screen, reified T : ScreenModel> ScreenModelFactory<S, T>.getValue(
+    thisRef: Any?,
+    property: Any?
+) = with(app()) {
+    rememberScreenModel { create(context<S>()) }
+}
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/Settings.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/Settings.kt
deleted file mode 100644
index 9d062f29bd9cca382adb6d864d2b39ef347b41fd..0000000000000000000000000000000000000000
--- a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/Settings.kt
+++ /dev/null
@@ -1,62 +0,0 @@
-package net.novagamestudios.kaffeekasse.ui
-
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.unit.dp
-import cafe.adriel.voyager.core.model.ScreenModel
-import net.novagamestudios.common_utils.Logger
-import net.novagamestudios.common_utils.compose.components.RowCenter
-import net.novagamestudios.common_utils.warn
-import net.novagamestudios.kaffeekasse.repositories.MutableSettingsStore
-import net.novagamestudios.kaffeekasse.repositories.Settings
-
-
-class SettingsViewModel(
-    private val mutableSettingsStore: MutableSettingsStore
-) : ScreenModel, Logger {
-    @Suppress("unused")
-    val settings by mutableSettingsStore
-
-    @Suppress("unused")
-    fun updateSettings(updater: suspend Settings.() -> Settings) {
-        if (!mutableSettingsStore.tryUpdate(updater)) {
-            warn { "Failed to update settings" }
-        }
-    }
-}
-
-
-@Composable
-fun SettingsDivider(
-    text: String,
-    modifier: Modifier = Modifier
-) {
-    RowCenter(modifier.padding(8.dp)) {
-        Text(
-            text,
-            style = MaterialTheme.typography.titleMedium
-        )
-//        Spacer(Modifier.width(8.dp))
-//        Box(
-//            Modifier
-//                .weight(1f)
-//                .height(1.dp)
-//                .background(MaterialTheme.colorScheme.outlineVariant)
-//        )
-    }
-}
-
-
-@Composable
-fun Settings(
-    @Suppress("UNUSED_PARAMETER") vm: SettingsViewModel,
-    @Suppress("UNUSED_PARAMETER") modifier: Modifier = Modifier
-) { }
-
-
-
-
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/Updates.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/Updates.kt
index c8bd0f33b0c0ac0cf5fdb63c3cdd7fdf77fa856d..b3a1c89761e46bf3ad9b5f378378759a8faa3579 100644
--- a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/Updates.kt
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/Updates.kt
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.widthIn
 import androidx.compose.foundation.rememberScrollState
 import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.foundation.verticalScroll
@@ -21,7 +22,6 @@ import androidx.compose.material3.Button
 import androidx.compose.material3.HorizontalDivider
 import androidx.compose.material3.Icon
 import androidx.compose.material3.IconButton
-import androidx.compose.material3.ListItem
 import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.OutlinedButton
 import androidx.compose.material3.Switch
@@ -51,26 +51,30 @@ import net.novagamestudios.common_utils.compose.components.CircularLoadingBox
 import net.novagamestudios.common_utils.compose.components.ColumnCenter
 import net.novagamestudios.common_utils.compose.components.LinearProgressIndicator
 import net.novagamestudios.common_utils.compose.components.RowCenter
+import net.novagamestudios.common_utils.compose.components.TransparentListItem
 import net.novagamestudios.common_utils.compose.state.ReentrantActionState
 import net.novagamestudios.common_utils.compose.state.collectAsStateIn
 import net.novagamestudios.common_utils.toastLong
 import net.novagamestudios.kaffeekasse.App
+import net.novagamestudios.kaffeekasse.App.Companion.settings
 import net.novagamestudios.kaffeekasse.BuildConfig
-import net.novagamestudios.kaffeekasse.model.AppRelease
-import net.novagamestudios.kaffeekasse.model.AppVersion
-import net.novagamestudios.kaffeekasse.model.format
+import net.novagamestudios.kaffeekasse.model.app.AppRelease
+import net.novagamestudios.kaffeekasse.model.app.AppVersion
+import net.novagamestudios.kaffeekasse.model.date_time.format
 import net.novagamestudios.kaffeekasse.repositories.InstallStatus
-import net.novagamestudios.kaffeekasse.repositories.LocalSettingsStore
-import net.novagamestudios.kaffeekasse.repositories.MutableSettingsStore
-import net.novagamestudios.kaffeekasse.repositories.Releases
+import net.novagamestudios.kaffeekasse.repositories.RepositoryProvider
 import net.novagamestudios.kaffeekasse.repositories.UpdateController
-import net.novagamestudios.kaffeekasse.util.openInBrowser
+import net.novagamestudios.kaffeekasse.repositories.releases.Releases
+import net.novagamestudios.kaffeekasse.ui.util.openInBrowser
+import net.novagamestudios.kaffeekasse.ui.util.screenmodel.GlobalScreenModelFactory
+import net.novagamestudios.kaffeekasse.ui.util.screenmodel.ScreenModelProvider
 import java.time.format.DateTimeFormatter
 
 
-class UpdatesViewModel private constructor(
+class UpdatesScreenModel private constructor(
     private val releases: Releases,
-    private val updateController: UpdateController
+    private val updateController: UpdateController,
+    private val gitLabProjectUrl: String
 ) : ScreenModel {
     var showAppInfo by mutableStateOf(false)
 
@@ -125,35 +129,35 @@ class UpdatesViewModel private constructor(
     }
 
     fun openInBrowser(context: Context) {
-        val url = App.instanceOrNull!!.gitLab.projectUrl
-        context.openInBrowser(url)
+        context.openInBrowser(gitLabProjectUrl)
     }
 
-    companion object {
-        @Composable fun vm() = App.globalScreenModel {
-            UpdatesViewModel(
-                releases = releases,
-                updateController = updateController
-            )
-        }
+    companion object : GlobalScreenModelFactory<App, UpdatesScreenModel>, ScreenModelProvider<UpdatesScreenModel> {
+        context (RepositoryProvider)
+        override fun create(app: App) = UpdatesScreenModel(
+            releases = releases,
+            updateController = updateController,
+            gitLabProjectUrl = app.gitLab.projectUrl
+        )
+        @get:Composable override val model by this
     }
 }
 
 
 @Composable
-fun Updates(vm: UpdatesViewModel = UpdatesViewModel.vm()) {
+fun UpdateDialogs(model: UpdatesScreenModel = UpdatesScreenModel.model) {
     val context = LocalContext.current
-    if (vm.showAppInfo) AppInfoDialog(
-        onDismissRequest = { vm.showAppInfo = false },
-        isCheckingForUpdates = vm.isChecking,
-        onCheckForUpdates = { vm.checkForUpdates() },
-        onOpenInBrowser = { vm.openInBrowser(context) }
+    if (model.showAppInfo) AppInfoDialog(
+        onDismissRequest = { model.showAppInfo = false },
+        isCheckingForUpdates = model.isChecking,
+        onCheckForUpdates = { model.checkForUpdates() },
+        onOpenInBrowser = { model.openInBrowser(context) }
     )
-    if (vm.isInstalling) InstallDialog(vm)
-    else if (vm.newerReleases.isNotEmpty() && vm.showReleasesDialog) NewerReleasesDialog(
-        onDismissRequest = { vm.showReleasesDialog = false },
-        newerReleases = vm.newerReleases,
-        onStartUpdate = { vm.update() }
+    if (model.isInstalling) InstallDialog(model)
+    else if (model.newerReleases.isNotEmpty() && model.showReleasesDialog) NewerReleasesDialog(
+        onDismissRequest = { model.showReleasesDialog = false },
+        newerReleases = model.newerReleases,
+        onStartUpdate = { model.update() }
     )
 }
 
@@ -214,23 +218,23 @@ private val releaseDateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
 
 @Composable
 private fun InstallDialog(
-    vm: UpdatesViewModel
+    model: UpdatesScreenModel
 ) = AlertDialog(
-    onDismissRequest = { vm.tryDismissInstall() },
+    onDismissRequest = { model.tryDismissInstall() },
     confirmButton = {
-        if (vm.installStatus is InstallStatus.Failure) TextButton(onClick = { vm.retryUpdate() }) {
+        if (model.installStatus is InstallStatus.Failure) TextButton(onClick = { model.retryUpdate() }) {
             Text("Retry")
         }
     },
     dismissButton = {
-        if (vm.installStatus is InstallStatus.Success) TextButton(onClick = { vm.tryDismissInstall() }) {
+        if (model.installStatus is InstallStatus.Success) TextButton(onClick = { model.tryDismissInstall() }) {
             Text("Cool")
-        } else if (vm.isInstallDismissible) TextButton(onClick = { vm.tryDismissInstall() }) {
+        } else if (model.isInstallDismissible) TextButton(onClick = { model.tryDismissInstall() }) {
             Text("Cancel")
         }
     },
     title = {
-        when (vm.installStatus) {
+        when (model.installStatus) {
             is InstallStatus.InProgress -> Text("Installing update")
             is InstallStatus.Success -> Text("Success")
             is InstallStatus.Failure -> Text("Failure")
@@ -239,7 +243,7 @@ private fun InstallDialog(
     },
     text = {
         Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
-            when (val status = vm.installStatus) {
+            when (val status = model.installStatus) {
                 is InstallStatus.InProgress.Installing -> {
                     Text("Installing")
                     LinearProgressIndicator(status.progress)
@@ -271,8 +275,8 @@ private fun InstallDialog(
 
 
 @Composable
-fun AppInfoTopBarAction(vm: UpdatesViewModel = UpdatesViewModel.vm()) {
-    IconButton(onClick = { vm.showAppInfo = true }) {
+fun AppInfoTopBarAction(model: UpdatesScreenModel = UpdatesScreenModel.model) {
+    IconButton(onClick = { model.showAppInfo = true }) {
         Icon(Icons.Default.Info, "Info")
     }
 }
@@ -293,10 +297,15 @@ private fun AppInfoDialog(
             }
         }
     },
+    modifier = Modifier.widthIn(max = 400.dp),
     icon = { Icon(Icons.Default.Info, null) },
     title = { Text("Kaffeekasse") },
     text = {
-        ColumnCenter(Modifier.fillMaxWidth()) {
+        ColumnCenter(
+            Modifier
+                .fillMaxWidth()
+                .verticalScroll(rememberScrollState())
+        ) {
             val versionText = listOfNotNull(
                 "Version: ${AppVersion.Current}",
                 "(debug)".takeIf { BuildConfig.DEBUG }
@@ -320,12 +329,19 @@ private fun AppInfoDialog(
             Spacer(Modifier.height(16.dp))
             Text("Made with love\nby\nJonas Broeckmann", textAlign = TextAlign.Center)
             Spacer(Modifier.height(16.dp))
-            val settingsStore = LocalSettingsStore.current
-            ListItem(
+            val settings = settings()
+            TransparentListItem(
+                headlineContent = { Text("Fancy animations") },
+                trailingContent = { Switch(
+                    settings.value.animationsEnabled,
+                    onCheckedChange = { new -> settings.tryUpdate { it.copy(animationsEnabled = new) } }
+                ) }
+            )
+            TransparentListItem(
                 headlineContent = { Text("Developer mode") },
                 trailingContent = { Switch(
-                    settingsStore.value.developerMode,
-                    onCheckedChange = { new -> settingsStore.tryUpdate { it.copy(developerMode = new) } }
+                    settings.value.developerMode,
+                    onCheckedChange = { new -> settings.tryUpdate { it.copy(developerMode = new) } }
                 ) }
             )
             Spacer(Modifier.height(16.dp))
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/hiwi_tracker/EnterWorkingHours.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/hiwi_tracker/EnterWorkingHours.kt
index 0c4408976494745b4418f5a52f46e7e059415132..1ead51a12d2424545a0f891191a5bc669e648299 100644
--- a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/hiwi_tracker/EnterWorkingHours.kt
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/hiwi_tracker/EnterWorkingHours.kt
@@ -43,7 +43,6 @@ import androidx.compose.material3.Text
 import androidx.compose.material3.TextButton
 import androidx.compose.material3.contentColorFor
 import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.derivedStateOf
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
@@ -54,7 +53,6 @@ import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.clip
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.ColorFilter
-import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.res.painterResource
 import androidx.compose.ui.text.AnnotatedString
 import androidx.compose.ui.text.SpanStyle
@@ -83,7 +81,6 @@ import net.novagamestudios.common_utils.compose.components.ColumnCenter
 import net.novagamestudios.common_utils.compose.components.RowCenter
 import net.novagamestudios.common_utils.compose.state.ReentrantActionState
 import net.novagamestudios.common_utils.compose.thenIf
-import net.novagamestudios.common_utils.toastLong
 import net.novagamestudios.common_utils.warn
 import net.novagamestudios.kaffeekasse.R
 import net.novagamestudios.kaffeekasse.model.hiwi_tracker.WorkEntry
@@ -94,10 +91,12 @@ import net.novagamestudios.kaffeekasse.model.hiwi_tracker.WorkEntry.Companion.Mi
 import net.novagamestudios.kaffeekasse.model.hiwi_tracker.WorkEntry.Companion.invalidLargeBreak
 import net.novagamestudios.kaffeekasse.model.hiwi_tracker.WorkEntry.Companion.invalidSmallBreak
 import net.novagamestudios.kaffeekasse.model.hiwi_tracker.WorkEntry.Companion.isValid
-import net.novagamestudios.kaffeekasse.repositories.I11Client
+import net.novagamestudios.kaffeekasse.repositories.i11.HiwiTrackerRepository
 import net.novagamestudios.kaffeekasse.ui.theme.disabled
 import net.novagamestudios.kaffeekasse.ui.util.ClockFace
 import net.novagamestudios.kaffeekasse.ui.util.TimePickerState
+import net.novagamestudios.kaffeekasse.ui.util.Toasts
+import net.novagamestudios.kaffeekasse.ui.util.ToastsState
 import net.novagamestudios.kaffeekasse.ui.util.monochrome
 import net.novagamestudios.kaffeekasse.util.minus
 import kotlin.math.roundToInt
@@ -109,7 +108,7 @@ import kotlin.time.Duration.Companion.seconds
 
 class EnterWorkingHoursState(
     private val coroutineScope: CoroutineScope,
-    private val hiwiTracker: I11Client.HiwiTracker,
+    private val hiwiTracker: HiwiTrackerRepository,
     internal val date: LocalDate,
     private val onSubmitted: suspend () -> Unit
 ) : Logger {
@@ -150,7 +149,7 @@ class EnterWorkingHoursState(
     private val loadingMutex = ReentrantActionState()
     val isLoading by loadingMutex
 
-    var errorToast by mutableStateOf<String?>(null)
+    val toasts = ToastsState()
     var indicateSuccess by mutableStateOf(false)
 
     fun submitWorkEntry(entry: WorkEntry) {
@@ -162,7 +161,7 @@ class EnterWorkingHoursState(
                     true
                 } catch (e: Exception) {
                     warn(e) { "Failed to submit entry" }
-                    errorToast = "Fehler: ${e.message ?: "Unbekannter Fehler"}"
+                    toasts.long("Fehler: ${e.message ?: "Unbekannter Fehler"}")
                     false
                 }
             }
@@ -242,17 +241,7 @@ fun EnterWorkingHoursForm(
             }
         )
     }
-}
-
-@Composable
-private fun FormToasts(state: EnterWorkingHoursState) {
-    val context = LocalContext.current
-    state.errorToast?.let {
-        LaunchedEffect(Unit) {
-            context.toastLong(it)
-            state.errorToast = null
-        }
-    }
+    Toasts(state.toasts)
 }
 
 @Composable
@@ -309,7 +298,8 @@ private fun FormFromTo(
             initialMode = TimePickerDialogState.Mode.To
         )
     },
-    duration = state.workEntry.workedDuration
+    duration = state.workEntry.workedDuration,
+    modifier = modifier
 )
 
 @Composable
@@ -442,7 +432,7 @@ private fun TimerPickerDialog(
         }
     },
     text = {
-        ColumnCenter {
+        ColumnCenter(Modifier.fillMaxWidth()) {
             val currentTimePickerState = when (state.mode) {
                 TimePickerDialogState.Mode.From -> state.fromTimePicker
                 TimePickerDialogState.Mode.To -> state.toTimePicker
@@ -450,7 +440,7 @@ private fun TimerPickerDialog(
             ClockFace(
                 currentTimePickerState,
                 Modifier
-                    .fillMaxWidth()
+                    .align(Alignment.CenterHorizontally)
                     .padding(bottom = 16.dp)
             )
             val selections = listOf(
@@ -547,7 +537,7 @@ private fun DurationInput(
             )
         },
         keyboardOptions = KeyboardOptions(
-            autoCorrect = false,
+            autoCorrectEnabled = false,
             keyboardType = KeyboardType.Number,
             imeAction = ImeAction.Done
         ),
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/hiwi_tracker/HiwiTrackerModule.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/hiwi_tracker/HiwiTrackerModule.kt
index 7bebf71433b313c84c812c7a95d65b2990ae3b3f..948b9b63501568ec4789d540f97dcc4b15e305e2 100644
--- a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/hiwi_tracker/HiwiTrackerModule.kt
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/hiwi_tracker/HiwiTrackerModule.kt
@@ -1,652 +1,39 @@
 package net.novagamestudios.kaffeekasse.ui.hiwi_tracker
 
-import androidx.compose.animation.animateColorAsState
-import androidx.compose.animation.core.animateFloatAsState
-import androidx.compose.foundation.Canvas
-import androidx.compose.foundation.combinedClickable
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.aspectRatio
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.layout.widthIn
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.foundation.verticalScroll
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.ChevronLeft
-import androidx.compose.material.icons.filled.ChevronRight
-import androidx.compose.material.icons.filled.DeleteForever
-import androidx.compose.material3.AlertDialog
-import androidx.compose.material3.Button
-import androidx.compose.material3.ButtonDefaults
-import androidx.compose.material3.CardDefaults
-import androidx.compose.material3.HorizontalDivider
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.ListItem
-import androidx.compose.material3.LocalContentColor
-import androidx.compose.material3.LocalTextStyle
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.ModalBottomSheet
-import androidx.compose.material3.OutlinedCard
-import androidx.compose.material3.ProvideTextStyle
-import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
-import androidx.compose.material3.rememberModalBottomSheetState
 import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.derivedStateOf
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberUpdatedState
-import androidx.compose.runtime.setValue
-import androidx.compose.runtime.snapshotFlow
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
-import androidx.compose.ui.geometry.CornerRadius
-import androidx.compose.ui.geometry.Offset
-import androidx.compose.ui.geometry.Size
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.SolidColor
-import androidx.compose.ui.layout.Layout
-import androidx.compose.ui.layout.Placeable
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.text.style.TextOverflow
-import androidx.compose.ui.unit.Constraints
-import androidx.compose.ui.unit.dp
 import cafe.adriel.voyager.core.model.ScreenModel
-import cafe.adriel.voyager.core.model.screenModelScope
-import cafe.adriel.voyager.navigator.LocalNavigator
-import cafe.adriel.voyager.navigator.currentOrThrow
-import io.woong.compose.grid.SimpleGridCells
-import io.woong.compose.grid.VerticalGrid
-import kotlinx.coroutines.flow.collectLatest
-import kotlinx.coroutines.launch
-import kotlinx.datetime.Clock
-import kotlinx.datetime.LocalDate
-import kotlinx.datetime.LocalTime
-import kotlinx.datetime.TimeZone
-import kotlinx.datetime.format
-import kotlinx.datetime.format.DayOfWeekNames
-import kotlinx.datetime.format.MonthNames
-import kotlinx.datetime.format.Padding
-import kotlinx.datetime.format.char
-import kotlinx.datetime.toKotlinLocalTime
-import kotlinx.datetime.todayIn
+import cafe.adriel.voyager.navigator.Navigator
 import net.novagamestudios.common_utils.Logger
-import net.novagamestudios.common_utils.compose.DashedShape
-import net.novagamestudios.common_utils.compose.components.BoxCenter
-import net.novagamestudios.common_utils.compose.components.CircularProgressIndicator
-import net.novagamestudios.common_utils.compose.components.RowCenter
-import net.novagamestudios.common_utils.compose.state.collectAsStateIn
-import net.novagamestudios.common_utils.debug
-import net.novagamestudios.common_utils.warn
-import net.novagamestudios.kaffeekasse.App
-import net.novagamestudios.kaffeekasse.model.format
-import net.novagamestudios.kaffeekasse.model.hiwi_tracker.MonthKey
-import net.novagamestudios.kaffeekasse.model.hiwi_tracker.MonthKey.Companion.toMonthKey
-import net.novagamestudios.kaffeekasse.model.hiwi_tracker.WorkEntry
-import net.novagamestudios.kaffeekasse.model.i11_portal.api.HiwiTrackerMonthData
-import net.novagamestudios.kaffeekasse.repositories.I11Client
-import net.novagamestudios.kaffeekasse.repositories.MutableSettingsStore
+import net.novagamestudios.kaffeekasse.model.session.Session
+import net.novagamestudios.kaffeekasse.repositories.RepositoryProvider
 import net.novagamestudios.kaffeekasse.ui.AppInfoTopBarAction
 import net.novagamestudios.kaffeekasse.ui.AppModuleSelection
-import net.novagamestudios.kaffeekasse.ui.HiwiTrackerNavigation
-import net.novagamestudios.kaffeekasse.ui.LogoutTopBarAction
-import net.novagamestudios.kaffeekasse.ui.kaffeekasse.components.FailureRetryScreen
-import net.novagamestudios.kaffeekasse.ui.theme.disabled
-import net.novagamestudios.kaffeekasse.ui.util.HorizontalKeyedPager
-import net.novagamestudios.kaffeekasse.ui.util.HorizontalPagedLayout
-import net.novagamestudios.kaffeekasse.ui.util.KeyedPagerState
-import net.novagamestudios.kaffeekasse.ui.util.PullToRefreshBox
-import net.novagamestudios.kaffeekasse.ui.util.onClickComingSoon
-import net.novagamestudios.kaffeekasse.ui.util.synchronizePagerState
-import net.novagamestudios.kaffeekasse.util.RichData
-import java.time.format.DateTimeFormatter
-import kotlin.math.absoluteValue
-import kotlin.math.max
-import kotlin.math.roundToInt
-import kotlin.time.Duration
-
-class HiwiTrackerModuleViewModel private constructor(
-    mutableSettingsStore: MutableSettingsStore,
-    private val hiwiTracker: I11Client.HiwiTracker
-) : ScreenModel, Logger {
-
-    val data by hiwiTracker.jsonData.collectAsStateIn(screenModelScope)
-
-    val initialMonth = Clock.System.todayIn(TimeZone.currentSystemDefault()).toMonthKey()
-    var currentMonth by mutableStateOf(initialMonth)
-    val dataByMonth get() = hiwiTracker.dataByMonth
-
-    val pagerState = object : KeyedPagerState<MonthKey>(initialMonth.monthIndex) {
-        override fun keyToIndex(key: MonthKey): Int {
-            return key.monthIndex
-        }
-
-        override fun indexToKey(index: Int): MonthKey {
-            return MonthKey(index)
-        }
-
-        override val pageCount = initialMonth.monthIndex + 100
-    }
-
-    suspend fun keepMonthDataUpToDate() {
-        snapshotFlow {
-            listOf(
-                currentMonth,
-                currentMonth - 1,
-                currentMonth + 1
-            ).filter { dataByMonth[it]?.dataOrNull == null }
-        }.collectLatest { toFetch ->
-            toFetch.forEach { fetchMonth(it) }
-        }
-    }
-
-
-    private suspend fun fetchMonth(month: MonthKey) {
-        debug { "Fetching data for month $month" }
-        try {
-            hiwiTracker.fetchDataForMonth(month)
-        } catch (e: Exception) {
-            warn(e) { "Failed to fetch data for month $month" }
-        }
-    }
-    fun refreshMonth(month: MonthKey) {
-        screenModelScope.launch { fetchMonth(month) }
-    }
+import net.novagamestudios.kaffeekasse.ui.login.LogoutTopBarAction
+import net.novagamestudios.kaffeekasse.ui.navigation.HiwiTrackerNavigation
+import net.novagamestudios.kaffeekasse.ui.util.screenmodel.ScreenModelFactory
 
 
-    var enterWorkingHoursState by mutableStateOf<EnterWorkingHoursState?>(null)
-    var enterDone by mutableStateOf(false)
-
+class HiwiTrackerModuleScreenModel private constructor(
+    val session: Session
+) : ScreenModel, Logger {
 
-    fun onClickDate(date: LocalDate) {
-        enterDone = false
-        enterWorkingHoursState = EnterWorkingHoursState(
-            coroutineScope = screenModelScope,
-            hiwiTracker = hiwiTracker,
-            date = date,
-            onSubmitted = { enterDone = true }
+    companion object : ScreenModelFactory<HiwiTrackerNavigation.Tab, HiwiTrackerModuleScreenModel> {
+        context (RepositoryProvider)
+        override fun create(screen: HiwiTrackerNavigation.Tab) = HiwiTrackerModuleScreenModel(
+            session = screen.session
         )
     }
-
-    fun onDeleteEntry(entry: HiwiTrackerMonthData.Entry) {
-        screenModelScope.launch {
-            hiwiTracker.deleteWorkEntry(entry.id)
-        }
-    }
-
-    companion object {
-        @Composable fun vm() = App.navigatorScreenModel {
-            HiwiTrackerModuleViewModel(
-                mutableSettingsStore = settingsStore,
-                hiwiTracker = i11Client.hiwiTracker
-            )
-        }
-    }
 }
 
-
-
-
 @Composable
-fun HiwiTrackerTopBarTitle() {
-    when (LocalNavigator.currentOrThrow.lastItem) {
-        HiwiTrackerNavigation.OverviewScreen -> AppModuleSelection()
+fun HiwiTrackerTopBarTitle(navigator: Navigator) {
+    when (navigator.lastItem) {
+        is HiwiTrackerNavigation.OverviewScreen -> AppModuleSelection()
     }
 }
 
 @Composable
-fun HiwiTrackerTopBarActions() {
+fun HiwiTrackerTopBarActions(model: HiwiTrackerModuleScreenModel) {
     AppInfoTopBarAction()
-    LogoutTopBarAction()
-}
-
-
-@Composable
-fun HiwiTrackerContent(
-    modifier: Modifier = Modifier,
-    vm: HiwiTrackerModuleViewModel = HiwiTrackerModuleViewModel.vm()
-) = PullToRefreshBox(
-    onRefresh = { vm.refreshMonth(vm.currentMonth) },
-    shouldRefresh = { vm.dataByMonth[vm.currentMonth]?.isLoading ?: false },
-    modifier.fillMaxSize()
-) {
-    LaunchedEffect(Unit) {
-//        vm.refreshIfNeeded()
-        vm.keepMonthDataUpToDate()
-    }
-    Column {
-        MonthSelection(
-            vm,
-            Modifier
-                .align(Alignment.CenterHorizontally)
-                .padding(horizontal = 16.dp, vertical = 8.dp)
-        )
-        when (val data = vm.dataByMonth[vm.currentMonth]?.value) {
-            is RichData.Loading -> BoxCenter(Modifier.fillMaxSize()) {
-                CircularProgressIndicator(data.progress)
-            }
-            is RichData.Data -> Column {
-                synchronizePagerState(
-                    pagerState = vm.pagerState,
-                    property = vm::currentMonth
-                )
-                HorizontalKeyedPager(state = vm.pagerState) { key ->
-                    BoxCenter(Modifier.fillMaxWidth()) {
-                        val pagerData = vm.dataByMonth[key]?.dataOrNull
-                        if (pagerData != null) CalendarForMonth(
-                            pagerData,
-                            onClick = vm::onClickDate,
-                            Modifier
-                                .padding(horizontal = 32.dp)
-                                .padding(bottom = 8.dp)
-                        )
-                    }
-                }
-                HorizontalDivider()
-                val scrollState = rememberScrollState()
-                MonthContent(
-                    vm,
-                    Modifier
-                        .weight(1f)
-                        .verticalScroll(scrollState)
-                )
-                LaunchedEffect(vm.currentMonth) {
-                    scrollState.animateScrollTo(0)
-                }
-            }
-            is RichData.Error -> FailureRetryScreen(
-                message = "Failed to fetch data",
-                errors = data.errorInfo,
-                Modifier.fillMaxSize(),
-                onRetry = { vm.refreshMonth(vm.currentMonth) }
-            )
-            else -> { }
-        }
-    }
-    vm.enterWorkingHoursState?.let { subVM ->
-        val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
-        ModalBottomSheet(
-            onDismissRequest = { vm.enterWorkingHoursState = null },
-            sheetState = sheetState
-        ) {
-            EnterWorkingHoursForm(subVM)
-            if (vm.enterDone) LaunchedEffect(Unit) {
-                launch { sheetState.hide() }.invokeOnCompletion { vm.enterWorkingHoursState = null }
-            }
-        }
-    }
-}
-
-@Composable
-private fun MonthSelection(
-    vm: HiwiTrackerModuleViewModel,
-    modifier: Modifier = Modifier
-) = RowCenter(
-    modifier,
-    horizontalArrangement = Arrangement.spacedBy(8.dp)
-) {
-    IconButton(onClick = { vm.currentMonth = vm.currentMonth - 1 }) {
-        Icon(Icons.Default.ChevronLeft, "Previous")
-    }
-    HorizontalPagedLayout(
-        state = vm.pagerState,
-        Modifier
-            .clip(RoundedCornerShape(8.dp))
-            .width(200.dp)
-    ) { index ->
-        Text(
-            remember(index) {
-                MonthKey(index).toLocalDate().format(LocalDate.Format {
-                    monthName(MonthNames.GERMAN_FULL)
-                    chars(" ")
-                    year(Padding.NONE)
-                })
-            },
-            Modifier.fillMaxWidth(),
-            textAlign = TextAlign.Center,
-            style = MaterialTheme.typography.titleLarge
-        )
-    }
-    IconButton(onClick = { vm.currentMonth = vm.currentMonth + 1 }) {
-        Icon(Icons.Default.ChevronRight, "Next")
-    }
-}
-
-
-@Composable
-private fun CalendarForMonth(
-    data: HiwiTrackerMonthData,
-    onClick: (LocalDate) -> Unit,
-    modifier: Modifier = Modifier
-) {
-    val calendarDays by remember { derivedStateOf { data.calendar.toSortedMap() } }
-    val today = remember { LocalDate.now() }
-    VerticalGrid(
-        columns = SimpleGridCells.Fixed(count = 7, fill = true),
-        modifier.widthIn(max = 400.dp),
-        horizontalArrangement = Arrangement.spacedBy(4.dp),
-        verticalArrangement = Arrangement.spacedBy(4.dp)
-    ) {
-        (1..7).forEach { isoDayNumber ->
-            Text(
-                remember(isoDayNumber) {
-                    DayOfWeekNames.GERMAN_FULL.names[isoDayNumber - 1].take(2)
-                },
-                Modifier.fillMaxWidth(),
-                color = LocalContentColor.current.disabled(),
-                textAlign = TextAlign.Center,
-                style = MaterialTheme.typography.labelSmall
-            )
-        }
-        calendarDays.forEach { (day, data) ->
-            OutlinedCard(
-                onClick = { onClick(day) },
-                modifier = Modifier
-                    .aspectRatio(1.4f)
-                    .fillMaxSize(),
-                shape = RoundedCornerShape(8.dp),
-                border = when (day) {
-                    today -> CardDefaults.outlinedCardBorder().copy(
-                        width = 2.dp,
-                        brush = SolidColor(MaterialTheme.colorScheme.primary)
-                    )
-                    else -> CardDefaults.outlinedCardBorder()
-                }
-            ) {
-                BoxCenter(Modifier.fillMaxSize()) {
-                    val localStyle = LocalTextStyle.current
-                    val style = when {
-                        data.disabled -> localStyle.copy(color = LocalContentColor.current.disabled())
-                        data.hasEntry -> localStyle.copy(
-                            color = MaterialTheme.colorScheme.primary,
-                            fontWeight = FontWeight.Bold
-                        )
-                        else -> localStyle
-                    }
-                    Text(
-                        day.dayOfMonth.toString(),
-                        style = style
-                    )
-                }
-            }
-        }
-    }
+    LogoutTopBarAction(model.session)
 }
-
-@Composable
-private fun MonthContent(
-    vm: HiwiTrackerModuleViewModel,
-    modifier: Modifier = Modifier
-) = Column(modifier) {
-    val data = vm.dataByMonth[vm.currentMonth]?.dataOrNull ?: return
-
-    MonthlyTimeDiagram(
-        data,
-        Modifier
-            .padding(8.dp)
-            .fillMaxWidth()
-    )
-    HorizontalDivider(Modifier.clip(DashedShape(6.dp)))
-    Column(Modifier.padding(8.dp)) {
-        val done = data.total.hoursBalance + data.totalMonth.hoursWorked
-        val todo = data.totalMonth.hoursThisMonth - done
-        ProvideTextStyle(MaterialTheme.typography.labelLarge) {
-            Row(Modifier.padding(horizontal = 8.dp)) {
-                Text("Erbracht:")
-                Spacer(Modifier.weight(1f))
-                Text(done.fHT())
-            }
-            Row(Modifier.padding(horizontal = 8.dp)) {
-                Text("Verbleibend:")
-                Spacer(Modifier.weight(1f))
-                Text(todo.fHT())
-            }
-        }
-    }
-    HorizontalDivider()
-    data.entries.forEach { entry ->
-        WorkEntry(
-            entry,
-            onClick = onClickComingSoon(),
-            onDelete = { vm.onDeleteEntry(entry) },
-        )
-    }
-}
-
-@Composable
-private fun WorkEntry(
-    entry: WorkEntry,
-    onClick: () -> Unit,
-    onDelete: () -> Unit,
-    modifier: Modifier = Modifier
-) {
-    var confirmDelete by remember { mutableStateOf(false) }
-    ListItem(
-        headlineContent = {
-            Row(
-                horizontalArrangement = Arrangement.spacedBy(12.dp),
-                verticalAlignment = Alignment.Bottom
-            ) {
-                val date = remember(entry) {
-                    entry.date.format(DateTimeFormatter.ofPattern("dd.MM."))
-                }
-                Text(date)
-                Text("${entry.begin.fHT()} - ${entry.end.fHT()}", fontWeight = FontWeight.Bold)
-                entry.breakDurationOrNull?.takeIf { it.isPositive() }?.let {
-                    Text("Pause: ${it.fHT()}", fontSize = LocalTextStyle.current.fontSize * 0.8)
-                }
-            }
-        },
-        modifier.combinedClickable(
-            onLongClick = { confirmDelete = true },
-            onClick = onClick
-        ),
-        supportingContent = {
-            Text(entry.note, overflow = TextOverflow.Ellipsis, maxLines = 1)
-        },
-        trailingContent = {
-            Text(entry.workedDuration.fHT(), style = MaterialTheme.typography.bodyLarge)
-        }
-    )
-    if (confirmDelete) AlertDialog(
-        onDismissRequest = { confirmDelete = false },
-        confirmButton = {
-            Button(
-                onClick = onDelete,
-                colors = ButtonDefaults.buttonColors(
-                    containerColor = MaterialTheme.colorScheme.errorContainer,
-                    contentColor = MaterialTheme.colorScheme.onErrorContainer
-                )
-            ) { Text("Löschen") }
-        },
-        dismissButton = {
-            TextButton(onClick = { confirmDelete = false }) { Text("Abbrechen") }
-        },
-        icon = {
-            Icon(Icons.Default.DeleteForever, "Delete", tint = MaterialTheme.colorScheme.error)
-        },
-        title = { Text("Eintrag löschen?") }
-    )
-}
-
-@Composable
-private fun MonthlyTimeDiagram(
-    data: HiwiTrackerMonthData,
-    modifier: Modifier = Modifier
-) {
-    val workedAtStartOfMonth by animateFloatAsState(
-        data.total.hoursBalance.inWholeSeconds.toFloat(),
-        label = "workedAtStartOfMonth"
-    )
-    val workedThisMonth by animateFloatAsState(
-        data.totalMonth.hoursWorked.inWholeSeconds.toFloat(),
-        label = "workedThisMonth"
-    )
-    val totalThisMonth by animateFloatAsState(
-        data.totalMonth.hoursThisMonth.inWholeSeconds.toFloat(),
-        label = "totalThisMonth"
-    )
-
-    val atStartOfMonthColor by animateColorAsState(
-        if (workedAtStartOfMonth >= 0) Color.Green else Color.Red,
-        label = "atStartOfMonthColor"
-    )
-    val thisMonthColor by rememberUpdatedState(LocalContentColor.current)
-
-    val lineColor by rememberUpdatedState(MaterialTheme.colorScheme.outlineVariant)
-
-    val xMin = minOf(workedAtStartOfMonth, 0f)
-    val xMax = maxOf(workedAtStartOfMonth + workedThisMonth, totalThisMonth)
-    val range = xMax - xMin
-
-    val xZeroFraction = -xMin / range
-    val xMonthFraction = (totalThisMonth - xMin) / range
-    Layout(
-        content = {
-            Canvas(Modifier) {
-                val factor = size.width / range
-
-                val xStart = 0f
-                val xZero = xZeroFraction * size.width
-                val xMonth = xMonthFraction * size.width
-                val xEnd = size.width
-
-                val x1 = 0f
-                val s1 = workedAtStartOfMonth.absoluteValue * factor
-
-                val x2 = (xZeroFraction + workedAtStartOfMonth / range) * size.width
-                val s2 = workedThisMonth.absoluteValue * factor
-
-                val hp = 6.dp.toPx()
-                val l = 1.dp.toPx()
-                val h = (size.height - l - 2 * hp) / 2f
-
-                drawLine(
-                    lineColor,
-                    Offset(xStart, hp + h + l / 2f),
-                    Offset(xEnd, hp + h + l / 2f),
-                    l
-                )
-                drawLine(lineColor, Offset(xZero, 0f), Offset(xZero, size.height), l)
-                drawLine(lineColor, Offset(xMonth, 0f), Offset(xMonth, size.height), l)
-
-                drawRoundRect(
-                    color = atStartOfMonthColor,
-                    topLeft = Offset(x1 + l, hp),
-                    size = Size(s1 - l, h),
-                    cornerRadius = CornerRadius(h / 2f),
-                    //                style = Stroke(
-                    //                    width = 1.dp.toPx(),
-                    //                    pathEffect = PathEffect.dashPathEffect(floatArrayOf(8f, 8f))
-                    //                )
-                )
-                if (data.totalMonth.hoursWorked > Duration.ZERO) drawRoundRect(
-                    thisMonthColor,
-                    Offset(x2 + l, hp + l + h),
-                    Size(s2 - l, h),
-                    cornerRadius = CornerRadius(h / 2f)
-                )
-            }
-            val style = MaterialTheme.typography.labelLarge
-            Text(data.total.hoursBalance.fHT(), style = style)
-            Text(data.totalMonth.hoursWorked.fHT(), style = style)
-            Text(Duration.ZERO.fHT(), style = style)
-            Text(data.totalMonth.hoursThisMonth.fHT(), style = style)
-        },
-        modifier
-    ) { measurables, constraints ->
-        check(measurables.size == 5)
-        val (
-            chartMeasurable,
-            firstLabelMeasurable,
-            secondLabelMeasurable,
-            zeroLabelMeasurable,
-            monthLabelMeasurable
-        ) = measurables
-
-        val firstLabel = firstLabelMeasurable.measure(Constraints())
-        val secondLabel = secondLabelMeasurable.measure(Constraints())
-        val zeroLabel = zeroLabelMeasurable.measure(Constraints())
-        val monthLabel = monthLabelMeasurable.measure(Constraints())
-
-        val yLabelWidth = 56.dp.toPx().roundToInt()
-        val yLabelHeight = firstLabel.height + secondLabel.height
-        val xLabelHeight = max(zeroLabel.height, monthLabel.height)
-
-        val chart = chartMeasurable.measure(
-            constraints.copy(
-                minWidth = constraints.minWidth - yLabelWidth,
-                maxWidth = constraints.maxWidth - yLabelWidth,
-                minHeight = yLabelHeight,
-                maxHeight = yLabelHeight
-            )
-        )
-
-        layout(
-            width = constraints.maxWidth,
-            height = yLabelHeight + xLabelHeight
-        ) {
-            chart.placeRelative(yLabelWidth, 0)
-
-            val yLabelSpacing = 4.dp.toPx().roundToInt()
-            firstLabel.placeRelative(yLabelWidth - firstLabel.width - yLabelSpacing, 0)
-            secondLabel.placeRelative(
-                yLabelWidth - secondLabel.width - yLabelSpacing,
-                firstLabel.height
-            )
-
-            fun Placeable.placeXLabelAt(fraction: Float) {
-                val x = yLabelWidth + fraction * chart.width - width / 2f
-                val xRange = coordinates?.let {
-                    0..(it.size.width - width)
-                } ?: Int.MIN_VALUE..Int.MAX_VALUE
-                placeRelative(
-                    x.roundToInt().coerceIn(xRange),
-                    chart.height
-                )
-            }
-            zeroLabel.placeXLabelAt(xZeroFraction)
-            monthLabel.placeXLabelAt(xMonthFraction)
-        }
-    }
-}
-
-
-private val HTTimeFormat by lazy {
-    LocalTime.Format {
-        hour()
-        char(':')
-        minute()
-    }
-}
-
-
-@Composable
-internal fun LocalTime.fHT() = remember(this) { formatHiwiTracker() }
-internal fun LocalTime.formatHiwiTracker() = if (this == java.time.LocalTime.MAX.toKotlinLocalTime()) "24:00" else format(HTTimeFormat)
-
-@Composable
-internal fun Duration.fHT() = remember(this) { formatHiwiTracker() }
-internal fun Duration.formatHiwiTracker(): String {
-    val hours = inWholeHours.absoluteValue
-    val minutes = (inWholeMinutes % 60).absoluteValue
-//    val sign = if (hours < 0) "-" else ""
-//    val h = if (hours > 0) "${hours}h" else ""
-//    val m = if (hours > 0 || minutes > 0) "${minutes}m" else ""
-    return listOfNotNull(
-        hours.takeIf { it != 0L || minutes == 0L }?.let { "${it}h" },
-        minutes.takeIf { it != 0L }?.let { "${it.toString().padStart(2, '0')}m" }
-    ).joinToString("", prefix = if (inWholeHours < 0) "-" else "")
-}
-
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/hiwi_tracker/Overview.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/hiwi_tracker/Overview.kt
new file mode 100644
index 0000000000000000000000000000000000000000..2e888779e33c943b711056e37f76da5e923a6fd3
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/hiwi_tracker/Overview.kt
@@ -0,0 +1,648 @@
+package net.novagamestudios.kaffeekasse.ui.hiwi_tracker
+
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ChevronLeft
+import androidx.compose.material.icons.filled.ChevronRight
+import androidx.compose.material.icons.filled.DeleteForever
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.LocalTextStyle
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.OutlinedCard
+import androidx.compose.material3.ProvideTextStyle
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.geometry.CornerRadius
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.layout.Layout
+import androidx.compose.ui.layout.Placeable
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.dp
+import cafe.adriel.voyager.core.model.ScreenModel
+import cafe.adriel.voyager.core.model.screenModelScope
+import io.woong.compose.grid.SimpleGridCells
+import io.woong.compose.grid.VerticalGrid
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+import kotlinx.datetime.Clock
+import kotlinx.datetime.LocalDate
+import kotlinx.datetime.LocalTime
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.format
+import kotlinx.datetime.format.DayOfWeekNames
+import kotlinx.datetime.format.MonthNames
+import kotlinx.datetime.format.Padding
+import kotlinx.datetime.format.char
+import kotlinx.datetime.toKotlinLocalTime
+import kotlinx.datetime.todayIn
+import net.novagamestudios.common_utils.Logger
+import net.novagamestudios.common_utils.compose.DashedShape
+import net.novagamestudios.common_utils.compose.components.BoxCenter
+import net.novagamestudios.common_utils.compose.components.CircularProgressIndicator
+import net.novagamestudios.common_utils.compose.components.RowCenter
+import net.novagamestudios.common_utils.debug
+import net.novagamestudios.common_utils.verbose
+import net.novagamestudios.kaffeekasse.api.hiwi_tracker.model.MonthDataResponse
+import net.novagamestudios.kaffeekasse.model.date_time.format
+import net.novagamestudios.kaffeekasse.model.hiwi_tracker.MonthKey
+import net.novagamestudios.kaffeekasse.model.hiwi_tracker.MonthKey.Companion.toMonthKey
+import net.novagamestudios.kaffeekasse.model.hiwi_tracker.WorkEntry
+import net.novagamestudios.kaffeekasse.model.session.Session
+import net.novagamestudios.kaffeekasse.repositories.MutableSettingsStore
+import net.novagamestudios.kaffeekasse.repositories.RepositoryProvider
+import net.novagamestudios.kaffeekasse.repositories.i11.HiwiTrackerRepository
+import net.novagamestudios.kaffeekasse.ui.kaffeekasse.components.FailureRetryScreen
+import net.novagamestudios.kaffeekasse.ui.navigation.HiwiTrackerNavigation
+import net.novagamestudios.kaffeekasse.ui.theme.disabled
+import net.novagamestudios.kaffeekasse.ui.util.HorizontalKeyedPager
+import net.novagamestudios.kaffeekasse.ui.util.HorizontalPagedLayout
+import net.novagamestudios.kaffeekasse.ui.util.KeyedPagerState
+import net.novagamestudios.kaffeekasse.ui.util.PullToRefreshBox
+import net.novagamestudios.kaffeekasse.ui.util.onClickComingSoon
+import net.novagamestudios.kaffeekasse.ui.util.screenmodel.ScreenModelFactory
+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 java.time.format.DateTimeFormatter
+import kotlin.math.absoluteValue
+import kotlin.math.max
+import kotlin.math.roundToInt
+import kotlin.time.Duration
+
+class OverviewScreenModel private constructor(
+    val session: Session,
+    @Suppress("UNUSED_PARAMETER") mutableSettingsStore: MutableSettingsStore,
+    private val hiwiTracker: HiwiTrackerRepository
+) : ScreenModel, Logger {
+
+    val initialMonth = Clock.System.todayIn(TimeZone.currentSystemDefault()).toMonthKey()
+    var currentMonth by mutableStateOf(initialMonth)
+
+    private val dataForMonthStates = mutableMapOf<MonthKey, RichDataState<MonthDataResponse>>()
+
+    val pagerState = object : KeyedPagerState<MonthKey>(initialMonth.monthIndex) {
+        override fun keyToIndex(key: MonthKey): Int {
+            return key.monthIndex
+        }
+
+        override fun indexToKey(index: Int): MonthKey {
+            return MonthKey(index)
+        }
+
+        override val pageCount = initialMonth.monthIndex + 100
+    }
+
+    fun dataForMonthState(month: MonthKey) = dataForMonthStates.getOrPut(month) {
+        hiwiTracker.getDataForMonth(month).collectAsRichStateHere()
+    }
+
+    suspend fun keepMonthDataUpToDate() {
+        snapshotFlow {
+            listOf(
+                currentMonth,
+                currentMonth - 1,
+                currentMonth + 1
+            )
+        }.collectLatest { toFetch ->
+            toFetch.forEach {
+                ensureDataForMonth(it)
+            }
+        }
+    }
+
+
+    private suspend fun ensureDataForMonth(month: MonthKey, reFetch: Boolean = false) {
+        debug { "Ensuring data for month $month" }
+        hiwiTracker.getDataForMonth(month).apply {
+            if (reFetch) {
+                verbose { "Refresh month: $month" }
+                refresh()
+            } else {
+                verbose { "Ensure clean data for month: $month" }
+                ensureCleanData()
+            }
+        }
+    }
+    fun refreshMonth(month: MonthKey) {
+        screenModelScope.launch {
+            ensureDataForMonth(month, reFetch = true)
+        }
+    }
+
+
+    var enterWorkingHoursState by mutableStateOf<EnterWorkingHoursState?>(null)
+    var enterDone by mutableStateOf(false)
+
+
+    fun onClickDate(date: LocalDate) {
+        enterDone = false
+        enterWorkingHoursState = EnterWorkingHoursState(
+            coroutineScope = screenModelScope,
+            hiwiTracker = hiwiTracker,
+            date = date,
+            onSubmitted = { enterDone = true }
+        )
+    }
+
+    fun onDeleteEntry(entry: MonthDataResponse.Entry) {
+        screenModelScope.launch {
+            hiwiTracker.deleteWorkEntry(entry.id)
+        }
+    }
+
+    companion object : ScreenModelFactory<HiwiTrackerNavigation.OverviewScreen, OverviewScreenModel> {
+        context (RepositoryProvider)
+        override fun create(screen: HiwiTrackerNavigation.OverviewScreen) = OverviewScreenModel(
+            session = screen.session,
+            mutableSettingsStore = settingsRepository,
+            hiwiTracker = hiwiTrackerRepository
+        )
+    }
+}
+
+
+
+
+
+@Composable
+fun Overview(
+    model: OverviewScreenModel,
+    modifier: Modifier = Modifier
+) = PullToRefreshBox(
+    refreshing = { 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)
+                        )
+                    }
+                }
+                HorizontalDivider()
+                val scrollState = rememberScrollState()
+                MonthContent(
+                    model,
+                    Modifier
+                        .weight(1f)
+                        .verticalScroll(scrollState)
+                )
+                LaunchedEffect(model.currentMonth) {
+                    scrollState.animateScrollTo(0)
+                }
+            }
+            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 }
+            }
+        }
+    }
+}
+
+@Composable
+private fun MonthSelection(
+    model: OverviewScreenModel,
+    modifier: Modifier = Modifier
+) = RowCenter(
+    modifier,
+    horizontalArrangement = Arrangement.spacedBy(8.dp)
+) {
+    IconButton(onClick = { model.currentMonth = model.currentMonth - 1 }) {
+        Icon(Icons.Default.ChevronLeft, "Previous")
+    }
+    HorizontalPagedLayout(
+        state = model.pagerState,
+        Modifier
+            .clip(RoundedCornerShape(8.dp))
+            .width(200.dp)
+    ) { index ->
+        Text(
+            remember(index) {
+                MonthKey(index).toLocalDate().format(LocalDate.Format {
+                    monthName(MonthNames.GERMAN_FULL)
+                    chars(" ")
+                    year(Padding.NONE)
+                })
+            },
+            Modifier.fillMaxWidth(),
+            textAlign = TextAlign.Center,
+            style = MaterialTheme.typography.titleLarge
+        )
+    }
+    IconButton(onClick = { model.currentMonth = model.currentMonth + 1 }) {
+        Icon(Icons.Default.ChevronRight, "Next")
+    }
+}
+
+
+@Composable
+private fun CalendarForMonth(
+    data: MonthDataResponse,
+    onClick: (LocalDate) -> Unit,
+    modifier: Modifier = Modifier
+) {
+    val calendarDays by remember { derivedStateOf { data.calendar.toSortedMap() } }
+    val today = remember { LocalDate.now() }
+    VerticalGrid(
+        columns = SimpleGridCells.Fixed(count = 7, fill = true),
+        modifier.widthIn(max = 400.dp),
+        horizontalArrangement = Arrangement.spacedBy(4.dp),
+        verticalArrangement = Arrangement.spacedBy(4.dp)
+    ) {
+        (1..7).forEach { isoDayNumber ->
+            Text(
+                remember(isoDayNumber) {
+                    DayOfWeekNames.GERMAN_FULL.names[isoDayNumber - 1].take(2)
+                },
+                Modifier.fillMaxWidth(),
+                color = LocalContentColor.current.disabled(),
+                textAlign = TextAlign.Center,
+                style = MaterialTheme.typography.labelSmall
+            )
+        }
+        calendarDays.forEach { (day, data) ->
+            OutlinedCard(
+                onClick = { onClick(day) },
+                modifier = Modifier
+                    .aspectRatio(1.4f)
+                    .fillMaxSize(),
+                shape = RoundedCornerShape(8.dp),
+                border = when (day) {
+                    today -> CardDefaults.outlinedCardBorder().copy(
+                        width = 2.dp,
+                        brush = SolidColor(MaterialTheme.colorScheme.primary)
+                    )
+                    else -> CardDefaults.outlinedCardBorder()
+                }
+            ) {
+                BoxCenter(Modifier.fillMaxSize()) {
+                    val localStyle = LocalTextStyle.current
+                    val style = when {
+                        data.disabled -> localStyle.copy(color = LocalContentColor.current.disabled())
+                        data.hasEntry -> localStyle.copy(
+                            color = MaterialTheme.colorScheme.primary,
+                            fontWeight = FontWeight.Bold
+                        )
+                        else -> localStyle
+                    }
+                    Text(
+                        day.dayOfMonth.toString(),
+                        style = style
+                    )
+                }
+            }
+        }
+    }
+}
+
+@Composable
+private fun MonthContent(
+    model: OverviewScreenModel,
+    modifier: Modifier = Modifier
+) = Column(modifier) {
+    val data = model.dataForMonthState(model.currentMonth).dataOrNull ?: return
+
+    MonthlyTimeDiagram(
+        data,
+        Modifier
+            .padding(8.dp)
+            .fillMaxWidth()
+    )
+    HorizontalDivider(Modifier.clip(DashedShape(6.dp)))
+    Column(Modifier.padding(8.dp)) {
+        val done = data.total.hoursBalance + data.totalMonth.hoursWorked
+        val todo = data.totalMonth.hoursThisMonth - done
+        ProvideTextStyle(MaterialTheme.typography.labelLarge) {
+            Row(Modifier.padding(horizontal = 8.dp)) {
+                Text("Erbracht:")
+                Spacer(Modifier.weight(1f))
+                Text(done.fHT())
+            }
+            Row(Modifier.padding(horizontal = 8.dp)) {
+                Text("Verbleibend:")
+                Spacer(Modifier.weight(1f))
+                Text(todo.fHT())
+            }
+        }
+    }
+    HorizontalDivider()
+    data.entries.forEach { entry ->
+        WorkEntry(
+            entry,
+            onClick = onClickComingSoon(),
+            onDelete = { model.onDeleteEntry(entry) },
+        )
+    }
+}
+
+@Composable
+private fun WorkEntry(
+    entry: WorkEntry,
+    onClick: () -> Unit,
+    onDelete: () -> Unit,
+    modifier: Modifier = Modifier
+) {
+    var confirmDelete by remember { mutableStateOf(false) }
+    ListItem(
+        headlineContent = {
+            Row(
+                horizontalArrangement = Arrangement.spacedBy(12.dp),
+                verticalAlignment = Alignment.Bottom
+            ) {
+                val date = remember(entry) {
+                    entry.date.format(DateTimeFormatter.ofPattern("dd.MM."))
+                }
+                Text(date)
+                Text("${entry.begin.fHT()} - ${entry.end.fHT()}", fontWeight = FontWeight.Bold)
+                entry.breakDurationOrNull?.takeIf { it.isPositive() }?.let {
+                    Text("Pause: ${it.fHT()}", fontSize = LocalTextStyle.current.fontSize * 0.8)
+                }
+            }
+        },
+        modifier.combinedClickable(
+            onLongClick = { confirmDelete = true },
+            onClick = onClick
+        ),
+        supportingContent = {
+            Text(entry.note, overflow = TextOverflow.Ellipsis, maxLines = 1)
+        },
+        trailingContent = {
+            Text(entry.workedDuration.fHT(), style = MaterialTheme.typography.bodyLarge)
+        }
+    )
+    if (confirmDelete) AlertDialog(
+        onDismissRequest = { confirmDelete = false },
+        confirmButton = {
+            Button(
+                onClick = onDelete,
+                colors = ButtonDefaults.buttonColors(
+                    containerColor = MaterialTheme.colorScheme.errorContainer,
+                    contentColor = MaterialTheme.colorScheme.onErrorContainer
+                )
+            ) { Text("Löschen") }
+        },
+        dismissButton = {
+            TextButton(onClick = { confirmDelete = false }) { Text("Abbrechen") }
+        },
+        icon = {
+            Icon(Icons.Default.DeleteForever, "Delete", tint = MaterialTheme.colorScheme.error)
+        },
+        title = { Text("Eintrag löschen?") }
+    )
+}
+
+@Composable
+private fun MonthlyTimeDiagram(
+    data: MonthDataResponse,
+    modifier: Modifier = Modifier
+) {
+    val workedAtStartOfMonth by animateFloatAsState(
+        data.total.hoursBalance.inWholeSeconds.toFloat(),
+        label = "workedAtStartOfMonth"
+    )
+    val workedThisMonth by animateFloatAsState(
+        data.totalMonth.hoursWorked.inWholeSeconds.toFloat(),
+        label = "workedThisMonth"
+    )
+    val totalThisMonth by animateFloatAsState(
+        data.totalMonth.hoursThisMonth.inWholeSeconds.toFloat(),
+        label = "totalThisMonth"
+    )
+
+    val atStartOfMonthColor by animateColorAsState(
+        if (workedAtStartOfMonth >= 0) Color.Green else Color.Red,
+        label = "atStartOfMonthColor"
+    )
+    val thisMonthColor by rememberUpdatedState(LocalContentColor.current)
+
+    val lineColor by rememberUpdatedState(MaterialTheme.colorScheme.outlineVariant)
+
+    val xMin = minOf(workedAtStartOfMonth, 0f)
+    val xMax = maxOf(workedAtStartOfMonth + workedThisMonth, totalThisMonth)
+    val range = xMax - xMin
+
+    val xZeroFraction = -xMin / range
+    val xMonthFraction = (totalThisMonth - xMin) / range
+    Layout(
+        content = {
+            Canvas(Modifier) {
+                val factor = size.width / range
+
+                val xStart = 0f
+                val xZero = xZeroFraction * size.width
+                val xMonth = xMonthFraction * size.width
+                val xEnd = size.width
+
+                val x1 = 0f
+                val s1 = workedAtStartOfMonth.absoluteValue * factor
+
+                val x2 = (xZeroFraction + workedAtStartOfMonth / range) * size.width
+                val s2 = workedThisMonth.absoluteValue * factor
+
+                val hp = 6.dp.toPx()
+                val l = 1.dp.toPx()
+                val h = (size.height - l - 2 * hp) / 2f
+
+                drawLine(
+                    lineColor,
+                    Offset(xStart, hp + h + l / 2f),
+                    Offset(xEnd, hp + h + l / 2f),
+                    l
+                )
+                drawLine(lineColor, Offset(xZero, 0f), Offset(xZero, size.height), l)
+                drawLine(lineColor, Offset(xMonth, 0f), Offset(xMonth, size.height), l)
+
+                drawRoundRect(
+                    color = atStartOfMonthColor,
+                    topLeft = Offset(x1 + l, hp),
+                    size = Size(s1 - l, h),
+                    cornerRadius = CornerRadius(h / 2f),
+                    //                style = Stroke(
+                    //                    width = 1.dp.toPx(),
+                    //                    pathEffect = PathEffect.dashPathEffect(floatArrayOf(8f, 8f))
+                    //                )
+                )
+                if (data.totalMonth.hoursWorked > Duration.ZERO) drawRoundRect(
+                    thisMonthColor,
+                    Offset(x2 + l, hp + l + h),
+                    Size(s2 - l, h),
+                    cornerRadius = CornerRadius(h / 2f)
+                )
+            }
+            val style = MaterialTheme.typography.labelLarge
+            Text(data.total.hoursBalance.fHT(), style = style)
+            Text(data.totalMonth.hoursWorked.fHT(), style = style)
+            Text(Duration.ZERO.fHT(), style = style)
+            Text(data.totalMonth.hoursThisMonth.fHT(), style = style)
+        },
+        modifier
+    ) { measurables, constraints ->
+        check(measurables.size == 5)
+        val (
+            chartMeasurable,
+            firstLabelMeasurable,
+            secondLabelMeasurable,
+            zeroLabelMeasurable,
+            monthLabelMeasurable
+        ) = measurables
+
+        val firstLabel = firstLabelMeasurable.measure(Constraints())
+        val secondLabel = secondLabelMeasurable.measure(Constraints())
+        val zeroLabel = zeroLabelMeasurable.measure(Constraints())
+        val monthLabel = monthLabelMeasurable.measure(Constraints())
+
+        val yLabelWidth = 56.dp.toPx().roundToInt()
+        val yLabelHeight = firstLabel.height + secondLabel.height
+        val xLabelHeight = max(zeroLabel.height, monthLabel.height)
+
+        val chart = chartMeasurable.measure(
+            constraints.copy(
+                minWidth = constraints.minWidth - yLabelWidth,
+                maxWidth = constraints.maxWidth - yLabelWidth,
+                minHeight = yLabelHeight,
+                maxHeight = yLabelHeight
+            )
+        )
+
+        layout(
+            width = constraints.maxWidth,
+            height = yLabelHeight + xLabelHeight
+        ) {
+            chart.placeRelative(yLabelWidth, 0)
+
+            val yLabelSpacing = 4.dp.toPx().roundToInt()
+            firstLabel.placeRelative(yLabelWidth - firstLabel.width - yLabelSpacing, 0)
+            secondLabel.placeRelative(
+                yLabelWidth - secondLabel.width - yLabelSpacing,
+                firstLabel.height
+            )
+
+            fun Placeable.placeXLabelAt(fraction: Float) {
+                val x = yLabelWidth + fraction * chart.width - width / 2f
+                val xRange = coordinates?.let {
+                    0..(it.size.width - width)
+                } ?: Int.MIN_VALUE..Int.MAX_VALUE
+                placeRelative(
+                    x.roundToInt().coerceIn(xRange),
+                    chart.height
+                )
+            }
+            zeroLabel.placeXLabelAt(xZeroFraction)
+            monthLabel.placeXLabelAt(xMonthFraction)
+        }
+    }
+}
+
+
+private val HTTimeFormat by lazy {
+    LocalTime.Format {
+        hour()
+        char(':')
+        minute()
+    }
+}
+
+
+@Composable
+internal fun LocalTime.fHT() = remember(this) { formatHiwiTracker() }
+internal fun LocalTime.formatHiwiTracker() = if (this == java.time.LocalTime.MAX.toKotlinLocalTime()) "24:00" else format(HTTimeFormat)
+
+@Composable
+internal fun Duration.fHT() = remember(this) { formatHiwiTracker() }
+internal fun Duration.formatHiwiTracker(): String {
+    val hours = inWholeHours.absoluteValue
+    val minutes = (inWholeMinutes % 60).absoluteValue
+//    val sign = if (hours < 0) "-" else ""
+//    val h = if (hours > 0) "${hours}h" else ""
+//    val m = if (hours > 0 || minutes > 0) "${minutes}m" else ""
+    return listOfNotNull(
+        hours.takeIf { it != 0L || minutes == 0L }?.let { "${it}h" },
+        minutes.takeIf { it != 0L }?.let { "${it.toString().padStart(2, '0')}m" }
+    ).joinToString("", prefix = if (inWholeHours < 0) "-" else "")
+}
+
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/Account.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/Account.kt
index 382f50d31df46bce40df0813189f8942f87e8ed7..df872a48cae08e0a8b0c99933498025995c907dd 100644
--- a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/Account.kt
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/Account.kt
@@ -12,15 +12,11 @@ import androidx.compose.foundation.rememberScrollState
 import androidx.compose.foundation.verticalScroll
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.filled.Person
-import androidx.compose.material3.CircularProgressIndicator
 import androidx.compose.material3.Icon
 import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.OutlinedTextFieldDefaults
 import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.derivedStateOf
-import androidx.compose.runtime.getValue
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.Color
@@ -28,90 +24,71 @@ import androidx.compose.ui.text.font.FontFamily
 import androidx.compose.ui.text.style.TextAlign
 import androidx.compose.ui.unit.dp
 import cafe.adriel.voyager.core.model.ScreenModel
-import cafe.adriel.voyager.core.model.screenModelScope
-import kotlinx.coroutines.launch
 import net.novagamestudios.common_utils.Logger
 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.compose.state.ReentrantActionState
-import net.novagamestudios.common_utils.compose.state.collectAsStateIn
 import net.novagamestudios.common_utils.format
-import net.novagamestudios.common_utils.warn
-import net.novagamestudios.kaffeekasse.App
-import net.novagamestudios.kaffeekasse.model.i11_portal.api.KaffeekasseData
-import net.novagamestudios.kaffeekasse.repositories.I11Client
-import net.novagamestudios.kaffeekasse.ui.kaffeekasse.components.FailureRetryScreen
+import net.novagamestudios.kaffeekasse.model.kaffeekasse.Account
+import net.novagamestudios.kaffeekasse.model.session.Session
+import net.novagamestudios.kaffeekasse.repositories.RepositoryProvider
+import net.novagamestudios.kaffeekasse.repositories.i11.KaffeekasseRepository
+import net.novagamestudios.kaffeekasse.ui.AppInfoTopBarAction
+import net.novagamestudios.kaffeekasse.ui.login.LogoutTopBarAction
+import net.novagamestudios.kaffeekasse.ui.navigation.KaffeekasseNavigation
+import net.novagamestudios.kaffeekasse.ui.util.FailureRetryScreen
 import net.novagamestudios.kaffeekasse.ui.util.PullToRefreshBox
+import net.novagamestudios.kaffeekasse.ui.util.RichDataContent
+import net.novagamestudios.kaffeekasse.ui.util.screenmodel.ScreenModelFactory
+import net.novagamestudios.kaffeekasse.util.richdata.collectAsRichStateHere
 
-class AccountViewModel private constructor(
-    private val kaffeekasse: I11Client.Kaffeekasse
+class AccountScreenModel private constructor(
+    val session: Session.WithRealUser,
+    private val kaffeekasse: KaffeekasseRepository
 ) : ScreenModel, Logger {
 
-    private val loadingMutex = ReentrantActionState()
-    val isLoading by loadingMutex
-
-    val isLoggedIn get() = kaffeekasse.isLoggedIn
-    val jsonData by kaffeekasse.jsonData.collectAsStateIn(screenModelScope)
-    val balance by derivedStateOf { jsonData?.balance?.myBalance }
-
-    fun fetchAccount() {
-        screenModelScope.launch {
-            loadingMutex.trueWhile {
-                try {
-                    kaffeekasse.fetchData()
-                } catch (e: Exception) {
-                    warn(e) { "Failed to scrape account" }
-                }
-            }
-        }
-    }
-    fun refreshIfNeeded() {
-        if (jsonData == null || kaffeekasse.jsonDataDirty) fetchAccount()
-    }
+    val account = kaffeekasse.account[session.realUser].collectAsRichStateHere()
 
-    companion object {
-        @Composable fun vm() = App.navigatorScreenModel {
-            AccountViewModel(
-                kaffeekasse = i11Client.kaffeekasse
-            )
-        }
+    companion object : ScreenModelFactory<KaffeekasseNavigation.AccountScreen, AccountScreenModel> {
+        context (RepositoryProvider)
+        override fun create(screen: KaffeekasseNavigation.AccountScreen) = AccountScreenModel(
+            session = screen.session,
+            kaffeekasse = kaffeekasseRepository
+        )
     }
 }
 
 
 @Composable
 fun Account(
-    modifier: Modifier = Modifier,
-    vm: AccountViewModel = AccountViewModel.vm()
+    model: AccountScreenModel,
+    modifier: Modifier = Modifier
+) = PullToRefreshBox(
+    state = model.account,
+    modifier.fillMaxSize()
 ) {
-    LaunchedEffect(Unit) {
-        vm.refreshIfNeeded()
-    }
-    PullToRefreshBox(
-        onRefresh = { vm.fetchAccount() },
-        shouldRefresh = { vm.isLoading },
-        modifier.fillMaxSize()
-    ) {
-        val balance = vm.balance
-        when {
-            !vm.isLoggedIn -> { }
-            vm.isLoading -> CircularProgressIndicator(Modifier.align(Alignment.Center))
-            balance != null -> AccountDetails(
-                balance = balance
-            )
-            else -> FailureRetryScreen(
+    RichDataContent(
+        state = model.account,
+        errorContent = { error ->
+            FailureRetryScreen(
+                error = error,
                 message = "Failed to fetch account",
-                errors = vm.jsonData?.errors ?: emptyList(),
-                Modifier.fillMaxSize(),
-                onRetry = { vm.fetchAccount() }
+                Modifier.fillMaxSize()
             )
+        },
+        loadingContent = { progress ->
+            CircularProgressIndicator(progress, Modifier.align(Alignment.Center))
         }
+    ) { account ->
+        AccountDetails(
+            account = account
+        )
     }
 }
 
 @Composable
 private fun AccountDetails(
-    balance: KaffeekasseData.Balance.MyBalance,
+    account: Account,
     modifier: Modifier = Modifier
 ) = BoxCenter(
     modifier
@@ -129,7 +106,7 @@ private fun AccountDetails(
         )
         Spacer(Modifier.height(16.dp))
         Text(
-            "${balance.firstName} ${balance.lastName}",
+            "${account.firstName} ${account.lastName}",
             textAlign = TextAlign.Center,
             style = MaterialTheme.typography.headlineMedium
         )
@@ -142,11 +119,11 @@ private fun AccountDetails(
                 style = MaterialTheme.typography.labelMedium
             )
             Text(
-                "${balance.total.format("%.2f")} €",
+                "${account.total.format("%.2f")} €",
                 Modifier.padding(8.dp),
                 color = when {
-                    balance.total > 0 -> Color.Green
-                    balance.total < 0 -> Color.Red
+                    account.total > 0 -> Color.Green
+                    account.total < 0 -> Color.Red
                     else -> Color.Unspecified
                 },
                 fontFamily = FontFamily.Monospace,
@@ -163,7 +140,7 @@ private fun AccountDetails(
                     style = MaterialTheme.typography.labelMedium
                 )
                 Text(
-                    "${balance.paid.format("%.2f")} €",
+                    "${account.paid.format("%.2f")} €",
                     Modifier.padding(8.dp),
                     fontFamily = FontFamily.Monospace,
                     style = MaterialTheme.typography.titleLarge
@@ -178,7 +155,7 @@ private fun AccountDetails(
                     style = MaterialTheme.typography.labelMedium
                 )
                 Text(
-                    "${balance.deposited.format("%.2f")} €",
+                    "${account.deposited.format("%.2f")} €",
                     Modifier.padding(8.dp),
                     fontFamily = FontFamily.Monospace,
                     style = MaterialTheme.typography.titleLarge
@@ -189,3 +166,10 @@ private fun AccountDetails(
         Spacer(Modifier.height(64.dp))
     }
 }
+
+
+@Composable
+fun AccountTopBarActions(model: AccountScreenModel) {
+    AppInfoTopBarAction()
+    LogoutTopBarAction(model.session)
+}
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 a5ee695d09af401a921c2b3c6db5d8f6a63b000b..4fb597fc7c3da91e554aa0eaec2a545706886b51 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
@@ -1,183 +1,107 @@
 package net.novagamestudios.kaffeekasse.ui.kaffeekasse
 
-import androidx.activity.compose.BackHandler
 import androidx.compose.animation.AnimatedVisibility
 import androidx.compose.animation.expandHorizontally
 import androidx.compose.animation.shrinkHorizontally
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.filled.Delete
-import androidx.compose.material.icons.filled.Person
-import androidx.compose.material.icons.filled.Receipt
 import androidx.compose.material3.Icon
 import androidx.compose.material3.IconButton
 import androidx.compose.runtime.Composable
 import cafe.adriel.voyager.core.model.ScreenModel
-import cafe.adriel.voyager.navigator.LocalNavigator
-import cafe.adriel.voyager.navigator.currentOrThrow
+import cafe.adriel.voyager.navigator.Navigator
 import net.novagamestudios.common_utils.Logger
-import net.novagamestudios.kaffeekasse.App
-import net.novagamestudios.kaffeekasse.KaffeekasseModule
+import net.novagamestudios.kaffeekasse.KaffeekasseModule.Companion.kaffeekasseCart
 import net.novagamestudios.kaffeekasse.model.kaffeekasse.MutableCart
 import net.novagamestudios.kaffeekasse.model.kaffeekasse.isNotEmpty
-import net.novagamestudios.kaffeekasse.ui.AppBackNavigation
-import net.novagamestudios.kaffeekasse.ui.AppInfoTopBarAction
+import net.novagamestudios.kaffeekasse.model.session.Session
+import net.novagamestudios.kaffeekasse.repositories.RepositoryProvider
 import net.novagamestudios.kaffeekasse.ui.AppModuleSelection
-import net.novagamestudios.kaffeekasse.ui.AppSubpageTitle
-import net.novagamestudios.kaffeekasse.ui.KaffeekasseNavigation
-import net.novagamestudios.kaffeekasse.ui.LogoutTopBarAction
+import net.novagamestudios.kaffeekasse.ui.kaffeekasse.ManualBillScreenModel.Companion.backNavigationHandler
+import net.novagamestudios.kaffeekasse.ui.navigation.AppSubpageTitle
+import net.novagamestudios.kaffeekasse.ui.navigation.KaffeekasseNavigation
+import net.novagamestudios.kaffeekasse.ui.theme.ifAnimationsEnabled
+import net.novagamestudios.kaffeekasse.ui.util.navigation.BackNavigationHandler
+import net.novagamestudios.kaffeekasse.ui.util.screenmodel.ScreenModelFactory
 
 
-class KaffeekasseModuleViewModel private constructor(
+class KaffeekasseModuleScreenModel private constructor(
+    session: Session.WithRealUser,
     val cart: MutableCart
 ) : ScreenModel, Logger {
-    companion object {
-        @Composable fun vm() = App.navigatorScreenModel {
-            KaffeekasseModuleViewModel(
-                cart = modules.require<KaffeekasseModule>().cart
-            )
-        }
+    val accountName = when (session) {
+        is Session.WithDevice -> session.realUser.displayName ?: "Unknown User"
+        else -> null
     }
-}
 
 
-@Composable
-fun KaffeekasseBackHandler(
-    vm: KaffeekasseModuleViewModel = KaffeekasseModuleViewModel.vm()
-) {
-    val navigator = LocalNavigator.currentOrThrow
-    val manualBillViewModel = ManualBillViewModel.vm()
-    BackHandler {
-        when (navigator.lastItem) {
-            is KaffeekasseNavigation.ManualBillScreen -> {
-                if (manualBillViewModel.navigateBack()) return@BackHandler
-                vm.cart.clear()
-            }
-            is KaffeekasseNavigation.AccountScreen -> navigator.pop()
-            is KaffeekasseNavigation.TransactionsScreen -> navigator.pop()
-        }
+    companion object : ScreenModelFactory<KaffeekasseNavigation.Tab, KaffeekasseModuleScreenModel> {
+        context (RepositoryProvider)
+        override fun create(screen: KaffeekasseNavigation.Tab) = KaffeekasseModuleScreenModel(
+            session = screen.session,
+            cart = kaffeekasseCart
+        )
     }
 }
 
 
+
+
 @Composable
-fun KaffeekasseTopBarTitle() {
-    when (LocalNavigator.currentOrThrow.lastItem) {
-        KaffeekasseNavigation.ManualBillScreen -> AppModuleSelection()
-        KaffeekasseNavigation.AccountScreen -> AppSubpageTitle("Konto")
-        KaffeekasseNavigation.TransactionsScreen -> AppSubpageTitle("Übersicht")
+fun KaffeekasseTopBarTitle(
+    model: KaffeekasseModuleScreenModel,
+    navigator: Navigator
+) {
+    when (navigator.lastItem) {
+        is KaffeekasseNavigation.ManualBillScreen -> {
+            val name = model.accountName
+            if (name != null) AppSubpageTitle("$name")
+            else AppModuleSelection()
+        }
+        is KaffeekasseNavigation.AccountScreen -> AppSubpageTitle("Konto")
+        is KaffeekasseNavigation.TransactionsScreen -> AppSubpageTitle("Übersicht")
     }
 }
 
-
 @Composable
 fun KaffeekasseTopBarNavigation(
-    vm: KaffeekasseModuleViewModel = KaffeekasseModuleViewModel.vm()
+    model: KaffeekasseModuleScreenModel,
+    navigator: Navigator
 ) {
-    val navigator = LocalNavigator.currentOrThrow
-    val manualBillViewModel = ManualBillViewModel.vm()
-    val canNavigateBack = navigator.canPop || manualBillViewModel.canNavigateBack
-    AppBackNavigation(
-        show = canNavigateBack,
-        onBack = {
-            if (navigator.canPop) navigator.pop()
-            else manualBillViewModel.navigateBack()
-        }
-    )
+    val backNavigationHandler = model.backNavigationHandler(navigator)
     AnimatedVisibility(
-        visible = !canNavigateBack && vm.cart.isNotEmpty(),
-        enter = expandHorizontally(),
-        exit = shrinkHorizontally()
+        visible = !backNavigationHandler.canNavigateBack() && model.cart.isNotEmpty(),
+        enter = expandHorizontally().ifAnimationsEnabled(),
+        exit = shrinkHorizontally().ifAnimationsEnabled()
     ) {
-        IconButton(onClick = { vm.cart.clear() }) {
+        IconButton(onClick = { model.cart.clear() }) {
             Icon(Icons.Default.Delete, "Empty cart")
         }
     }
 }
 
 @Composable
-fun KaffeekasseTopBarActions() {
-    val navigator = LocalNavigator.currentOrThrow
-    when (navigator.lastItem) {
-        KaffeekasseNavigation.ManualBillScreen -> ManualBillTopBarActions()
-        KaffeekasseNavigation.AccountScreen -> AccountTopBarActions()
-        KaffeekasseNavigation.TransactionsScreen -> TransactionsTopBarActions()
+fun KaffeekasseTopBarActions(
+    navigator: Navigator
+) {
+    when (val screen = navigator.lastItem) {
+        is KaffeekasseNavigation.ManualBillScreen -> ManualBillTopBarActions(screen.model)
+        is KaffeekasseNavigation.AccountScreen -> AccountTopBarActions(screen.model)
+        is KaffeekasseNavigation.TransactionsScreen -> TransactionsTopBarActions(screen.model)
     }
 }
 
-@Composable
-fun ManualBillTopBarActions() {
-    val navigator = LocalNavigator.currentOrThrow
-    IconButton(onClick = {
-        navigator.push(KaffeekasseNavigation.AccountScreen)
-    }) {
-        Icon(Icons.Default.Person, "Konto")
-    }
-    IconButton(onClick = {
-        navigator.push(KaffeekasseNavigation.TransactionsScreen)
-    }) {
-        Icon(Icons.Default.Receipt, "Übersicht")
-    }
-    LogoutTopBarAction()
-}
+
 
 @Composable
-fun AccountTopBarActions() {
-    AppInfoTopBarAction()
-    LogoutTopBarAction()
+fun KaffeekasseModuleScreenModel.backNavigationHandler(navigator: Navigator): BackNavigationHandler {
+    return when (val screen = navigator.lastItem) {
+        is KaffeekasseNavigation.ManualBillScreen -> screen.model.backNavigationHandler(navigator)
+        is KaffeekasseNavigation.AccountScreen -> BackNavigationHandler.default()
+        is KaffeekasseNavigation.TransactionsScreen -> BackNavigationHandler.default()
+        else -> BackNavigationHandler.Disabled
+    }
 }
 
 
-//@Composable
-//fun TransactionsTopBarActions() {
-//    AppInfoTopBarAction()
-//    LogoutTopBarAction()
-//}
-
-//@Composable
-//fun KaffeekasseTopBarActions(
-//    vm: KaffeekasseModuleViewModel = viewModel(factory = KaffeekasseModuleViewModel.Factory)
-//) = when (vm.destination) {
-//    ManualBill -> {
-//        IconButton(onClick = { vm.destination = Account }) {
-//            Icon(Icons.Default.Person, "Konto")
-//        }
-//        IconButton(onClick = { vm.destination = Transactions }) {
-//            Icon(Icons.Default.Receipt, "Übersicht")
-//        }
-//        LogoutTopBarAction()
-//    }
-//    Account -> {
-//        AppInfoTopBarAction()
-//        LogoutTopBarAction()
-//    }
-//    Transactions -> {
-//        TransactionsTopBarActions()
-//    }
-//}
-
-//@Composable
-//fun KaffeekasseContent(
-//    modifier: Modifier = Modifier,
-//    vm: KaffeekasseModuleViewModel = viewModel(factory = KaffeekasseModuleViewModel.Factory)
-//) = AnimatedContent(
-//    targetState = vm.destination,
-//    modifier,
-//    label = "KaffeekasseContent"
-//) {
-//    when (it) {
-//        ManualBill -> DynamicManualBill()
-//        Account -> Account()
-//        Transactions -> Transactions()
-//    }
-//}
-
-//@Composable
-//fun KaffeekasseBackHandler(
-//    vm: KaffeekasseModuleViewModel = viewModel(factory = KaffeekasseModuleViewModel.Factory)
-//) {
-//    if (vm.canNavigateBack) BackHandler { vm.navigateBack() }
-//    else if (vm.cart.isNotEmpty()) BackHandler { vm.cart.clear() }
-//}
-
-
 
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 57337687d5d3d6fd75a1c5707d3ff44134cf7f3b..3da7ef89e24ecf23245cef4f7a5f1a4086c83544 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
@@ -12,9 +12,11 @@ import androidx.compose.foundation.pager.PagerState
 import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.filled.Favorite
-import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material.icons.filled.Person
+import androidx.compose.material.icons.filled.Receipt
 import androidx.compose.material3.HorizontalDivider
 import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
 import androidx.compose.material3.ScrollableTabRow
 import androidx.compose.material3.Tab
 import androidx.compose.material3.TabRowDefaults
@@ -25,6 +27,7 @@ import androidx.compose.runtime.derivedStateOf
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.key
 import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
@@ -32,65 +35,68 @@ import androidx.compose.ui.draw.clip
 import androidx.compose.ui.unit.dp
 import cafe.adriel.voyager.core.model.ScreenModel
 import cafe.adriel.voyager.core.model.screenModelScope
-import kotlinx.coroutines.launch
+import cafe.adriel.voyager.navigator.LocalNavigator
+import cafe.adriel.voyager.navigator.Navigator
+import cafe.adriel.voyager.navigator.currentOrThrow
 import net.novagamestudios.common_utils.Logger
-import net.novagamestudios.common_utils.compose.state.ReentrantActionState
-import net.novagamestudios.common_utils.compose.state.collectAsStateIn
+import net.novagamestudios.common_utils.compose.components.CircularProgressIndicator
 import net.novagamestudios.common_utils.compose.tabIndicatorOffset
-import net.novagamestudios.common_utils.warn
-import net.novagamestudios.kaffeekasse.App
-import net.novagamestudios.kaffeekasse.KaffeekasseModule
+import net.novagamestudios.kaffeekasse.KaffeekasseModule.Companion.kaffeekasseCart
 import net.novagamestudios.kaffeekasse.model.kaffeekasse.MutableCart
-import net.novagamestudios.kaffeekasse.repositories.I11Client
+import net.novagamestudios.kaffeekasse.model.kaffeekasse.isEmpty
+import net.novagamestudios.kaffeekasse.model.session.Session
+import net.novagamestudios.kaffeekasse.model.session.deviceOrNull
+import net.novagamestudios.kaffeekasse.repositories.RepositoryProvider
+import net.novagamestudios.kaffeekasse.repositories.i11.KaffeekasseRepository
 import net.novagamestudios.kaffeekasse.ui.kaffeekasse.components.CategorizedItems
-import net.novagamestudios.kaffeekasse.ui.kaffeekasse.components.CategorizedItemsViewModel
+import net.novagamestudios.kaffeekasse.ui.kaffeekasse.components.CategorizedItemsState
 import net.novagamestudios.kaffeekasse.ui.kaffeekasse.components.Checkout
-import net.novagamestudios.kaffeekasse.ui.kaffeekasse.components.CheckoutViewModel
+import net.novagamestudios.kaffeekasse.ui.kaffeekasse.components.CheckoutState
 import net.novagamestudios.kaffeekasse.ui.kaffeekasse.components.CustomItems
-import net.novagamestudios.kaffeekasse.ui.kaffeekasse.components.FailureRetryScreen
+import net.novagamestudios.kaffeekasse.ui.kaffeekasse.components.CustomItemsState
+import net.novagamestudios.kaffeekasse.ui.login.LogoutTopBarAction
+import net.novagamestudios.kaffeekasse.ui.navigation.KaffeekasseNavigation
+import net.novagamestudios.kaffeekasse.ui.util.FailureRetryScreen
+import net.novagamestudios.kaffeekasse.ui.util.RichDataContent
+import net.novagamestudios.kaffeekasse.ui.util.navigation.BackNavigationHandler
+import net.novagamestudios.kaffeekasse.ui.util.navigation.BackNavigationHandler.Companion.then
+import net.novagamestudios.kaffeekasse.ui.util.screenmodel.ScreenModelFactory
+import net.novagamestudios.kaffeekasse.util.richdata.collectAsRichStateHere
 
 
-class ManualBillViewModel(
-    private val kaffeekasse: I11Client.Kaffeekasse,
+class ManualBillScreenModel private constructor(
+    repositoryProvider: RepositoryProvider,
+    val session: Session.WithRealUser,
+    private val kaffeekasse: KaffeekasseRepository,
     val cart: MutableCart
 ) : ScreenModel, Logger {
 
-    private val loadingMutex = ReentrantActionState()
-    val isLoading by loadingMutex
-
-    val isLoggedIn get() = kaffeekasse.isLoggedIn
-    var initial by mutableStateOf(true)
-        private set
-    val jsonData by kaffeekasse.jsonData.collectAsStateIn(screenModelScope)
-    val details by kaffeekasse.manualBillDetails.collectAsStateIn(screenModelScope)
-
-    private suspend fun fetch() = loadingMutex.trueWhile {
-        initial = false
-        try {
-            kaffeekasse.fetchManualBillDetails()
-        } catch (e: Exception) {
-            warn(e) { "Failed to scrape manual bill details" }
-        }
+    val stock = kaffeekasse.stock.collectAsRichStateHere()
+
+    val itemGroups get() = stock.dataOrNull?.itemGroups ?: emptyList()
+
+    val categorizedItemsByGroup by derivedStateOf {
+        itemGroups.map { CategorizedItemsState(it.items) }
     }
 
-    fun fetchManualBillDetails() {
-        screenModelScope.launch {
-            fetch()
+    val customItemsState by lazy {
+        with(repositoryProvider) {
+            CustomItemsState.create(
+                user = session.realUser,
+                computationScope = screenModelScope
+            )
         }
     }
 
-    val itemGroups get() = details?.itemGroups ?: emptyList()
-
-    val categorizedItemsByGroup by derivedStateOf {
-        itemGroups.map { CategorizedItemsViewModel(it.items) }
+    val checkoutState by derivedStateOf {
+        with(repositoryProvider) {
+            CheckoutState.create(
+                session = session,
+                coroutineScope = screenModelScope,
+                onSubmitted = { categorizedItemsByGroup.forEach { it.reset() } }
+            )
+        }
     }
-    private val currentCategorizedItems get() = categorizedItemsByGroup.getOrNull(currentGroupIndex)
-    val checkoutViewModel = CheckoutViewModel(
-        screenModelScope,
-        kaffeekasse,
-        cart,
-        onSubmitted = { categorizedItemsByGroup.forEach { it.reset() } }
-    )
 
     var currentPagerIndex by mutableStateOf(0)
     val pagerState = object : PagerState(currentPagerIndex) {
@@ -100,25 +106,42 @@ class ManualBillViewModel(
         get() = currentPagerIndex - 1
         set(value) { currentPagerIndex = value + 1 }
 
-    suspend fun refreshIfNeeded() {
-        if (initial || details == null || kaffeekasse.manualBillDetailsDirty) fetch()
+    @Suppress("unused")
+    suspend fun autoScrollToDeviceItemGroup() {
+        val initialItemGroupIndex = session.deviceOrNull?.itemTypeId
+            ?.let { id -> itemGroups.indexOfFirst { it.id == id }.takeIf { it >= 0 } }
+        if (initialItemGroupIndex != null) {
+            currentGroupIndex = initialItemGroupIndex
+//            pagerState.scrollToPage(currentPagerIndex)
+        }
     }
 
-    val canNavigateBack get() = currentCategorizedItems?.canNavigateBack == true
-    fun navigateBack(): Boolean {
-        if (canNavigateBack) {
-            currentCategorizedItems?.navigateBack()
-            return true
+    companion object : ScreenModelFactory<KaffeekasseNavigation.ManualBillScreen, ManualBillScreenModel> {
+        context (RepositoryProvider)
+        override fun create(screen: KaffeekasseNavigation.ManualBillScreen) = ManualBillScreenModel(
+            repositoryProvider = this@RepositoryProvider,
+            session = screen.session,
+            kaffeekasse = kaffeekasseRepository,
+            cart = kaffeekasseCart
+        )
+
+        @Composable
+        fun ManualBillScreenModel.backNavigationHandler(navigator: Navigator): BackNavigationHandler {
+            val categorizedItems = categorizedItemsByGroup.getOrNull(currentGroupIndex)
+            return remember(categorizedItems, cart) {
+                categorizedItems?.backNavigation then CartBackNavigationHandler(cart)
+            }
         }
-        return false
     }
 
-    companion object {
-        @Composable fun vm() = App.navigatorScreenModel {
-            ManualBillViewModel(
-                kaffeekasse = i11Client.kaffeekasse,
-                cart = KaffeekasseModule.cart
-            )
+    private class CartBackNavigationHandler(
+        private val cart: MutableCart
+    ) : BackNavigationHandler {
+        override fun canNavigateBack() = false
+        override fun onNavigateBack(): Boolean {
+            if (cart.isEmpty()) return false
+            cart.clear()
+            return true
         }
     }
 }
@@ -126,72 +149,81 @@ class ManualBillViewModel(
 
 
 
+
 @Composable
 fun DynamicManualBill(
+    model: ManualBillScreenModel,
     modifier: Modifier = Modifier,
-    vm: ManualBillViewModel = ManualBillViewModel.vm(),
     postTabsContent: (@Composable () -> Unit)? = null
 ) = Box(modifier.fillMaxSize()) {
-    LaunchedEffect(Unit) {
-        vm.refreshIfNeeded()
-    }
-    when {
-        !vm.isLoggedIn -> { }
-        vm.initial || vm.isLoading -> CircularProgressIndicator(Modifier.align(Alignment.Center))
-        vm.details != null -> {
-            TabbedItemGroups(
-                vm = vm,
-                postTabsContent = postTabsContent
-            )
-            Checkout(
-                vm = vm.checkoutViewModel,
-                Modifier.align(Alignment.BottomEnd)
+    RichDataContent(
+        state = model.stock,
+        errorContent = { error ->
+            FailureRetryScreen(
+                error = error,
+                message = "Failed to fetch details",
+                Modifier.fillMaxSize()
             )
+        },
+        loadingContent = { progress ->
+            CircularProgressIndicator(progress, Modifier.align(Alignment.Center))
         }
-        else -> FailureRetryScreen(
-            message = "Failed to fetch details",
-            errors = vm.jsonData?.errors ?: emptyList(),
-            Modifier.align(Alignment.Center),
-            onRetry = { vm.fetchManualBillDetails() }
+    ) {
+        TabbedItemGroups(
+            model = model,
+            postTabsContent = postTabsContent
+        )
+        Checkout(
+            state = model.checkoutState,
+            Modifier.align(Alignment.BottomEnd)
         )
     }
 }
 
 @Composable
 private fun TabbedItemGroups(
-    vm: ManualBillViewModel,
+    model: ManualBillScreenModel,
     modifier: Modifier = Modifier,
     postTabsContent: (@Composable () -> Unit)? = null
 ) = Column(modifier.fillMaxSize()) {
     key(Unit) {
-        LaunchedEffect(vm.currentPagerIndex) {
-            vm.pagerState.animateScrollToPage(vm.currentPagerIndex)
+        LaunchedEffect(model.currentPagerIndex) {
+            model.pagerState.animateScrollToPage(model.currentPagerIndex)
         }
-        LaunchedEffect(vm.pagerState.targetPage) {
-            vm.currentPagerIndex = vm.pagerState.targetPage
+        LaunchedEffect(model.pagerState.targetPage) {
+            model.currentPagerIndex = model.pagerState.targetPage
         }
+//        LaunchedEffect(vm.stock != null) {
+//            if (vm.stock != null) vm.autoScrollToDeviceItemGroup()
+//        }
     }
     Box {
         ScrollableTabRow(
-            selectedTabIndex = vm.currentPagerIndex,
+            selectedTabIndex = model.currentPagerIndex,
             if (postTabsContent != null) Modifier.padding(end = 48.dp) else Modifier,
             edgePadding = 16.dp,
-            indicator = { TabRowDefaults.PrimaryIndicator(Modifier.tabIndicatorOffset(vm.pagerState, it)) },
+            indicator = { TabRowDefaults.PrimaryIndicator(Modifier.tabIndicatorOffset(model.pagerState, it)) },
             divider = { }
         ) {
             Tab(
-                selected = vm.currentPagerIndex == 0,
-                onClick = { vm.currentPagerIndex = 0 },
+                selected = model.currentPagerIndex == 0,
+                onClick = { model.currentPagerIndex = 0 },
                 Modifier
                     .clip(RoundedCornerShape(8.dp))
                     .width(48.dp),
             ) {
                 Icon(Icons.Default.Favorite, "Favoriten")
             }
-            vm.itemGroups.forEachIndexed { index, itemGroup ->
+            model.itemGroups.forEachIndexed { index, itemGroup ->
                 Tab(
-                    selected = vm.currentGroupIndex == index,
-                    onClick = { vm.currentGroupIndex = index },
+                    selected = model.currentGroupIndex == index,
+                    onClick = {
+                        if (model.currentGroupIndex == index) {
+                            model.categorizedItemsByGroup[index].reset()
+                        } else {
+                            model.currentGroupIndex = index
+                        }
+                    },
                     Modifier.clip(RoundedCornerShape(8.dp)),
                     text = { Text(itemGroup.name) }
                 )
@@ -206,15 +238,16 @@ private fun TabbedItemGroups(
         }
     }
     HorizontalDivider()
-    HorizontalPager(vm.pagerState) { index ->
+    HorizontalPager(model.pagerState) { index ->
         if (index == 0) CustomItems(
-            cart = vm.cart,
+            state = model.customItemsState,
+            cart = model.cart,
             Modifier
                 .weight(1f)
                 .fillMaxHeight()
         ) else CategorizedItems(
-            vm = vm.categorizedItemsByGroup[index - 1],
-            cart = vm.cart,
+            state = model.categorizedItemsByGroup[index - 1],
+            cart = model.cart,
             Modifier
                 .weight(1f)
                 .fillMaxHeight()
@@ -223,3 +256,23 @@ private fun TabbedItemGroups(
 }
 
 
+@Composable
+fun ManualBillTopBarActions(
+    model: ManualBillScreenModel
+) {
+    val navigator = LocalNavigator.currentOrThrow
+    IconButton(onClick = {
+        navigator.push(KaffeekasseNavigation.AccountScreen(model.session))
+    }) {
+        Icon(Icons.Default.Person, "Konto")
+    }
+    IconButton(onClick = {
+        navigator.push(KaffeekasseNavigation.TransactionsScreen(model.session))
+    }) {
+        Icon(Icons.Default.Receipt, "Übersicht")
+    }
+    LogoutTopBarAction(model.session)
+}
+
+
+
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/Transactions.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/Transactions.kt
index 58b582851a4cf010cebc1d9ae2abe22a0a576c07..a41f13d100f5f801f0c43b9712041516e6eb5aca 100644
--- a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/Transactions.kt
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/Transactions.kt
@@ -24,13 +24,12 @@ import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.ProvideTextStyle
 import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.collectAsState
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.key
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshotFlow
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.scale
@@ -39,142 +38,106 @@ import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.unit.dp
 import cafe.adriel.voyager.core.model.ScreenModel
 import cafe.adriel.voyager.core.model.screenModelScope
-import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.launch
 import net.novagamestudios.common_utils.Logger
-import net.novagamestudios.common_utils.compose.state.ReentrantActionState
-import net.novagamestudios.common_utils.compose.state.collectAsStateIn
-import net.novagamestudios.common_utils.verbose
-import net.novagamestudios.common_utils.warn
 import net.novagamestudios.kaffeekasse.App
 import net.novagamestudios.kaffeekasse.data.category
 import net.novagamestudios.kaffeekasse.data.cleanFullName
 import net.novagamestudios.kaffeekasse.model.kaffeekasse.Transaction
-import net.novagamestudios.kaffeekasse.repositories.I11Client
-import net.novagamestudios.kaffeekasse.repositories.LocalSettingsStore
-import net.novagamestudios.kaffeekasse.ui.kaffeekasse.components.FailureRetryScreen
+import net.novagamestudios.kaffeekasse.model.session.Session
+import net.novagamestudios.kaffeekasse.repositories.RepositoryProvider
+import net.novagamestudios.kaffeekasse.repositories.i11.KaffeekasseRepository
 import net.novagamestudios.kaffeekasse.ui.kaffeekasse.components.cards.CategoryIcon
+import net.novagamestudios.kaffeekasse.ui.navigation.KaffeekasseNavigation
+import net.novagamestudios.kaffeekasse.ui.util.FailureRetryScreen
 import net.novagamestudios.kaffeekasse.ui.util.PullToRefreshBox
-import net.novagamestudios.kaffeekasse.util.openInBrowser
+import net.novagamestudios.kaffeekasse.ui.util.RichDataContent
+import net.novagamestudios.kaffeekasse.ui.util.openInBrowser
+import net.novagamestudios.kaffeekasse.ui.util.screenmodel.ScreenModelFactory
+import net.novagamestudios.kaffeekasse.util.richdata.collectAsRichStateHere
 import java.time.LocalDate
 import java.time.format.DateTimeFormatter
 
-class TransactionsViewModel private constructor(
-    private val kaffeekasse: I11Client.Kaffeekasse
+class TransactionsScreenModel private constructor(
+    private val session: Session.WithRealUser,
+    private val kaffeekasse: KaffeekasseRepository
 ) : ScreenModel, Logger {
 
-    private val loadingMutex = ReentrantActionState()
-    val isLoading by loadingMutex
+    val transactions = kaffeekasse.transactions[session.realUser].collectAsRichStateHere()
 
-    val jsonData by kaffeekasse.jsonData.collectAsStateIn(screenModelScope)
-    val transactionsFlow = kaffeekasse.transactions.asStateFlow()
-    val chartViewModel = TransactionsChartsViewModel(screenModelScope, transactionsFlow)
+    val chartsState by lazy { TransactionsChartsState(screenModelScope, snapshotFlow { transactions.dataOrNull }) }
 
     var showCharts by mutableStateOf(false)
 
-    init {
-        verbose { "TransactionsViewModel created" }
-    }
-
-    override fun onDispose() {
-        verbose { "TransactionsViewModel cleared" }
-    }
-
-    fun refresh() {
-        fetchTransactions()
-//        chartViewModel.refresh()
-    }
-    fun refreshIfNeeded() {
-        if (transactionsFlow.value == null || kaffeekasse.transactionsDirty) refresh()
-    }
-    fun fetchTransactions() {
-        screenModelScope.launch {
-            loadingMutex.trueWhile {
-                try {
-                    kaffeekasse.fetchTransactions()
-                } catch (e: Exception) {
-                    warn(e) { "Failed to scrape transactions" }
-                }
-            }
-        }
-    }
     fun openInBrowser(context: Context) {
-        context.openInBrowser(kaffeekasse.transactionsUrl)
+        context.openInBrowser(kaffeekasse.scraper.transactionsUrl)
     }
 
-    companion object {
-        @Composable fun vm() = App.navigatorScreenModel {
-            TransactionsViewModel(
-                kaffeekasse = i11Client.kaffeekasse
-            )
-        }
+    companion object : ScreenModelFactory<KaffeekasseNavigation.TransactionsScreen, TransactionsScreenModel> {
+        context (RepositoryProvider)
+        override fun create(screen: KaffeekasseNavigation.TransactionsScreen) = TransactionsScreenModel(
+            session = screen.session,
+            kaffeekasse = kaffeekasseRepository
+        )
     }
 }
 
 
 @Composable
 fun Transactions(
-    modifier: Modifier = Modifier,
-    vm: TransactionsViewModel = TransactionsViewModel.vm()
+    model: TransactionsScreenModel,
+    modifier: Modifier = Modifier
+) = PullToRefreshBox(
+    state = model.transactions,
+    modifier.fillMaxSize()
 ) {
-    LaunchedEffect(Unit) {
-        vm.refreshIfNeeded()
-    }
-    PullToRefreshBox(
-        onRefresh = { vm.refresh() },
-        shouldRefresh = { vm.isLoading },
-        modifier.fillMaxSize()
-    ) {
-        val transactions = vm.transactionsFlow.collectAsState().value
-        when {
-            vm.isLoading -> { }
-            transactions != null -> {
-                val state1 = rememberLazyListState()
-                val state2 = rememberLazyListState()
-                AnimatedContent(
-                    targetState = vm.showCharts,
-                    label = "Transactions",
-                ) { showCharts ->
-                    val lazyListState = if (vm.showCharts) state2 else state1
-                    when {
-                        showCharts -> TransactionsCharts(
-                            vm = vm.chartViewModel,
-                            state = lazyListState
-                        )
-                        else -> TransactionsList(
-                            transactions = transactions,
-                            state = lazyListState
-                        )
-                    }
-                }
-                key(Unit) {
-                    val lazyListState = if (vm.showCharts) state2 else state1
-                    if (lazyListState.canScrollBackward) HorizontalDivider(Modifier.align(Alignment.TopCenter))
-                }
-            }
-            else -> FailureRetryScreen(
+    RichDataContent(
+        state = model.transactions,
+        errorContent = { error ->
+            FailureRetryScreen(
+                error = error,
                 message = "Failed to fetch transactions",
-                errors = vm.jsonData?.errors ?: emptyList(),
-                Modifier.fillMaxSize(),
-                onRetry = { vm.fetchTransactions() }
+                Modifier.fillMaxSize()
             )
         }
+    ) { transactions ->
+        val state1 = rememberLazyListState()
+        val state2 = rememberLazyListState()
+        AnimatedContent(
+            targetState = model.showCharts,
+            label = "Transactions",
+        ) { showCharts ->
+            val lazyListState = if (model.showCharts) state2 else state1
+            when {
+                showCharts -> TransactionsCharts(
+                    state = model.chartsState,
+                    lazyListState = lazyListState
+                )
+                else -> TransactionsList(
+                    transactions = transactions,
+                    state = lazyListState
+                )
+            }
+        }
+        key(Unit) {
+            val lazyListState = if (model.showCharts) state2 else state1
+            if (lazyListState.canScrollBackward) HorizontalDivider(Modifier.align(Alignment.TopCenter))
+        }
     }
 }
 
 @Composable
 fun TransactionsTopBarActions(
-    vm: TransactionsViewModel = TransactionsViewModel.vm()
+    model: TransactionsScreenModel
 ) {
     val context = LocalContext.current
-    when (vm.showCharts) {
-        true -> TransactionsChartsFilter(vm.chartViewModel)
-        false -> IconButton(onClick = { vm.openInBrowser(context) }) {
+    when (model.showCharts) {
+        true -> TransactionsChartsFilter(model.chartsState)
+        false -> IconButton(onClick = { model.openInBrowser(context) }) {
             Icon(Icons.Default.OpenInBrowser, "Open in browser")
         }
     }
-    IconButton(onClick = { vm.showCharts = !vm.showCharts }) {
-        when (vm.showCharts) {
+    IconButton(onClick = { model.showCharts = !model.showCharts }) {
+        when (model.showCharts) {
             true -> Icon(Icons.AutoMirrored.Filled.List, "List")
             false -> Icon(Icons.Default.BarChart, "Charts")
         }
@@ -229,7 +192,6 @@ private fun TransactionListItem(
     modifier: Modifier = Modifier
 ) = ListItem(
     headlineContent = {
-        val settings by LocalSettingsStore.current
         val style = LocalTextStyle.current
         when (val purpose = transaction.purpose) {
             is Transaction.Purpose.Purchase -> Row(verticalAlignment = Alignment.CenterVertically) {
@@ -246,7 +208,7 @@ private fun TransactionListItem(
                 } else {
                     Text(knownItems.joinToString("/") { it.cleanFullName }, style = style)
                 }
-                if (settings.developerMode) ProvideTextStyle(MaterialTheme.typography.labelSmall) {
+                if (App.developerMode) ProvideTextStyle(MaterialTheme.typography.labelSmall) {
                     if (knownItems.isEmpty()) Text(
                         "Unknown",
                         Modifier.padding(start = 4.dp),
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/TransactionsCharts.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/TransactionsCharts.kt
index 4663c2ee01bf161f86f38ebd53c3d8eb7a621a03..723d84287d440a666a917b020a71699806b6470e 100644
--- a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/TransactionsCharts.kt
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/TransactionsCharts.kt
@@ -68,8 +68,8 @@ import com.patrykandpatrick.vico.core.scroll.InitialScroll
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.SharingStarted
-import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.collectLatest
 import kotlinx.coroutines.flow.flowOn
 import kotlinx.coroutines.flow.mapLatest
@@ -78,9 +78,9 @@ import kotlinx.coroutines.launch
 import kotlinx.coroutines.supervisorScope
 import net.novagamestudios.common_utils.Logger
 import net.novagamestudios.common_utils.debug
-import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem
+import net.novagamestudios.kaffeekasse.model.kaffeekasse.ItemCategory
 import net.novagamestudios.kaffeekasse.model.kaffeekasse.Transaction
-import net.novagamestudios.kaffeekasse.ui.kaffeekasse.TransactionsChartsViewModel.ChartSettings.TimeFilter
+import net.novagamestudios.kaffeekasse.ui.kaffeekasse.TransactionsChartsState.ChartSettings.TimeFilter
 import net.novagamestudios.kaffeekasse.util.charts.BackgroundShader
 import net.novagamestudios.kaffeekasse.util.charts.IntegerAxisItemPlacer
 import java.time.LocalDate
@@ -88,17 +88,15 @@ import java.time.LocalDateTime
 import java.time.format.DateTimeFormatter
 import kotlin.math.roundToInt
 
-class TransactionsChartsViewModel(
+class TransactionsChartsState(
     private val coroutineScope: CoroutineScope,
-    private val transactionsFlow: StateFlow<List<Transaction>?>
+    private val transactionsFlow: Flow<List<Transaction>?>
 ) : Logger {
 
     var chartSettings by mutableStateOf(ChartSettings(
         timeFilter = TimeFilter.LastMonth,
         categoryFilter = { true }
     ))
-//    var overTimeEntryModel by mutableStateOf<ChartEntryModel?>(null)
-//        private set
 
     @OptIn(ExperimentalCoroutinesApi::class)
     private val balanceOverTimeData by lazy {
@@ -187,7 +185,7 @@ class TransactionsChartsViewModel(
 
     data class ChartSettings(
         val timeFilter: TimeFilter,
-        val categoryFilter: (KnownItem.Category) -> Boolean
+        val categoryFilter: (ItemCategory) -> Boolean
     ) {
         sealed interface TimeFilter {
             val start: LocalDateTime?
@@ -234,9 +232,9 @@ class TransactionsChartsViewModel(
 
 @Composable
 fun TransactionsCharts(
-    vm: TransactionsChartsViewModel,
+    state: TransactionsChartsState,
     modifier: Modifier = Modifier,
-    state: LazyListState = rememberLazyListState()
+    lazyListState: LazyListState = rememberLazyListState()
 ) = ProvideChartStyle(
     LocalChartStyle.current.copy(
         lineLayer = remember { ChartStyle.LineLayer(
@@ -258,24 +256,24 @@ fun TransactionsCharts(
 ) {
     LazyColumn(
         modifier.fillMaxSize(),
-        state = state,
+        state = lazyListState,
         horizontalAlignment = Alignment.CenterHorizontally,
         verticalArrangement = Arrangement.Center
     ) {
         item {
-            BalanceOverTimeChart(vm, Modifier.fillMaxWidth())
+            BalanceOverTimeChart(state, Modifier.fillMaxWidth())
         }
     }
 }
 
 @Composable
 fun TransactionsChartsFilter(
-    vm: TransactionsChartsViewModel,
+    state: TransactionsChartsState,
     modifier: Modifier = Modifier
 ) = Box(modifier) {
     var showDropdown by remember { mutableStateOf(false) }
     TextButton(onClick = { showDropdown = !showDropdown }) {
-        ChartTimeFilterLabel(vm.chartSettings.also { debug { "settings $it" } }.timeFilter)
+        ChartTimeFilterLabel(state.chartSettings.also { debug { "settings $it" } }.timeFilter)
         val animatedRotation by animateFloatAsState(
             if (showDropdown) 180f else 0f,
             label = "Rotation"
@@ -298,7 +296,7 @@ fun TransactionsChartsFilter(
                 text = { ChartTimeFilterLabel(filter) },
                 onClick = {
                     Logger("").debug { "filter $filter" }
-                    vm.chartSettings = vm.chartSettings.copy(timeFilter = filter)
+                    state.chartSettings = state.chartSettings.copy(timeFilter = filter)
                     showDropdown = false
                 }
             )
@@ -308,7 +306,7 @@ fun TransactionsChartsFilter(
 
 @Composable
 private fun BalanceOverTimeChart(
-    vm: TransactionsChartsViewModel,
+    state: TransactionsChartsState,
     modifier: Modifier = Modifier
 ) = Column(modifier) {
     Text(
@@ -345,7 +343,7 @@ private fun BalanceOverTimeChart(
                 )
             )
         ),
-        modelProducer = vm.overTimeChartModelProducer,
+        modelProducer = state.overTimeChartModelProducer,
         Modifier.height(300.dp),
         marker = rememberMarkerComponent(
             label = rememberTextComponent(
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 ea5eaf7d1275646e3562a4cdaf641dfce6b01635..30f34dc16e6d0d17341bbe1948ee60fa1dd7f63a 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
@@ -6,6 +6,7 @@ import androidx.compose.material3.DropdownMenuItem
 import androidx.compose.material3.ExposedDropdownMenuBox
 import androidx.compose.material3.ExposedDropdownMenuDefaults
 import androidx.compose.material3.Icon
+import androidx.compose.material3.MenuAnchorType
 import androidx.compose.material3.OutlinedTextField
 import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
@@ -14,13 +15,20 @@ import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Modifier
-import net.novagamestudios.kaffeekasse.model.kaffeekasse.ManualBillDetails
+import net.novagamestudios.kaffeekasse.model.kaffeekasse.PurchaseAccount
+
+
+class AccountSelectionState<out PA : PurchaseAccount>(
+    val accounts: List<PA>
+) {
+    var selectedIndex by mutableStateOf(0)
+    val selectedAccount: PA? get() = accounts.getOrNull(selectedIndex)
+}
+
 
 @Composable
 fun AccountSelection(
-    accounts: List<ManualBillDetails.Account>,
-    selectedAccountIndex: Int,
-    onSelect : (Int) -> Unit,
+    state: AccountSelectionState<*>,
     modifier: Modifier = Modifier
 ) {
     var expanded by remember { mutableStateOf(false) }
@@ -29,12 +37,11 @@ fun AccountSelection(
         onExpandedChange = { expanded = it },
         modifier
     ) {
-        val selectedAccount = accounts.getOrNull(selectedAccountIndex)
         OutlinedTextField(
-            selectedAccount?.name ?: "",
+            state.selectedAccount?.name?.let { "$it" } ?: "",
             onValueChange = { },
             Modifier
-                .menuAnchor(),
+                .menuAnchor(MenuAnchorType.PrimaryNotEditable),
             readOnly = true,
             label = { Text("Konto") },
             leadingIcon = { Icon(Icons.Default.Person, "Konto") },
@@ -44,15 +51,15 @@ fun AccountSelection(
             expanded,
             onDismissRequest = { expanded = false }
         ) {
-            accounts.forEachIndexed { index, account ->
+            state.accounts.forEachIndexed { index, account ->
                 DropdownMenuItem(
-                    text = { Text(account.name) },
+                    text = { Text("${account.name}") },
                     onClick = {
-                        onSelect(index)
+                        state.selectedIndex = index
                         expanded = false
                     }
                 )
             }
         }
     }
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/components/CategorizedItems.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/components/CategorizedItems.kt
index 8ddc50678362473d479da9476c7a8e0be97ea950..8f414f277ce0549a185f10ba5645d444f3dce142 100644
--- a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/components/CategorizedItems.kt
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/components/CategorizedItems.kt
@@ -1,5 +1,6 @@
 package net.novagamestudios.kaffeekasse.ui.kaffeekasse.components
 
+import android.content.Context
 import androidx.compose.animation.AnimatedContent
 import androidx.compose.animation.ContentTransform
 import androidx.compose.animation.fadeIn
@@ -9,6 +10,7 @@ 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
@@ -16,20 +18,23 @@ import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.platform.LocalContext
-import androidx.lifecycle.ViewModel
 import kotlinx.coroutines.launch
+import net.novagamestudios.common_utils.compose.state.MutableDataStoreState
 import net.novagamestudios.common_utils.toastShort
+import net.novagamestudios.kaffeekasse.App.Companion.settings
 import net.novagamestudios.kaffeekasse.model.kaffeekasse.Cart
-import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem
-import net.novagamestudios.kaffeekasse.model.kaffeekasse.ManualBillDetails
+import net.novagamestudios.kaffeekasse.model.kaffeekasse.Item
+import net.novagamestudios.kaffeekasse.model.kaffeekasse.ItemCategory
 import net.novagamestudios.kaffeekasse.model.kaffeekasse.MutableCart
-import net.novagamestudios.kaffeekasse.repositories.LocalSettingsStore
+import net.novagamestudios.kaffeekasse.repositories.UserSettings
 import net.novagamestudios.kaffeekasse.ui.kaffeekasse.components.cards.BasicCardGrid
 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.kaffeekasse.ui.util.navigation.BackNavigationHandler
 
-class CategorizedItemsViewModel(
-    items: Iterable<ManualBillDetails.Item>
+class CategorizedItemsState(
+    items: Iterable<Item>
 ) {
     val itemsByCategory = items
         .groupBy { it.category }
@@ -45,7 +50,7 @@ class CategorizedItemsViewModel(
     var selectedCategory by mutableStateOf(homeCategory)
         private set
 
-    fun select(category: KnownItem.Category) {
+    fun select(category: ItemCategory) {
         selectedCategory = category
     }
 
@@ -56,18 +61,27 @@ class CategorizedItemsViewModel(
     fun reset() {
         selectedCategory = homeCategory
     }
+
+    val backNavigation = BackNavigationHandler.derivedOf(
+        canNavigateBack = { selectedCategory != homeCategory },
+        onNavigateBack = {
+            if (selectedCategory == homeCategory) return@derivedOf false
+            selectedCategory = homeCategory
+            true
+        }
+    )
 }
 
 @Composable
 fun CategorizedItems(
-    vm: CategorizedItemsViewModel,
+    state: CategorizedItemsState,
     cart: MutableCart,
     modifier: Modifier = Modifier
 ) = Box(modifier) {
     AnimatedContent(
-        targetState = vm.selectedCategory,
+        targetState = state.selectedCategory,
         Modifier.fillMaxHeight(),
-        transitionSpec = {
+        transitionSpec = ifAnimationsEnabled {
             val categoryGridScale = 1.6f
             val itemGridScale = 0.5f
             if (targetState == null) ContentTransform(
@@ -81,12 +95,12 @@ fun CategorizedItems(
         label = "ItemGroupSelection"
     ) { category ->
         if (category == null) LazyCategoryCardGrid(
-            itemsByCategory = vm.categoriesWithItems,
+            itemsByCategory = state.categoriesWithItems,
             cart = cart,
-            onClick = { vm.select(it) },
+            onClick = { state.select(it) },
             spacerForFAB = true
         ) else LazyItemCardGrid(
-            items = vm.itemsByCategory[category] ?: emptyList(),
+            items = state.itemsByCategory[category] ?: emptyList(),
             cart = cart,
             spacerForFAB = true
         )
@@ -95,9 +109,9 @@ fun CategorizedItems(
 
 @Composable
 private fun LazyCategoryCardGrid(
-    itemsByCategory: List<Pair<KnownItem.Category, List<ManualBillDetails.Item>>>,
+    itemsByCategory: List<Pair<ItemCategory, List<Item>>>,
     cart: Cart,
-    onClick: (KnownItem.Category) -> Unit,
+    onClick: (ItemCategory) -> Unit,
     modifier: Modifier = Modifier,
     scrollable: Boolean = true,
     spacerForFAB: Boolean = false
@@ -116,7 +130,7 @@ private fun LazyCategoryCardGrid(
 
 @Composable
 fun LazyItemCardGrid(
-    items: List<ManualBillDetails.Item>,
+    items: List<Item>,
     cart: MutableCart,
     modifier: Modifier = Modifier,
     scrollable: Boolean = true,
@@ -133,7 +147,7 @@ fun LazyItemCardGrid(
 
 @Composable
 fun ItemCardGrid(
-    items: List<ManualBillDetails.Item>,
+    items: List<Item>,
     cart: MutableCart,
     modifier: Modifier = Modifier,
     spacerForFAB: Boolean = false
@@ -148,11 +162,11 @@ fun ItemCardGrid(
 
 @Composable
 private fun ItemCard(
-    item: ManualBillDetails.Item,
+    item: Item,
     cart: MutableCart
 ) {
     val coroutineScope = rememberCoroutineScope()
-    val settingsStore = LocalSettingsStore.current
+    val userSettings by settings().userSettings.collectAsState()
     val context = LocalContext.current
     net.novagamestudios.kaffeekasse.ui.kaffeekasse.components.cards.ItemCard(
         item = item,
@@ -166,20 +180,25 @@ private fun ItemCard(
         onRemove = { cart -= item },
         onLongClick = {
             coroutineScope.launch {
-                settingsStore.update {
-                    val list = it.favoriteItems.toMutableList()
-                    val wasFavorite = item.id in list
-                    if (wasFavorite) list -= item.id
-                    else list += item.id
-                    it.copy(favoriteItems = list).also {
-                        context.toastShort(
-                            if (wasFavorite) "Aus Favoriten entfernt"
-                            else "Zu Favoriten hinzugefügt"
-                        )
-                    }
-                }
+                userSettings?.addToFavourites(item, context)
             }
         }
     )
 }
 
+private suspend fun MutableDataStoreState<UserSettings>.addToFavourites(
+    item: Item,
+    context: Context
+) = update { old ->
+    val list = old.favoriteItemIds.toMutableList()
+    val wasFavorite = item.id in list
+    if (wasFavorite) list -= item.id
+    else list += item.id
+    old.copy(favoriteItemIds = list).also {
+        context.toastShort(
+            if (wasFavorite) "Aus Favoriten entfernt"
+            else "Zu Favoriten hinzugefügt"
+        )
+    }
+}
+
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 7837b3bae0969cd269c8209e62a2cb9d16fa5e10..502ea5f338297de9fff26de5790cbbcfe7845ee2 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
@@ -24,7 +24,6 @@ import androidx.compose.material3.FloatingActionButtonDefaults
 import androidx.compose.material3.HorizontalDivider
 import androidx.compose.material3.Icon
 import androidx.compose.material3.IconButton
-import androidx.compose.material3.ListItem
 import androidx.compose.material3.LocalContentColor
 import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.ModalBottomSheet
@@ -34,72 +33,132 @@ import androidx.compose.material3.rememberModalBottomSheetState
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
-import androidx.compose.runtime.key
-import androidx.compose.runtime.mutableIntStateOf
 import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
 import androidx.compose.runtime.setValue
-import androidx.compose.runtime.snapshotFlow
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.scale
 import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.platform.LocalContext
 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.map
 import kotlinx.coroutines.launch
 import net.novagamestudios.common_utils.Logger
 import net.novagamestudios.common_utils.compose.components.BoxCenter
 import net.novagamestudios.common_utils.compose.components.CircularLoadingBox
+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.compose.state.collectAsStateIn
-import net.novagamestudios.common_utils.toastLong
 import net.novagamestudios.common_utils.warn
+import net.novagamestudios.kaffeekasse.KaffeekasseModule.Companion.kaffeekasseCart
+import net.novagamestudios.kaffeekasse.api.kaffeekasse.model.APIPurchaseAccount
 import net.novagamestudios.kaffeekasse.model.kaffeekasse.Cart
-import net.novagamestudios.kaffeekasse.model.kaffeekasse.ManualBillDetails
 import net.novagamestudios.kaffeekasse.model.kaffeekasse.MutableCart
-import net.novagamestudios.kaffeekasse.data.category
+import net.novagamestudios.kaffeekasse.model.kaffeekasse.PurchaseAccount
+import net.novagamestudios.kaffeekasse.model.kaffeekasse.ScraperPurchaseAccount
 import net.novagamestudios.kaffeekasse.model.kaffeekasse.isEmpty
 import net.novagamestudios.kaffeekasse.model.kaffeekasse.isNotEmpty
-import net.novagamestudios.kaffeekasse.repositories.I11Client
+import net.novagamestudios.kaffeekasse.model.session.Session
+import net.novagamestudios.kaffeekasse.model.session.User
+import net.novagamestudios.kaffeekasse.repositories.RepositoryProvider
+import net.novagamestudios.kaffeekasse.repositories.i11.KaffeekasseRepository
 import net.novagamestudios.kaffeekasse.ui.kaffeekasse.components.cards.CategoryIcon
+import net.novagamestudios.kaffeekasse.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
+import net.novagamestudios.kaffeekasse.util.richdata.dataOrNull
+import net.novagamestudios.kaffeekasse.util.richdata.flatMapLatestRich
+import net.novagamestudios.kaffeekasse.util.richdata.mapRich
+import net.novagamestudios.kaffeekasse.util.richdata.stateIn
 import kotlin.time.Duration.Companion.seconds
 
 
-class CheckoutViewModel(
+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,
-    private val kaffeekasse: I11Client.Kaffeekasse,
-    val cart: MutableCart,
+    protected val kaffeekasse: KaffeekasseRepository,
+    override val cart: MutableCart,
     private val onSubmitted: suspend () -> Unit
-) : Logger {
-
-    var showModal by mutableStateOf(false)
+) : Logger, CheckoutState {
 
-    val accounts by kaffeekasse.manualBillDetails
-        .map { it?.accounts ?: emptyList() }
-        .collectAsStateIn(coroutineScope, emptyList())
+    override var showModal by mutableStateOf(false)
 
     private val loadingMutex = ReentrantActionState()
-    val isCheckoutLoading by loadingMutex
+    override val isCheckoutLoading by loadingMutex
+
+    override val toasts = ToastsState()
 
-    var errorToast by mutableStateOf<String?>(null)
+    override var indicateSuccess by mutableStateOf(false)
 
-    var indicateSuccess by mutableStateOf(false)
+    protected abstract val purchaseAccounts: RichDataFlow<List<PA>>
 
-    fun submitCart(account: ManualBillDetails.Account) {
+    override val accountSelectionState: RichDataState<AccountSelectionState<PA>> by lazy {
+        purchaseAccounts
+            .mapRich { accounts -> AccountSelectionState(accounts) }
+            .collectAsRichStateIn(coroutineScope)
+    }
+
+    protected abstract suspend fun refreshPurchaseAccountsIfNeeded()
+    override fun refreshPurchaseAccounts() {
+        coroutineScope.launch {
+            loadingMutex.trueWhile {
+                refreshPurchaseAccountsIfNeeded()
+            }
+        }
+    }
+    protected abstract suspend fun performSubmitCart(purchaseAccount: PA)
+
+    override fun submitCart() {
         if (cart.isEmpty()) return
+        val purchaseAccount = accountSelectionState.dataOrNull?.selectedAccount ?: return
         coroutineScope.launch {
             val success = loadingMutex.trueWhile {
                 try {
-                    kaffeekasse.submitCart(account, cart)
+                    performSubmitCart(purchaseAccount)
                     true
                 } catch (e: Exception) {
                     warn(e) { "Failed to submit cart" }
-                    errorToast = "Fehler: ${e.message ?: "Unbekannter Fehler"}"
+                    toasts.long("Fehler: ${e.message ?: e::class.simpleName ?: "Unbekannter Fehler"}")
                     false
                 }
             }
@@ -114,36 +173,113 @@ class CheckoutViewModel(
     }
 }
 
+private class ScraperCheckoutState(
+    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]
+
+    override suspend fun refreshPurchaseAccountsIfNeeded() {
+        purchaseAccounts.ensureCleanData()
+    }
+    override suspend fun performSubmitCart(purchaseAccount: ScraperPurchaseAccount) {
+        kaffeekasse.purchase(
+            asUser = user,
+            cart = cart,
+            account = purchaseAccount
+        )
+    }
+}
+
+private class APICheckoutState(
+    private val user: User,
+    coroutineScope: CoroutineScope,
+    kaffeekasse: KaffeekasseRepository,
+    cart: MutableCart,
+    onSubmitted: suspend () -> Unit
+) : CheckoutStateBase<APIPurchaseAccount>(coroutineScope, kaffeekasse, cart, onSubmitted) {
+
+    private val selfUserBasic = kaffeekasse.basicUserInfoList.mapRich { userList ->
+        val selfDisplayName = user.displayName ?: return@mapRich null
+        userList.firstOrNull { it.name.firstLast == selfDisplayName }
+    }.stateIn(coroutineScope)
+
+    private val selfUserExtended = selfUserBasic.flatMapLatestRich {
+        kaffeekasse.getExtendedUserInfo(it.id)
+    }
+
+//    override val purchaseAccounts: RichDataFlow<List<PurchaseAccount>> = combineRich(
+//        selfUserExtended,
+//        kaffeekasse.basicUserInfoList
+//    ) { self, userList ->
+//        // FIXME wrong way around
+//
+//        val fromWhitelist = self.whitelist.orEmpty()
+//        val fromBlacklist = self.blacklist?.let { blacklist ->
+//            val blacklistIds = blacklist.mapTo(mutableSetOf()) { it.id }
+//            userList.filter { it.id !in blacklistIds }
+//        }.orEmpty()
+//
+//        listOf(self) + fromWhitelist + fromBlacklist
+//    }
+
+    override val purchaseAccounts = combineRich(
+        kaffeekasse.manualBillAccounts[user],
+        kaffeekasse.basicUserInfoList
+    ) { scraperAccounts, apiAccounts ->
+        scraperAccounts.mapNotNull { account -> apiAccounts.firstOrNull { it.name == account.name } }
+    }
+
+    override suspend fun refreshPurchaseAccountsIfNeeded() {
+        kaffeekasse.basicUserInfoList.ensureCleanData()
+        selfUserBasic.value.dataOrNull?.let {
+            kaffeekasse.getExtendedUserInfo(it.id).ensureCleanData()
+        }
+    }
+    override suspend fun performSubmitCart(purchaseAccount: APIPurchaseAccount) {
+        kaffeekasse.purchase(
+            asUser = user,
+            cart = cart,
+            targetAccount = purchaseAccount.takeUnless {
+                it.id == selfUserBasic.value.dataOrNull?.id
+            }
+        )
+    }
+}
+
 
 @Composable
 fun Checkout(
-    vm: CheckoutViewModel,
+    state: CheckoutState,
     modifier: Modifier = Modifier
 ) {
-    if (vm.cart.isNotEmpty()) CheckoutFAB(
-        itemCount = vm.cart.itemCount,
-        onClick = { vm.showModal = true },
+    if (state.cart.isNotEmpty()) CheckoutFAB(
+        itemCount = state.cart.itemCount,
+        onClick = { state.showModal = true },
         modifier
     )
-    if (vm.showModal) CheckoutModal(
-        accounts = vm.accounts,
-        cart = vm.cart,
-        onSubmit = {
-            vm.submitCart(it)
-        },
-        onDismissRequest = { vm.showModal = false },
-        isLoading = vm.isCheckoutLoading,
-        indicateSuccess = vm.indicateSuccess
-    )
-    key(Unit) {
-        val context = LocalContext.current
-        vm.errorToast?.let {
-            LaunchedEffect(Unit) {
-                context.toastLong(it)
-                vm.errorToast = null
-            }
+    if (state.showModal) {
+        LaunchedEffect(Unit) {
+            state.refreshPurchaseAccounts()
         }
+        CheckoutModal(
+            accountSelectionState = state.accountSelectionState.dataOrNull ?: AccountSelectionState(emptyList()),
+            cart = state.cart,
+            onSubmit = { state.submitCart() },
+            onDismissRequest = { state.showModal = false },
+            isLoading = state.isCheckoutLoading,
+            indicateSuccess = state.indicateSuccess
+        )
     }
+    Toasts(state.toasts)
 }
 
 @Composable
@@ -164,9 +300,9 @@ private fun CheckoutFAB(
 
 @Composable
 private fun CheckoutModal(
-    accounts: List<ManualBillDetails.Account>,
+    accountSelectionState: AccountSelectionState<*>,
     cart: MutableCart,
-    onSubmit: (ManualBillDetails.Account) -> Unit,
+    onSubmit: () -> Unit,
     onDismissRequest: () -> Unit,
     modifier: Modifier = Modifier,
     sheetState: SheetState = rememberModalBottomSheetState(),
@@ -179,7 +315,7 @@ private fun CheckoutModal(
         sheetState = sheetState
     ) {
         CheckoutDetails(
-            accounts = accounts,
+            accountSelectionState = accountSelectionState,
             cart = cart,
             onSubmit = onSubmit,
             Modifier
@@ -198,9 +334,9 @@ private fun CheckoutModal(
 
 @Composable
 private fun CheckoutDetails(
-    accounts: List<ManualBillDetails.Account>,
+    accountSelectionState: AccountSelectionState<*>,
     cart: MutableCart,
-    onSubmit: (ManualBillDetails.Account) -> Unit,
+    onSubmit: () -> Unit,
     modifier: Modifier = Modifier,
     isLoading: Boolean = false,
     indicateSuccess: Boolean = false
@@ -219,22 +355,16 @@ private fun CheckoutDetails(
         verticalAlignment = Alignment.Bottom
     ) {
         // Account
-        var selectedAccount by remember { mutableIntStateOf(accounts.indexOfFirst { it.isDefault }) }
-        AccountSelection(
-            accounts = accounts,
-            selectedAccountIndex = selectedAccount,
-            onSelect = { selectedAccount = it }
-        )
+        AccountSelection(accountSelectionState)
 
         Spacer(Modifier.weight(1f).width(16.dp))
 
         // Submit
-        val account = accounts.getOrNull(selectedAccount)
         if (indicateSuccess) BoxCenter(Modifier.size(56.dp)) {
             Icon(Icons.Default.Check, "Success", tint = Color.Green)
         } else CircularLoadingBox(isLoading) {
             FloatingActionButton(
-                onClick = { if (account != null) onSubmit(account) },
+                onClick = onSubmit,
                 elevation = FloatingActionButtonDefaults.bottomAppBarFabElevation(),
             ) {
                 Icon(Icons.AutoMirrored.Filled.Send, "Submit")
@@ -250,17 +380,14 @@ private fun CheckoutCartEntry(
     isLoading: () -> Boolean,
     onRemove: () -> Unit,
     modifier: Modifier = Modifier
-) = ListItem(
+) = TransparentListItem(
     headlineContent = {
         Row(verticalAlignment = Alignment.CenterVertically) {
-            val knownItem = entry.item.knownItem
             Text("${entry.count}x", Modifier.padding(end = 8.dp))
-            if (knownItem != null) {
-                CategoryIcon(knownItem.category,
-                    Modifier
-                        .padding(end = 4.dp)
-                        .scale(0.8f))
-            }
+            CategoryIcon(entry.item.category,
+                Modifier
+                    .padding(end = 4.dp)
+                    .scale(0.8f))
             Text(entry.item.cleanFullName)
         }
     },
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/components/CustomItems.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/components/CustomItems.kt
index 2c0f3e9fb5f16fd1b86bac567c4d3159b62e08b6..a0b715c75ae3fa8295808fbc807a4d2a308e21bb 100644
--- a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/components/CustomItems.kt
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/components/CustomItems.kt
@@ -19,76 +19,92 @@ import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.clip
 import androidx.compose.ui.text.style.TextAlign
 import androidx.compose.ui.unit.dp
-import cafe.adriel.voyager.core.model.ScreenModel
-import cafe.adriel.voyager.core.model.screenModelScope
+import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.collectLatest
-import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.emptyFlow
 import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.flatMapLatest
 import kotlinx.coroutines.flow.map
-import kotlinx.coroutines.flow.stateIn
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
 import net.novagamestudios.common_utils.Logger
 import net.novagamestudios.common_utils.compose.DashedShape
 import net.novagamestudios.common_utils.compose.components.ColumnCenter
 import net.novagamestudios.common_utils.compose.state.ReentrantActionState
-import net.novagamestudios.common_utils.compose.state.collectAsStateIn
 import net.novagamestudios.common_utils.verbose
-import net.novagamestudios.common_utils.warn
-import net.novagamestudios.kaffeekasse.App
-import net.novagamestudios.kaffeekasse.model.kaffeekasse.ManualBillDetails
+import net.novagamestudios.kaffeekasse.model.kaffeekasse.Item
 import net.novagamestudios.kaffeekasse.model.kaffeekasse.MutableCart
 import net.novagamestudios.kaffeekasse.model.kaffeekasse.Transaction
-import net.novagamestudios.kaffeekasse.repositories.I11Client
-import net.novagamestudios.kaffeekasse.repositories.SettingsStore
+import net.novagamestudios.kaffeekasse.model.session.User
+import net.novagamestudios.kaffeekasse.repositories.RepositoryProvider
+import net.novagamestudios.kaffeekasse.repositories.SettingsRepository
+import net.novagamestudios.kaffeekasse.repositories.i11.KaffeekasseRepository
 import net.novagamestudios.kaffeekasse.ui.theme.disabled
+import net.novagamestudios.kaffeekasse.util.richdata.asRichDataFlow
+import net.novagamestudios.kaffeekasse.util.richdata.collectAsRichStateIn
+import net.novagamestudios.kaffeekasse.util.richdata.combineRich
+import net.novagamestudios.kaffeekasse.util.richdata.dataOrNull
+import net.novagamestudios.kaffeekasse.util.richdata.mapRich
+import net.novagamestudios.kaffeekasse.util.richdata.stateIn
 import java.time.LocalDateTime
 
-class CustomItemsViewModel private constructor(
-    settingsStore: SettingsStore,
-    private val kaffeekasse: I11Client.Kaffeekasse
-) : ScreenModel, Logger {
+class CustomItemsState private constructor(
+    private val user: User,
+    private val computationScope: CoroutineScope,
+    settingsRepository: SettingsRepository,
+    private val kaffeekasse: KaffeekasseRepository
+) : Logger {
 
-    private val allItems = kaffeekasse.manualBillDetails
-        .map { details -> details?.itemGroups?.flatMap { it.items } ?: emptyList() }
-        .stateIn(screenModelScope, SharingStarted.Eagerly, emptyList())
+    private val allItems = kaffeekasse.stock
+        .mapRich { details -> details.itemGroups.flatMap { it.items } }
+        .stateIn(computationScope, SharingStarted.Eagerly)
     private val allItemsById = allItems
-        .map { items -> items.associateBy { it.id } }
-        .stateIn(screenModelScope, SharingStarted.Eagerly, emptyMap())
+        .mapRich { items -> items.associateBy { it.id } }
+        .stateIn(computationScope, SharingStarted.Eagerly)
     private val allItemsByName = allItems
-        .map { items -> items.associateBy { it.name } }
-        .stateIn(screenModelScope, SharingStarted.Eagerly, emptyMap())
+        .mapRich { items -> items.associateBy { it.originalName } }
+        .stateIn(computationScope, SharingStarted.Eagerly)
 
-//    val favoriteItems by derivedStateOf { settings.favoriteItems.mapNotNull { item -> allItemsById[item] } }
-    val favoriteItems by combine(
-        settingsStore.values.map { it.favoriteItems }.distinctUntilChanged(),
+    @OptIn(ExperimentalCoroutinesApi::class)
+    val favoriteItems = combineRich(
+        settingsRepository.userSettings.flatMapLatest { it?.values ?: emptyFlow() }.map { it.favoriteItemIds }.distinctUntilChanged().asRichDataFlow(),
         allItemsById
-    ) { favoriteItems, itemsById -> favoriteItems.mapNotNull { itemsById[it] } }
-        .collectAsStateIn(screenModelScope, emptyList())
+    ) { favoriteItems, itemsById ->
+        favoriteItems.mapNotNull { itemsById[it] }
+    }.collectAsRichStateIn(computationScope)
 
-    private val suggestedItems = mutableStateListOf<ManualBillDetails.Item>()
+    private val suggestedItems = mutableStateListOf<Item>()
     val displayedSuggestedItems by derivedStateOf {
         val displayed = suggestedItems.toMutableList()
-        displayed -= favoriteItems.toSet()
+        displayed -= favoriteItems.dataOrNull.orEmpty().toSet()
         displayed.take(SuggestedItemsCount)
     }
 
+    private val userTransactions = kaffeekasse.transactions[user]
+    private val userTransactionsState = userTransactions.collectAsRichStateIn(computationScope)
+
     private val suggestionsMutex = ReentrantActionState()
-    val isLoadingSuggestions by suggestionsMutex
+    val isLoadingSuggestions by derivedStateOf {
+        suggestionsMutex.value || userTransactionsState.isLoading
+    }
+
 
     init {
-        verbose { "CustomItemsViewModel initialized" }
-        screenModelScope.launch {
-            kaffeekasse.transactions
+        verbose { "${CustomItemsState::class.simpleName} initialized" }
+        computationScope.launch {
+            userTransactions
+                .map { it.dataOrNull }
                 .filterNotNull()
                 .collectLatest { transactions ->
                     suggestionsMutex.trueWhile {
+                        val favourites = favoriteItems.dataOrNull.orEmpty().toSet()
                         val calculated = withContext(Dispatchers.IO) {
-                            calculateSuggestions(allItemsByName.value, transactions)
-                                .filter { it !in favoriteItems }
+                            calculateSuggestions(allItemsByName.value.dataOrNull.orEmpty(), transactions)
+                                .filter { it !in favourites }
                                 .toList()
                         }
                         suggestedItems.clear()
@@ -98,17 +114,9 @@ class CustomItemsViewModel private constructor(
         }
     }
 
-    override fun onDispose() {
-        verbose { "CustomItemsViewModel cleared" }
-    }
-
     fun fetch() {
-        screenModelScope.launch {
-            try {
-                kaffeekasse.fetchTransactions()
-            } catch (e: Exception) {
-                warn(e) { "Failed to fetch transactions" }
-            }
+        computationScope.launch {
+            userTransactions.ensureCleanData()
         }
     }
 
@@ -116,10 +124,11 @@ class CustomItemsViewModel private constructor(
 
         // calculate suggestions based on recency and frequency of transactions
         private fun calculateSuggestions(
-            allItemsByName: Map<String, ManualBillDetails.Item>,
+            allItemsByName: Map<String, Item>,
             transactions: List<Transaction>
-        ): Sequence<ManualBillDetails.Item> {
-            val statsByItem = transactions
+        ): Sequence<Item> {
+            val statsByItem: Map<Item, ItemStats> = transactions.asSequence()
+                .take(TransactionsLimit)
                 .mapNotNull {
                     val purpose = it.purpose as? Transaction.Purpose.Purchase ?: return@mapNotNull null
                     val item = allItemsByName[purpose.itemName] ?: return@mapNotNull null
@@ -141,7 +150,7 @@ class CustomItemsViewModel private constructor(
             val frequentPurchases = recentPurchases
                 .sortedByDescending { it.value.purchaseCount }
 
-            val pointsByItem = mutableMapOf<ManualBillDetails.Item, Double>()
+            val pointsByItem = mutableMapOf<Item, Double>()
 
             recentPurchases.asSequence().take(RecentItemsConsidered).forEachIndexed { i, (item, _) ->
                 pointsByItem[item] = (pointsByItem[item] ?: 0.0) + (1.0 - i / RecentItemsConsidered.toDouble()) * RecentItemsWeight
@@ -161,6 +170,7 @@ class CustomItemsViewModel private constructor(
             val purchaseCount: Int
         )
 
+        private const val TransactionsLimit = 100
         private const val RecentItemsConsidered = 10
         private const val FrequentItemsConsidered = 10
         private const val RecentItemsWeight = 1.0
@@ -169,30 +179,34 @@ class CustomItemsViewModel private constructor(
         private const val SuggestedItemsCount = 6
 
 
-        @Composable fun vm() = App.navigatorScreenModel {
-            CustomItemsViewModel(
-                settingsStore = settingsStore,
-                kaffeekasse = i11Client.kaffeekasse
-            )
-        }
+        context (RepositoryProvider)
+        fun create(
+            user: User,
+            computationScope: CoroutineScope
+        ) = CustomItemsState(
+            user = user,
+            computationScope = computationScope,
+            settingsRepository = settingsRepository,
+            kaffeekasse = kaffeekasseRepository
+        )
     }
 }
 
 @Composable
 fun CustomItems(
+    state: CustomItemsState,
     cart: MutableCart,
-    modifier: Modifier = Modifier,
-    vm: CustomItemsViewModel = CustomItemsViewModel.vm()
+    modifier: Modifier = Modifier
 ) = Column(modifier.verticalScroll(rememberScrollState())) {
     ColumnCenter(Modifier.fillMaxWidth()) {
-        if (vm.favoriteItems.isEmpty()) Text(
+        if (state.favoriteItems.dataOrNull.orEmpty().isEmpty()) Text(
             "Lange drücken, um Waren den Favoriten hinzuzufügen",
             Modifier.padding(horizontal = 16.dp, vertical = 64.dp),
             color = LocalContentColor.current.disabled(),
             textAlign = TextAlign.Center,
             style = MaterialTheme.typography.labelLarge
         ) else ItemCardGrid(
-            items = vm.favoriteItems,
+            items = state.favoriteItems.dataOrNull.orEmpty(),
             cart = cart
         )
     }
@@ -204,19 +218,29 @@ fun CustomItems(
     )
     ColumnCenter(Modifier.fillMaxWidth()) {
         LaunchedEffect(Unit) {
-            if (vm.displayedSuggestedItems.isEmpty()) vm.fetch()
+            if (state.displayedSuggestedItems.isEmpty()) state.fetch()
         }
         Text(
             "Vorschläge".uppercase(),
             Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
             style = MaterialTheme.typography.labelLarge
         )
-        if (vm.isLoadingSuggestions) {
+        if (state.isLoadingSuggestions) {
             CircularProgressIndicator(Modifier.padding(vertical = 64.dp))
-        } else ItemCardGrid(
-            items = vm.displayedSuggestedItems,
-            cart = cart,
-            spacerForFAB = true
-        )
+        } else if (state.displayedSuggestedItems.isEmpty()) {
+            Text(
+                "Keine Vorschläge",
+                Modifier.padding(horizontal = 16.dp, vertical = 64.dp),
+                color = LocalContentColor.current.disabled(),
+                textAlign = TextAlign.Center,
+                style = MaterialTheme.typography.labelLarge
+            )
+        } else {
+            ItemCardGrid(
+                items = state.displayedSuggestedItems,
+                cart = cart,
+                spacerForFAB = true
+            )
+        }
     }
 }
\ No newline at end of file
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/components/FailureRetryScreen.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/components/FailureRetryScreen.kt
index 9207fa718d7ab32d25460bdc103b3303055214e5..8d9c066f820a1539791457a32f45d095dcbf5737 100644
--- a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/components/FailureRetryScreen.kt
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/components/FailureRetryScreen.kt
@@ -43,4 +43,4 @@ fun FailureRetryScreen(
             textAlign = TextAlign.Center
         )
     }
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/components/cards/Category.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/components/cards/Category.kt
index 0bfb823dfb93978c3b9c978e7462eea8907de071..a3c442a3394511d977210aa958db0a4e4f9d8caa 100644
--- a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/components/cards/Category.kt
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/components/cards/Category.kt
@@ -21,12 +21,12 @@ import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.clip
 import androidx.compose.ui.graphics.Color
 import net.novagamestudios.common_utils.compose.components.BoxCenter
-import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem.*
 import net.novagamestudios.kaffeekasse.data.cleanName
+import net.novagamestudios.kaffeekasse.model.kaffeekasse.ItemCategory
 
 @Composable
 fun CategoryCard(
-    category: Category,
+    category: ItemCategory,
     highlighted: Boolean,
     onClick: () -> Unit,
     modifier: Modifier = Modifier
@@ -52,18 +52,18 @@ fun CategoryCard(
 
 @Composable
 fun CategoryIcon(
-    category: Category,
+    category: ItemCategory,
     modifier: Modifier = Modifier,
     tint: Color = LocalContentColor.current
 ) = Icon(
     when (category) {
-        Category.ColdBeverage -> Icons.Default.WineBar
-        Category.HotBeverage -> Icons.Default.Coffee
-        Category.Snack -> Icons.Default.Fastfood
-        Category.IceCream -> Icons.Default.Icecream
-        Category.Food -> Icons.Default.LocalPizza
-        Category.Fruit -> Icons.Default.NoFood
-        Category.Other -> Icons.Default.LocalOffer
+        ItemCategory.ColdBeverage -> Icons.Default.WineBar
+        ItemCategory.HotBeverage -> Icons.Default.Coffee
+        ItemCategory.Snack -> Icons.Default.Fastfood
+        ItemCategory.IceCream -> Icons.Default.Icecream
+        ItemCategory.Food -> Icons.Default.LocalPizza
+        ItemCategory.Fruit -> Icons.Default.NoFood
+        ItemCategory.Other -> Icons.Default.LocalOffer
     },
     contentDescription = category.cleanName,
     modifier,
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/components/cards/Item.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/components/cards/Item.kt
index 00ff64e0e0dfb58e173aa59725858ee7e0997010..746ad62f788708cb2cf5a2492754aae23f7a6702 100644
--- a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/components/cards/Item.kt
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/kaffeekasse/components/cards/Item.kt
@@ -23,11 +23,12 @@ import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.filled.Add
 import androidx.compose.material.icons.filled.Image
 import androidx.compose.material.icons.filled.Remove
+import androidx.compose.material.icons.rounded.Warning
 import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CircularProgressIndicator
 import androidx.compose.material3.Icon
 import androidx.compose.material3.IconButton
 import androidx.compose.material3.LocalContentColor
-import androidx.compose.material3.LocalTextStyle
 import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.ProvideTextStyle
 import androidx.compose.material3.Text
@@ -35,7 +36,6 @@ import androidx.compose.runtime.Composable
 import androidx.compose.runtime.collectAsState
 import androidx.compose.runtime.derivedStateOf
 import androidx.compose.runtime.getValue
-import androidx.compose.runtime.key
 import androidx.compose.runtime.remember
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
@@ -43,22 +43,31 @@ import androidx.compose.ui.draw.clip
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.ColorFilter
 import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.res.painterResource
 import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
 import androidx.compose.ui.unit.dp
+import coil.compose.SubcomposeAsyncImage
+import coil.request.ImageRequest
+import io.ktor.http.URLBuilder
+import io.ktor.http.URLProtocol
+import net.novagamestudios.common_utils.LocalLogger
 import net.novagamestudios.common_utils.compose.components.BoxCenter
 import net.novagamestudios.common_utils.compose.maskedCircleIcon
+import net.novagamestudios.common_utils.info
+import net.novagamestudios.common_utils.warn
+import net.novagamestudios.kaffeekasse.App
 import net.novagamestudios.kaffeekasse.app
-import net.novagamestudios.kaffeekasse.data.drawableResource
+import net.novagamestudios.kaffeekasse.model.kaffeekasse.Item
 import net.novagamestudios.kaffeekasse.model.kaffeekasse.KnownItem
-import net.novagamestudios.kaffeekasse.model.kaffeekasse.ManualBillDetails
-import net.novagamestudios.kaffeekasse.data.estimatedPrice
 import net.novagamestudios.kaffeekasse.model.kaffeekasse.Transaction
-import net.novagamestudios.kaffeekasse.repositories.LocalSettingsStore
+import net.novagamestudios.kaffeekasse.model.session.Session
+import net.novagamestudios.kaffeekasse.util.richdata.collectAsRichState
 
 @Composable
 fun ItemCard(
-    item: ManualBillDetails.Item,
+    item: Item,
     highlighted: Boolean,
     count: Int,
     onClick: () -> Unit,
@@ -75,7 +84,6 @@ fun ItemCard(
         ),
     highlighted = highlighted
 ) {
-    val settings by LocalSettingsStore.current
     BoxCenter(
         Modifier
             .weight(1f)
@@ -106,18 +114,18 @@ fun ItemCard(
             onRemove = onRemove
         )
     }
-    if (settings.developerMode) ProvideTextStyle(MaterialTheme.typography.labelSmall) {
+    if (App.developerMode) ProvideTextStyle(MaterialTheme.typography.labelSmall) {
         Row {
-            Text(item.id)
+            Text("${item.id}")
             Spacer(Modifier.weight(1f))
-            Text(item.knownItem?.name ?: "Unknown")
+            Text(item.originalName, textAlign = TextAlign.End)
         }
     }
 }
 
 @Composable
 private fun ItemTitle(
-    item: ManualBillDetails.Item,
+    item: Item,
     modifier: Modifier = Modifier
 ) = Text(
     item.cleanProductName,
@@ -127,7 +135,7 @@ private fun ItemTitle(
 
 @Composable
 private fun ItemInformation(
-    item: ManualBillDetails.Item,
+    item: Item,
     modifier: Modifier = Modifier
 ) = Row(modifier.heightIn(min = 20.dp)) {
     item.cleanVariantName?.let {
@@ -137,48 +145,131 @@ private fun ItemInformation(
         )
     }
     Spacer(Modifier.weight(1f))
-    val settings by LocalSettingsStore.current
-    val transactions by app().i11Client.kaffeekasse.transactions.collectAsState()
-    val lastUnitPrice by remember { derivedStateOf { transactions?.findLastUnitPrice(item) } }
-    (lastUnitPrice ?: item.knownItem?.estimatedPrice)?.let {
-        val highlighted = it != item.knownItem?.estimatedPrice && settings.developerMode
-        Text(
-            remember(it) { String.format("≈ %.2f€", it) },
-            color = if (highlighted) Color.Yellow else  Color.Unspecified,
-            style = MaterialTheme.typography.bodyMedium
-        )
+
+    val exactPrice = item.price
+    if (exactPrice != null) ItemPrice(
+        price = exactPrice,
+        estimated = false,
+        highlighted = false
+    ) else EstimatedItemPrice(item)
+}
+
+
+@Composable
+private fun ItemPrice(
+    price: Double,
+    estimated: Boolean,
+    highlighted: Boolean,
+    modifier: Modifier = Modifier
+) = Text(
+    remember(price, estimated) {
+        listOfNotNull(
+            if (estimated) "≈" else null,
+            String.format("%.2f€", price)
+        ).joinToString(" ")
+    },
+    modifier,
+    color = if (highlighted) Color.Yellow else Color.Unspecified,
+    style = MaterialTheme.typography.bodyMedium
+)
+
+@Composable
+private fun EstimatedItemPrice(
+    item: Item,
+    modifier: Modifier = Modifier
+) {
+    val app = app()
+    val session = app.portalRepository.session.collectAsState().value
+    if (session !is Session.WithRealUser) return
+    val transactionsState = remember { app.kaffeekasseRepository.transactions[session.realUser] }.collectAsRichState()
+    val lastUnitPrice by remember(transactionsState) {
+        derivedStateOf {
+            transactionsState.dataOrNull?.findLastUnitPrice(item)
+        }
     }
+    val estimatedPrice = lastUnitPrice ?: item.estimatedPrice ?: return
+    val priceDiverges = lastUnitPrice != item.estimatedPrice
+    ItemPrice(
+        price = estimatedPrice,
+        estimated = true,
+        highlighted = priceDiverges && App.developerMode,
+        modifier = modifier
+    )
 }
 
+
 // TODO move
-private fun List<Transaction>.findLastUnitPrice(item: ManualBillDetails.Item): Double? {
+private fun List<Transaction>.findLastUnitPrice(item: Item): Double? {
     val lastPurchase = this
+        .asSequence()
+        .take(100)
         .map { it.purpose }
         .filterIsInstance<Transaction.Purpose.Purchase>()
         .firstOrNull {
             val knownId = it.knownId
-            if (knownId != null) knownId == item.id else it.itemName == item.name
+            if (knownId != null) knownId == item.id else it.itemName == item.originalName
         }
     return lastPurchase?.unitPrice
 }
 
 @Composable
 fun ItemImage(
-    item: ManualBillDetails.Item,
+    item: Item,
     modifier: Modifier = Modifier
 ) {
-    val known = item.knownItem
-    if (known == KnownItem.MilkMachine) {
+    // Special case for milk
+    if (item.id == KnownItem.MilkMachine.id) {
         Box(modifier.background(Color.White))
         return
     }
-    val drawable = remember(known) { known?.drawableResource }
-    if (drawable != null) Image(
-        painterResource(drawable),
-        item.cleanFullName,
-        modifier,
-        contentScale = ContentScale.Fit,
-    ) else Image(
+    // Known items with images
+    remember(item) { item.imageDrawable }?.let {
+        Image(
+            painterResource(it),
+            item.cleanFullName,
+            modifier,
+            contentScale = ContentScale.Fit,
+        )
+        return
+    }
+    // Custom images from API
+    item.imageUrl?.let {
+        val logger = LocalLogger.current
+        SubcomposeAsyncImage(
+            model = ImageRequest.Builder(LocalContext.current)
+                .listener(
+                    onStart = { logger.info { "Loading image for $item" } },
+                    onError = { _, error -> logger.warn(error.throwable) { "Failed to load image for $item" } }
+                )
+                .data(remember(it) {
+                    URLBuilder(it).apply {
+                        if (protocol == URLProtocol.HTTP) protocol = URLProtocol.HTTPS
+                    }.build().toString()
+                })
+                .crossfade(true)
+                .build(),
+            contentDescription = item.cleanFullName,
+            modifier,
+            loading = {
+                BoxCenter {
+                    CircularProgressIndicator()
+                }
+            },
+            error = {
+                Image(
+                    Icons.Rounded.Warning,
+                    item.cleanFullName,
+                    modifier,
+                    contentScale = ContentScale.Fit,
+                    colorFilter = ColorFilter.tint(LocalContentColor.current.copy(alpha = 0.1f))
+                )
+            },
+            contentScale = ContentScale.Fit
+        )
+        return
+    }
+    // Default image
+    Image(
         Icons.Default.Image,
         item.cleanFullName,
         modifier,
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/login/Login.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/login/Login.kt
new file mode 100644
index 0000000000000000000000000000000000000000..75dc802e8b27b8e5a6a83f0c4e645c80ccaab3ef
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/login/Login.kt
@@ -0,0 +1,328 @@
+package net.novagamestudios.kaffeekasse.ui.login
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.IntrinsicSize
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.Logout
+import androidx.compose.material.icons.filled.ChevronRight
+import androidx.compose.material.icons.filled.Key
+import androidx.compose.material.icons.filled.PhonelinkErase
+import androidx.compose.material.icons.filled.ScreenLockLandscape
+import androidx.compose.material.icons.filled.SmartScreen
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.ProvideTextStyle
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusDirection
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.unit.dp
+import cafe.adriel.voyager.core.model.ScreenModel
+import cafe.adriel.voyager.core.model.screenModelScope
+import cafe.adriel.voyager.navigator.Navigator
+import kotlinx.coroutines.launch
+import net.novagamestudios.common_utils.Logger
+import net.novagamestudios.common_utils.compose.components.CircularLoadingBox
+import net.novagamestudios.kaffeekasse.R
+import net.novagamestudios.kaffeekasse.app
+import net.novagamestudios.kaffeekasse.data.cleanName
+import net.novagamestudios.kaffeekasse.model.credentials.DeviceCredentials
+import net.novagamestudios.kaffeekasse.model.credentials.isValid
+import net.novagamestudios.kaffeekasse.model.session.Device
+import net.novagamestudios.kaffeekasse.model.session.Session
+import net.novagamestudios.kaffeekasse.model.session.deviceOrNull
+import net.novagamestudios.kaffeekasse.repositories.LoginRepository
+import net.novagamestudios.kaffeekasse.repositories.RepositoryProvider
+import net.novagamestudios.kaffeekasse.repositories.i11.PortalRepository
+import net.novagamestudios.kaffeekasse.ui.AppInfoTopBarAction
+import net.novagamestudios.kaffeekasse.ui.FullscreenIconButton
+import net.novagamestudios.kaffeekasse.ui.navigation.LoginNavigation
+import net.novagamestudios.kaffeekasse.ui.util.screenmodel.ScreenModelFactory
+import net.novagamestudios.kaffeekasse.ui.util.rememberSerializableState
+import net.novagamestudios.kaffeekasse.ui.util.screenmodel.collectAsStateHere
+
+
+class LoginScreenModel private constructor(
+    private val loginRepository: LoginRepository,
+    private val portal: PortalRepository
+) : ScreenModel, Logger {
+
+    val session by portal.session.collectAsStateHere()
+
+    val isLoading by loginRepository.isPerformingAction.collectAsStateHere()
+
+    var showLoginDeviceDialog by mutableStateOf(false)
+
+
+    fun loginDevice(deviceCredentials: DeviceCredentials) {
+        screenModelScope.launch {
+            loginRepository.loginDevice(deviceCredentials)
+            showLoginDeviceDialog = false
+        }
+    }
+
+    fun logoutDevice() {
+        screenModelScope.launch {
+            loginRepository.logoutDevice()
+        }
+    }
+
+    companion object : ScreenModelFactory<LoginNavigation.Companion, LoginScreenModel> {
+        context (RepositoryProvider)
+        override fun create(screen: LoginNavigation.Companion) = LoginScreenModel(
+            loginRepository = loginRepository,
+            portal = portalRepository
+        )
+    }
+}
+
+
+
+@Composable
+fun LoginAdditional(
+    model: LoginScreenModel,
+    navigator: Navigator
+) {
+    val session = model.session
+    when (navigator.lastItem) {
+        is LoginNavigation.FormScreen -> {
+            if (session is Session.WithDevice) navigator.replace(LoginNavigation.UserSelectionScreen(session.device))
+        }
+        is LoginNavigation.UserSelectionScreen -> {
+            if (session !is Session.WithDevice) navigator.replace(LoginNavigation.FormScreen)
+        }
+    }
+    if (model.showLoginDeviceDialog) LoginDeviceDialog(model)
+}
+
+
+@Composable
+private fun LoginDeviceDialog(
+    model: LoginScreenModel,
+    modifier: Modifier = Modifier
+) {
+    val inputState = rememberSerializableState {
+        mutableStateOf(DeviceCredentials.Empty)
+    }
+    val input by inputState
+    AlertDialog(
+        onDismissRequest = { model.showLoginDeviceDialog = false },
+        confirmButton = {
+            Button(
+                onClick = { model.loginDevice(input) },
+                enabled = !model.isLoading && input.isValid
+            ) {
+                Text("Einloggen")
+            }
+        },
+        modifier,
+        dismissButton = {
+            TextButton(
+                onClick = { model.showLoginDeviceDialog = false },
+                enabled = !model.isLoading
+            ) {
+                Text("Abbrechen")
+            }
+        },
+        text = {
+            CircularLoadingBox(loading = model.isLoading) {
+                LoginDeviceForm(
+                    inputState = inputState,
+                    onDone = { model.loginDevice(input) }
+                )
+            }
+        }
+    )
+}
+
+@Composable
+private fun LoginDeviceForm(
+    inputState: MutableState<DeviceCredentials>,
+    onDone: () -> Unit,
+    modifier: Modifier = Modifier
+) {
+    var input by inputState
+    Column(
+        modifier.width(IntrinsicSize.Min),
+        verticalArrangement = Arrangement.spacedBy(8.dp),
+        horizontalAlignment = Alignment.CenterHorizontally
+    ) {
+        val focusManager = LocalFocusManager.current
+        OutlinedTextField(
+            value = input.deviceId,
+            onValueChange = { input = input.copy(deviceId = it.uppercase()) },
+            label = { Text("Geräte-Id") },
+            placeholder = { Text("XX:XX:XX:XX:XX:XX") },
+            leadingIcon = { Icon(Icons.Default.SmartScreen, "Geräte-Id") },
+            keyboardOptions = KeyboardOptions(
+                keyboardType = KeyboardType.Text,
+                imeAction = ImeAction.Next
+            ),
+            keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) }),
+            singleLine = true
+        )
+        OutlinedTextField(
+            value = input.apiKey,
+            onValueChange = { input = input.copy(apiKey = it) },
+            label = { Text("API-Schlüssel") },
+            leadingIcon = { Icon(Icons.Default.Key, "API-Schlüssel") },
+            keyboardOptions = KeyboardOptions(
+                keyboardType = KeyboardType.Password,
+                imeAction = ImeAction.Done
+            ),
+            keyboardActions = KeyboardActions(onDone = { onDone() }),
+            singleLine = true
+        )
+    }
+}
+
+
+@Composable
+fun LoginTopBarTitle(
+    model: LoginScreenModel
+) {
+    model.session.deviceOrNull?.let {
+        Row(
+            verticalAlignment = Alignment.CenterVertically
+        ) {
+            Image(
+                painterResource(R.drawable.logo_edited),
+                "Kaffeekasse",
+                Modifier.size(32.dp)
+            )
+            DeviceInfo(it, Modifier.padding(16.dp))
+        }
+    }
+}
+
+context (RowScope)
+@Composable
+fun LoginTopBarActions(
+    model: LoginScreenModel
+) {
+    val session = model.session
+    if (session !is Session.WithRealUser) {
+        when (session) {
+            is Session.WithDevice -> {
+                var confirmLogout by remember { mutableStateOf(false) }
+                IconButton(
+                    onClick = { confirmLogout = true },
+                    enabled = !model.isLoading
+                ) {
+                    Icon(Icons.Default.PhonelinkErase, "Gerät ausloggen")
+                }
+                if (confirmLogout) ConfirmLogoutDeviceDialog(
+                    onDismiss = { confirmLogout = false },
+                    onConfirm = { model.logoutDevice() }
+                )
+            }
+            else -> {
+                IconButton(
+                    onClick = { model.showLoginDeviceDialog = true },
+                    enabled = !model.isLoading
+                ) {
+                    Icon(Icons.Default.ScreenLockLandscape, "Gerät einloggen")
+                }
+            }
+        }
+    }
+    AppInfoTopBarAction()
+    FullscreenIconButton()
+}
+
+
+
+
+@Composable
+private fun DeviceInfo(
+    device: Device,
+    modifier: Modifier = Modifier
+) = Row(
+    modifier,
+    verticalAlignment = Alignment.CenterVertically
+) {
+    Text(device.name ?: "Unbekanntes Gerät")
+    device.knownItemGroup?.let {
+        ProvideTextStyle(MaterialTheme.typography.titleMedium) {
+            Spacer(Modifier.width(16.dp))
+            Icon(Icons.Default.ChevronRight, null)
+            Text(
+                it.cleanName,
+                style = MaterialTheme.typography.titleMedium
+            )
+        }
+    }
+}
+
+@Composable
+private fun ConfirmLogoutDeviceDialog(
+    onDismiss: () -> Unit,
+    onConfirm: () -> Unit,
+    modifier: Modifier = Modifier
+) = AlertDialog(
+    onDismissRequest = onDismiss,
+    confirmButton = {
+        Button(
+            onClick = onConfirm
+        ) {
+            Text("Ausloggen")
+        }
+    },
+    modifier,
+    dismissButton = {
+        TextButton(
+            onClick = onDismiss
+        ) {
+            Text("Abbrechen")
+        }
+    },
+    text = {
+        Text("Möchtest du das Gerät wirklich ausloggen?")
+    }
+)
+
+@Composable
+fun LogoutTopBarAction(session: Session) {
+    val app = app()
+    val loginRepository = app.loginRepository
+    val isLoading by loginRepository.isPerformingAction.collectAsState()
+    CircularLoadingBox(loading = isLoading) {
+        IconButton(
+            onClick = { app.launch { loginRepository.logoutUser() } },
+            enabled = session !is Session.Empty
+        ) {
+            Icon(
+                Icons.AutoMirrored.Filled.Logout,
+                contentDescription = "Ausloggen",
+                tint = MaterialTheme.colorScheme.primary
+            )
+        }
+    }
+}
+
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/login/LoginForm.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/login/LoginForm.kt
new file mode 100644
index 0000000000000000000000000000000000000000..8b91c3152d0736c82ab230ebb025be3fde560197
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/login/LoginForm.kt
@@ -0,0 +1,240 @@
+package net.novagamestudios.kaffeekasse.ui.login
+
+import android.content.Context
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.IntrinsicSize
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Password
+import androidx.compose.material.icons.filled.Person
+import androidx.compose.material.icons.filled.Visibility
+import androidx.compose.material.icons.filled.VisibilityOff
+import androidx.compose.material.icons.rounded.Coffee
+import androidx.compose.material3.Button
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.autofill.AutofillNode
+import androidx.compose.ui.autofill.AutofillType
+import androidx.compose.ui.composed
+import androidx.compose.ui.focus.FocusDirection
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.layout.boundsInWindow
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.platform.LocalAutofill
+import androidx.compose.ui.platform.LocalAutofillTree
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.ui.unit.dp
+import cafe.adriel.voyager.core.model.ScreenModel
+import cafe.adriel.voyager.core.model.screenModelScope
+import kotlinx.coroutines.launch
+import net.novagamestudios.common_utils.Logger
+import net.novagamestudios.common_utils.compose.components.BoxCenter
+import net.novagamestudios.kaffeekasse.model.credentials.Login
+import net.novagamestudios.kaffeekasse.model.credentials.isValid
+import net.novagamestudios.kaffeekasse.repositories.LoginRepository
+import net.novagamestudios.kaffeekasse.repositories.RepositoryProvider
+import net.novagamestudios.kaffeekasse.repositories.SettingsRepository
+import net.novagamestudios.kaffeekasse.repositories.i11.PortalRepository
+import net.novagamestudios.kaffeekasse.ui.kaffeekasse.components.FailureRetryScreen
+import net.novagamestudios.kaffeekasse.ui.navigation.LoginNavigation
+import net.novagamestudios.kaffeekasse.ui.util.screenmodel.ScreenModelFactory
+import net.novagamestudios.kaffeekasse.ui.util.rememberSerializableState
+import net.novagamestudios.kaffeekasse.ui.util.screenmodel.collectAsStateHere
+
+
+class LoginFormScreenModel private constructor(
+    private val loginRepository: LoginRepository,
+    private val settings: SettingsRepository,
+    private val portal: PortalRepository
+) : ScreenModel, Logger {
+
+    val session by portal.session.collectAsStateHere()
+
+    val autoLogin by derivedStateOf { settings.value.autoLogin }
+
+    var initialFormInput by mutableStateOf(Login.Empty)
+
+    val loginErrors by loginRepository.errors.collectAsStateHere()
+
+
+    fun login(login: Login, autoLogin: Boolean, activityContext: Context) {
+        screenModelScope.launch {
+            loginRepository.login(login, autoLogin, activityContext)
+        }
+    }
+
+    fun tryAutoLogin(activityContext: Context) {
+        screenModelScope.launch {
+            loginRepository.tryAutoLogin(activityContext)
+        }
+    }
+
+    companion object : ScreenModelFactory<LoginNavigation.FormScreen, LoginFormScreenModel> {
+        context (RepositoryProvider)
+        override fun create(screen: LoginNavigation.FormScreen) = LoginFormScreenModel(
+            loginRepository = loginRepository,
+            settings = settingsRepository,
+            portal = portalRepository
+        )
+    }
+}
+
+
+@Composable
+fun LoginForm(
+    model: LoginFormScreenModel,
+    modifier: Modifier = Modifier
+) = BoxCenter(modifier) {
+    val activityContext = LocalContext.current
+    LaunchedEffect(Unit) {
+        model.initialFormInput = Login.Empty
+        model.tryAutoLogin(activityContext)
+    }
+    Column(
+        Modifier.width(IntrinsicSize.Min),
+        verticalArrangement = Arrangement.spacedBy(8.dp),
+        horizontalAlignment = Alignment.CenterHorizontally
+    ) {
+
+        Icon(
+            Icons.Rounded.Coffee,
+            "Kaffeekasse",
+            Modifier
+                .padding(16.dp)
+                .size(48.dp),
+            tint = LocalContentColor.current.copy(alpha = 0.5f)
+        )
+
+        val focusManager = LocalFocusManager.current
+        var currentLogin by rememberSerializableState(model.initialFormInput) { mutableStateOf(model.initialFormInput) }
+        var autoLogin by rememberSaveable(model.autoLogin) { mutableStateOf(model.autoLogin) }
+        OutlinedTextField(
+            value = currentLogin.username,
+            onValueChange = { currentLogin = currentLogin.copy(username = it) },
+            Modifier.autofill(
+                listOf(AutofillType.Username),
+                onFill = { currentLogin = currentLogin.copy(username = it) }
+            ),
+            label = { Text("Nutzername") },
+            leadingIcon = { Icon(Icons.Default.Person, "Nutzername") },
+            keyboardOptions = KeyboardOptions(
+                keyboardType = KeyboardType.Text,
+                imeAction = ImeAction.Next
+            ),
+            keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) }),
+            singleLine = true
+        )
+        var showPassword by remember { mutableStateOf(false) }
+        OutlinedTextField(
+            value = currentLogin.password,
+            onValueChange = { currentLogin = currentLogin.copy(password = it) },
+            Modifier.autofill(
+                listOf(AutofillType.Password),
+                onFill = { currentLogin = currentLogin.copy(password = it) }
+            ),
+            label = { Text("Passwort") },
+            leadingIcon = { Icon(Icons.Default.Password, "Passwort") },
+            trailingIcon = {
+                IconButton(onClick = { showPassword = !showPassword }) {
+                    Icon(
+                        if (showPassword) Icons.Default.Visibility else Icons.Default.VisibilityOff,
+                        if (showPassword) "Hide password" else "Show password"
+                    )
+                }
+            },
+            visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
+            keyboardOptions = KeyboardOptions(
+                keyboardType = KeyboardType.Password,
+                imeAction = ImeAction.Done
+            ),
+            keyboardActions = KeyboardActions(onDone = {
+                model.login(
+                    currentLogin,
+                    autoLogin,
+                    activityContext
+                )
+            }),
+            singleLine = true
+        )
+        ListItem(
+            headlineContent = {
+                Text(
+                    "Automatisch einloggen",
+                    style = MaterialTheme.typography.labelLarge
+                )
+            },
+            trailingContent = {
+                Switch(
+                    checked = autoLogin,
+                    onCheckedChange = { autoLogin = it }
+                )
+            }
+        )
+
+        Button(
+            onClick = { model.login(currentLogin, autoLogin, activityContext) },
+            Modifier.align(Alignment.End),
+            enabled = currentLogin.isValid
+        ) {
+            Text("Einloggen")
+        }
+
+        val errors = model.loginErrors
+        if (errors != null) FailureRetryScreen(
+            message = "Failed to login",
+            errors = errors,
+            Modifier.padding(top = 16.dp),
+            onRetry = null
+        ) else Spacer(Modifier.height(48.dp + 16.dp))
+    }
+}
+
+
+fun Modifier.autofill(
+    autofillTypes: List<AutofillType>,
+    onFill: (String) -> Unit
+) = composed {
+    val autofill = LocalAutofill.current
+    val autofillNode = AutofillNode(
+        autofillTypes = autofillTypes,
+        onFill = onFill
+    )
+    LocalAutofillTree.current += autofillNode
+    this
+        .onGloballyPositioned { autofillNode.boundingBox = it.boundsInWindow() }
+        .onFocusChanged {
+            autofill?.run {
+                if (it.isFocused) requestAutofillForNode(autofillNode)
+                else cancelAutofillForNode(autofillNode)
+            }
+        }
+}
+
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
new file mode 100644
index 0000000000000000000000000000000000000000..138e61e2a68549db46fed56688c1189054cb7518
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/login/UserSelection.kt
@@ -0,0 +1,509 @@
+package net.novagamestudios.kaffeekasse.ui.login
+
+import android.content.res.Configuration
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.GridItemSpan
+import androidx.compose.foundation.lazy.grid.LazyGridState
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.rememberLazyGridState
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.Lock
+import androidx.compose.material.icons.filled.Password
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.DividerDefaults
+import androidx.compose.material3.DockedSearchBar
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedCard
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.ProvideTextStyle
+import androidx.compose.material3.SearchBarDefaults
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.pulltorefresh.PullToRefreshContainer
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.zIndex
+import cafe.adriel.voyager.core.model.ScreenModel
+import cafe.adriel.voyager.core.model.screenModelScope
+import cafe.adriel.voyager.navigator.Navigator
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.launch
+import net.novagamestudios.common_utils.Logger
+import net.novagamestudios.common_utils.compose.components.LinearProgressIndicator
+import net.novagamestudios.common_utils.compose.components.RowCenter
+import net.novagamestudios.common_utils.compose.state.rememberDerivedStateOf
+import net.novagamestudios.common_utils.warn
+import net.novagamestudios.kaffeekasse.App
+import net.novagamestudios.kaffeekasse.api.kaffeekasse.model.BasicUserInfo
+import net.novagamestudios.kaffeekasse.model.kaffeekasse.UserAuthCredentials
+import net.novagamestudios.kaffeekasse.repositories.LoginRepository
+import net.novagamestudios.kaffeekasse.repositories.RepositoryProvider
+import net.novagamestudios.kaffeekasse.repositories.i11.KaffeekasseRepository
+import net.novagamestudios.kaffeekasse.ui.handleSession
+import net.novagamestudios.kaffeekasse.ui.navigation.LoginNavigation
+import net.novagamestudios.kaffeekasse.ui.util.AlphabetSelectionChar
+import net.novagamestudios.kaffeekasse.ui.util.FailureRetryScreen
+import net.novagamestudios.kaffeekasse.ui.util.HorizontalSelectionBar
+import net.novagamestudios.kaffeekasse.ui.util.RichDataContent
+import net.novagamestudios.kaffeekasse.ui.util.Toasts
+import net.novagamestudios.kaffeekasse.ui.util.ToastsState
+import net.novagamestudios.kaffeekasse.ui.util.VerticalSelectionBar
+import net.novagamestudios.kaffeekasse.ui.util.rememberPullToRefreshState
+import net.novagamestudios.kaffeekasse.ui.util.screenmodel.ScreenModelFactory
+import net.novagamestudios.kaffeekasse.util.richdata.asRichDataFlow
+import net.novagamestudios.kaffeekasse.util.richdata.combineRich
+import net.novagamestudios.kaffeekasse.util.richdata.dataOrNull
+import net.novagamestudios.kaffeekasse.util.richdata.stateIn
+import net.novagamestudios.kaffeekasse.util.richdata.withFunctions
+
+
+class UserSelectionScreenModel private constructor(
+    private val loginRepository: LoginRepository,
+    kaffeekasse: KaffeekasseRepository
+) : ScreenModel, Logger {
+
+    val users = kaffeekasse.basicUserInfoList
+
+    var searchQuery by mutableStateOf("")
+    
+    val filteredUsers = run {
+        val data = combineRich(
+            snapshotFlow { searchQuery.lowercase() }.asRichDataFlow(),
+            users,
+            loadDuringTransform = true
+        ) { query, users ->
+            val sorted = users.asSequence().sortedBy { it.name }
+            if (query.isBlank()) return@combineRich sorted.toList()
+            sorted.filter { query in it.name.firstLast.lowercase() }.toList()
+        }
+            .flowOn(Dispatchers.IO)
+            .stateIn(screenModelScope)
+        data withFunctions users
+    }
+
+    var userAuthDialog by mutableStateOf<UserAuthDialogState?>(null)
+
+    val toasts = ToastsState()
+
+    data class UserAuthDialogState(
+        val user: BasicUserInfo,
+        val pin: String = ""
+    ) {
+        val auth get() = UserAuthCredentials(pin = pin.takeIf { it.isNotBlank() })
+    }
+
+    fun loginUser(
+        user: BasicUserInfo,
+        auth: UserAuthCredentials = UserAuthCredentials.Empty,
+        navigator: Navigator? = null
+    ) {
+        if (user.mayHavePin && auth.pin == null) {
+            userAuthDialog = UserAuthDialogState(user)
+            return
+        }
+        screenModelScope.launch {
+            when (val result = loginRepository.login(user, auth)) {
+                is LoginRepository.LoginResult.Success -> {
+                    userAuthDialog = null
+                    navigator?.handleSession(result.session)
+                }
+                is LoginRepository.LoginResult.Failure -> {
+                    toasts.short(result.error)
+                }
+            }
+        }
+    }
+
+    fun loginUser(dialog: UserAuthDialogState) {
+        loginUser(dialog.user, dialog.auth)
+    }
+
+    companion object : ScreenModelFactory<LoginNavigation.UserSelectionScreen, UserSelectionScreenModel> {
+        context (RepositoryProvider)
+        override fun create(screen: LoginNavigation.UserSelectionScreen) = UserSelectionScreenModel(
+            loginRepository = loginRepository,
+            kaffeekasse = kaffeekasseRepository,
+        )
+    }
+}
+
+
+
+
+
+@Composable
+fun UserSelection(
+    model: UserSelectionScreenModel,
+    modifier: Modifier = Modifier
+) {
+    val pullToRefreshState = rememberPullToRefreshState(model.users)
+    Box(
+        modifier
+            .fillMaxSize()
+            .clip(RectangleShape)
+            .nestedScroll(pullToRefreshState.nestedScrollConnection),
+        contentAlignment = Alignment.TopCenter
+    ) {
+        UserSearchBar(
+            query = model.searchQuery,
+            onQueryChange = { model.searchQuery = it },
+            onSearch = {
+                model.filteredUsers.value
+                    .dataOrNull
+                    ?.singleOrNull()
+                    ?.let { model.loginUser(it) }
+            },
+            Modifier
+                .padding(8.dp)
+                .align(Alignment.TopCenter)
+        )
+
+        RichDataContent(
+            source = model.filteredUsers,
+            errorContent = { error ->
+                warn { "Failed to load users: ${error.messages}" }
+                FailureRetryScreen(
+                    error = error,
+                    message = "Failed to load users",
+                    modifier = Modifier.fillMaxSize()
+                )
+            },
+            loadingContent = { progress ->
+                LinearProgressIndicator(
+                    progress,
+                    Modifier
+                        .padding(top = 8.dp)
+                        .align(Alignment.BottomCenter)
+                        .fillMaxWidth()
+                )
+            },
+            dataContent = { users ->
+                UserGridWithSelectionBar(
+                    users = users,
+                    onClick = { model.loginUser(it) },
+                    modifier = Modifier.align(Alignment.Center)
+                )
+            }
+        )
+        PullToRefreshContainer(pullToRefreshState, Modifier.zIndex(2f))
+    }
+    model.userAuthDialog?.let { state ->
+        UserAuthDialog(
+            state,
+            onChange = { model.userAuthDialog = it },
+            onDismiss = { model.userAuthDialog = null },
+            onSubmit = { model.loginUser(state) }
+        )
+    }
+    Toasts(model.toasts)
+}
+
+@Composable
+private fun UserSearchBar(
+    query: String,
+    onQueryChange: (String) -> Unit,
+    onSearch: () -> Unit,
+    modifier: Modifier = Modifier
+) = DockedSearchBar(
+    inputField = {
+        val focusRequester = remember { FocusRequester() }
+        SearchBarDefaults.InputField(
+            query = query,
+            onQueryChange = onQueryChange,
+            onSearch = { onSearch() },
+            expanded = false,
+            onExpandedChange = { },
+            Modifier
+                .fillMaxWidth()
+                .focusRequester(focusRequester),
+            placeholder = { Text("Search users") },
+            leadingIcon = { Icon(Icons.Default.Search, "Search") },
+            trailingIcon = {
+                if (query.isNotEmpty()) IconButton(
+                    onClick = { onQueryChange("") }
+                ) {
+                    Icon(Icons.Default.Close, "Clear search")
+                }
+            },
+        )
+    },
+    expanded = false,
+    onExpandedChange = { },
+    modifier,
+    content = { }
+)
+
+@Composable
+private fun UserGridWithSelectionBar(
+    users: List<BasicUserInfo>,
+    onClick: (BasicUserInfo) -> Unit,
+    modifier: Modifier = Modifier
+) {
+    val searchBarOffset = SearchBarDefaults.InputFieldHeight
+
+    val gridState = rememberLazyGridState()
+    val coroutineScope = rememberCoroutineScope()
+
+    val currentStartChars: Set<Char> by rememberDerivedStateOf {
+        val start = gridState.layoutInfo.viewportStartOffset + gridState.layoutInfo.beforeContentPadding
+        val end = gridState.layoutInfo.viewportEndOffset - gridState.layoutInfo.afterContentPadding
+        gridState.layoutInfo.visibleItemsInfo
+            .asSequence()
+            .filter {
+                val mid = it.offset.y + (it.size.height / 2)
+                mid in start..<end
+            }
+            .mapNotNull { it.contentType as? Int }
+            .mapNotNullTo(mutableSetOf()) { users.getOrNull(it)?.name?.char }
+    }
+    val userIndexAndCharIndexByChar: Map<Char, Pair<Int, Int>> by rememberDerivedStateOf {
+        users.asSequence()
+            .withIndex()
+            .map { (userIndex, user) ->
+                user.name.char to userIndex
+            }
+            .distinctBy { (char, _) -> char }
+            .withIndex()
+            .associate { (charIndex, pair) ->
+                val (char, userIndex) = pair
+                char to Pair(userIndex, charIndex)
+            }
+    }
+    val chars = remember { ('A'..'Z').toList() }
+
+    @Composable
+    fun Grid(modifier: Modifier = Modifier) = UserGrid(
+        gridState = gridState,
+        users = users,
+        onClick = onClick,
+        topOffset = searchBarOffset,
+        modifier
+    )
+
+    val onSelectIndex: (Int) -> Unit = block@{ index ->
+        val (userIndex, charIndex) = (userIndexAndCharIndexByChar[chars[index]] ?: return@block)
+        val sectionIndex = userIndex + charIndex
+        coroutineScope.launch {
+            gridState.scrollToItem(sectionIndex)
+        }
+    }
+
+    val itemContent: @Composable (Int) -> Unit = { i ->
+        val char = chars[i]
+        AlphabetSelectionChar(
+            char,
+            highlighted = char in currentStartChars,
+            enabled = char in userIndexAndCharIndexByChar
+        )
+    }
+
+
+    when {
+        users.isEmpty() -> Text("Niemand hat diesen Namen 🤔", modifier)
+        LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE -> Column(modifier) {
+            Grid(Modifier.weight(1f))
+            HorizontalDivider()
+            HorizontalSelectionBar(
+                itemCount = chars.size,
+                onSelect = onSelectIndex,
+                itemContent = itemContent
+            )
+        }
+        else -> Row(modifier) {
+            VerticalSelectionBar(
+                itemCount = chars.size,
+                onSelect = onSelectIndex,
+                Modifier.padding(top = searchBarOffset),
+                itemContent = itemContent
+            )
+            Grid(Modifier.weight(1f))
+        }
+    }
+}
+
+
+@Composable
+private fun UserGrid(
+    gridState: LazyGridState,
+    users: List<BasicUserInfo>,
+    onClick: (BasicUserInfo) -> Unit,
+    topOffset: Dp,
+    modifier: Modifier = Modifier
+) = LazyVerticalGrid(
+    columns = GridCells.Adaptive(minSize = 250.dp),
+    modifier,
+    state = gridState,
+    contentPadding = PaddingValues(
+        start = 8.dp,
+        top = 12.dp + topOffset,
+        end = 8.dp,
+        bottom = 12.dp
+    ),
+    horizontalArrangement = Arrangement.Center
+) {
+    var prevChar: Char? = null
+    users.forEachIndexed { i, user ->
+        val char = user.name.char
+        if (prevChar != char) {
+            item(
+                key = char,
+                span = { GridItemSpan(maxLineSpan) },
+                contentType = char
+            ) {
+                Row(
+                    Modifier.padding(6.dp),
+                    verticalAlignment = Alignment.CenterVertically
+                ) {
+                    Text(
+                        "$char",
+                        Modifier.padding(horizontal = 6.dp),
+                        color = DividerDefaults.color,
+                        fontSize = 24.sp,
+                        fontWeight = FontWeight.Bold
+                    )
+                    HorizontalDivider(thickness = 1.dp)
+                }
+            }
+        }
+        prevChar = char
+        item(
+            key = i,
+            contentType = i
+        ) {
+            UserItem(
+                user = user,
+                onClick = { onClick(user) },
+                Modifier
+                    .padding(6.dp)
+                    .widthIn(max = 300.dp)
+            )
+        }
+    }
+}
+
+
+@Composable
+private fun UserItem(
+    user: BasicUserInfo,
+    onClick: () -> Unit,
+    modifier: Modifier = Modifier
+) = OutlinedCard(
+    onClick = onClick,
+    modifier
+) {
+    RowCenter(
+        Modifier
+            .height(56.dp)
+            .padding(horizontal = 16.dp)
+    ) {
+        if (App.developerMode) Text(
+            "${user.id}",
+            Modifier.padding(end = 8.dp),
+            style = MaterialTheme.typography.labelSmall
+        )
+        ProvideTextStyle(MaterialTheme.typography.titleMedium) {
+            val (lastName, firstName) = remember(user) {
+                val (last, first) = user.name
+                last to first
+            }
+            Text(lastName)
+            if (firstName != null) {
+                Text(",")
+                Spacer(Modifier.width(8.dp))
+                Text(firstName)
+            }
+        }
+        Spacer(Modifier.weight(1f))
+        if (user.noPinSet == false) {
+            Icon(Icons.Default.Lock, "No PIN set")
+        }
+    }
+}
+
+
+@Composable
+private fun UserAuthDialog(
+    state: UserSelectionScreenModel.UserAuthDialogState,
+    onChange: (UserSelectionScreenModel.UserAuthDialogState) -> Unit,
+    onDismiss: () -> Unit,
+    onSubmit: () -> Unit,
+    modifier: Modifier = Modifier
+) = AlertDialog(
+    onDismissRequest = { onDismiss() },
+    confirmButton = {
+        Button(
+            onClick = onSubmit,
+            enabled = state.pin.isNotBlank()
+        ) { Text("Einloggen") }
+    },
+    modifier,
+    dismissButton = {
+        TextButton(onClick = onDismiss) { Text("Abbrechen") }
+    },
+    icon = { Icon(Icons.Default.Password, "PIN") },
+    title = { Text(state.user.name.firstLast) },
+    text = {
+        val focusRequester = remember { FocusRequester() }
+        OutlinedTextField(
+            state.pin,
+            onValueChange = { onChange(state.copy(pin = it)) },
+            Modifier.focusRequester(focusRequester),
+            label = { Text("PIN") },
+            leadingIcon = { Icon(Icons.Default.Lock, "PIN") },
+            visualTransformation = PasswordVisualTransformation(),
+            keyboardOptions = KeyboardOptions(
+                keyboardType = KeyboardType.Number,
+                imeAction = ImeAction.Done
+            ),
+            keyboardActions = KeyboardActions(
+                onDone = { onSubmit() }
+            ),
+            singleLine = true
+        )
+        LaunchedEffect(Unit) {
+            focusRequester.requestFocus()
+        }
+    }
+)
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/navigation/AppScaffold.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/navigation/AppScaffold.kt
new file mode 100644
index 0000000000000000000000000000000000000000..fdaa9fdda1ed0f94928ca8e1e9cb92fde8553bd7
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/navigation/AppScaffold.kt
@@ -0,0 +1,135 @@
+package net.novagamestudios.kaffeekasse.ui.navigation
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.expandHorizontally
+import androidx.compose.animation.shrinkHorizontally
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.TopAppBarScrollBehavior
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.unit.dp
+import cafe.adriel.voyager.core.screen.Screen
+import cafe.adriel.voyager.navigator.Navigator
+import net.novagamestudios.kaffeekasse.CrashHandling
+import net.novagamestudios.kaffeekasse.ui.theme.LocalAnimationSwitch
+import net.novagamestudios.kaffeekasse.ui.theme.ifAnimationsEnabled
+import net.novagamestudios.kaffeekasse.ui.util.navigation.BackNavigationHandler
+import net.novagamestudios.kaffeekasse.ui.util.navigation.debugNavigation
+
+@Composable
+fun AppScaffoldNavigator(
+    key: String,
+    initialRoute: List<Screen>,
+    title: @Composable (Navigator) -> Unit = { },
+    navigationIcon: @Composable (Navigator) -> Unit = { },
+    actions: @Composable RowScope.(Navigator) -> Unit = { },
+    topAppBarScrollBehavior: TopAppBarScrollBehavior? = TopAppBarDefaults.enterAlwaysScrollBehavior(),
+    backNavigationHandlerProvider: @Composable (Navigator) -> BackNavigationHandler = { BackNavigationHandler.default() },
+    content: @Composable Navigator.() -> Unit = { AppScaffoldNavigatorDefaultContent("$key-content") }
+) = Navigator(
+    screens = initialRoute,
+    onBackPressed = null,
+    key = key
+) { navigator ->
+    debugNavigation()
+    CrashHandling.updateNavigation(navigator)
+    val backNavigationHandler = backNavigationHandlerProvider(navigator)
+    AppScaffold(
+        topBar = {
+            AppTopBar(
+                title = { title(navigator) },
+                navigationIcon = {
+                    DefaultBackNavigation(backNavigationHandler)
+                    navigationIcon(navigator)
+                },
+                actions = { actions(navigator) },
+                scrollBehavior = topAppBarScrollBehavior
+            )
+        },
+        topAppBarScrollBehavior = topAppBarScrollBehavior
+    ) {
+        navigator.content()
+        backNavigationHandler.BackHandler()
+    }
+}
+
+@Composable
+fun Navigator.AppScaffoldNavigatorDefaultContent(
+    key: String
+) {
+    AppScreenTransition(this, key = key) { screen ->
+        screen.Content()
+    }
+}
+
+@Composable
+private fun AppScaffold(
+    modifier: Modifier = Modifier,
+    topBar: @Composable () -> Unit = { },
+    topAppBarScrollBehavior: TopAppBarScrollBehavior? = null,
+    content: @Composable () -> Unit
+) = Scaffold(
+    modifier.run {
+        if (topAppBarScrollBehavior == null || !LocalAnimationSwitch.current) this
+        else nestedScroll(topAppBarScrollBehavior.nestedScrollConnection)
+    },
+    topBar = topBar
+) { paddingValues ->
+    Box(
+        Modifier
+            .padding(paddingValues)
+            .fillMaxSize(),
+        propagateMinConstraints = true
+    ) {
+        content()
+    }
+}
+
+@Composable
+fun AppTopBar(
+    title: @Composable () -> Unit,
+    modifier: Modifier = Modifier,
+    navigationIcon: @Composable () -> Unit = {},
+    actions: @Composable RowScope.() -> Unit = {},
+    scrollBehavior: TopAppBarScrollBehavior? = null
+) = TopAppBar(
+    title = { title() },
+    modifier = modifier,
+    navigationIcon = { navigationIcon() },
+    actions = { actions() },
+    colors = TopAppBarDefaults.topAppBarColors(
+        scrolledContainerColor = MaterialTheme.colorScheme.surface
+    ),
+    scrollBehavior = scrollBehavior.takeIf { LocalAnimationSwitch.current }
+)
+
+@Composable
+fun AppSubpageTitle(text: String) = Text(text, Modifier.padding(start = 8.dp))
+
+@Composable
+fun DefaultBackNavigation(
+    handler: BackNavigationHandler
+) {
+    AnimatedVisibility(
+        visible = handler.canNavigateBack(),
+        enter = expandHorizontally().ifAnimationsEnabled(),
+        exit = shrinkHorizontally().ifAnimationsEnabled()
+    ) {
+        IconButton(onClick = { handler.onNavigateBack() }) {
+            Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
+        }
+    }
+}
\ No newline at end of file
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/navigation/AppScreens.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/navigation/AppScreens.kt
new file mode 100644
index 0000000000000000000000000000000000000000..ebbea3a7c606464d029ba886e4170f7588239d13
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/navigation/AppScreens.kt
@@ -0,0 +1,190 @@
+package net.novagamestudios.kaffeekasse.ui.navigation
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import cafe.adriel.voyager.core.screen.Screen
+import cafe.adriel.voyager.navigator.tab.Tab
+import cafe.adriel.voyager.navigator.tab.TabNavigator
+import cafe.adriel.voyager.navigator.tab.TabOptions
+import net.novagamestudios.common_utils.compose.components.BoxCenter
+import net.novagamestudios.kaffeekasse.model.session.Device
+import net.novagamestudios.kaffeekasse.model.session.Session
+import net.novagamestudios.kaffeekasse.model.session.deviceOrNull
+import net.novagamestudios.kaffeekasse.ui.AppModulesScreenModel
+import net.novagamestudios.kaffeekasse.ui.getValue
+import net.novagamestudios.kaffeekasse.ui.hiwi_tracker.HiwiTrackerModuleScreenModel
+import net.novagamestudios.kaffeekasse.ui.hiwi_tracker.HiwiTrackerTopBarActions
+import net.novagamestudios.kaffeekasse.ui.hiwi_tracker.HiwiTrackerTopBarTitle
+import net.novagamestudios.kaffeekasse.ui.hiwi_tracker.Overview
+import net.novagamestudios.kaffeekasse.ui.hiwi_tracker.OverviewScreenModel
+import net.novagamestudios.kaffeekasse.ui.kaffeekasse.Account
+import net.novagamestudios.kaffeekasse.ui.kaffeekasse.AccountScreenModel
+import net.novagamestudios.kaffeekasse.ui.kaffeekasse.DynamicManualBill
+import net.novagamestudios.kaffeekasse.ui.kaffeekasse.KaffeekasseModuleScreenModel
+import net.novagamestudios.kaffeekasse.ui.kaffeekasse.KaffeekasseTopBarActions
+import net.novagamestudios.kaffeekasse.ui.kaffeekasse.KaffeekasseTopBarNavigation
+import net.novagamestudios.kaffeekasse.ui.kaffeekasse.KaffeekasseTopBarTitle
+import net.novagamestudios.kaffeekasse.ui.kaffeekasse.ManualBillScreenModel
+import net.novagamestudios.kaffeekasse.ui.kaffeekasse.Transactions
+import net.novagamestudios.kaffeekasse.ui.kaffeekasse.TransactionsScreenModel
+import net.novagamestudios.kaffeekasse.ui.kaffeekasse.backNavigationHandler
+import net.novagamestudios.kaffeekasse.ui.login.LoginAdditional
+import net.novagamestudios.kaffeekasse.ui.login.LoginForm
+import net.novagamestudios.kaffeekasse.ui.login.LoginFormScreenModel
+import net.novagamestudios.kaffeekasse.ui.login.LoginScreenModel
+import net.novagamestudios.kaffeekasse.ui.login.LoginTopBarActions
+import net.novagamestudios.kaffeekasse.ui.login.LoginTopBarTitle
+import net.novagamestudios.kaffeekasse.ui.login.UserSelection
+import net.novagamestudios.kaffeekasse.ui.login.UserSelectionScreenModel
+import net.novagamestudios.kaffeekasse.ui.util.screenmodel.ScreenModelProvider
+import net.novagamestudios.kaffeekasse.ui.util.screenmodel.ScreenProvidingModel
+
+
+
+
+
+
+
+sealed interface LoginNavigation {
+    companion object : LoginNavigation, ScreenProvidingModel<LoginScreenModel> {
+        @get:Composable override val model by LoginScreenModel
+        private val initialScreen: Screen @Composable get() = model
+            .session
+            .deviceOrNull
+            ?.let { UserSelectionScreen(it) }
+            ?: FormScreen
+        @Composable override fun Content() = AppScaffoldNavigator(
+            key = "login-navigator",
+            initialRoute = listOf(initialScreen),
+            title = { LoginTopBarTitle(model) },
+            actions = { LoginTopBarActions(model) },
+            topAppBarScrollBehavior = null,
+            content = {
+                if (model.isLoading) {
+                    BoxCenter(Modifier.fillMaxSize()) {
+                        CircularProgressIndicator()
+                    }
+                } else {
+                    AppScaffoldNavigatorDefaultContent("login-navigator-content")
+                }
+                LoginAdditional(model, this)
+            }
+        )
+    }
+
+    data object FormScreen : LoginNavigation, ScreenProvidingModel<LoginFormScreenModel> {
+        @get:Composable override val model by LoginFormScreenModel
+        @Composable override fun Content() = LoginForm(model)
+    }
+
+    data class UserSelectionScreen(
+        val device: Device
+    ) : LoginNavigation, ScreenProvidingModel<UserSelectionScreenModel> {
+        @get:Composable override val model by UserSelectionScreenModel
+        @Composable override fun Content() = UserSelection(model)
+    }
+}
+
+
+
+
+sealed interface ModuleTab : Tab {
+    val session: Session.WithRealUser
+}
+
+
+data class AppModulesScreen(
+    val session: Session.WithRealUser
+) : ScreenProvidingModel<AppModulesScreenModel> {
+    @get:Composable override val model by AppModulesScreenModel
+    @Composable override fun Content() {
+        val model = model
+        TabNavigator(
+            remember { model.initialModuleTab() },
+            disposeNestedNavigators = true,
+            key = "app-modules-tabs",
+        ) { tabNavigator ->
+            AppTabTransition(
+                tabNavigator = tabNavigator,
+                Modifier
+                    .background(MaterialTheme.colorScheme.background)
+                    .fillMaxSize(),
+                key = "app-modules"
+            )
+            val currentTab = tabNavigator.current
+            LaunchedEffect(currentTab) {
+                if (currentTab is ModuleTab) model.onNavigateModuleTab(currentTab)
+            }
+        }
+    }
+}
+
+
+sealed interface KaffeekasseNavigation {
+
+    data class Tab(
+        override val session: Session.WithRealUser
+    ) : ModuleTab, KaffeekasseNavigation, ScreenModelProvider<KaffeekasseModuleScreenModel> {
+        @get:Composable override val model by KaffeekasseModuleScreenModel
+        @Composable override fun Content() = AppScaffoldNavigator(
+            key = "kaffeekasse-navigator",
+            initialRoute = listOf(ManualBillScreen(session)),
+            title = { KaffeekasseTopBarTitle(model, it) },
+            navigationIcon = { KaffeekasseTopBarNavigation(model, it) },
+            actions = { KaffeekasseTopBarActions(it) },
+            backNavigationHandlerProvider = { model.backNavigationHandler(it) }
+        )
+        override val options @Composable get() = remember { TabOptions(1u, "Kaffeekasse") }
+    }
+
+    data class ManualBillScreen(
+        val session: Session.WithRealUser
+    ) : KaffeekasseNavigation, ScreenProvidingModel<ManualBillScreenModel> {
+        @get:Composable override val model by ManualBillScreenModel
+        @Composable override fun Content() = DynamicManualBill(model)
+    }
+
+    data class AccountScreen(
+        val session: Session.WithRealUser
+    ) : KaffeekasseNavigation, ScreenProvidingModel<AccountScreenModel> {
+        @get:Composable override val model by AccountScreenModel
+        @Composable override fun Content() = Account(model)
+    }
+
+    data class TransactionsScreen(
+        val session: Session.WithRealUser
+    ) : KaffeekasseNavigation, ScreenProvidingModel<TransactionsScreenModel> {
+        @get:Composable override val model by TransactionsScreenModel
+        @Composable override fun Content() = Transactions(model)
+    }
+}
+
+
+sealed interface HiwiTrackerNavigation {
+
+    data class Tab(
+        override val session: Session.WithRealUser
+    ) : ModuleTab, HiwiTrackerNavigation, ScreenModelProvider<HiwiTrackerModuleScreenModel> {
+        @get:Composable override val model by HiwiTrackerModuleScreenModel
+        @Composable override fun Content() = AppScaffoldNavigator(
+            key = "hiwitracker-navigator",
+            initialRoute = listOf(OverviewScreen(session)),
+            title = { HiwiTrackerTopBarTitle(it) },
+            actions = { HiwiTrackerTopBarActions(model) }
+        )
+        override val options @Composable get() = remember { TabOptions(2u, "Hiwi Tracker") }
+    }
+
+    data class OverviewScreen(
+        val session: Session.WithRealUser
+    ) : HiwiTrackerNavigation, ScreenProvidingModel<OverviewScreenModel> {
+        @get:Composable override val model by OverviewScreenModel
+        @Composable override fun Content() = Overview(model)
+    }
+}
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/navigation/Transitions.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/navigation/Transitions.kt
new file mode 100644
index 0000000000000000000000000000000000000000..863245160673bcdcb3836475acdd87473c233add
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/navigation/Transitions.kt
@@ -0,0 +1,66 @@
+package net.novagamestudios.kaffeekasse.ui.navigation
+
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.AnimatedContentTransitionScope
+import androidx.compose.animation.AnimatedVisibilityScope
+import androidx.compose.animation.ContentTransform
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.scaleIn
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import cafe.adriel.voyager.core.screen.Screen
+import cafe.adriel.voyager.navigator.Navigator
+import cafe.adriel.voyager.navigator.tab.Tab
+import cafe.adriel.voyager.navigator.tab.TabNavigator
+import cafe.adriel.voyager.transitions.ScreenTransitionContent
+import net.novagamestudios.kaffeekasse.ui.theme.ifAnimationsEnabled
+
+private fun appTransitionSpec() = ContentTransform(
+    targetContentEnter = fadeIn(
+        tween(durationMillis = 220, delayMillis = 90)
+    ) + scaleIn(
+        tween(durationMillis = 220, delayMillis = 90),
+        initialScale = 0.92f
+    ),
+    initialContentExit = fadeOut(
+        tween(durationMillis = 90)
+    )
+)
+
+@Composable
+fun AppScreenTransition(
+    navigator: Navigator,
+    modifier: Modifier = Modifier,
+    key: String = "app-screen-transition",
+    transitionSpec: AnimatedContentTransitionScope<Screen>.() -> ContentTransform = { appTransitionSpec() },
+    content: ScreenTransitionContent = { it.Content() }
+) = AnimatedContent(
+    targetState = navigator.lastItem,
+    transitionSpec = ifAnimationsEnabled(transitionSpec),
+    modifier = modifier,
+    label = key
+) { screen ->
+    navigator.saveableState("$key-saveable", screen) {
+        content(screen)
+    }
+}
+
+@Composable
+fun AppTabTransition(
+    tabNavigator: TabNavigator,
+    modifier: Modifier = Modifier,
+    key: String = "app-tab-transition",
+    transitionSpec: AnimatedContentTransitionScope<Tab>.() -> ContentTransform = { appTransitionSpec() },
+    content: @Composable AnimatedVisibilityScope.(Tab) -> Unit = { it.Content() }
+) = AnimatedContent(
+    targetState = tabNavigator.current,
+    transitionSpec = ifAnimationsEnabled(transitionSpec),
+    modifier = modifier,
+    label = key
+) { tab ->
+    tabNavigator.saveableState("$key-saveable", tab) {
+        content(tab)
+    }
+}
\ No newline at end of file
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/theme/Theme.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/theme/Theme.kt
index c8e84fc9bd65d404587440f0992ac1be60e8f848..d2e1463f344285d4e161066b942bc65e0c8d2086 100644
--- a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/theme/Theme.kt
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/theme/Theme.kt
@@ -2,20 +2,30 @@ package net.novagamestudios.kaffeekasse.ui.theme
 
 import android.app.Activity
 import android.os.Build
+import androidx.compose.animation.AnimatedContentTransitionScope
+import androidx.compose.animation.ContentTransform
+import androidx.compose.animation.EnterTransition
+import androidx.compose.animation.ExitTransition
 import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.darkColorScheme
 import androidx.compose.material3.dynamicDarkColorScheme
 import androidx.compose.material3.dynamicLightColorScheme
 import androidx.compose.material3.lightColorScheme
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.ProvidableCompositionLocal
 import androidx.compose.runtime.SideEffect
+import androidx.compose.runtime.State
+import androidx.compose.runtime.compositionLocalOf
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.toArgb
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.platform.LocalView
 import androidx.core.view.WindowCompat
-import net.novagamestudios.kaffeekasse.repositories.LocalSettingsStore
+import net.novagamestudios.kaffeekasse.App.Companion.settings
+import net.novagamestudios.kaffeekasse.repositories.Settings
 import net.novagamestudios.kaffeekasse.repositories.Settings.Companion.isDarkMode
+import net.novagamestudios.kaffeekasse.ui.util.derived
 
 private val DarkColorScheme = darkColorScheme(
     primary = AppYellow,
@@ -31,7 +41,8 @@ private val LightColorScheme = lightColorScheme(
 
 @Composable
 fun KaffeekasseTheme(
-    darkTheme: Boolean = LocalSettingsStore.current.value.isDarkMode,
+    settings: State<Settings> = settings(),
+    darkTheme: Boolean = settings.isDarkMode,
     // Dynamic color is available on Android 12+
     dynamicColor: Boolean = true,
     content: @Composable () -> Unit
@@ -58,13 +69,30 @@ fun KaffeekasseTheme(
 
     MaterialTheme(
         colorScheme = colorScheme,
-        typography = Typography,
-        content = content
-    )
+        typography = Typography
+    ) {
+        CompositionLocalProvider(
+            LocalAnimationSwitch provides settings.derived { animationsEnabled }.value
+        ) {
+            content()
+        }
+    }
 }
 
 
-@Suppress("unused")
 fun Color.enabled(enabled: Boolean = true) = disabled(!enabled)
 fun Color.disabled(disabled: Boolean = true) = if (disabled) copy(alpha = 0.38f) else this
 
+
+val LocalAnimationSwitch: ProvidableCompositionLocal<Boolean> = compositionLocalOf { true }
+
+typealias TransitionSpec<T> = AnimatedContentTransitionScope<T>.() -> ContentTransform
+@Composable
+fun <T> ifAnimationsEnabled(transitionSpec: TransitionSpec<T>): TransitionSpec<T> = if (LocalAnimationSwitch.current) transitionSpec else {
+    { ContentTransform(EnterTransition.None, ExitTransition.None) }
+}
+
+@Composable
+fun EnterTransition.ifAnimationsEnabled(): EnterTransition = if (LocalAnimationSwitch.current) this else EnterTransition.None
+@Composable
+fun ExitTransition.ifAnimationsEnabled(): ExitTransition = if (LocalAnimationSwitch.current) this else ExitTransition.None
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/util/Helpers.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/util/Helpers.kt
index c240501392ed7bb9ac1795b435bacd64cab24945..d740b225547a81cf21619448d7864538bf89741c 100644
--- a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/util/Helpers.kt
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/util/Helpers.kt
@@ -1,11 +1,28 @@
 package net.novagamestudios.kaffeekasse.ui.util
 
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import androidx.compose.animation.core.AnimationSpec
+import androidx.compose.animation.core.TwoWayConverter
+import androidx.compose.animation.core.VectorConverter
+import androidx.compose.animation.core.animateValueAsState
+import androidx.compose.animation.core.spring
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisallowComposableCalls
+import androidx.compose.runtime.State
+import androidx.compose.runtime.derivedStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.ColorFilter
 import androidx.compose.ui.graphics.ColorMatrix
 import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.TextUnit
+import io.ktor.http.Url
+import net.novagamestudios.common_utils.LoggerForFun
+import net.novagamestudios.common_utils.error
 import net.novagamestudios.common_utils.toastShort
 
 
@@ -35,3 +52,59 @@ private fun ColorMatrix.setToTint(tint: Color) {
 }
 
 
+@Composable
+fun animateTextUnitAsState(
+    targetValue: TextUnit,
+    animationSpec: AnimationSpec<TextUnit> = spring(),
+    label: String = "TextUnitAnimation",
+    finishedListener: ((TextUnit) -> Unit)? = null
+): State<TextUnit> {
+    val density = LocalDensity.current
+    return animateValueAsState(
+        targetValue = targetValue,
+        typeConverter = remember(density) {
+            val dpConverter = Dp.VectorConverter
+            with(density) {
+                TwoWayConverter(
+                    convertToVector = { dpConverter.convertToVector( it.toDp()) },
+                    convertFromVector = { dpConverter.convertFromVector(it).toSp() }
+                )
+            }
+        },
+        animationSpec = animationSpec,
+        label = label,
+        finishedListener = finishedListener
+    )
+}
+
+
+@Composable
+fun <T, R> State<T>.derived(
+    calculation: @DisallowComposableCalls T.() -> R
+) = remember(this) {
+    derivedStateOf {
+        value.calculation()
+    }
+}
+
+fun Context.openInBrowser(url: String) = openInBrowser(Url(url))
+fun Context.openInBrowser(url: Url) {
+    val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(url.toString()))
+    startActivity(browserIntent)
+}
+
+
+/**
+ * See [https://issuetracker.google.com/issues/226665301](https://issuetracker.google.com/issues/226665301)
+ */
+fun removeScrollableTabRowMinimumTabWidth() = with(LoggerForFun()) {
+    try {
+        Class
+            .forName("androidx.compose.material3.TabRowKt")
+            .getDeclaredField("ScrollableTabRowMinimumTabWidth")
+            .apply { isAccessible = true }
+            .set(this, 0f)
+    } catch (e: Exception) {
+        error(e) { "Failed to remove ScrollableTabRowMinimumTabWidth" }
+    }
+}
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/util/KaffeekassePreview.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/util/KaffeekassePreview.kt
deleted file mode 100644
index f7737c8df4178fe3e940c42e05f90f0cb67f866f..0000000000000000000000000000000000000000
--- a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/util/KaffeekassePreview.kt
+++ /dev/null
@@ -1,41 +0,0 @@
-package net.novagamestudios.kaffeekasse.ui.util
-
-import android.annotation.SuppressLint
-import android.content.res.Configuration
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.CompositionLocalProvider
-import androidx.compose.ui.tooling.preview.Preview
-import net.novagamestudios.kaffeekasse.repositories.LocalSettingsStore
-import net.novagamestudios.kaffeekasse.repositories.Settings
-import net.novagamestudios.kaffeekasse.repositories.rememberMockedSettingsStore
-import net.novagamestudios.kaffeekasse.ui.theme.KaffeekasseTheme
-
-
-
-@Preview(
-    showBackground = false,
-    showSystemUi = true,
-    device = "id:pixel_3a",
-    uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL
-)
-annotation class PreviewDevice
-
-@Preview(
-    showBackground = false,
-    showSystemUi = false,
-    uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL
-)
-annotation class PreviewMinimal
-
-
-@SuppressLint("RememberReturnType")
-@Composable
-fun PreviewTheme(
-    initialSettings: Settings = Settings(),
-    content: @Composable () -> Unit
-) = CompositionLocalProvider(
-    LocalSettingsStore provides rememberMockedSettingsStore(initialSettings)
-) {
-    KaffeekasseTheme(content = content)
-}
-
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/util/PagerState.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/util/PagerState.kt
index 8b3478ae0537dba5d731e6d4f93c57b5328b6c11..433f6469366f43d3392b69eba3e00d1f510b6ba0 100644
--- a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/util/PagerState.kt
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/util/PagerState.kt
@@ -3,6 +3,7 @@ package net.novagamestudios.kaffeekasse.ui.util
 import androidx.compose.animation.core.AnimationSpec
 import androidx.compose.animation.core.spring
 import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.gestures.TargetedFlingBehavior
 import androidx.compose.foundation.gestures.snapping.SnapFlingBehavior
 import androidx.compose.foundation.layout.PaddingValues
 import androidx.compose.foundation.pager.PageSize
@@ -57,15 +58,16 @@ fun <K : Any> HorizontalKeyedPager(
     modifier: Modifier = Modifier,
     contentPadding: PaddingValues = PaddingValues(0.dp),
     pageSize: PageSize = PageSize.Fill,
-    beyondBoundsPageCount: Int = PagerDefaults.BeyondBoundsPageCount,
+    beyondViewportPageCount: Int = PagerDefaults.BeyondViewportPageCount,
     pageSpacing: Dp = 0.dp,
     verticalAlignment: Alignment.Vertical = Alignment.CenterVertically,
-    flingBehavior: SnapFlingBehavior = PagerDefaults.flingBehavior(state = state),
+    flingBehavior: TargetedFlingBehavior = PagerDefaults.flingBehavior(state = state),
     userScrollEnabled: Boolean = true,
     reverseLayout: Boolean = false,
-    pageNestedScrollConnection: NestedScrollConnection = remember(state) {
-        PagerDefaults.pageNestedScrollConnection(state, Orientation.Horizontal)
-    },
+    pageNestedScrollConnection: NestedScrollConnection = PagerDefaults.pageNestedScrollConnection(
+        state,
+        Orientation.Horizontal
+    ),
     pageContent: @Composable PagerScope.(page: K) -> Unit
 ) {
     androidx.compose.foundation.pager.HorizontalPager(
@@ -73,7 +75,7 @@ fun <K : Any> HorizontalKeyedPager(
         modifier = modifier,
         contentPadding = contentPadding,
         pageSize = pageSize,
-        beyondBoundsPageCount = beyondBoundsPageCount,
+        beyondViewportPageCount = beyondViewportPageCount,
         pageSpacing = pageSpacing,
         verticalAlignment = verticalAlignment,
         flingBehavior = flingBehavior,
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/util/PullToRefreshBox.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/util/PullToRefreshBox.kt
index 444ecb1c0725dd9f0b2403e34c220a216dfd3e67..0f75704cd761fc501df3c7d3b5ed16294c3c5fc7 100644
--- a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/util/PullToRefreshBox.kt
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/util/PullToRefreshBox.kt
@@ -1,37 +1,199 @@
 package net.novagamestudios.kaffeekasse.ui.util
 
+import androidx.compose.animation.core.animate
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.pulltorefresh.PullToRefreshContainer
+import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
 import androidx.compose.material3.pulltorefresh.PullToRefreshState
-import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
 import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.key
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshotFlow
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
+import androidx.compose.ui.input.nestedscroll.NestedScrollSource
 import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.Velocity
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import kotlin.math.abs
+import kotlin.math.pow
 
 @Composable
 fun PullToRefreshBox(
+    refreshing: () -> Boolean,
     onRefresh: () -> Unit,
-    shouldRefresh: () -> Boolean,
     modifier: Modifier = Modifier,
-    state: PullToRefreshState = rememberPullToRefreshState(),
     content: @Composable BoxScope.() -> Unit
 ) {
-    key(Unit) {
-        if (shouldRefresh()) LaunchedEffect(Unit) {
-            state.startRefresh()
-        } else LaunchedEffect(Unit) {
-            state.endRefresh()
-        }
-        if (state.isRefreshing) LaunchedEffect(Unit) {
-            onRefresh()
-        }
-    }
+    val state = rememberPullToRefreshState(refreshing, onRefresh)
     Box(modifier.nestedScroll(state.nestedScrollConnection)) {
         content()
         PullToRefreshContainer(state, Modifier.align(Alignment.TopCenter))
     }
-}
\ No newline at end of file
+}
+
+@Composable
+fun rememberPullToRefreshState(
+    refreshing: () -> Boolean,
+    onRefresh: () -> Unit
+): PullToRefreshState {
+    val syncScope = rememberCoroutineScope()
+    val positionalThresholdPx = with(LocalDensity.current) {
+        PullToRefreshDefaults.PositionalThreshold.toPx()
+    }
+    return remember(refreshing, onRefresh, positionalThresholdPx) {
+        PullToRefreshStateImpl(
+            syncScope = syncScope,
+            refreshing = refreshing,
+            positionalThreshold = positionalThresholdPx,
+            enabled = { true },
+            onRefresh = onRefresh
+        )
+    }
+}
+
+
+@ExperimentalMaterial3Api
+internal class PullToRefreshStateImpl(
+    syncScope: CoroutineScope,
+    private val refreshing: () -> Boolean,
+    override val positionalThreshold: Float,
+    enabled: () -> Boolean,
+    private val onRefresh: () -> Unit
+) : PullToRefreshState {
+
+    override val progress get() = adjustedDistancePulled / positionalThreshold
+    override val verticalOffset: Float get() = _verticalOffset
+
+    override val isRefreshing get() = refreshing()
+
+    override fun startRefresh() {
+        onRefresh()
+    }
+
+    override fun endRefresh() {
+//        _verticalOffset = 0f
+    }
+
+    init {
+        syncScope.launch {
+            snapshotFlow { isRefreshing }
+                .collect { refreshing ->
+                    if (!refreshing) {
+                        animateTo(0f)
+                    } else {
+                        animateTo(positionalThreshold)
+                    }
+                }
+        }
+    }
+
+
+    override var nestedScrollConnection = object : NestedScrollConnection {
+        override fun onPreScroll(
+            available: Offset,
+            source: NestedScrollSource,
+        ): Offset = when {
+            !enabled() -> Offset.Zero
+            // Swiping up
+            source == NestedScrollSource.UserInput && available.y < 0 -> {
+                consumeAvailableOffset(available)
+            }
+            else -> Offset.Zero
+        }
+
+        override fun onPostScroll(
+            consumed: Offset,
+            available: Offset,
+            source: NestedScrollSource
+        ): Offset = when {
+            !enabled() -> Offset.Zero
+            // Swiping down
+            source == NestedScrollSource.UserInput && available.y > 0 -> {
+                consumeAvailableOffset(available)
+            }
+            else -> Offset.Zero
+        }
+
+        override suspend fun onPreFling(available: Velocity): Velocity {
+            return Velocity(0f, onRelease(available.y))
+        }
+    }
+
+    /** Helper method for nested scroll connection */
+    fun consumeAvailableOffset(available: Offset): Offset {
+        val y = if (isRefreshing) 0f else {
+            val newOffset = (distancePulled + available.y).coerceAtLeast(0f)
+            val dragConsumed = newOffset - distancePulled
+            distancePulled = newOffset
+            _verticalOffset = calculateVerticalOffset()
+            dragConsumed
+        }
+        return Offset(0f, y)
+    }
+
+    /** Helper method for nested scroll connection. Calls onRefresh callback when triggered */
+    suspend fun onRelease(velocity: Float): Float {
+        if (isRefreshing) return 0f // Already refreshing, do nothing
+        // Trigger refresh
+        if (adjustedDistancePulled > positionalThreshold) {
+            startRefresh()
+        } else {
+            animateTo(0f)
+        }
+
+        val consumed = when {
+            // We are flinging without having dragged the pull refresh (for example a fling inside
+            // a list) - don't consume
+            distancePulled == 0f -> 0f
+            // If the velocity is negative, the fling is upwards, and we don't want to prevent the
+            // the list from scrolling
+            velocity < 0f -> 0f
+            // We are showing the indicator, and the fling is downwards - consume everything
+            else -> velocity
+        }
+        distancePulled = 0f
+        return consumed
+    }
+
+    suspend fun animateTo(offset: Float) {
+        animate(initialValue = verticalOffset, targetValue = offset) { value, _ ->
+            _verticalOffset = value
+        }
+    }
+
+    /** Provides custom vertical offset behavior for [PullToRefreshContainer] */
+    fun calculateVerticalOffset(): Float = when {
+        // If drag hasn't gone past the threshold, the position is the adjustedDistancePulled.
+        adjustedDistancePulled <= positionalThreshold -> adjustedDistancePulled
+        else -> {
+            // How far beyond the threshold pull has gone, as a percentage of the threshold.
+            val overshootPercent = abs(progress) - 1.0f
+            // Limit the overshoot to 200%. Linear between 0 and 200.
+            val linearTension = overshootPercent.coerceIn(0f, 2f)
+            // Non-linear tension. Increases with linearTension, but at a decreasing rate.
+            val tensionPercent = linearTension - linearTension.pow(2) / 4
+            // The additional offset beyond the threshold.
+            val extraOffset = positionalThreshold * tensionPercent
+            positionalThreshold + extraOffset
+        }
+    }
+
+    companion object {
+    }
+
+    internal var distancePulled by mutableFloatStateOf(0f)
+    private val adjustedDistancePulled: Float get() = distancePulled * DragMultiplier
+    private var _verticalOffset by mutableFloatStateOf(0f)
+}
+
+private const val DragMultiplier = 0.5f
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/util/RichData.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/util/RichData.kt
new file mode 100644
index 0000000000000000000000000000000000000000..7294906c6164a8e494b2a125e9c35a5bbb4ee130
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/util/RichData.kt
@@ -0,0 +1,116 @@
+package net.novagamestudios.kaffeekasse.ui.util
+
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.material3.pulltorefresh.PullToRefreshState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Modifier
+import kotlinx.coroutines.launch
+import net.novagamestudios.common_utils.compose.components.Progress
+import net.novagamestudios.kaffeekasse.ui.kaffeekasse.components.FailureRetryScreen
+import net.novagamestudios.kaffeekasse.util.richdata.RichData
+import net.novagamestudios.kaffeekasse.util.richdata.RichDataFunctions
+import net.novagamestudios.kaffeekasse.util.richdata.RichDataSource
+import net.novagamestudios.kaffeekasse.util.richdata.RichDataStateWithFunctions
+import net.novagamestudios.kaffeekasse.util.richdata.collectAsRichState
+
+
+@Composable
+fun <T : Any> PullToRefreshBox(
+    source: RichDataSource<T>,
+    modifier: Modifier = Modifier,
+    content: @Composable BoxScope.() -> Unit
+) = PullToRefreshBox(
+    state = source.collectAsRichState(),
+    modifier = modifier,
+    content = content
+)
+
+@Composable
+fun <T : Any> PullToRefreshBox(
+    state: RichDataStateWithFunctions<T>,
+    modifier: Modifier = Modifier,
+    content: @Composable BoxScope.() -> Unit
+) {
+    val coroutineScope = rememberCoroutineScope()
+    PullToRefreshBox(
+        refreshing = { state.isLoading },
+        onRefresh = { coroutineScope.launch { state.refresh() } },
+        modifier,
+        content = content
+    )
+}
+
+@Composable
+fun <T : Any> rememberPullToRefreshState(
+    data: RichDataSource<T>,
+): PullToRefreshState {
+    val dataState = data.collectAsRichState()
+    val coroutineScope = rememberCoroutineScope()
+    return rememberPullToRefreshState(
+        refreshing = { dataState.isLoading },
+        onRefresh = { coroutineScope.launch { data.refresh() } }
+    )
+}
+
+@Composable
+inline fun <T : Any> RichDataContent(
+    source: RichDataSource<T>,
+    ensureClean: Boolean = true,
+    errorContent: @Composable RichDataContentScope<T>.(RichData.Error<T>) -> Unit = { },
+    loadingContent: @Composable RichDataContentScope<T>.(Progress) -> Unit = { },
+    noneContent: @Composable RichDataContentScope<T>.() -> Unit = { },
+    dataContent: @Composable RichDataContentScope<T>.(T) -> Unit
+) = RichDataContent(
+    state = source.collectAsRichState(),
+    ensureClean = ensureClean,
+    errorContent = errorContent,
+    loadingContent = loadingContent,
+    noneContent = noneContent,
+    dataContent = dataContent
+)
+
+
+@Composable
+inline fun <T : Any> RichDataContent(
+    state: RichDataStateWithFunctions<T>,
+    ensureClean: Boolean = true,
+    errorContent: @Composable RichDataContentScope<T>.(RichData.Error<T>) -> Unit = { },
+    loadingContent: @Composable RichDataContentScope<T>.(Progress) -> Unit = { },
+    noneContent: @Composable RichDataContentScope<T>.() -> Unit = { },
+    dataContent: @Composable RichDataContentScope<T>.(T) -> Unit
+) {
+    if (ensureClean) LaunchedEffect(Unit) { state.ensureCleanData() }
+    val scope = remember(state) { object : RichDataContentScope<T> { override val functions = state } }
+    when (val value = state.value) {
+        is RichData.None<T> -> scope.noneContent()
+        is RichData.Loading<T> -> scope.loadingContent(value.progress)
+        is RichData.Data<T> -> scope.dataContent(value.data)
+        is RichData.Error<T> -> scope.errorContent(value)
+    }
+}
+
+interface RichDataContentScope<T : Any> {
+    val functions: RichDataFunctions<T>
+}
+
+
+context (RichDataContentScope<T>)
+@Composable
+fun <T : Any> FailureRetryScreen(
+    error: RichData.Error<T>,
+    message: String,
+    modifier: Modifier = Modifier
+) {
+    val coroutineScope = rememberCoroutineScope()
+    FailureRetryScreen(
+        message = message,
+        errors = error.messages,
+        onRetry = { coroutineScope.launch { functions.refresh() } },
+        modifier = modifier
+    )
+}
+
+
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/util/SelectionBar.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/util/SelectionBar.kt
new file mode 100644
index 0000000000000000000000000000000000000000..decc46ea498e0f14142a82eace2e9cb8d8db88d8
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/util/SelectionBar.kt
@@ -0,0 +1,133 @@
+package net.novagamestudios.kaffeekasse.ui.util
+
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.foundation.gestures.detectHorizontalDragGestures
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.gestures.detectVerticalDragGestures
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import net.novagamestudios.kaffeekasse.ui.theme.disabled
+
+@Composable
+fun VerticalSelectionBar(
+    itemCount: Int,
+    onSelect: (Int) -> Unit,
+    modifier: Modifier = Modifier,
+    itemContent: @Composable (Int) -> Unit
+) = Column(
+    modifier
+        .fillMaxHeight()
+        .verticalSelectionBarModifier(
+            itemCount = itemCount,
+            onSelect = onSelect
+        ),
+    verticalArrangement = Arrangement.SpaceAround,
+    horizontalAlignment = Alignment.CenterHorizontally
+) {
+    for (item in 0 until itemCount) key(item) {
+        itemContent(item)
+    }
+}
+
+@Composable
+fun HorizontalSelectionBar(
+    itemCount: Int,
+    onSelect: (Int) -> Unit,
+    modifier: Modifier = Modifier,
+    itemContent: @Composable (Int) -> Unit
+) = Row(
+    modifier
+        .fillMaxWidth()
+        .horizontalSelectionBarModifier(
+            itemCount = itemCount,
+            onSelect = onSelect
+        ),
+    horizontalArrangement = Arrangement.SpaceAround,
+    verticalAlignment = Alignment.CenterVertically
+) {
+    for (item in 0 until itemCount) key(item) {
+        itemContent(item)
+    }
+}
+
+private fun Modifier.verticalSelectionBarModifier(
+    itemCount: Int,
+    onSelect: (Int) -> Unit
+): Modifier {
+    fun selectAtRatio(ratio: Float) = onSelect((ratio * itemCount).toInt())
+    return this
+        .pointerInput(Unit) {
+            detectTapGestures { offset ->
+                selectAtRatio(offset.y / size.height)
+            }
+        }
+        .pointerInput(Unit) {
+            detectVerticalDragGestures { change, _ ->
+                selectAtRatio(change.position.y / size.height)
+            }
+        }
+        .padding(4.dp)
+}
+
+private fun Modifier.horizontalSelectionBarModifier(
+    itemCount: Int,
+    onSelect: (Int) -> Unit
+): Modifier {
+    fun selectAtRatio(ratio: Float) = onSelect((ratio * itemCount).toInt())
+    return this
+        .pointerInput(Unit) {
+            detectTapGestures { offset ->
+                selectAtRatio(offset.x / size.width)
+            }
+        }
+        .pointerInput(Unit) {
+            detectHorizontalDragGestures { change, _ ->
+                selectAtRatio(change.position.x / size.width)
+            }
+        }
+        .padding(4.dp)
+}
+
+@Composable
+fun AlphabetSelectionChar(
+    char: Char,
+    modifier: Modifier = Modifier,
+    highlighted: Boolean = false,
+    enabled: Boolean = true,
+) {
+    val scale by animateFloatAsState(
+        if (highlighted) 1f else 0.7f,
+        label = "CharScaleAnimation"
+    )
+    Text(
+        "$char",
+        modifier.graphicsLayer {
+            scaleX = scale
+            scaleY = scale
+        },
+        color = when {
+            enabled && highlighted -> MaterialTheme.colorScheme.primary
+            enabled -> LocalContentColor.current
+            else -> LocalContentColor.current.disabled()
+        },
+        fontSize = 24.sp,
+        fontWeight = if (highlighted) FontWeight.Bold else FontWeight.Normal
+    )
+}
\ No newline at end of file
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/util/SerializableSaver.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/util/SerializableSaver.kt
new file mode 100644
index 0000000000000000000000000000000000000000..e68fda6ada22825e3a4dcb3e18b705778a68b384
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/util/SerializableSaver.kt
@@ -0,0 +1,52 @@
+package net.novagamestudios.kaffeekasse.ui.util
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.saveable.Saver
+import androidx.compose.runtime.saveable.SaverScope
+import androidx.compose.runtime.saveable.rememberSaveable
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.serializer
+
+
+class SerializableSaver<T>(
+    private val serializer: KSerializer<T>,
+    private val json: Json = DefaultJson
+) : Saver<T, String> {
+    override fun restore(value: String): T? = runCatching<T?> {
+        json.decodeFromString(serializer, value)
+    }.getOrNull()
+    override fun SaverScope.save(value: T): String? = runCatching {
+        json.encodeToString(serializer, value)
+    }.getOrNull()
+    companion object {
+        val DefaultJson by lazy { Json }
+        inline operator fun <reified T> invoke(json: Json = DefaultJson) = SerializableSaver(
+            serializer<T>(), json)
+    }
+}
+
+@Composable
+inline fun <reified T : Any> rememberSerializable(
+    vararg inputs: Any?,
+    key: String? = null,
+    noinline init: () -> T
+): T = rememberSaveable(
+    *inputs,
+    saver = SerializableSaver<T>(),
+    key = key,
+    init = init
+)
+
+@Composable
+inline fun <reified T> rememberSerializableState(
+    vararg inputs: Any?,
+    key: String? = null,
+    noinline init: () -> MutableState<T>
+): MutableState<T> = rememberSaveable(
+    *inputs,
+    stateSaver = SerializableSaver<T>(),
+    key = key,
+    init = init
+)
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/util/Toast.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/util/Toast.kt
new file mode 100644
index 0000000000000000000000000000000000000000..4b2b15a7c5e40a3f559db058f1bb5dd405153824
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/util/Toast.kt
@@ -0,0 +1,43 @@
+package net.novagamestudios.kaffeekasse.ui.util
+
+import android.content.Context
+import android.widget.Toast
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.platform.LocalContext
+
+
+class ToastsState {
+    internal var builder by mutableStateOf<ToastBuilder?>(null)
+    internal fun interface ToastBuilder : (Context) -> Toast
+
+    private fun build(builder: ToastBuilder) {
+        this.builder = builder
+    }
+
+    fun long(text: String, config: Toast.() -> Unit = { }) = build { context ->
+        Toast.makeText(context, text, Toast.LENGTH_LONG).apply(config)
+    }
+    fun long(resId: Int, config: Toast.() -> Unit = { }) = build { context ->
+        Toast.makeText(context, resId, Toast.LENGTH_LONG).apply(config)
+    }
+    fun short(text: String, config: Toast.() -> Unit = { }) = build { context ->
+        Toast.makeText(context, text, Toast.LENGTH_SHORT).apply(config)
+    }
+    fun short(resId: Int, config: Toast.() -> Unit = { }) = build { context ->
+        Toast.makeText(context, resId, Toast.LENGTH_SHORT).apply(config)
+    }
+}
+
+
+@Composable
+fun Toasts(state: ToastsState) {
+    state.builder?.let { builder ->
+        builder(LocalContext.current).show()
+        state.builder = null
+    }
+}
+
+
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/util/navigation/BackNavigationHandler.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/util/navigation/BackNavigationHandler.kt
new file mode 100644
index 0000000000000000000000000000000000000000..eafa69a88d42c823319f547f145fa0ff773e839a
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/util/navigation/BackNavigationHandler.kt
@@ -0,0 +1,67 @@
+package net.novagamestudios.kaffeekasse.ui.util.navigation
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import cafe.adriel.voyager.navigator.LocalNavigator
+import cafe.adriel.voyager.navigator.Navigator
+import cafe.adriel.voyager.navigator.currentOrThrow
+
+interface BackNavigationHandler {
+
+    /**
+     * @return true if back navigation ui components should be shown.
+     * false does not mean that the navigation can not be handled
+     */
+    fun canNavigateBack(): Boolean
+
+    /**
+     * @return true if the navigation was handled and false if it should be passed on
+     */
+    fun onNavigateBack(): Boolean
+
+    @Composable
+    fun BackHandler() = androidx.activity.compose.BackHandler {
+        onNavigateBack()
+    }
+
+    companion object {
+        val Disabled = object : BackNavigationHandler {
+            override fun canNavigateBack() = false
+            override fun onNavigateBack() = true
+            @Composable
+            override fun BackHandler() { }
+        }
+        fun forNavigator(navigator: Navigator) = object : BackNavigationHandler {
+            override fun canNavigateBack() = navigator.canPop
+            override fun onNavigateBack(): Boolean {
+                navigator.pop()
+                return true
+            }
+        }
+        @Composable
+        fun default(): BackNavigationHandler {
+            val navigator = LocalNavigator.currentOrThrow
+            return remember(navigator) { forNavigator(navigator) }
+        }
+        infix fun BackNavigationHandler?.then(parent: BackNavigationHandler?) = object :
+            BackNavigationHandler {
+            override fun canNavigateBack(): Boolean {
+                return this@then?.canNavigateBack() == true || parent?.canNavigateBack() == true
+            }
+            override fun onNavigateBack(): Boolean {
+                return this@then?.onNavigateBack() == true || parent?.onNavigateBack() == true
+            }
+        }
+
+        fun derivedOf(
+            canNavigateBack: () -> Boolean,
+            onNavigateBack: () -> Boolean
+        ) = object : BackNavigationHandler {
+            private val _canNavigateBack by derivedStateOf(canNavigateBack)
+            override fun canNavigateBack() = _canNavigateBack
+            override fun onNavigateBack() = onNavigateBack()
+        }
+    }
+}
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/util/navigation/Navigation.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/util/navigation/Navigation.kt
new file mode 100644
index 0000000000000000000000000000000000000000..2aabdf67219712f62d8bc412da1c89b816902170
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/util/navigation/Navigation.kt
@@ -0,0 +1,56 @@
+package net.novagamestudios.kaffeekasse.ui.util.navigation
+
+import androidx.compose.runtime.Composable
+import cafe.adriel.voyager.core.annotation.InternalVoyagerApi
+import cafe.adriel.voyager.core.screen.Screen
+import cafe.adriel.voyager.navigator.LocalNavigator
+import cafe.adriel.voyager.navigator.Navigator
+import cafe.adriel.voyager.navigator.currentOrThrow
+import net.novagamestudios.common_utils.debug
+
+@OptIn(InternalVoyagerApi::class)
+@Composable
+fun debugNavigation() {
+    fun StringBuilder.addNavigator(navigator: Navigator) {
+        navigator.parent?.let { addNavigator(it) }
+        val indent = "  ".repeat(navigator.level)
+        appendLine("${indent}Navigator: ${navigator.key}")
+        navigator.items.forEach { screen ->
+            appendLine("${indent}- ${screen.key}: ${screen::class.qualifiedName}")
+        }
+    }
+    val navigator = LocalNavigator.currentOrThrow
+    val result = StringBuilder().apply {
+        addNavigator(navigator)
+    }.toString()
+    debug { result }
+}
+
+tailrec fun Navigator.findNavigatorOrNull(predicate: (Navigator) -> Boolean): Navigator? {
+    if (predicate(this)) return this
+    return parent?.findNavigatorOrNull(predicate)
+}
+
+@OptIn(InternalVoyagerApi::class)
+fun Navigator.requireWithKey(key: String) = findNavigatorOrNull { it.key == key }
+    ?: error("Navigator with key '$key' not found")
+
+
+@Composable
+inline fun <reified T : Screen> nearestScreenOrNull(): T? {
+    var navigator = LocalNavigator.current
+    while (navigator != null) {
+        val screen = navigator.items.filterIsInstance<T>().lastOrNull()
+        if (screen != null) return screen
+        navigator = navigator.parent
+    }
+    return null
+}
+
+@Composable
+inline fun <reified T : Screen> nearestScreen(): T {
+    return nearestScreenOrNull() ?: error("No screen of type ${T::class.qualifiedName} found")
+}
+
+@Suppress("RecursivePropertyAccessor")
+val Navigator.root: Navigator get() = parent?.root ?: this
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/util/screenmodel/ScreenModelFactory.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/util/screenmodel/ScreenModelFactory.kt
new file mode 100644
index 0000000000000000000000000000000000000000..d97099ac722e52ca3c7e43533a04c9a094366d3a
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/util/screenmodel/ScreenModelFactory.kt
@@ -0,0 +1,16 @@
+package net.novagamestudios.kaffeekasse.ui.util.screenmodel
+
+import android.app.Application
+import cafe.adriel.voyager.core.model.ScreenModel
+import cafe.adriel.voyager.core.screen.Screen
+import net.novagamestudios.kaffeekasse.repositories.RepositoryProvider
+
+fun interface ScreenModelFactory<in S : Screen, out T : ScreenModel> {
+    context (RepositoryProvider)
+    fun create(screen: S): T
+}
+
+fun interface GlobalScreenModelFactory<in A : Application, out T : ScreenModel> {
+    context (RepositoryProvider)
+    fun create(app: A): T
+}
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/util/screenmodel/ScreenModelProvider.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/util/screenmodel/ScreenModelProvider.kt
new file mode 100644
index 0000000000000000000000000000000000000000..5ccb2437fecdb764187d109ab15d96e3acc3d252
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/util/screenmodel/ScreenModelProvider.kt
@@ -0,0 +1,9 @@
+package net.novagamestudios.kaffeekasse.ui.util.screenmodel
+
+import androidx.compose.runtime.Composable
+import cafe.adriel.voyager.core.model.ScreenModel
+
+interface ScreenModelProvider<out T : ScreenModel> {
+    @get:Composable
+    val model: T
+}
\ No newline at end of file
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/util/screenmodel/ScreenProvidingModel.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/util/screenmodel/ScreenProvidingModel.kt
new file mode 100644
index 0000000000000000000000000000000000000000..23570476a56fc9c5214c3b618c0850442845a18f
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/util/screenmodel/ScreenProvidingModel.kt
@@ -0,0 +1,6 @@
+package net.novagamestudios.kaffeekasse.ui.util.screenmodel
+
+import cafe.adriel.voyager.core.model.ScreenModel
+import cafe.adriel.voyager.core.screen.Screen
+
+interface ScreenProvidingModel<out T : ScreenModel> : Screen, ScreenModelProvider<T>
\ No newline at end of file
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/ui/util/screenmodel/State.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/util/screenmodel/State.kt
new file mode 100644
index 0000000000000000000000000000000000000000..2b68d8dc6f418caae07c78332b949cc8b4e1b3e4
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/ui/util/screenmodel/State.kt
@@ -0,0 +1,20 @@
+package net.novagamestudios.kaffeekasse.ui.util.screenmodel
+
+import cafe.adriel.voyager.core.model.ScreenModel
+import cafe.adriel.voyager.core.model.screenModelScope
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.StateFlow
+import net.novagamestudios.common_utils.compose.state.collectAsStateIn
+
+context (CoroutineScope)
+fun <T> Flow<T>.collectAsStateHere(initialValue: T) = collectAsStateIn(this@CoroutineScope, initialValue)
+
+context (CoroutineScope)
+fun <T> StateFlow<T>.collectAsStateHere() = collectAsStateIn(this@CoroutineScope)
+
+context (ScreenModel)
+fun <T> Flow<T>.collectAsStateHere(initialValue: T) = collectAsStateIn(screenModelScope, initialValue)
+
+context (ScreenModel)
+fun <T> StateFlow<T>.collectAsStateHere() = collectAsStateIn(screenModelScope)
\ No newline at end of file
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/util/MutableCookiesStorage.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/util/MutableCookiesStorage.kt
new file mode 100644
index 0000000000000000000000000000000000000000..fffe74eacc05f61493715dc7981f5c44125eb098
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/util/MutableCookiesStorage.kt
@@ -0,0 +1,114 @@
+package net.novagamestudios.kaffeekasse.util
+
+import androidx.compose.runtime.mutableStateListOf
+import io.ktor.client.plugins.cookies.CookiesStorage
+import io.ktor.http.Cookie
+import io.ktor.http.Url
+import io.ktor.http.hostIsIp
+import io.ktor.http.isSecure
+import io.ktor.util.date.GMTDate
+import io.ktor.util.date.getTimeMillis
+import io.ktor.util.toLowerCasePreservingASCIIRules
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlin.math.min
+
+
+
+val Cookie.isExpired get() = expires?.let { it < GMTDate() } ?: false
+
+class MutableCookiesStorage(
+    private val container: MutableList<Cookie>,
+) : CookiesStorage {
+    private var oldestCookie: Long = 0L
+    private val mutex = Mutex()
+
+    override suspend fun get(requestUrl: Url): List<Cookie> = mutex.withLock {
+        val now = getTimeMillis()
+        if (now >= oldestCookie) cleanup(now)
+
+        return@withLock container.filter { it.matches(requestUrl) }
+    }
+
+    override suspend fun addCookie(requestUrl: Url, cookie: Cookie): Unit = mutex.withLock {
+        with(cookie) {
+            if (name.isBlank()) return@withLock
+        }
+
+        container.removeAll { it.name == cookie.name && it.matches(requestUrl) }
+        container.add(cookie.fillDefaults(requestUrl))
+        cookie.expires?.timestamp?.let { expires ->
+            if (oldestCookie > expires) {
+                oldestCookie = expires
+            }
+        }
+    }
+
+    @Suppress("unused")
+    suspend fun removeCookie(requestUrl: Url, cookie: Cookie): Unit = mutex.withLock {
+        container.removeAll { it.name == cookie.name && it.matches(requestUrl) }
+    }
+    suspend fun clear(requestUrl: Url): Unit = mutex.withLock {
+        container.removeAll { it.matches(requestUrl) }
+    }
+
+    override fun close() {
+    }
+
+    private fun cleanup(timestamp: Long) {
+        container.removeAll { cookie ->
+            val expires = cookie.expires?.timestamp ?: return@removeAll false
+            expires < timestamp
+        }
+
+        val newOldest = container.fold(Long.MAX_VALUE) { acc, cookie ->
+            cookie.expires?.timestamp?.let { min(acc, it) } ?: acc
+        }
+
+        oldestCookie = newOldest
+    }
+}
+
+private fun Cookie.matches(requestUrl: Url): Boolean {
+    val domain = domain?.toLowerCasePreservingASCIIRules()?.trimStart('.')
+        ?: error("Domain field should have the default value")
+
+    val path = with(path) {
+        val current = path ?: error("Path field should have the default value")
+        if (current.endsWith('/')) current else "$path/"
+    }
+
+    val host = requestUrl.host.toLowerCasePreservingASCIIRules()
+    val requestPath = let {
+        val pathInRequest = requestUrl.encodedPath
+        if (pathInRequest.endsWith('/')) pathInRequest else "$pathInRequest/"
+    }
+
+    if (host != domain && (hostIsIp(host) || !host.endsWith(".$domain"))) {
+        return false
+    }
+
+    if (path != "/" &&
+        requestPath != path &&
+        !requestPath.startsWith(path)
+    ) {
+        return false
+    }
+
+    return !(secure && !requestUrl.protocol.isSecure())
+}
+
+private fun Cookie.fillDefaults(requestUrl: Url): Cookie {
+    var result = this
+
+    if (result.path?.startsWith("/") != true) {
+        result = result.copy(path = requestUrl.encodedPath)
+    }
+
+    if (result.domain.isNullOrBlank()) {
+        result = result.copy(domain = requestUrl.host)
+    }
+
+    return result
+}
+
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/util/ReentrantMutex.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/util/ReentrantMutex.kt
new file mode 100644
index 0000000000000000000000000000000000000000..2fac48e1e99702c7eabfff3095d4ce169217b094
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/util/ReentrantMutex.kt
@@ -0,0 +1,23 @@
+package net.novagamestudios.kaffeekasse.util
+
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+import kotlin.coroutines.CoroutineContext
+import kotlin.coroutines.coroutineContext
+
+
+//class ReentrantMutex {
+//    private val mutex = Mutex()
+//    val isLocked get() = mutex.isLocked
+//    suspend fun <T> withReentrantLock(block: suspend () -> T) = mutex.withReentrantLock(block)
+//}
+
+suspend fun <T> Mutex.withReentrantLock(block: suspend () -> T): T {
+    val key = ReentrantMutexContextKey(this)
+    if (coroutineContext[key] != null) return block()
+    return withContext(ReentrantMutexContextElement(key)) { withLock { block() } }
+}
+
+private class ReentrantMutexContextElement(override val key: ReentrantMutexContextKey) : CoroutineContext.Element
+private data class ReentrantMutexContextKey(val mutex: Mutex) : CoroutineContext.Key<ReentrantMutexContextElement>
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/util/RichData.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/util/RichData.kt
deleted file mode 100644
index 17ce97def3a66f138ee273120113330f3f8af0e2..0000000000000000000000000000000000000000
--- a/app/src/main/java/net/novagamestudios/kaffeekasse/util/RichData.kt
+++ /dev/null
@@ -1,68 +0,0 @@
-package net.novagamestudios.kaffeekasse.util
-
-import androidx.compose.runtime.State
-import androidx.compose.runtime.derivedStateOf
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.setValue
-import kotlinx.coroutines.sync.Mutex
-import kotlinx.coroutines.sync.withLock
-import net.novagamestudios.common_utils.Logger
-import net.novagamestudios.common_utils.compose.components.Progress
-import net.novagamestudios.common_utils.warn
-
-sealed interface RichData<T : Any, E : Any> {
-    class Empty<T : Any, E : Any> : RichData<T, E>
-    data class Loading<T : Any, E : Any>(
-        val progress: Progress
-    ) : RichData<T, E>
-    sealed interface Result<T : Any, E : Any> : RichData<T, E>
-    data class Data<T : Any, E : Any>(
-        val data: T
-    ) : Result<T, E>
-    data class Error<T : Any, E : Any>(
-        val errorInfo: E
-    ) : Result<T, E>
-}
-
-
-class RichDataState<T : Any, E : Any>(
-    initialValue: RichData<T, E> = RichData.Empty()
-) : State<RichData<T, E>>, Logger {
-
-    private val mutex = Mutex()
-    override var value: RichData<T, E> by mutableStateOf(initialValue)
-        private set
-
-    val isLoading by derivedStateOf { value is RichData.Loading }
-    val dataOrNull by derivedStateOf { (value as? RichData.Data)?.data }
-    val errorOrNull by derivedStateOf { (value as? RichData.Error)?.errorInfo }
-
-    suspend fun calculate(
-        onError: (Throwable) -> RichData<T, E> = { e ->
-            warn(e) { "Unhandled error" }
-            RichData.Empty()
-        },
-        block: suspend (onProgress: (Progress) -> Unit) -> RichData.Result<T, E>
-    ): Unit = mutex.withLock {
-        value = RichData.Loading(Progress.Indeterminate)
-        value = try {
-            block { value = RichData.Loading(it) }
-        } catch (e: Throwable) {
-            onError(e)
-        }
-    }
-
-    suspend fun invalidate() = mutex.withLock {
-        value = RichData.Empty()
-    }
-}
-
-fun RichDataState<*, *>.asState(): State<RichData<*, *>> = this
-fun <T : Any, E : Any> RichDataState<T, E>?.orEmpty(): State<RichData<T, E>> {
-    return this ?: object : State<RichData<T, E>> {
-        override val value: RichData<T, E> = RichData.Empty()
-    }
-}
-
-
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/util/Serialization.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/util/Serialization.kt
new file mode 100644
index 0000000000000000000000000000000000000000..d8a1431308dc7c77eb9ff5ec655b4e7cf224d24d
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/util/Serialization.kt
@@ -0,0 +1,18 @@
+package net.novagamestudios.kaffeekasse.util
+
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.descriptors.PrimitiveKind
+import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+
+
+object IntAsBooleanSerializer : KSerializer<Boolean> {
+    override val descriptor = PrimitiveSerialDescriptor("IntAsBoolean", PrimitiveKind.INT)
+    override fun serialize(encoder: Encoder, value: Boolean) {
+        encoder.encodeInt(if (value) 1 else 0)
+    }
+    override fun deserialize(decoder: Decoder): Boolean {
+        return decoder.decodeInt() != 0
+    }
+}
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/util/Util.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/util/Util.kt
index 78ff91b9c78cc7f04ac3cf6bd89fa07c3c0c97d7..44d29309106ce4014dd9b0d0987b6761f677947e 100644
--- a/app/src/main/java/net/novagamestudios/kaffeekasse/util/Util.kt
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/util/Util.kt
@@ -1,21 +1,37 @@
 package net.novagamestudios.kaffeekasse.util
 
-import android.content.Context
-import android.content.Intent
-import android.net.Uri
-import io.ktor.http.Url
+import kotlinx.coroutines.flow.FlowCollector
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.sync.Mutex
 import kotlinx.datetime.LocalTime
 import kotlin.time.Duration
 import kotlin.time.Duration.Companion.nanoseconds
 
 
-fun Context.openInBrowser(url: String) = openInBrowser(Url(url))
-fun Context.openInBrowser(url: Url) {
-    val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(url.toString()))
-    startActivity(browserIntent)
+operator fun LocalTime.minus(other: LocalTime): Duration {
+    return (toNanosecondOfDay() - other.toNanosecondOfDay()).nanoseconds
 }
 
 
-operator fun LocalTime.minus(other: LocalTime): Duration {
-    return (toNanosecondOfDay() - other.toNanosecondOfDay()).nanoseconds
+inline fun Mutex.tryWithLock(owner: Any? = null, block: () -> Unit): Boolean {
+    if (!tryLock(owner)) return false
+    try {
+        block()
+    } finally {
+        unlock(owner)
+    }
+    return true
 }
+
+fun <T, R> StateFlow<T>.mapState(
+    transform: (T) -> R
+): StateFlow<R> = object : StateFlow<R> {
+    override val replayCache: List<R> get() = this@mapState.replayCache.map(transform)
+    override val value: R get() = transform(this@mapState.value)
+    override suspend fun collect(collector: FlowCollector<R>): Nothing {
+        this@mapState.collect { value -> collector.emit(transform(value)) }
+    }
+}
+
+context (T)
+fun <T> context(): T = this@T
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/util/richdata/KeyedMultiDataSource.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/util/richdata/KeyedMultiDataSource.kt
new file mode 100644
index 0000000000000000000000000000000000000000..8eb6b012f6f6fb4ec38d9ee63d6775869dd21a04
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/util/richdata/KeyedMultiDataSource.kt
@@ -0,0 +1,35 @@
+package net.novagamestudios.kaffeekasse.util.richdata
+
+import kotlinx.coroutines.CoroutineScope
+
+class KeyedMultiDataSource<K, T : Any>(
+    private val coroutineScope: CoroutineScope,
+    private val producer: suspend RichDataCollector<T>.(K) -> RichData<T>,
+) {
+    private val dataByKey = mutableMapOf<K, RichDataSource<T>>()
+
+    operator fun get(key: K): RichDataSource<T> = dataByKey.getOrPut(key) {
+        with(coroutineScope) {
+            RichDataSource.RichDataSource {
+                producer(key)
+            }
+        }
+    }
+
+    fun getOrNull(key: K): RichDataSource<T>? = dataByKey[key]
+
+    fun clear() {
+        dataByKey.clear()
+    }
+
+    fun markDirty() {
+        dataByKey.values.forEach { it.markDirty() }
+    }
+
+    companion object {
+        context (CoroutineScope)
+        operator fun <K, T : Any> invoke(
+            producer: suspend RichDataCollector<T>.(K) -> RichData<T>
+        ) = KeyedMultiDataSource(this@CoroutineScope, producer)
+    }
+}
\ No newline at end of file
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/util/richdata/RichData.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/util/richdata/RichData.kt
new file mode 100644
index 0000000000000000000000000000000000000000..dde83043727a22df527ff31583a1f984101828ac
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/util/richdata/RichData.kt
@@ -0,0 +1,50 @@
+package net.novagamestudios.kaffeekasse.util.richdata
+
+import androidx.compose.runtime.Immutable
+import net.novagamestudios.common_utils.compose.components.Progress
+
+@Immutable
+sealed interface RichData<out T : Any> {
+    @Immutable class None<out T : Any> : RichData<T>
+    @Immutable data class Loading<out T : Any>(
+        val progress: Progress
+    ) : RichData<T>
+    @Immutable data class Data<out T : Any>(
+        val data: T,
+        val isDirty: Boolean = false
+    ) : RichData<T>
+    @Immutable interface Error<out T : Any> : RichData<T> {
+        val messages: List<String>
+    }
+    companion object {
+        fun <T : Any> of(value: T?, isDirty: Boolean = false): RichData<T> = if (value == null) None() else Data(value, isDirty)
+    }
+}
+
+data class RichDataExceptionError<T : Any>(
+    val exception: Exception
+) : RichData.Error<T> {
+    override val messages: List<String> by lazy {
+        val list = mutableListOf<String>()
+        var current: Throwable? = exception
+        while (current != null) {
+            list.add(current.message ?: current::class.simpleName ?: "Unknown error")
+            current = current.cause
+        }
+        list
+    }
+}
+
+data class RichDataCombinedError<T : Any>(
+    val errors: List<RichData.Error<T>>
+) : RichData.Error<T> {
+    override val messages: List<String> by lazy { errors.flatMap { it.messages } }
+}
+
+
+
+val <T : Any> RichData<T>.isLoading: Boolean get() = this is RichData.Loading || this is RichData.None // Hopefully this will not lead to issues in the future ¯\_(ツ)_/¯
+val <T : Any> RichData<T>.isNone: Boolean get() = this is RichData.None
+val <T : Any> RichData<T>.dataOrNull: T? get() = (this as? RichData.Data)?.data
+val <T : Any> RichData<T>.errorOrNull: RichData.Error<T>? get() = this as? RichData.Error
+
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/util/richdata/RichDataFlow.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/util/richdata/RichDataFlow.kt
new file mode 100644
index 0000000000000000000000000000000000000000..71c1a3a27970700897ae367854a1c6cde4e1998f
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/util/richdata/RichDataFlow.kt
@@ -0,0 +1,151 @@
+package net.novagamestudios.kaffeekasse.util.richdata
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.channelFlow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.combineTransform
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.transform
+import kotlinx.coroutines.flow.transformLatest
+import net.novagamestudios.common_utils.compose.components.Progress
+import net.novagamestudios.kaffeekasse.util.mapState
+
+
+typealias RichDataFlow<T> = Flow<RichData<T>>
+typealias RichDataStateFlow<T> = StateFlow<RichData<T>>
+typealias MutableRichDataStateFlow<T> = MutableStateFlow<RichData<T>>
+
+
+fun <T : Any> StateFlow<T?>.asRichDataStateFlow(): RichDataStateFlow<T> = mapState { RichData.of(it) }
+fun <T : Any> Flow<T?>.asRichDataFlow(): RichDataFlow<T> = map { RichData.of(it) }
+
+fun <T : Any> RichDataFlow<T>.stateIn(
+    coroutineScope: CoroutineScope,
+    started: SharingStarted = SharingStarted.Lazily,
+): RichDataStateFlow<T> {
+    return stateIn(coroutineScope, started, RichData.None())
+}
+
+fun <T : Any> Flow<T>.richStateIn(
+    coroutineScope: CoroutineScope,
+    started: SharingStarted = SharingStarted.Lazily,
+): RichDataStateFlow<T> {
+    return asRichDataFlow().stateIn(coroutineScope, started, RichData.None())
+}
+
+@Suppress("UNCHECKED_CAST")
+fun <T : Any, R : Any> RichDataFlow<T>.mapRich(
+    loadDuringTransform: Boolean = false,
+    transform: suspend (T) -> R?
+): RichDataFlow<R> = transform {
+    if (loadDuringTransform) emit(RichData.Loading(Progress.Indeterminate))
+    emit(when (it) {
+        is RichData.None -> RichData.None()
+        is RichData.Loading -> RichData.Loading(it.progress)
+        is RichData.Data -> RichData.of(transform(it.data), it.isDirty)
+        is RichData.Error -> it as RichData.Error<R>
+    })
+}
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@Suppress("UNCHECKED_CAST")
+fun <T : Any, R : Any> RichDataFlow<T>.mapLatestRich(
+    loadDuringTransform: Boolean = false,
+    transform: suspend (T) -> R?
+): RichDataFlow<R> = transformLatest {
+    if (loadDuringTransform) emit(RichData.Loading(Progress.Indeterminate))
+    emit(when (it) {
+        is RichData.None -> RichData.None()
+        is RichData.Loading -> RichData.Loading(it.progress)
+        is RichData.Data -> RichData.of(transform(it.data), it.isDirty)
+        is RichData.Error -> it as RichData.Error<R>
+    })
+}
+
+@Suppress("UNCHECKED_CAST")
+fun <T : Any, R : Any> RichDataFlow<T>.flatMapLatestRich(transform: suspend (T) -> RichDataFlow<R>?): RichDataFlow<R> = channelFlow {
+    collectLatest { latest ->
+        when (latest) {
+            is RichData.None -> send(RichData.None())
+            is RichData.Loading -> send(RichData.Loading(latest.progress))
+            is RichData.Data -> {
+                val flow = transform(latest.data)
+                if (flow != null) flow.collect { send(it) } else send(RichData.None())
+            }
+            is RichData.Error -> send(latest as RichData.Error<R>)
+        }
+    }
+}
+
+@Suppress("UNCHECKED_CAST")
+inline fun <T : Any, R : Any> combineRich(
+    vararg flows: RichDataFlow<T>,
+    loadDuringTransform: Boolean = false,
+    crossinline transform: suspend (List<T>) -> R
+): RichDataFlow<R> {
+    return combineTransform<RichData<T>, RichData<R>>(*flows) { array ->
+        if (loadDuringTransform) emit(RichData.Loading(Progress.Indeterminate))
+        emit(if (array.all { it is RichData.Data }) {
+            val data = array.map { (it as RichData.Data).data }
+            RichData.Data(transform(data), array.any { (it as RichData.Data).isDirty })
+        } else if (array.any { it is RichData.Error }) {
+            RichDataCombinedError(array.filterIsInstance<RichData.Error<T>>().map { it as RichData.Error<R> })
+        } else if (array.any { it is RichData.Loading }) {
+            RichData.Loading(Progress.Indeterminate)
+        } else {
+            RichData.None()
+        })
+    }
+}
+
+@Suppress("UNCHECKED_CAST")
+inline fun <T1 : Any, T2 : Any, R : Any> combineRich(
+    flow1: RichDataFlow<T1>,
+    flow2: RichDataFlow<T2>,
+    loadDuringTransform: Boolean = false,
+    crossinline transform: suspend (T1, T2) -> R?
+): RichDataFlow<R> {
+    return combineTransform(flow1, flow2) { data1, data2 ->
+        if (loadDuringTransform) emit(RichData.Loading(Progress.Indeterminate))
+        emit(if (data1 is RichData.Data && data2 is RichData.Data) {
+            RichData.of(transform(data1.data, data2.data), data1.isDirty || data2.isDirty)
+        } else if (data1 is RichData.Error || data2 is RichData.Error) {
+            RichDataCombinedError(listOfNotNull(data1 as? RichData.Error<R>, data2 as? RichData.Error<R>))
+        } else if (data1 is RichData.Loading || data2 is RichData.Loading) {
+            RichData.Loading(Progress.Indeterminate)
+        } else {
+            RichData.None()
+        })
+    }
+}
+
+@Suppress("UNCHECKED_CAST")
+inline fun <T1 : Any, T2 : Any, T3 : Any, R : Any> combineRich(
+    flow1: RichDataFlow<T1>,
+    flow2: RichDataFlow<T2>,
+    flow3: RichDataFlow<T3>,
+    loadDuringTransform: Boolean = false,
+    crossinline transform: suspend (T1, T2, T3) -> R?
+): RichDataFlow<R> {
+    return combineTransform(flow1, flow2, flow3) { data1, data2, data3 ->
+        if (loadDuringTransform) emit(RichData.Loading(Progress.Indeterminate))
+        emit(if (data1 is RichData.Data && data2 is RichData.Data && data3 is RichData.Data) {
+            RichData.of(transform(data1.data, data2.data, data3.data), data1.isDirty || data2.isDirty || data3.isDirty)
+        } else if (data1 is RichData.Error || data2 is RichData.Error || data3 is RichData.Error) {
+            RichDataCombinedError(listOfNotNull(data1 as? RichData.Error<R>, data2 as? RichData.Error<R>, data3 as? RichData.Error<R>))
+        } else if (data1 is RichData.Loading || data2 is RichData.Loading || data3 is RichData.Loading) {
+            RichData.Loading(Progress.Indeterminate)
+        } else {
+            RichData.None()
+        })
+    }
+}
+
+
+
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/util/richdata/RichDataFunctions.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/util/richdata/RichDataFunctions.kt
new file mode 100644
index 0000000000000000000000000000000000000000..bf7fce133fe961cb3c39ef902708d68820166ab5
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/util/richdata/RichDataFunctions.kt
@@ -0,0 +1,50 @@
+package net.novagamestudios.kaffeekasse.util.richdata
+
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.supervisorScope
+
+interface RichDataFunctions<out T : Any> {
+    fun markDirty()
+    suspend fun ensureCleanData()
+    suspend fun refresh()
+
+    companion object {
+        @Suppress("unused")
+        val NOOP: RichDataFunctions<Any> = object : RichDataFunctions<Any> {
+            override fun markDirty() = Unit
+            override suspend fun ensureCleanData() = Unit
+            override suspend fun refresh() = Unit
+        }
+    }
+}
+
+operator fun <T : Any> RichDataFunctions<T>.plus(
+    other: RichDataFunctions<T>
+): RichDataFunctions<T> = object : RichDataFunctions<T> {
+    override fun markDirty() {
+        this@plus.markDirty()
+        other.markDirty()
+    }
+    override suspend fun ensureCleanData(): Unit = supervisorScope {
+        launch { this@plus.ensureCleanData() }
+        launch { other.ensureCleanData() }
+    }
+    override suspend fun refresh(): Unit = supervisorScope {
+        launch { this@plus.refresh() }
+        launch { other.refresh() }
+    }
+}
+
+
+
+infix fun <T : Any> RichDataStateFlow<out T>.withFunctions(
+    functions: RichDataFunctions<T>
+): RichDataSource<T> = object : RichDataSource<T>,
+    RichDataStateFlow<T> by this,
+    RichDataFunctions<T> by functions { }
+
+infix fun <T : Any> RichDataState<T>.withFunctions(
+    functions: RichDataFunctions<T>
+): RichDataStateWithFunctions<T> = object : RichDataStateWithFunctions<T>,
+    RichDataState<T> by this,
+    RichDataFunctions<T> by functions { }
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/util/richdata/RichDataSource.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/util/richdata/RichDataSource.kt
new file mode 100644
index 0000000000000000000000000000000000000000..c3db23acfab1e95da739fd69933dac4ecde7b1e6
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/util/richdata/RichDataSource.kt
@@ -0,0 +1,106 @@
+package net.novagamestudios.kaffeekasse.util.richdata
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.FlowCollector
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.channelFlow
+import net.novagamestudios.common_utils.compose.components.Progress
+import kotlin.coroutines.suspendCoroutine
+
+typealias RichDataCollector<T> = FlowCollector<RichData<T>>
+typealias RichDataFactory<T> = suspend RichDataCollector<T>.() -> RichData<T>
+
+
+interface RichDataSource<out T : Any> : RichDataFunctions<T>, RichDataStateFlow<T>, RichDataFlow<T> {
+    companion object {
+        context (CoroutineScope)
+        fun <T : Any> RichDataSource(
+            producer: RichDataFactory<T>
+        ): RichDataSource<T> = RichDataProducer(this@CoroutineScope, producer)
+    }
+}
+
+
+private typealias Updater<T> = suspend RichDataCollector<T>.(RichData<T>) -> Unit
+
+private class RichDataProducer<T : Any>(
+    coroutineScope: CoroutineScope,
+    producer: RichDataFactory<T>
+) : RichDataSource<T> {
+    private val wrappedProducer: Updater<T> = updater@{
+        emit(RichData.Loading(Progress.Indeterminate))
+        emit(producer())
+    }
+
+    private val updaterChannel = Channel<Updater<T>>(Channel.UNLIMITED)
+
+    private val outFlow: StateFlow<RichData<T>> = channelFlow {
+        var current: RichData<T> = RichData.None()
+        val collector = RichDataCollector<T> {
+            current = it
+            this.send(it)
+        }
+        with(collector) {
+            for (updater in updaterChannel) {
+                try {
+                    updater(current)
+                } catch (e: Exception) {
+                    emit(RichDataExceptionError(e))
+                }
+            }
+        }
+    }.stateIn(coroutineScope, SharingStarted.Lazily)
+
+    override val replayCache get() = outFlow.replayCache
+    override suspend fun collect(collector: RichDataCollector<T>) = outFlow.collect(collector)
+    override val value: RichData<T> get() = outFlow.value
+
+    init {
+        enqueueUpdater(wrappedProducer)
+    }
+
+    private fun enqueueUpdater(updater: Updater<T>) {
+        updaterChannel.trySend(updater)
+    }
+    private suspend fun awaitUpdater(updater: Updater<T>): Unit = suspendCoroutine{ continuation ->
+        enqueueUpdater { current ->
+            updater(current)
+            continuation.resumeWith(Result.success(Unit))
+        }
+    }
+
+    override fun markDirty() = enqueueUpdater { current ->
+        if (current is RichData.Data && !current.isDirty) {
+            emit(current.copy(isDirty = true))
+        }
+//        if (current is RichData.Data) {
+//            emit(RichData.None())
+//        }
+    }
+
+    override suspend fun ensureCleanData() = awaitUpdater { current ->
+        val needsUpdate = when (current) {
+            is RichData.Data -> current.isDirty
+            is RichData.Error -> true
+            is RichData.Loading -> false
+            is RichData.None -> true
+        }
+        if (needsUpdate) wrappedProducer(current)
+    }
+
+    override suspend fun refresh() = awaitUpdater(wrappedProducer)
+}
+
+
+context (RichDataCollector<T>)
+suspend fun <T : Any> progress(progress: Progress) {
+    emit(RichData.Loading(progress))
+}
+
+context (RichDataCollector<T>)
+suspend fun <T : Any> progress(progress: Float) {
+    emit(RichData.Loading(Progress.Determinate(progress)))
+}
+
diff --git a/app/src/main/java/net/novagamestudios/kaffeekasse/util/richdata/RichDataState.kt b/app/src/main/java/net/novagamestudios/kaffeekasse/util/richdata/RichDataState.kt
new file mode 100644
index 0000000000000000000000000000000000000000..ff2beac72cab35065972afa7be1aa7e84d89eeb4
--- /dev/null
+++ b/app/src/main/java/net/novagamestudios/kaffeekasse/util/richdata/RichDataState.kt
@@ -0,0 +1,81 @@
+package net.novagamestudios.kaffeekasse.util.richdata
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.State
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import cafe.adriel.voyager.core.model.ScreenModel
+import cafe.adriel.voyager.core.model.screenModelScope
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+
+@Stable
+interface RichDataState<out T : Any> : State<RichData<T>> {
+    val isLoading: Boolean
+    val isNone: Boolean
+    val dataOrNull: T?
+    val errorOrNull: RichData.Error<T>?
+}
+
+@Stable
+interface RichDataStateWithFunctions<T : Any> : RichDataState<T>, RichDataFunctions<T>
+
+@Stable
+class MutableRichDataState<T : Any>(
+    private val initial: RichData<T> = RichData.None()
+) : MutableState<RichData<T>> by mutableStateOf(initial), RichDataState<T> {
+    override val isLoading by derivedStateOf { value.isLoading }
+    override val isNone by derivedStateOf { value.isNone }
+    override val dataOrNull by derivedStateOf { value.dataOrNull }
+    override val errorOrNull by derivedStateOf { value.errorOrNull }
+}
+
+fun <T : Any> MutableRichDataState<T>.asRichDataState(): RichDataState<T> = this
+
+fun <T : Any> RichDataFlow<T>.collectAsRichStateIn(coroutineScope: CoroutineScope): RichDataState<T> {
+    val mutableState = MutableRichDataState<T>()
+    coroutineScope.launch {
+        collect { mutableState.value = it }
+    }
+    return mutableState.asRichDataState()
+}
+fun <T : Any> RichDataSource<T>.collectAsRichStateIn(coroutineScope: CoroutineScope): RichDataStateWithFunctions<T> {
+    return (this as RichDataFlow<T>).collectAsRichStateIn(coroutineScope) withFunctions this
+}
+
+context (CoroutineScope)
+fun <T : Any> RichDataFlow<T>.collectAsRichStateHere() = collectAsRichStateIn(this@CoroutineScope)
+context (CoroutineScope)
+fun <T : Any> RichDataSource<T>.collectAsRichStateHere() = collectAsRichStateIn(this@CoroutineScope)
+
+context (ScreenModel)
+fun <T : Any> RichDataFlow<T>.collectAsRichStateHere() = collectAsRichStateIn(screenModelScope)
+context (ScreenModel)
+fun <T : Any> RichDataSource<T>.collectAsRichStateHere() = collectAsRichStateIn(screenModelScope)
+
+
+@Composable
+fun <T : Any> RichDataFlow<T>.collectAsRichState(): RichDataState<T> {
+    val result = remember { MutableRichDataState<T>() }
+    LaunchedEffect(this) {
+        collect { result.value = it }
+    }
+    return result
+}
+
+@Composable
+fun <T : Any> RichDataSource<T>.collectAsRichState(): RichDataStateWithFunctions<T> {
+    val result = (this as RichDataFlow<T>).collectAsRichState()
+    return remember(result) { result withFunctions this }
+}
+
+
+
+
+
+
diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml
index 9ee9997b0b4726e57c27b2f7b21462b604ff8a88..6cebe0573049e86fb1299185be4b828d28887057 100644
--- a/app/src/main/res/xml/data_extraction_rules.xml
+++ b/app/src/main/res/xml/data_extraction_rules.xml
@@ -5,7 +5,7 @@
 -->
 <data-extraction-rules>
     <cloud-backup>
-        <!-- TODO: Use <include> and <exclude> to control what is backed up.
+        <!--
         <include .../>
         <exclude .../>
         -->
diff --git a/app/src/test/kotlin/net/novagamestudios/kaffeekasse/model/app/AppVersionTest.kt b/app/src/test/kotlin/net/novagamestudios/kaffeekasse/model/app/AppVersionTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..382da80be4d3aff5557e4d4f81126e20ea2e095e
--- /dev/null
+++ b/app/src/test/kotlin/net/novagamestudios/kaffeekasse/model/app/AppVersionTest.kt
@@ -0,0 +1,91 @@
+package net.novagamestudios.kaffeekasse.model.app
+
+import org.junit.Assert
+import org.junit.Test
+
+class AppVersionTest {
+    @Test
+    fun stringParsingValid() {
+        Assert.assertEquals(AppVersion(1, 2, 3, AppVersion.Type.Stable, 0), AppVersion("1.2.3"))
+        Assert.assertEquals(AppVersion(1, 2, 3, AppVersion.Type.Beta, 0), AppVersion("1.2.3-beta"))
+        Assert.assertEquals(AppVersion(1, 2, 3, AppVersion.Type.Beta, 1), AppVersion("1.2.3-beta1"))
+        Assert.assertEquals(AppVersion(1, 2, 3, AppVersion.Type.Beta, 1), AppVersion("1.2.3-beta01"))
+        Assert.assertEquals(AppVersion(1, 2, 3, AppVersion.Type.Alpha, 0), AppVersion("1.2.3-alpha"))
+        Assert.assertEquals(AppVersion(1, 2, 3, AppVersion.Type.Alpha, 1), AppVersion("1.2.3-alpha1"))
+        Assert.assertEquals(AppVersion(1, 2, 3, AppVersion.Type.Alpha, 1), AppVersion("1.2.3-alpha01"))
+    }
+    @Test
+    fun stringParsingInvalid() {
+        Assert.assertNull(AppVersion(""))
+        Assert.assertNull(AppVersion("1.2"))
+        Assert.assertNull(AppVersion("1.2.3-"))
+        Assert.assertNull(AppVersion("1.2.3-1"))
+        Assert.assertNull(AppVersion("1.2.3-01"))
+        Assert.assertNull(AppVersion("1.2.3-unknown"))
+        Assert.assertNull(AppVersion("1.2.3-unknown1"))
+        Assert.assertNull(AppVersion("1.2.3-unknown01"))
+    }
+    @Test
+    fun stringFindingValid() {
+        Assert.assertEquals(AppVersion(1, 2, 3, AppVersion.Type.Stable, 0), AppVersion.findIn("version 1.2.3 "))
+        Assert.assertEquals(AppVersion(1, 2, 3, AppVersion.Type.Stable, 0), AppVersion.findIn("version 1.2.3-1"))
+        Assert.assertEquals(AppVersion(1, 2, 3, AppVersion.Type.Stable, 0), AppVersion.findIn("version 1.2.3 -unknown"))
+        Assert.assertEquals(AppVersion(1, 2, 3, AppVersion.Type.Stable, 0), AppVersion.findIn("version 1.2.3 -unknown1"))
+        Assert.assertEquals(AppVersion(1, 2, 3, AppVersion.Type.Stable, 0), AppVersion.findIn("version 1.2.3 -unknown01"))
+        Assert.assertEquals(AppVersion(1, 2, 3, AppVersion.Type.Beta, 0), AppVersion.findIn("version 1.2.3-beta "))
+        Assert.assertEquals(AppVersion(1, 2, 3, AppVersion.Type.Beta, 1), AppVersion.findIn("version 1.2.3-beta1 "))
+        Assert.assertEquals(AppVersion(1, 2, 3, AppVersion.Type.Beta, 1), AppVersion.findIn("version 1.2.3-beta01 "))
+        Assert.assertEquals(AppVersion(1, 2, 3, AppVersion.Type.Alpha, 0), AppVersion.findIn("version 1.2.3-alpha "))
+        Assert.assertEquals(AppVersion(1, 2, 3, AppVersion.Type.Alpha, 1), AppVersion.findIn("version 1.2.3-alpha1 "))
+        Assert.assertEquals(AppVersion(1, 2, 3, AppVersion.Type.Alpha, 1), AppVersion.findIn("version 1.2.3-alpha01 "))
+    }
+    @Test
+    fun stringFindingInvalid() {
+        Assert.assertNull(AppVersion.findIn(""))
+        Assert.assertNull(AppVersion.findIn("version 1.2 "))
+        Assert.assertNull(AppVersion.findIn("version 1.2.3-unknown "))
+    }
+    @Test
+    fun stringFormatting() {
+        Assert.assertEquals("1.2.3", AppVersion(1, 2, 3, AppVersion.Type.Stable, 0).toString())
+        Assert.assertEquals("1.2.3-beta00", AppVersion(1, 2, 3, AppVersion.Type.Beta, 0).toString())
+        Assert.assertEquals("1.2.3-beta01", AppVersion(1, 2, 3, AppVersion.Type.Beta, 1).toString())
+        Assert.assertEquals("1.2.3-alpha00", AppVersion(1, 2, 3, AppVersion.Type.Alpha, 0).toString())
+        Assert.assertEquals("1.2.3-alpha01", AppVersion(1, 2, 3, AppVersion.Type.Alpha, 1).toString())
+    }
+    @Test
+    fun comparison() {
+        val list = listOf(
+            AppVersion(1, 2, 3, AppVersion.Type.Alpha, 0),
+            AppVersion(1, 2, 3, AppVersion.Type.Alpha, 1),
+            AppVersion(1, 2, 3, AppVersion.Type.Beta, 0),
+            AppVersion(1, 2, 3, AppVersion.Type.Beta, 1),
+            AppVersion(1, 2, 3, AppVersion.Type.Stable, 0),
+            AppVersion(1, 2, 4, AppVersion.Type.Stable, 0),
+            AppVersion(1, 3, 3, AppVersion.Type.Stable, 0),
+            AppVersion(2, 2, 3, AppVersion.Type.Stable, 0),
+        )
+        for (i in list.indices) {
+            for (j in list.indices) {
+                if (i < j) {
+                    assert(list[i] < list[j])
+                    assert(list[j] > list[i])
+                    assert(list[i] <= list[j])
+                    assert(list[j] >= list[i])
+                } else if (i == j) {
+                    assert(list[i] == list[j])
+                    assert(list[j] == list[i])
+                    assert(list[i] <= list[j])
+                    assert(list[j] <= list[i])
+                    assert(list[i] >= list[j])
+                    assert(list[j] >= list[i])
+                } else {
+                    assert(list[i] > list[j])
+                    assert(list[j] < list[i])
+                    assert(list[i] >= list[j])
+                    assert(list[j] <= list[i])
+                }
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/build.gradle.kts b/build.gradle.kts
index de030ed4a3fbfc299fa38a9a4696002c50584381..400050bca31f3838ec99a8174a099c66b72c369f 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1,6 +1,8 @@
 // Top-level build file where you can add configuration options common to all sub-projects/modules.
 plugins {
+    alias(libs.plugins.gradle.versions) apply false
     alias(libs.plugins.android.application) apply false
     alias(libs.plugins.kotlin.android) apply false
     alias(libs.plugins.kotlinx.serialization) apply false
+    alias(libs.plugins.dokka) apply false
 }
\ No newline at end of file
diff --git a/gradle.properties b/gradle.properties
index 3c5031eb7d63f785752b1914cc8692a453d1cc63..04d69230f81a2c83052f791cf9ddf53829fc1efd 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1,16 +1,6 @@
-# Project-wide Gradle settings.
-# IDE (e.g. Android Studio) users:
-# Gradle settings configured through the IDE *will override*
-# any settings specified in this file.
-# For more details on how to configure your build environment visit
-# http://www.gradle.org/docs/current/userguide/build_environment.html
 # Specifies the JVM arguments used for the daemon process.
 # The setting is particularly useful for tweaking memory settings.
 org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
-# When configured, Gradle will run in incubating parallel mode.
-# This option should only be used with decoupled projects. More details, visit
-# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
-# org.gradle.parallel=true
 # AndroidX package structure to make it clearer which packages are bundled with the
 # Android operating system, and which are packaged with your app's APK
 # https://developer.android.com/topic/libraries/support-library/androidx-rn
@@ -20,4 +10,13 @@ kotlin.code.style=official
 # Enables namespacing of each library's R class so that its R class includes only the
 # resources declared in the library itself and none from the library's dependencies,
 # thereby reducing the size of the R class for that library
-android.nonTransitiveRClass=true
\ No newline at end of file
+android.nonTransitiveRClass=true
+
+#kotlin.experimental.tryK2=true
+#android.lint.useK2Uast=true
+
+# See https://github.com/ben-manes/gradle-versions-plugin/issues/859
+# Remove when version of AGP >= 8.3.1
+systemProp.javax.xml.parsers.SAXParserFactory=com.sun.org.apache.xerces.internal.jaxp.SAXParserFactoryImpl
+systemProp.javax.xml.transform.TransformerFactory=com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl
+systemProp.javax.xml.parsers.DocumentBuilderFactory=com.sun.org.apache.xerces.internal.jaxp.DocumentBuilderFactoryImpl
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 0a4f1e8d01dc7167c5487c99e52d4e6f6c5d0af0..f8bf34cc13f4117925a2b895b1626174cf95795c 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
-#Mon Nov 27 19:34:08 CET 2023
+#Thu Apr 18 16:02:39 CEST 2024
 distributionBase=GRADLE_USER_HOME
 distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
 zipStoreBase=GRADLE_USER_HOME
 zipStorePath=wrapper/dists
diff --git a/settings.gradle.kts b/settings.gradle.kts
index bdca8c7b8e77ec26815faa36dd998afbd8317162..41c5bac1df596447a49483a98054120e4495d206 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -6,7 +6,9 @@ pluginManagement {
     }
 }
 dependencyResolutionManagement {
+    @Suppress("UnstableApiUsage")
     repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+    @Suppress("UnstableApiUsage")
     repositories {
         google()
         mavenCentral()
@@ -15,40 +17,49 @@ dependencyResolutionManagement {
     }
     versionCatalogs {
         create("libs") {
-            version("kotlin", "1.9.10")
-            version("compose-bom", "2024.02.01")
-            version("androidx-lifecycle", "2.7.0")
+            version("kotlin", "1.9.20")
+            version("dokka", "1.9.20")
+            version("compose-compiler", "1.5.5")
+            version("compose-bom", "2024.05.00")
+            version("material3", "1.3.0-alpha05")
+            version("androidx-lifecycle", "2.8.0-rc01")
             version("ktor", "2.3.8")
             version("vico", "2.0.0-alpha.8")
             version("voyager", "1.0.0")
+            version("coil", "2.6.0")
+            version("acra", "5.11.3")
 
 
+            plugin("gradle-versions", "com.github.ben-manes.versions").version("0.51.0")
             plugin("android-application", "com.android.application").version("8.2.0")
             plugin("kotlin-android", "org.jetbrains.kotlin.android").versionRef("kotlin")
             plugin("kotlinx-serialization", "org.jetbrains.kotlin.plugin.serialization").versionRef("kotlin")
+            plugin("dokka", "org.jetbrains.dokka").versionRef("dokka")
 
 
-            library("androidx-core", "androidx.core", "core-ktx").version("1.12.0")
+            library("dokka-android", "org.jetbrains.dokka", "android-documentation-plugin").versionRef("dokka")
+
+            library("androidx-core", "androidx.core", "core-ktx").version("1.13.1")
             library("androidx-lifecycle-runtime", "androidx.lifecycle", "lifecycle-runtime-ktx").versionRef("androidx-lifecycle")
             library("androidx-lifecycle-viewmodel-compose", "androidx.lifecycle", "lifecycle-viewmodel-compose").versionRef("androidx-lifecycle")
 
             library("androidx-navigation-compose", "androidx.navigation", "navigation-compose").version("2.7.7")
-            library("androidx-activity-compose", "androidx.activity", "activity-compose").version("1.8.2")
+            library("androidx-activity-compose", "androidx.activity", "activity-compose").version("1.9.0")
 
             library("compose-bom", "androidx.compose", "compose-bom").versionRef("compose-bom")
             library("compose-ui", "androidx.compose.ui", "ui").withoutVersion()
             library("compose-ui-graphics", "androidx.compose.ui", "ui-graphics").withoutVersion()
             library("compose-ui-tooling-preview", "androidx.compose.ui", "ui-tooling-preview").withoutVersion()
-            library("compose-ui-text-google-fonts", "androidx.compose.ui", "ui-text-google-fonts").version("1.6.2")
-            library("compose-material3", "androidx.compose.material3", "material3").version("1.2.0")
-            library("compose-material-icons-extended", "androidx.compose.material", "material-icons-extended").version("1.6.2")
-            library("compose-grid", "io.woong.compose.grid", "grid").version("1.2.1")
+            library("compose-ui-text-google-fonts", "androidx.compose.ui", "ui-text-google-fonts").withoutVersion()
+            library("compose-material-icons-extended", "androidx.compose.material", "material-icons-extended").version("1.6.7")
+            library("compose-material3", "androidx.compose.material3", "material3").versionRef("material3")
+            library("compose-grid", "io.woong.compose.grid", "grid").version("1.2.2")
 
-            library("androidx-credentials", "androidx.credentials", "credentials").version("1.3.0-alpha01")
-            library("androidx-datastore", "androidx.datastore", "datastore-preferences-android").version("1.1.0-alpha07")
+            library("androidx-credentials", "androidx.credentials", "credentials").version("1.3.0-alpha03")
+            library("androidx-datastore", "androidx.datastore", "datastore-preferences-android").version("1.1.1")
 
             library("kotlinx-datetime", "org.jetbrains.kotlinx", "kotlinx-datetime").version("0.6.0-RC.2")
-            library("kotlinx-serialization-json", "org.jetbrains.kotlinx", "kotlinx-serialization-json").version("1.6.0")
+            library("kotlinx-serialization-json", "org.jetbrains.kotlinx", "kotlinx-serialization-json").version("1.6.3")
 
             library("ktor-client-core", "io.ktor", "ktor-client-core").versionRef("ktor")
             library("ktor-client-okhttp", "io.ktor", "ktor-client-okhttp").versionRef("ktor")
@@ -56,7 +67,7 @@ dependencyResolutionManagement {
             library("ktor-serialization-kotlinx-json", "io.ktor", "ktor-serialization-kotlinx-json").versionRef("ktor")
 
             library("skrapeit", "it.skrape", "skrapeit").version("1.2.2")
-            library("skrapeit-ktor", "it.skrape", "skrapeit-ktor-extension").version("1.0.0")
+            library("skrapeit-ktor", "it.skrape", "skrapeit-ktor-extension").version("1.2.2")
 
             library("vico-core", "com.patrykandpatrick.vico", "core").versionRef("vico")
             library("vico-compose", "com.patrykandpatrick.vico", "compose").versionRef("vico")
@@ -69,7 +80,19 @@ dependencyResolutionManagement {
             library("voyager-tabnavigator", "cafe.adriel.voyager", "voyager-tab-navigator").versionRef("voyager")
             library("voyager-transitions", "cafe.adriel.voyager", "voyager-transitions").versionRef("voyager")
 
-            library("commonutils", "com.gitlab.JojoIV", "common_utils").version("6e8e35fce9")
+            library("coil", "io.coil-kt", "coil").versionRef("coil")
+            library("coil-compose", "io.coil-kt", "coil-compose").versionRef("coil")
+
+            library("acra-http", "ch.acra", "acra-http").versionRef("acra")
+            library("acra-mail", "ch.acra", "acra-mail").versionRef("acra")
+            library("acra-core", "ch.acra", "acra-core").versionRef("acra")
+            library("acra-dialog", "ch.acra", "acra-dialog").versionRef("acra")
+            library("acra-notification", "ch.acra", "acra-notification").versionRef("acra")
+            library("acra-toast", "ch.acra", "acra-toast").versionRef("acra")
+            library("acra-limiter", "ch.acra", "acra-limiter").versionRef("acra")
+            library("acra-advancedscheduler", "ch.acra", "acra-advanced-scheduler").versionRef("acra")
+
+            library("commonutils", "com.gitlab.JojoIV", "common_utils").version("2d5e5c9a17")
         }
     }
 }