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") } } }