diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 9a0c769..4ede3a5 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -1,4 +1,4 @@ -// import org.jetbrains.compose.desktop.application.dsl.TargetFormat +import org.jetbrains.compose.desktop.application.dsl.TargetFormat import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { @@ -6,12 +6,22 @@ plugins { alias(libs.plugins.androidApplication) alias(libs.plugins.composeMultiplatform) alias(libs.plugins.composeCompiler) + + kotlin("plugin.serialization") version "2.1.0" +} + +compose.resources { + publicResClass = true + generateResClass = always } kotlin { androidTarget { compilerOptions { jvmTarget.set(JvmTarget.JVM_11) } } + jvm("desktop") + listOf( + iosX64(), iosArm64(), iosSimulatorArm64(), ).forEach { iosTarget -> @@ -22,9 +32,12 @@ kotlin { } sourceSets { + val desktopMain by getting + androidMain.dependencies { implementation(compose.preview) implementation(libs.androidx.activity.compose) + implementation("io.ktor:ktor-client-cio:3.3.3") } commonMain.dependencies { implementation(compose.runtime) @@ -35,8 +48,29 @@ kotlin { implementation(compose.components.uiToolingPreview) implementation(libs.androidx.lifecycle.viewmodelCompose) implementation(libs.androidx.lifecycle.runtimeCompose) + + implementation(compose.materialIconsExtended) + implementation("cafe.adriel.voyager:voyager-navigator:1.0.0") + implementation("cafe.adriel.voyager:voyager-tab-navigator:1.0.0") + implementation("cafe.adriel.voyager:voyager-screenmodel:1.0.0") + implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.7.1") + + implementation("io.ktor:ktor-client-core:3.3.3") + implementation("io.ktor:ktor-client-content-negotiation:3.3.3") + implementation("io.ktor:ktor-serialization-kotlinx-json:3.3.3") + implementation("io.ktor:ktor-client-auth:3.3.3") + implementation("io.ktor:ktor-client-logging:3.3.3") } commonTest.dependencies { implementation(libs.kotlin.test) } + + desktopMain.dependencies { + implementation(compose.desktop.currentOs) + implementation("io.ktor:ktor-client-cio:3.3.3") + } + + iosMain.dependencies { + implementation("io.ktor:ktor-client-darwin:3.3.3") + } } } @@ -61,7 +95,16 @@ android { versionName = "1.0" } packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } } - buildTypes { getByName("release") { isMinifyEnabled = false } } + buildFeatures { buildConfig = true } + buildTypes { + debug { + buildConfigField("String", "API_BASE_URL", "\"http://10.0.2.2:3000\"") + } + getByName("release") { + isMinifyEnabled = false + buildConfigField("String", "API_BASE_URL", "\"https://filc.space\"") + } + } compileOptions { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 @@ -69,3 +112,14 @@ android { } dependencies { debugImplementation(compose.uiTooling) } + +compose.desktop { + application { + mainClass = "hu.petrik.filcapp.MainKt" + nativeDistributions { + targetFormats(TargetFormat.Dmg) + packageName = "Filcapp" + packageVersion = "1.0.0" + } + } +} diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index 26403a7..de6216c 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -1,6 +1,8 @@ + + +""".trimIndent() + +@Composable +actual fun AuthWebView(apiBaseUrl: String, onSessionAcquired: (String) -> Unit, onDismiss: () -> Unit) { + Box(Modifier.fillMaxSize()) { + AndroidView( + factory = { ctx -> + WebView(ctx).apply { + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + CookieManager.getInstance().removeAllCookies(null) + CookieManager.getInstance().flush() + CookieManager.getInstance().setAcceptCookie(true) + CookieManager.getInstance().setAcceptThirdPartyCookies(this, true) + webViewClient = object : WebViewClient() { + override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { + // Rewrite localhost → emulator host passthrough so the OAuth callback + // reaches the dev server and sends the correct cookies. + val url = request.url.toString() + val rewritten = url + .replace("http://localhost:", "http://10.0.2.2:") + .replace("https://localhost:", "https://10.0.2.2:") + if (rewritten != url) { + view.loadUrl(rewritten) + return true + } + return false + } + + override fun onPageFinished(view: WebView, url: String) { + val raw = CookieManager.getInstance().getCookie(url) ?: return + val token = raw.split(";") + .map { it.trim() } + .firstOrNull { it.startsWith("filc.session_token=") } + ?.removePrefix("filc.session_token=") + if (token != null) onSessionAcquired(token) + } + } + loadDataWithBaseURL(apiBaseUrl, AUTH_HTML, "text/html", "UTF-8", null) + } + }, + modifier = Modifier.fillMaxSize(), + ) + IconButton(onClick = onDismiss, modifier = Modifier.align(Alignment.TopStart)) { + Icon(Icons.Default.Close, contentDescription = "Close") + } + } +} diff --git a/composeApp/src/androidMain/kotlin/hu/petrik/filcapp/auth/ProfileImageDecoder.android.kt b/composeApp/src/androidMain/kotlin/hu/petrik/filcapp/auth/ProfileImageDecoder.android.kt new file mode 100644 index 0000000..a1e478d --- /dev/null +++ b/composeApp/src/androidMain/kotlin/hu/petrik/filcapp/auth/ProfileImageDecoder.android.kt @@ -0,0 +1,16 @@ +package hu.petrik.filcapp.auth + +import android.graphics.BitmapFactory +import android.util.Base64 +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap + +actual fun base64ToImageBitmap(base64: String): ImageBitmap? { + val data = base64.substringAfter(",", base64) + return try { + val bytes = Base64.decode(data, Base64.DEFAULT) + BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap() + } catch (_: Exception) { + null + } +} diff --git a/composeApp/src/androidMain/kotlin/hu/petrik/filcapp/auth/SessionStore.android.kt b/composeApp/src/androidMain/kotlin/hu/petrik/filcapp/auth/SessionStore.android.kt new file mode 100644 index 0000000..dd18094 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/hu/petrik/filcapp/auth/SessionStore.android.kt @@ -0,0 +1,11 @@ +package hu.petrik.filcapp.auth + +import android.content.SharedPreferences + +actual object SessionStore { + internal lateinit var prefs: SharedPreferences + + actual fun get(): String? = prefs.getString("session_token", null) + actual fun set(token: String) { prefs.edit().putString("session_token", token).apply() } + actual fun clear() { prefs.edit().remove("session_token").apply() } +} diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/App.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/App.kt index 544a8bd..9bb5e74 100644 --- a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/App.kt +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/App.kt @@ -1,47 +1,108 @@ package hu.petrik.filcapp -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.safeContentPadding -import androidx.compose.material3.Button -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.material3.* import androidx.compose.runtime.* -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import filcapp.composeapp.generated.resources.Res -import filcapp.composeapp.generated.resources.compose_multiplatform -import org.jetbrains.compose.resources.painterResource +import cafe.adriel.voyager.navigator.tab.CurrentTab +import cafe.adriel.voyager.navigator.tab.TabNavigator +import hu.petrik.filcapp.auth.AuthState +import hu.petrik.filcapp.components.TopBar +import hu.petrik.filcapp.screens.CohortSwitcher +import hu.petrik.filcapp.screens.HomeTab +import hu.petrik.filcapp.screens.NewsTab +import hu.petrik.filcapp.screens.RoomSwitcher +import hu.petrik.filcapp.screens.SettingsSheet +import hu.petrik.filcapp.screens.SplashScreen +import hu.petrik.filcapp.screens.SubstitutionTab +import hu.petrik.filcapp.screens.TeacherSwitcher +import hu.petrik.filcapp.screens.TimetableMode +import hu.petrik.filcapp.screens.TimetableState +import hu.petrik.filcapp.screens.TimetableTab +import hu.petrik.filcapp.screens.WelcomeScreen import org.jetbrains.compose.ui.tooling.preview.Preview +private enum class AppScreen { Splash, Welcome, Main } + @Composable @Preview fun App() { - MaterialTheme { - var showContent by remember { mutableStateOf(false) } - Column( - modifier = - Modifier - .background(MaterialTheme.colorScheme.primaryContainer) - .safeContentPadding() - .fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Button(onClick = { showContent = !showContent }) { Text("Click me!") } - AnimatedVisibility(showContent) { - val greeting = remember { Greeting().greet() } - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Image(painterResource(Res.drawable.compose_multiplatform), null) - Text("Compose: $greeting") + var screen by remember { mutableStateOf(AppScreen.Splash) } + + AppTheme { + when (screen) { + AppScreen.Splash -> SplashScreen( + onAuthResolved = { loggedIn -> + screen = if (loggedIn) AppScreen.Main else AppScreen.Welcome + }, + ) + AppScreen.Welcome -> WelcomeScreen( + onLoggedIn = { screen = AppScreen.Main }, + ) + AppScreen.Main -> MainContent(onLogout = { + AuthState.logout() + screen = AppScreen.Welcome + }) + } + } +} + +@Composable +private fun MainContent(onLogout: () -> Unit = {}) { + var settingsOpen by remember { mutableStateOf(false) } + + TabNavigator(HomeTab) { tabNavigator -> + Scaffold( + topBar = { + val isTimetable = tabNavigator.current.options.index == TimetableTab.options.index + val timetableLeading: (@Composable () -> Unit)? = if (isTimetable) { + when (TimetableState.timetableMode) { + TimetableMode.Class -> { { CohortSwitcher() } } + TimetableMode.Room -> { { RoomSwitcher() } } + TimetableMode.Teacher -> { { TeacherSwitcher() } } } + } else null + TopBar(leadingContent = timetableLeading, onProfileClick = { settingsOpen = true }) + }, + bottomBar = { BottomNavigationBar(tabNavigator) }, + modifier = Modifier.fillMaxSize(), + contentWindowInsets = WindowInsets.systemBars, + ) { paddingValues -> + Box(modifier = Modifier.padding(paddingValues).fillMaxSize()) { + CurrentTab() } } + + if (settingsOpen) { + SettingsSheet( + onDismiss = { settingsOpen = false }, + onLogout = { settingsOpen = false; onLogout() }, + ) + } + } +} + +@Composable +private fun BottomNavigationBar(tabNavigator: TabNavigator) { + NavigationBar( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ) { + listOf(HomeTab, TimetableTab, SubstitutionTab, NewsTab).forEach { tab -> + val isSelected = tabNavigator.current.options.index == tab.options.index + NavigationBarItem( + icon = { + tab.options.icon?.let { painter -> + Icon(painter, contentDescription = tab.options.title) + } + }, + label = { Text(tab.options.title) }, + selected = isSelected, + onClick = { tabNavigator.current = tab }, + ) + } } } diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/AppPreferences.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/AppPreferences.kt new file mode 100644 index 0000000..db36237 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/AppPreferences.kt @@ -0,0 +1,8 @@ +package hu.petrik.filcapp + +expect object AppPreferences { + fun getAccentHue(): Float + fun setAccentHue(hue: Float) + fun getThemeMode(): Int // 0=system, 1=light, 2=dark + fun setThemeMode(mode: Int) +} diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/AppTheme.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/AppTheme.kt new file mode 100644 index 0000000..98c2c2a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/AppTheme.kt @@ -0,0 +1,140 @@ +package hu.petrik.filcapp + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.ui.graphics.Color +import kotlin.math.abs +import kotlin.math.floor + +object AppSettings { + private val _accentHue = mutableFloatStateOf(AppPreferences.getAccentHue()) + private val _themeMode = mutableFloatStateOf(AppPreferences.getThemeMode().toFloat()) + + var accentHue: Float + get() = _accentHue.floatValue + set(value) { + _accentHue.floatValue = value + AppPreferences.setAccentHue(value) + } + + // 0 = system, 1 = light, 2 = dark + var themeMode: Int + get() = _themeMode.floatValue.toInt() + set(value) { + _themeMode.floatValue = value.toFloat() + AppPreferences.setThemeMode(value) + } +} + +fun hslColor(hue: Float, saturation: Float, lightness: Float): Color { + val c = (1f - abs(2f * lightness - 1f)) * saturation + val h = hue / 60f + val x = c * (1f - abs(h % 2f - 1f)) + val m = lightness - c / 2f + val (r1, g1, b1) = when (floor(h).toInt().coerceIn(0, 5)) { + 0 -> Triple(c, x, 0f) + 1 -> Triple(x, c, 0f) + 2 -> Triple(0f, c, x) + 3 -> Triple(0f, x, c) + 4 -> Triple(x, 0f, c) + else -> Triple(c, 0f, x) + } + return Color( + red = (r1 + m).coerceIn(0f, 1f), + green = (g1 + m).coerceIn(0f, 1f), + blue = (b1 + m).coerceIn(0f, 1f), + ) +} + +fun buildColorScheme(hue: Float): ColorScheme = lightColorScheme( + primary = hslColor(hue, 0.65f, 0.38f), + onPrimary = Color.White, + primaryContainer = hslColor(hue, 0.75f, 0.92f), + onPrimaryContainer = hslColor(hue, 0.65f, 0.15f), + secondary = hslColor((hue + 40f) % 360f, 0.30f, 0.40f), + onSecondary = Color.White, + secondaryContainer = hslColor((hue + 40f) % 360f, 0.35f, 0.90f), + onSecondaryContainer = hslColor((hue + 40f) % 360f, 0.30f, 0.15f), + tertiary = hslColor((hue + 80f) % 360f, 0.35f, 0.40f), + onTertiary = Color.White, + tertiaryContainer = hslColor((hue + 80f) % 360f, 0.40f, 0.90f), + onTertiaryContainer = hslColor((hue + 80f) % 360f, 0.35f, 0.15f), + background = hslColor(hue, 0.06f, 0.99f), + onBackground = hslColor(hue, 0.08f, 0.10f), + surface = hslColor(hue, 0.06f, 0.99f), + onSurface = hslColor(hue, 0.08f, 0.10f), + surfaceVariant = hslColor(hue, 0.18f, 0.88f), + onSurfaceVariant = hslColor(hue, 0.10f, 0.30f), + surfaceBright = hslColor(hue, 0.06f, 0.99f), + surfaceDim = hslColor(hue, 0.10f, 0.87f), + surfaceContainerLowest = Color.White, + surfaceContainerLow = hslColor(hue, 0.12f, 0.96f), + surfaceContainer = hslColor(hue, 0.14f, 0.94f), + surfaceContainerHigh = hslColor(hue, 0.16f, 0.91f), + surfaceContainerHighest = hslColor(hue, 0.18f, 0.88f), + error = Color(0xFFBA1A1A), + onError = Color.White, + errorContainer = Color(0xFFFFDAD6), + onErrorContainer = Color(0xFF410002), + outline = hslColor(hue, 0.10f, 0.52f), + outlineVariant = hslColor(hue, 0.14f, 0.80f), + scrim = Color(0xFF000000), + inverseSurface = hslColor(hue, 0.08f, 0.12f), + inverseOnSurface = hslColor(hue, 0.06f, 0.95f), + inversePrimary = hslColor(hue, 0.75f, 0.78f), +) + +fun buildDarkColorScheme(hue: Float): ColorScheme = darkColorScheme( + primary = hslColor(hue, 0.75f, 0.75f), + onPrimary = hslColor(hue, 0.65f, 0.18f), + primaryContainer = hslColor(hue, 0.60f, 0.28f), + onPrimaryContainer = hslColor(hue, 0.75f, 0.90f), + secondary = hslColor((hue + 40f) % 360f, 0.35f, 0.70f), + onSecondary = hslColor((hue + 40f) % 360f, 0.30f, 0.15f), + secondaryContainer = hslColor((hue + 40f) % 360f, 0.25f, 0.28f), + onSecondaryContainer = hslColor((hue + 40f) % 360f, 0.35f, 0.88f), + tertiary = hslColor((hue + 80f) % 360f, 0.40f, 0.72f), + onTertiary = hslColor((hue + 80f) % 360f, 0.35f, 0.15f), + tertiaryContainer = hslColor((hue + 80f) % 360f, 0.30f, 0.26f), + onTertiaryContainer = hslColor((hue + 80f) % 360f, 0.40f, 0.88f), + background = hslColor(hue, 0.08f, 0.08f), + onBackground = hslColor(hue, 0.08f, 0.90f), + surface = hslColor(hue, 0.08f, 0.08f), + onSurface = hslColor(hue, 0.08f, 0.90f), + surfaceVariant = hslColor(hue, 0.12f, 0.18f), + onSurfaceVariant = hslColor(hue, 0.10f, 0.72f), + surfaceBright = hslColor(hue, 0.10f, 0.26f), + surfaceDim = hslColor(hue, 0.08f, 0.06f), + surfaceContainerLowest = hslColor(hue, 0.06f, 0.04f), + surfaceContainerLow = hslColor(hue, 0.08f, 0.10f), + surfaceContainer = hslColor(hue, 0.10f, 0.14f), + surfaceContainerHigh = hslColor(hue, 0.12f, 0.18f), + surfaceContainerHighest = hslColor(hue, 0.14f, 0.22f), + error = Color(0xFFFFB4AB), + onError = Color(0xFF690005), + errorContainer = Color(0xFF93000A), + onErrorContainer = Color(0xFFFFDAD6), + outline = hslColor(hue, 0.10f, 0.50f), + outlineVariant = hslColor(hue, 0.12f, 0.28f), + scrim = Color(0xFF000000), + inverseSurface = hslColor(hue, 0.08f, 0.90f), + inverseOnSurface = hslColor(hue, 0.06f, 0.14f), + inversePrimary = hslColor(hue, 0.65f, 0.38f), +) + +@Composable +fun AppTheme(content: @Composable () -> Unit) { + val systemDark = isSystemInDarkTheme() + val isDark = when (AppSettings.themeMode) { + 1 -> false + 2 -> true + else -> systemDark + } + val colorScheme = if (isDark) buildDarkColorScheme(AppSettings.accentHue) else buildColorScheme(AppSettings.accentHue) + MaterialTheme(colorScheme = colorScheme, content = content) +} diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/Config.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/Config.kt new file mode 100644 index 0000000..785f7a2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/Config.kt @@ -0,0 +1,3 @@ +package hu.petrik.filcapp + +expect val apiBaseUrl: String diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/ClassroomApi.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/ClassroomApi.kt new file mode 100644 index 0000000..71d3973 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/ClassroomApi.kt @@ -0,0 +1,30 @@ +package hu.petrik.filcapp.api + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.* +import io.ktor.http.ContentType +import io.ktor.http.contentType +import io.ktor.http.isSuccess + +import hu.petrik.filcapp.models.Classroom + +public class ClassroomApi(private val client: HttpClient) { + suspend fun getTimetableClassroomsAll(): APIResult> { + return try { + val response = client.get { + url("/timetable/classrooms/getAll") + } + if (response.status.isSuccess()) { + val envelope = response.body>>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + +} diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/Client.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/Client.kt new file mode 100644 index 0000000..3d73148 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/Client.kt @@ -0,0 +1,45 @@ +package hu.petrik.filcapp.api.client + +import hu.petrik.filcapp.apiBaseUrl +import hu.petrik.filcapp.auth.SessionStore +import io.ktor.client.* +import io.ktor.client.plugins.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.plugins.logging.* +import io.ktor.client.request.* +import io.ktor.serialization.kotlinx.json.* +import io.ktor.client.plugins.api.* +import kotlinx.serialization.json.Json + +val APIClient = HttpClient { + install(Logging) { + level = LogLevel.ALL + logger = object : Logger { + override fun log(message: String) { + println("API_DEBUG: $message") + } + } + } + + install(ContentNegotiation) { + json(Json { ignoreUnknownKeys = true; isLenient = true; coerceInputValues = true }) + } + + // Prefix every request path with /api so generated API files can use bare paths. + install(createClientPlugin("ApiPathPrefix") { + onRequest { request, _ -> + val segments = request.url.pathSegments.filter { it.isNotEmpty() } + if (segments.firstOrNull() != "api") { + request.url.pathSegments = listOf("api") + segments + } + } + }) + + defaultRequest { + url(apiBaseUrl) + val token = SessionStore.get() + if (token != null) { + headers.append("Cookie", "filc.session_token=$token") + } + } +} diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/CohortApi.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/CohortApi.kt new file mode 100644 index 0000000..ac89ed8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/CohortApi.kt @@ -0,0 +1,48 @@ +package hu.petrik.filcapp.api + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.* +import io.ktor.http.ContentType +import io.ktor.http.contentType +import io.ktor.http.isSuccess + +import hu.petrik.filcapp.models.Cohort + +public class CohortApi(private val client: HttpClient) { + suspend fun getCohort(): APIResult> { + return try { + val response = client.get { + url("/cohort") + } + if (response.status.isSuccess()) { + val envelope = response.body>>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + + @RequiresAuth + suspend fun getTimetableCohortsAllForTimetableByTimetableId(timetableId: String): APIResult> { + return try { + val response = client.get { + url("/timetable/cohorts/getAllForTimetable/${timetableId}") + } + if (response.status.isSuccess()) { + val envelope = response.body>>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + +} diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/DoorlockApi.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/DoorlockApi.kt new file mode 100644 index 0000000..f26a29a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/DoorlockApi.kt @@ -0,0 +1,303 @@ +package hu.petrik.filcapp.api + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.* +import io.ktor.http.ContentType +import io.ktor.http.contentType +import io.ktor.http.isSuccess + +import hu.petrik.filcapp.models.CardListResponse +import hu.petrik.filcapp.models.CardResponse +import hu.petrik.filcapp.models.DeviceListResponse +import hu.petrik.filcapp.models.DeviceResponse +import hu.petrik.filcapp.models.DeviceStatsResponse +import hu.petrik.filcapp.models.DoorlockActivationResponse +import hu.petrik.filcapp.models.DoorlockLogListResponse +import hu.petrik.filcapp.models.DoorlockStatsResponse +import hu.petrik.filcapp.models.DoorlockUserListResponse + +public class DoorlockApi(private val client: HttpClient) { + @RequiresAuth + suspend fun getDoorlockCards(): APIResult { + return try { + val response = client.get { + url("/doorlock/cards") + } + if (response.status.isSuccess()) { + val envelope = response.body>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + + @RequiresAuth + suspend fun postDoorlockCards(body: CardResponse): APIResult { + return try { + val response = client.post { + url("/doorlock/cards") + setBody(body) + contentType(ContentType.Application.Json) + } + if (response.status.isSuccess()) { + val envelope = response.body>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + + @RequiresAuth + suspend fun getDoorlockCardsUsers(): APIResult { + return try { + val response = client.get { + url("/doorlock/cards/users") + } + if (response.status.isSuccess()) { + val envelope = response.body>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + + @RequiresAuth + suspend fun putDoorlockCardsById(id: String, body: CardResponse): APIResult { + return try { + val response = client.put { + url("/doorlock/cards/${id}") + setBody(body) + contentType(ContentType.Application.Json) + } + if (response.status.isSuccess()) { + val envelope = response.body>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + + @RequiresAuth + suspend fun deleteDoorlockCardsById(id: String): APIResult { + return try { + val response = client.delete { + url("/doorlock/cards/${id}") + } + if (response.status.isSuccess()) { + val envelope = response.body>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + + @RequiresAuth + suspend fun getDoorlockDevices(): APIResult { + return try { + val response = client.get { + url("/doorlock/devices") + } + if (response.status.isSuccess()) { + val envelope = response.body>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + + @RequiresAuth + suspend fun postDoorlockDevices(body: DeviceResponse): APIResult { + return try { + val response = client.post { + url("/doorlock/devices") + setBody(body) + contentType(ContentType.Application.Json) + } + if (response.status.isSuccess()) { + val envelope = response.body>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + + @RequiresAuth + suspend fun putDoorlockDevicesById(id: String, body: DeviceResponse): APIResult { + return try { + val response = client.put { + url("/doorlock/devices/${id}") + setBody(body) + contentType(ContentType.Application.Json) + } + if (response.status.isSuccess()) { + val envelope = response.body>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + + @RequiresAuth + suspend fun deleteDoorlockDevicesById(id: String): APIResult { + return try { + val response = client.delete { + url("/doorlock/devices/${id}") + } + if (response.status.isSuccess()) { + val envelope = response.body>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + + @RequiresAuth + suspend fun getDoorlockDevicesByIdStats(id: String): APIResult { + return try { + val response = client.get { + url("/doorlock/devices/${id}/stats") + } + if (response.status.isSuccess()) { + val envelope = response.body>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + + @RequiresAuth + suspend fun getDoorlockLogs(): APIResult { + return try { + val response = client.get { + url("/doorlock/logs") + } + if (response.status.isSuccess()) { + val envelope = response.body>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + + @RequiresAuth + suspend fun getDoorlockSelfCards(): APIResult { + return try { + val response = client.get { + url("/doorlock/self/cards") + } + if (response.status.isSuccess()) { + val envelope = response.body>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + + @RequiresAuth + suspend fun postDoorlockSelfCardsByIdActivate(id: String, body: DoorlockActivationResponse): APIResult { + return try { + val response = client.post { + url("/doorlock/self/cards/${id}/activate") + setBody(body) + contentType(ContentType.Application.Json) + } + if (response.status.isSuccess()) { + val envelope = response.body>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + + @RequiresAuth + suspend fun putDoorlockSelfCardsByIdFrozen(id: String, body: CardResponse): APIResult { + return try { + val response = client.put { + url("/doorlock/self/cards/${id}/frozen") + setBody(body) + contentType(ContentType.Application.Json) + } + if (response.status.isSuccess()) { + val envelope = response.body>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + + @RequiresAuth + suspend fun getDoorlockStatsOverview(): APIResult { + return try { + val response = client.get { + url("/doorlock/stats/overview") + } + if (response.status.isSuccess()) { + val envelope = response.body>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + +} diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/LessonApi.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/LessonApi.kt new file mode 100644 index 0000000..af9c8c9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/LessonApi.kt @@ -0,0 +1,81 @@ +package hu.petrik.filcapp.api + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.* +import io.ktor.http.ContentType +import io.ktor.http.contentType +import io.ktor.http.isSuccess + +import hu.petrik.filcapp.models.EnrichedLesson + +public class LessonApi(private val client: HttpClient) { + suspend fun getTimetableLessonsForCohortByCohortId(cohortId: String): APIResult> { + return try { + val response = client.get { + url("/timetable/lessons/getForCohort/${cohortId}") + } + if (response.status.isSuccess()) { + val envelope = response.body>>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + + suspend fun getTimetableLessonsForIdByLessonId(lessonId: String): APIResult { + return try { + val response = client.get { + url("/timetable/lessons/getForId/${lessonId}") + } + if (response.status.isSuccess()) { + val envelope = response.body>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + + suspend fun getTimetableLessonsForRoomByClassroomId(classroomId: String): APIResult> { + return try { + val response = client.get { + url("/timetable/lessons/getForRoom/${classroomId}") + } + if (response.status.isSuccess()) { + val envelope = response.body>>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + + suspend fun getTimetableLessonsForTeacherByTeacherId(teacherId: String): APIResult> { + return try { + val response = client.get { + url("/timetable/lessons/getForTeacher/${teacherId}") + } + if (response.status.isSuccess()) { + val envelope = response.body>>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + +} diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/MovedLessonApi.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/MovedLessonApi.kt new file mode 100644 index 0000000..8df047a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/MovedLessonApi.kt @@ -0,0 +1,140 @@ +package hu.petrik.filcapp.api + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.* +import io.ktor.http.ContentType +import io.ktor.http.contentType +import io.ktor.http.isSuccess + +import hu.petrik.filcapp.models.MovedLesson +import hu.petrik.filcapp.models.MovedLessonWithRelations + +public class MovedLessonApi(private val client: HttpClient) { + suspend fun getTimetableMovedLessons(): APIResult> { + return try { + val response = client.get { + url("/timetable/movedLessons") + } + if (response.status.isSuccess()) { + val envelope = response.body>>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + + @RequiresAuth + suspend fun postTimetableMovedLessons(body: MovedLesson): APIResult { + return try { + val response = client.post { + url("/timetable/movedLessons") + setBody(body) + contentType(ContentType.Application.Json) + } + if (response.status.isSuccess()) { + val envelope = response.body>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + + suspend fun getTimetableMovedLessonsCohortByCohortId(cohortId: String): APIResult> { + return try { + val response = client.get { + url("/timetable/movedLessons/cohort/${cohortId}") + } + if (response.status.isSuccess()) { + val envelope = response.body>>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + + suspend fun getTimetableMovedLessonsCohortByCohortIdRelevant(cohortId: String): APIResult> { + return try { + val response = client.get { + url("/timetable/movedLessons/cohort/${cohortId}/relevant") + } + if (response.status.isSuccess()) { + val envelope = response.body>>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + + suspend fun getTimetableMovedLessonsRelevant(timetableId: String): APIResult> { + return try { + val response = client.get { + url("/timetable/movedLessons/relevant") + } + if (response.status.isSuccess()) { + val envelope = response.body>>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + + @RequiresAuth + suspend fun putTimetableMovedLessonsById(id: String, body: MovedLesson): APIResult { + return try { + val response = client.put { + url("/timetable/movedLessons/${id}") + setBody(body) + contentType(ContentType.Application.Json) + } + if (response.status.isSuccess()) { + val envelope = response.body>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + + @RequiresAuth + suspend fun deleteTimetableMovedLessonsById(id: String): APIResult { + return try { + val response = client.delete { + url("/timetable/movedLessons/${id}") + } + if (response.status.isSuccess()) { + val envelope = response.body>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + +} diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/NewsAnnouncementsApi.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/NewsAnnouncementsApi.kt new file mode 100644 index 0000000..0711adc --- /dev/null +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/NewsAnnouncementsApi.kt @@ -0,0 +1,107 @@ +package hu.petrik.filcapp.api + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.* +import io.ktor.http.ContentType +import io.ktor.http.contentType +import io.ktor.http.isSuccess + +import hu.petrik.filcapp.models.Announcement + +public class NewsAnnouncementsApi(private val client: HttpClient) { + @RequiresAuth + suspend fun getNewsAnnouncements(): APIResult> { + return try { + val response = client.get { + url("/news/announcements") + } + if (response.status.isSuccess()) { + val envelope = response.body>>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + + @RequiresAuth + suspend fun postNewsAnnouncements(body: Announcement): APIResult { + return try { + val response = client.post { + url("/news/announcements") + setBody(body) + contentType(ContentType.Application.Json) + } + if (response.status.isSuccess()) { + val envelope = response.body>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + + @RequiresAuth + suspend fun getNewsAnnouncementsById(id: String): APIResult { + return try { + val response = client.get { + url("/news/announcements/${id}") + } + if (response.status.isSuccess()) { + val envelope = response.body>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + + @RequiresAuth + suspend fun deleteNewsAnnouncementsById(id: String): APIResult { + return try { + val response = client.delete { + url("/news/announcements/${id}") + } + if (response.status.isSuccess()) { + val envelope = response.body>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + + @RequiresAuth + suspend fun patchNewsAnnouncementsById(id: String, body: Announcement): APIResult { + return try { + val response = client.patch { + url("/news/announcements/${id}") + setBody(body) + contentType(ContentType.Application.Json) + } + if (response.status.isSuccess()) { + val envelope = response.body>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + +} diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/NewsBlogsApi.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/NewsBlogsApi.kt new file mode 100644 index 0000000..264cc51 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/NewsBlogsApi.kt @@ -0,0 +1,181 @@ +package hu.petrik.filcapp.api + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.* +import io.ktor.http.ContentType +import io.ktor.http.contentType +import io.ktor.http.isSuccess + +import hu.petrik.filcapp.models.BlogPost + +public class NewsBlogsApi(private val client: HttpClient) { + suspend fun getNewsBlogs(): APIResult> { + return try { + val response = client.get { + url("/news/blogs") + } + if (response.status.isSuccess()) { + val envelope = response.body>>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + + @RequiresAuth + suspend fun postNewsBlogs(body: BlogPost): APIResult { + return try { + val response = client.post { + url("/news/blogs") + setBody(body) + contentType(ContentType.Application.Json) + } + if (response.status.isSuccess()) { + val envelope = response.body>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + + @RequiresAuth + suspend fun getNewsBlogsDrafts(): APIResult> { + return try { + val response = client.get { + url("/news/blogs/drafts") + } + if (response.status.isSuccess()) { + val envelope = response.body>>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + + @RequiresAuth + suspend fun getNewsBlogsIdById(id: String): APIResult { + return try { + val response = client.get { + url("/news/blogs/id/${id}") + } + if (response.status.isSuccess()) { + val envelope = response.body>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + + @RequiresAuth + suspend fun deleteNewsBlogsById(id: String): APIResult { + return try { + val response = client.delete { + url("/news/blogs/${id}") + } + if (response.status.isSuccess()) { + val envelope = response.body>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + + @RequiresAuth + suspend fun patchNewsBlogsById(id: String, body: BlogPost): APIResult { + return try { + val response = client.patch { + url("/news/blogs/${id}") + setBody(body) + contentType(ContentType.Application.Json) + } + if (response.status.isSuccess()) { + val envelope = response.body>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + + @RequiresAuth + suspend fun postNewsBlogsByIdPublish(id: String, body: BlogPost): APIResult { + return try { + val response = client.post { + url("/news/blogs/${id}/publish") + setBody(body) + contentType(ContentType.Application.Json) + } + if (response.status.isSuccess()) { + val envelope = response.body>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + + @RequiresAuth + suspend fun postNewsBlogsByIdUnpublish(id: String, body: BlogPost): APIResult { + return try { + val response = client.post { + url("/news/blogs/${id}/unpublish") + setBody(body) + contentType(ContentType.Application.Json) + } + if (response.status.isSuccess()) { + val envelope = response.body>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + + suspend fun getNewsBlogsBySlug(slug: String): APIResult { + return try { + val response = client.get { + url("/news/blogs/${slug}") + } + if (response.status.isSuccess()) { + val envelope = response.body>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + +} diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/NewsSystemMessagesApi.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/NewsSystemMessagesApi.kt new file mode 100644 index 0000000..451fe53 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/NewsSystemMessagesApi.kt @@ -0,0 +1,107 @@ +package hu.petrik.filcapp.api + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.* +import io.ktor.http.ContentType +import io.ktor.http.contentType +import io.ktor.http.isSuccess + +import hu.petrik.filcapp.models.SystemMessage + +public class NewsSystemMessagesApi(private val client: HttpClient) { + @RequiresAuth + suspend fun getNewsSystemMessages(): APIResult> { + return try { + val response = client.get { + url("/news/system-messages") + } + if (response.status.isSuccess()) { + val envelope = response.body>>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + + @RequiresAuth + suspend fun postNewsSystemMessages(body: SystemMessage): APIResult { + return try { + val response = client.post { + url("/news/system-messages") + setBody(body) + contentType(ContentType.Application.Json) + } + if (response.status.isSuccess()) { + val envelope = response.body>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + + @RequiresAuth + suspend fun getNewsSystemMessagesById(id: String): APIResult { + return try { + val response = client.get { + url("/news/system-messages/${id}") + } + if (response.status.isSuccess()) { + val envelope = response.body>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + + @RequiresAuth + suspend fun deleteNewsSystemMessagesById(id: String): APIResult { + return try { + val response = client.delete { + url("/news/system-messages/${id}") + } + if (response.status.isSuccess()) { + val envelope = response.body>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + + @RequiresAuth + suspend fun patchNewsSystemMessagesById(id: String, body: SystemMessage): APIResult { + return try { + val response = client.patch { + url("/news/system-messages/${id}") + setBody(body) + contentType(ContentType.Application.Json) + } + if (response.status.isSuccess()) { + val envelope = response.body>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + +} diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/PingApi.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/PingApi.kt new file mode 100644 index 0000000..11a8e35 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/PingApi.kt @@ -0,0 +1,48 @@ +package hu.petrik.filcapp.api + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.* +import io.ktor.http.ContentType +import io.ktor.http.contentType +import io.ktor.http.isSuccess + +import hu.petrik.filcapp.models.PingResponse +import hu.petrik.filcapp.models.UptimeResponse + +public class PingApi(private val client: HttpClient) { + suspend fun getPing(): APIResult { + return try { + val response = client.get { + url("/ping") + } + if (response.status.isSuccess()) { + val envelope = response.body>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + + suspend fun getPingUptime(): APIResult { + return try { + val response = client.get { + url("/ping/uptime") + } + if (response.status.isSuccess()) { + val envelope = response.body>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + +} diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/Result.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/Result.kt new file mode 100644 index 0000000..2f4d536 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/Result.kt @@ -0,0 +1,79 @@ +package hu.petrik.filcapp.api + +import kotlinx.serialization.Serializable + +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class RequiresAuth + +@Serializable +public data class ApiErrorMessage( + val success: Boolean, + val message: String +) + +@Serializable +public data class ApiEnvelope( + val success: Boolean, + val data: T +) + +public sealed interface APIResult { + data class Success(val data: T) : APIResult + data class Failure(val error: ApiError) : APIResult +} + +public fun APIResult.getOrThrow(): T { + return when (this) { + is APIResult.Success -> this.data + is APIResult.Failure -> throw UnwrappedResultException(this.error) + } +} + +public fun APIResult.getOrElse(default: (error: ApiError) -> T): T { + return when (this) { + is APIResult.Success -> this.data + is APIResult.Failure -> default(this.error) + } +} + +public inline fun APIResult.map(transform: (T) -> R): APIResult { + return when (this) { + is APIResult.Success -> APIResult.Success(transform(this.data)) + is APIResult.Failure -> this + } +} + +public inline fun APIResult.flatMap(transform: (T) -> APIResult): APIResult { + return when (this) { + is APIResult.Success -> transform(this.data) + is APIResult.Failure -> this + } +} + +public inline fun APIResult.fold( + onFailure: (error: ApiError) -> R, + onSuccess: (value: T) -> R +): R { + return when (this) { + is APIResult.Failure -> onFailure(this.error) + is APIResult.Success -> onSuccess(this.data) + } +} + +public inline fun APIResult.mapError(transform: (ApiError) -> ApiError): APIResult { + return when (this) { + is APIResult.Success -> this + is APIResult.Failure -> APIResult.Failure(transform(this.error)) + } +} + +public class UnwrappedResultException(val apiError: ApiError) : + RuntimeException("Attempted to unwrap a Result that contained an error: $apiError") + +public sealed interface ApiError { + data class NetworkError(val cause: Throwable) : ApiError + data class BackendError(val httpCode: Int, val message: String) : ApiError + data class SerializationError(val details: String) : ApiError + data class Unknown(val cause: Throwable) : ApiError +} diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/SubstitutionApi.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/SubstitutionApi.kt new file mode 100644 index 0000000..a00c0cf --- /dev/null +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/SubstitutionApi.kt @@ -0,0 +1,127 @@ +package hu.petrik.filcapp.api + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.* +import io.ktor.http.ContentType +import io.ktor.http.contentType +import io.ktor.http.isSuccess + +import hu.petrik.filcapp.models.Substitution +import hu.petrik.filcapp.models.SubstitutionWithRelations +import hu.petrik.filcapp.models.SubstitutionsByCohort + +public class SubstitutionApi(private val client: HttpClient) { + @RequiresAuth + suspend fun getTimetableSubstitutions(): APIResult> { + return try { + val response = client.get { + url("/timetable/substitutions") + } + if (response.status.isSuccess()) { + val envelope = response.body>>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + + @RequiresAuth + suspend fun postTimetableSubstitutions(body: Substitution): APIResult { + return try { + val response = client.post { + url("/timetable/substitutions") + setBody(body) + contentType(ContentType.Application.Json) + } + if (response.status.isSuccess()) { + val envelope = response.body>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + + @RequiresAuth + suspend fun getTimetableSubstitutionsCohortByCohortId(cohortId: String): APIResult { + return try { + val response = client.get { + url("/timetable/substitutions/cohort/${cohortId}") + } + if (response.status.isSuccess()) { + val envelope = response.body>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + + @RequiresAuth + suspend fun getTimetableSubstitutionsRelevant(): APIResult> { + return try { + val response = client.get { + url("/timetable/substitutions/relevant") + } + if (response.status.isSuccess()) { + val envelope = response.body>>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + + @RequiresAuth + suspend fun putTimetableSubstitutionsById(id: String, body: Substitution): APIResult { + return try { + val response = client.put { + url("/timetable/substitutions/${id}") + setBody(body) + contentType(ContentType.Application.Json) + } + if (response.status.isSuccess()) { + val envelope = response.body>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + + @RequiresAuth + suspend fun deleteTimetableSubstitutionsById(id: String): APIResult { + return try { + val response = client.delete { + url("/timetable/substitutions/${id}") + } + if (response.status.isSuccess()) { + val envelope = response.body>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + +} diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/TeacherApi.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/TeacherApi.kt new file mode 100644 index 0000000..a387d75 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/TeacherApi.kt @@ -0,0 +1,30 @@ +package hu.petrik.filcapp.api + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.* +import io.ktor.http.ContentType +import io.ktor.http.contentType +import io.ktor.http.isSuccess + +import hu.petrik.filcapp.models.Teacher + +public class TeacherApi(private val client: HttpClient) { + suspend fun getTimetableTeachersAll(): APIResult> { + return try { + val response = client.get { + url("/timetable/teachers/getAll") + } + if (response.status.isSuccess()) { + val envelope = response.body>>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + +} diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/TimetableApi.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/TimetableApi.kt new file mode 100644 index 0000000..60f523f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/TimetableApi.kt @@ -0,0 +1,67 @@ +package hu.petrik.filcapp.api + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.* +import io.ktor.http.ContentType +import io.ktor.http.contentType +import io.ktor.http.isSuccess + +import hu.petrik.filcapp.models.Timetable + +public class TimetableApi(private val client: HttpClient) { + @RequiresAuth + suspend fun getTimetableTimetables(): APIResult> { + return try { + val response = client.get { + url("/timetable/timetables") + } + if (response.status.isSuccess()) { + val envelope = response.body>>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + + @RequiresAuth + suspend fun getTimetableTimetablesLatestValid(): APIResult { + return try { + val response = client.get { + url("/timetable/timetables/latestValid") + } + if (response.status.isSuccess()) { + val envelope = response.body>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + + @RequiresAuth + suspend fun getTimetableTimetablesValid(): APIResult> { + return try { + val response = client.get { + url("/timetable/timetables/valid") + } + if (response.status.isSuccess()) { + val envelope = response.body>>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + +} diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/UsersApi.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/UsersApi.kt new file mode 100644 index 0000000..44240cd --- /dev/null +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/UsersApi.kt @@ -0,0 +1,52 @@ +package hu.petrik.filcapp.api + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.* +import io.ktor.http.ContentType +import io.ktor.http.contentType +import io.ktor.http.isSuccess + +import hu.petrik.filcapp.models.User +import hu.petrik.filcapp.models.UserListResponse + +public class UsersApi(private val client: HttpClient) { + @RequiresAuth + suspend fun getUsers(): APIResult { + return try { + val response = client.get { + url("/users") + } + if (response.status.isSuccess()) { + val envelope = response.body>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + + @RequiresAuth + suspend fun patchUsersById(id: String, body: User): APIResult { + return try { + val response = client.patch { + url("/users/${id}") + setBody(body) + contentType(ContentType.Application.Json) + } + if (response.status.isSuccess()) { + val envelope = response.body>() + APIResult.Success(envelope.data) + } else { + val errorBody = response.body() + APIResult.Failure(ApiError.BackendError(response.status.value, errorBody.message)) + } + } catch (e: Exception) { + APIResult.Failure(ApiError.Unknown(e)) + } + } + +} diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/auth/AuthApi.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/auth/AuthApi.kt new file mode 100644 index 0000000..aa6c683 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/auth/AuthApi.kt @@ -0,0 +1,39 @@ +package hu.petrik.filcapp.auth + +import hu.petrik.filcapp.api.client.APIClient +import hu.petrik.filcapp.apiBaseUrl +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.contentType +import io.ktor.http.isSuccess +import kotlinx.serialization.Serializable + +@Serializable +private data class SocialSignInRequest(val provider: String, val callbackURL: String) + +@Serializable +private data class SocialSignInResponse(val url: String, val redirect: Boolean) + +@Serializable +data class SessionUser( + val id: String = "", + val name: String = "", + val email: String = "", + val cohortId: String? = null, + val nickname: String? = null, + val displayName: String = "", + val roles: List = emptyList(), + val image: String? = null, +) + +@Serializable +private data class SessionResponse(val user: SessionUser? = null) + +suspend fun fetchSessionUser(): SessionUser? { + val response = APIClient.get("$apiBaseUrl/api/auth/get-session") + if (!response.status.isSuccess()) return null + return response.body().user +} diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/auth/AuthState.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/auth/AuthState.kt new file mode 100644 index 0000000..eb72c2a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/auth/AuthState.kt @@ -0,0 +1,47 @@ +package hu.petrik.filcapp.auth + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue + +object AuthState { + var sessionToken: String? by mutableStateOf(null) + private set + var cohortId: String? by mutableStateOf(null) + private set + var displayName: String? by mutableStateOf(null) + private set + var profileImage: String? by mutableStateOf(null) + private set + + fun init() { + sessionToken = SessionStore.get() + } + + fun login(token: String) { + SessionStore.set(token) + sessionToken = token + } + + suspend fun fetchSession(): Boolean { + val user = fetchSessionUser() + if (user == null) { + logout() + return false + } + cohortId = user.cohortId + displayName = user.displayName.ifEmpty { user.name } + profileImage = user.image + return true + } + + fun logout() { + SessionStore.clear() + sessionToken = null + cohortId = null + displayName = null + profileImage = null + } + + val isLoggedIn: Boolean get() = sessionToken != null +} diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/auth/AuthWebView.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/auth/AuthWebView.kt new file mode 100644 index 0000000..415403c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/auth/AuthWebView.kt @@ -0,0 +1,10 @@ +package hu.petrik.filcapp.auth + +import androidx.compose.runtime.Composable + +@Composable +expect fun AuthWebView( + apiBaseUrl: String, + onSessionAcquired: (String) -> Unit, + onDismiss: () -> Unit, +) diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/auth/ProfileImageDecoder.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/auth/ProfileImageDecoder.kt new file mode 100644 index 0000000..39b25f1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/auth/ProfileImageDecoder.kt @@ -0,0 +1,5 @@ +package hu.petrik.filcapp.auth + +import androidx.compose.ui.graphics.ImageBitmap + +expect fun base64ToImageBitmap(base64: String): ImageBitmap? diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/auth/SessionStore.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/auth/SessionStore.kt new file mode 100644 index 0000000..a64343b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/auth/SessionStore.kt @@ -0,0 +1,7 @@ +package hu.petrik.filcapp.auth + +expect object SessionStore { + fun get(): String? + fun set(token: String) + fun clear() +} diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/components/DateView.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/components/DateView.kt new file mode 100644 index 0000000..95151e1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/components/DateView.kt @@ -0,0 +1,489 @@ +package hu.petrik.filcapp.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cafe.adriel.voyager.navigator.tab.LocalTabNavigator +import hu.petrik.filcapp.models.EnrichedLesson +import hu.petrik.filcapp.models.Period +import hu.petrik.filcapp.models.MovedLessonWithRelations +import hu.petrik.filcapp.models.SubstitutionWithRelations +import hu.petrik.filcapp.models.Teacher +import hu.petrik.filcapp.screens.TimetableMode +import hu.petrik.filcapp.screens.TimetableState +import hu.petrik.filcapp.screens.TimetableTab +import kotlinx.coroutines.delay +import kotlinx.datetime.* +import kotlin.time.Clock + +private val ColorCancelled = Color(0xFFE53935) +private val ColorMoved = Color(0xFF9C27B0) +private val ColorSubstituted = Color(0xFFF59E0B) + +@OptIn(kotlin.time.ExperimentalTime::class) +@Composable +fun DateView(modifier: Modifier = Modifier) { + var currentDateTime by remember { + mutableStateOf(Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault())) + } + + LaunchedEffect(Unit) { + while (true) { + currentDateTime = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()) + delay(1000L) + } + } + + val dayOfWeek = currentDateTime.dayOfWeek.name + .lowercase().replaceFirstChar { it.uppercase() } + + val monthName = when (currentDateTime.month) { + Month.JANUARY -> "Jan"; Month.FEBRUARY -> "Feb"; Month.MARCH -> "Mar" + Month.APRIL -> "Apr"; Month.MAY -> "May"; Month.JUNE -> "Jun" + Month.JULY -> "Jul"; Month.AUGUST -> "Aug"; Month.SEPTEMBER -> "Sep" + Month.OCTOBER -> "Oct"; Month.NOVEMBER -> "Nov"; Month.DECEMBER -> "Dec" + } + + val day = currentDateTime.dayOfMonth.toString().padStart(2, '0') + val year = currentDateTime.year + val hour = currentDateTime.hour.toString().padStart(2, '0') + val minute = currentDateTime.minute.toString().padStart(2, '0') + + val status = rememberLessonStatus(currentDateTime) + val tabNavigator = LocalTabNavigator.current + + Row( + modifier = modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.Top, + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = dayOfWeek, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Normal, + color = MaterialTheme.colorScheme.onBackground, + ) + Text( + text = "$day, $monthName $year", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Normal, + color = MaterialTheme.colorScheme.onBackground, + ) + Text( + text = "$hour:$minute", + style = MaterialTheme.typography.displayMedium, + fontWeight = FontWeight.Bold, + fontSize = 48.sp, + color = MaterialTheme.colorScheme.onBackground, + ) + } + + Box( + modifier = Modifier + .weight(1f) + .aspectRatio(1f) + .background( + color = when (status) { + is LessonStatus.InClass -> MaterialTheme.colorScheme.primaryContainer + is LessonStatus.Break -> MaterialTheme.colorScheme.secondaryContainer + is LessonStatus.NotStarted -> MaterialTheme.colorScheme.tertiaryContainer + is LessonStatus.Done, LessonStatus.NoData -> MaterialTheme.colorScheme.surfaceVariant + }, + shape = RoundedCornerShape(24.dp), + ) + .then( + if (status is LessonStatus.InClass) + Modifier.clickable { + TimetableState.timetableMode = TimetableMode.Class + tabNavigator.current = TimetableTab + } + else Modifier + ), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.padding(12.dp), + ) { + when (status) { + is LessonStatus.InClass -> { + Text( + text = "In class", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + status.lines.forEachIndexed { index, line -> + if (index > 0) { + Spacer(Modifier.height(4.dp)) + HorizontalDivider( + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.2f), + thickness = 0.5.dp, + ) + Spacer(Modifier.height(4.dp)) + } + val nameColor = when { + line.isCancelled -> ColorCancelled + line.isMovedHere -> ColorMoved + line.substituteTeacher != null -> ColorSubstituted + else -> MaterialTheme.colorScheme.onPrimaryContainer + } + Text( + text = line.name, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = nameColor, + textAlign = TextAlign.Center, + maxLines = 2, + ) + if (line.isCancelled) { + Text( + text = "Cancelled", + style = MaterialTheme.typography.labelSmall, + color = ColorCancelled, + textAlign = TextAlign.Center, + ) + } else if (line.substituteTeacher != null) { + Text( + text = line.substituteTeacher, + style = MaterialTheme.typography.labelSmall, + color = ColorSubstituted, + textAlign = TextAlign.Center, + ) + } + } + } + is LessonStatus.NotStarted -> { + Text( + text = "First class", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onTertiaryContainer, + ) + Text( + text = "starts at ${status.firstStart}", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onTertiaryContainer, + textAlign = TextAlign.Center, + ) + } + is LessonStatus.Break -> { + Text( + text = "Break", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + Text( + text = "until ${status.nextStart}", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSecondaryContainer, + textAlign = TextAlign.Center, + ) + } + is LessonStatus.Done -> { + Text( + text = "School done", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + LessonStatus.NoData -> { + Text( + text = "—", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } +} + +// ── Upcoming classes ────────────────────────────────────────────────────────── + +data class SubjectLine( + val name: String, + val detail: String?, + val isCancelled: Boolean = false, + val isMovedHere: Boolean = false, + val substituteTeacher: String? = null, +) +data class UpcomingLesson(val lines: List, val startTime: String) + +@OptIn(kotlin.time.ExperimentalTime::class) +@Composable +fun UpcomingClasses(modifier: Modifier = Modifier) { + val now = remember { Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()) } + val upcoming = rememberUpcomingLessons(now, limit = 3) + val tabNavigator = LocalTabNavigator.current + + Column(modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp)) { + Text( + text = "Upcoming", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onBackground, + ) + Spacer(Modifier.height(8.dp)) + if (upcoming.isEmpty()) { + Text( + text = "No more classes today", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + upcoming.forEach { lesson -> + Row( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(12.dp)) + .clickable { + TimetableState.timetableMode = TimetableMode.Class + tabNavigator.current = TimetableTab + } + .padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(modifier = Modifier.weight(1f)) { + lesson.lines.forEachIndexed { i, line -> + if (i > 0) { + Spacer(Modifier.height(6.dp)) + HorizontalDivider( + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.2f), + thickness = 0.5.dp, + ) + Spacer(Modifier.height(6.dp)) + } + val nameColor = when { + line.isCancelled -> ColorCancelled + line.isMovedHere -> ColorMoved + line.substituteTeacher != null -> ColorSubstituted + else -> MaterialTheme.colorScheme.onSurface + } + Text( + text = line.name, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = nameColor, + maxLines = 1, + ) + val statusLabel = when { + line.isCancelled -> "Cancelled" + line.isMovedHere -> "Moved here" + else -> null + } + if (statusLabel != null) { + Text( + text = statusLabel, + style = MaterialTheme.typography.labelSmall, + color = nameColor, + maxLines = 1, + ) + } + if (line.substituteTeacher != null) { + Text( + text = line.substituteTeacher, + style = MaterialTheme.typography.labelSmall, + color = ColorSubstituted, + maxLines = 1, + ) + } else if (line.detail != null && !line.isCancelled) { + Text( + text = line.detail, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + } + } + Spacer(Modifier.width(8.dp)) + Text( + text = lesson.startTime, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } + } + } +} + +// ── Status & upcoming helpers ───────────────────────────────────────────────── + +sealed interface LessonStatus { + data class InClass(val lines: List) : LessonStatus + data class NotStarted(val firstStart: String) : LessonStatus + data class Break(val nextStart: String) : LessonStatus + data object Done : LessonStatus + data object NoData : LessonStatus +} + +private data class Slot( + val start: LocalTime, + val end: LocalTime, + val subject: String, + val room: String?, + val teacher: String?, + val isCancelled: Boolean = false, + val isMovedHere: Boolean = false, + val substituteTeacher: String? = null, +) + +private fun buildTodaySlots( + lessons: List, + movedLessons: List, + substitutions: List, + teacherMap: Map, + today: LocalDate, +): List { + val dayNum = today.dayOfWeek.isoDayNumber.toString() + val movedToday = movedLessons.filter { it.movedLesson.date.startsWith(today.toString()) } + val movedAwayIds = movedLessons + .filter { ml -> + !ml.movedLesson.date.startsWith(today.toString()) && + ml.dayDefinition?.days?.contains(dayNum) == true + } + .flatMap { it.lessons }.toSet() + + val activeSubs = substitutions + .filter { it.substitution.date.startsWith(today.toString()) } + .flatMap { sub -> sub.lessons.map { id -> id to sub } } + .toMap() + + val base = lessons.filter { lesson -> + lesson.day.days.filterNotNull().any { it == dayNum } && lesson.id !in movedAwayIds + } + val extra = movedToday.mapNotNull { ml -> lessons.find { it.id in ml.lessons } } + + return (base + extra).mapNotNull { lesson -> + val ml = movedToday.firstOrNull { lesson.id in it.lessons } + val p: Period = ml?.period ?: lesson.period ?: return@mapNotNull null + val start = runCatching { LocalTime.parse(p.startTime.take(5)) }.getOrNull() ?: return@mapNotNull null + val end = runCatching { LocalTime.parse(p.endTime.take(5)) }.getOrNull() ?: return@mapNotNull null + val subject = lesson.subject?.name ?: return@mapNotNull null + val room = (ml?.classroom?.name ?: lesson.classrooms.firstOrNull()?.name)?.let { "Room $it" } + val teacher = lesson.teachers.firstOrNull()?.name + + val sub = activeSubs[lesson.id] + val isCancelled = sub != null && sub.teacher == null + val isSubstituted = sub != null && sub.teacher != null + val substituteTeacher = if (isSubstituted) { + sub!!.substitution.substituter?.let { id -> teacherMap[id] } + ?.let { "${it.lastName} ${it.firstName}" } + ?: sub.teacher?.let { "${it.lastName} ${it.firstName}" } + } else null + + Slot( + start = start, + end = end, + subject = subject, + room = room, + teacher = teacher, + isCancelled = isCancelled, + isMovedHere = ml != null, + substituteTeacher = substituteTeacher, + ) + }.sortedBy { it.start } +} + +private fun formatSlotGroupLines(slots: List): List { + val bySubject = slots.groupBy { it.subject } + return if (bySubject.size == 1) { + val teachers = slots.mapNotNull { it.teacher }.distinct() + val rooms = slots.mapNotNull { it.room }.distinct() + val detail = buildList { + if (teachers.isNotEmpty()) add(teachers.joinToString(", ")) + if (rooms.isNotEmpty()) add(rooms.joinToString(", ")) + }.joinToString(" · ").takeIf { it.isNotEmpty() } + val slot = slots.first() + listOf(SubjectLine( + name = slot.subject, + detail = detail, + isCancelled = slot.isCancelled, + isMovedHere = slot.isMovedHere, + substituteTeacher = slot.substituteTeacher, + )) + } else { + slots.map { slot -> + val detail = listOfNotNull(slot.teacher, slot.room).joinToString(" · ").takeIf { it.isNotEmpty() } + SubjectLine( + name = slot.subject, + detail = detail, + isCancelled = slot.isCancelled, + isMovedHere = slot.isMovedHere, + substituteTeacher = slot.substituteTeacher, + ) + } + } +} + +@Composable +private fun rememberLessonStatus(now: LocalDateTime): LessonStatus { + val lessons = TimetableState.lessons + val movedLessons = TimetableState.movedLessons + val substitutions = TimetableState.substitutions + val teacherMap = TimetableState.teacherMap + if (!TimetableState.timetableLoaded || lessons.isEmpty()) return LessonStatus.NoData + + val slots = buildTodaySlots(lessons, movedLessons, substitutions, teacherMap, now.date) + if (slots.isEmpty()) return LessonStatus.NoData + + val time = now.time + val currents = slots.filter { time >= it.start && time <= it.end } + if (currents.isNotEmpty()) return LessonStatus.InClass(formatSlotGroupLines(currents)) + + val next = slots.firstOrNull { it.start > time } + if (next != null) { + val anyPast = slots.any { it.end < time } + return if (anyPast) LessonStatus.Break(next.start.toString().take(5)) + else LessonStatus.NotStarted(next.start.toString().take(5)) + } + + return LessonStatus.Done +} + +@Composable +private fun rememberUpcomingLessons(now: LocalDateTime, limit: Int): List { + val lessons = TimetableState.lessons + val movedLessons = TimetableState.movedLessons + val substitutions = TimetableState.substitutions + val teacherMap = TimetableState.teacherMap + if (!TimetableState.timetableLoaded || lessons.isEmpty()) return emptyList() + + val slots = buildTodaySlots(lessons, movedLessons, substitutions, teacherMap, now.date) + val time = now.time + val future = slots.filter { it.start > time } + + val groups = future.groupBy { it.start }.entries.sortedBy { it.key } + + return groups.take(limit).map { (start, group) -> + UpcomingLesson(formatSlotGroupLines(group), start.toString().take(5)) + } +} diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/components/NoticesBanner.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/components/NoticesBanner.kt new file mode 100644 index 0000000..72dd482 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/components/NoticesBanner.kt @@ -0,0 +1,279 @@ +@file:OptIn(kotlin.time.ExperimentalTime::class, androidx.compose.material3.ExperimentalMaterial3Api::class) + +package hu.petrik.filcapp.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import hu.petrik.filcapp.models.Announcement +import hu.petrik.filcapp.models.SystemMessage +import kotlinx.datetime.Instant +import kotlinx.serialization.json.* +import kotlin.time.Clock + +object NoticeState { + var announcements by mutableStateOf>(emptyList()) + var systemMessages by mutableStateOf>(emptyList()) + var loaded by mutableStateOf(false) + var sectionExpanded by mutableStateOf(true) +} + +internal sealed class Notice { + abstract val id: String + abstract val title: String + abstract val content: List + abstract val validFrom: String + abstract val validUntil: String + abstract val cohortIds: List + + data class Ann(val data: Announcement) : Notice() { + override val id = data.id + override val title = data.title + override val content = data.content + override val validFrom = data.validFrom + override val validUntil = data.validUntil + override val cohortIds = data.cohortIds + } + + data class Sys(val data: SystemMessage) : Notice() { + override val id = data.id + override val title = data.title + override val content = data.content + override val validFrom = data.validFrom + override val validUntil = data.validUntil + override val cohortIds = data.cohortIds + } +} + +private fun isCurrentlyValid(validFrom: String, validUntil: String): Boolean { + return try { + val from = Instant.parse(validFrom) + val until = Instant.parse(validUntil) + val now = Clock.System.now() + now >= from && now <= until + } catch (_: Exception) { + true + } +} + +internal fun extractNoticeText(element: JsonElement): String = when (element) { + is JsonPrimitive -> element.contentOrNull ?: "" + is JsonObject -> { + val direct = element["content"] + if (direct != null) extractNoticeText(direct) + else element.values.joinToString(" ") { extractNoticeText(it) } + } + is JsonArray -> element.joinToString("\n") { extractNoticeText(it) } +} + +@Composable +fun NoticesBanner(cohortId: String?, modifier: Modifier = Modifier) { + val notices = remember(NoticeState.announcements, NoticeState.systemMessages, cohortId) { + val anns = NoticeState.announcements.map { Notice.Ann(it) } + val sys = NoticeState.systemMessages.map { Notice.Sys(it) } + (anns + sys).filter { n -> + isCurrentlyValid(n.validFrom, n.validUntil) && + (n.cohortIds.isEmpty() || cohortId == null || n.cohortIds.contains(cohortId)) + }.sortedByDescending { it.validFrom } + } + + if (notices.isEmpty()) return + + var detailNotice by remember { mutableStateOf(null) } + val expanded = NoticeState.sectionExpanded + + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp) + .clip(RoundedCornerShape(14.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), + ) { + // Section header + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { NoticeState.sectionExpanded = !expanded } + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Announcements", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + Surface( + shape = RoundedCornerShape(6.dp), + color = MaterialTheme.colorScheme.secondaryContainer, + ) { + Text( + text = "${notices.size}", + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + fontWeight = FontWeight.SemiBold, + ) + } + Spacer(Modifier.width(8.dp)) + Icon( + imageVector = if (expanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, + contentDescription = if (expanded) "Collapse" else "Expand", + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + AnimatedVisibility( + visible = expanded, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + Column { + HorizontalDivider( + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.15f), + thickness = 0.5.dp, + ) + notices.forEachIndexed { index, notice -> + if (index > 0) { + HorizontalDivider( + modifier = Modifier.padding(horizontal = 14.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.1f), + thickness = 0.5.dp, + ) + } + NoticeRow(notice = notice, onClick = { detailNotice = notice }) + } + } + } + } + + detailNotice?.let { notice -> + NoticeDetailSheet(notice = notice, onDismiss = { detailNotice = null }) + } +} + +@Composable +private fun NoticeRow(notice: Notice, onClick: () -> Unit) { + val isSystem = notice is Notice.Sys + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (isSystem) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = "System message", + modifier = Modifier.size(15.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + text = notice.title, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun NoticeDetailSheet(notice: Notice, onDismiss: () -> Unit) { + val bodyText = remember(notice.content) { + notice.content.joinToString("\n\n") { extractNoticeText(it) }.trim() + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { + Column(modifier = Modifier.fillMaxSize()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .padding(bottom = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.weight(1f), + ) { + if (notice is Notice.Sys) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = "System message", + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + text = notice.title, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + IconButton(onClick = onDismiss) { + Icon(Icons.Default.Close, contentDescription = "Close") + } + } + HorizontalDivider(thickness = 0.5.dp) + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 20.dp, vertical = 16.dp), + ) { + if (bodyText.isNotBlank()) { + items(bodyText.split("\n\n")) { paragraph -> + if (paragraph.isNotBlank()) { + Text( + text = paragraph.trim(), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.85f), + ) + Spacer(Modifier.height(12.dp)) + } + } + } else { + item { + Text( + text = "No content", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/components/TopBar.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/components/TopBar.kt new file mode 100644 index 0000000..09ea395 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/components/TopBar.kt @@ -0,0 +1,74 @@ +package hu.petrik.filcapp.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import hu.petrik.filcapp.auth.AuthState +import hu.petrik.filcapp.auth.base64ToImageBitmap + +@Composable +fun TopBar(leadingContent: (@Composable () -> Unit)? = null, onProfileClick: () -> Unit = {}) { + val displayName = AuthState.displayName ?: "" + val profileImage = AuthState.profileImage + val avatar: ImageBitmap? = remember(profileImage) { + profileImage?.let { base64ToImageBitmap(it) } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.statusBars) + .padding(horizontal = 20.dp, vertical = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + if (leadingContent != null) { + leadingContent() + } else { + Column { + Text( + text = "Petrik", + style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Normal), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = displayName, + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + ) + } + } + + Box( + modifier = Modifier + .size(44.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant) + .clickable(onClick = onProfileClick), + contentAlignment = Alignment.Center, + ) { + if (avatar != null) { + Image( + bitmap = avatar, + contentDescription = "Profile picture", + modifier = Modifier.fillMaxSize(), + ) + } else { + Text( + text = displayName.firstOrNull()?.uppercase() ?: "?", + style = MaterialTheme.typography.titleMedium, + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/models/Models.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/models/Models.kt new file mode 100644 index 0000000..1c80a5e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/models/Models.kt @@ -0,0 +1,403 @@ +package hu.petrik.filcapp.models + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +@Serializable +data class Announcement ( + val author: Author? = null, + val authorId: String, + val cohortIds: List = emptyList(), + val content: List = emptyList(), + val createdAt: String, + val id: String, + val title: String, + val updatedAt: String, + val validFrom: String, + val validUntil: String, +) + +@Serializable +data class AuditLog ( + val buttonPressed: Boolean, + val cardData: String? = null, + val cardId: String? = null, + val deviceId: String, + val id: Int, + val result: Boolean, + val timestamp: String, + val userId: String? = null, +) + +@Serializable +data class Author ( + val id: String, + val image: String? = null, + val name: String, +) + +@Serializable +data class BlogPost ( + val author: Author? = null, + val authorId: String, + val content: List = emptyList(), + val createdAt: String, + val id: String, + val publishedAt: String? = null, + val slug: String, + val status: String, + val title: String, + val updatedAt: String, +) + +@Serializable +data class Card ( + val authorizedDevices: List = emptyList(), + val cardData: String, + val createdAt: String, + val enabled: Boolean, + val frozen: Boolean, + val id: String, + val name: String, + val owner: Card_owner? = null, + val updatedAt: String, + val userId: String? = null, +) + +@Serializable +data class CardListResponse ( + val cards: List = emptyList(), +) + +@Serializable +data class CardResponse ( + val card: Card, +) + +@Serializable +data class Card_authorizedDevices ( + val id: String, + val name: String, +) + +@Serializable +data class Card_owner ( + val email: String, + val id: String, + val name: String, + val nickname: String? = null, +) + +@Serializable +data class Classroom ( + val buildingId: String? = null, + val capacity: Int? = null, + val id: String, + val name: String, + val short: String, +) + +@Serializable +data class Cohort ( + val classroomIds: List = emptyList(), + val id: String, + val name: String, + val short: String, + val teacherId: String? = null, + val timetableId: String, +) + +@Serializable +data class DayDefinition ( + val createdAt: String, + val days: List = emptyList(), + val id: String, + val name: String, + val short: String, + val updatedAt: String, +) + +@Serializable +data class Device ( + val apiToken: String, + val createdAt: String, + val id: String, + val lastResetReason: String? = null, + val location: String? = null, + val name: String, + val updatedAt: String, +) + +@Serializable +data class DeviceHealthStat ( + val debug: DeviceHealthStat_debug, + val fwVersion: String, + val id: Double, + val ramFree: Double, + val storage: DeviceHealthStat_storage, + val timestamp: String, + val uptime: Double, +) + +@Serializable +data class DeviceHealthStat_debug ( + val deviceState: String, + val errors: DeviceHealthStat_debug_errors, + val lastResetReason: String, +) + +@Serializable +data class DeviceHealthStat_debug_errors ( + val db: Boolean, + val nfc: Boolean, + val ota: Boolean, + val sd: Boolean, + val wifi: Boolean, +) + +@Serializable +data class DeviceHealthStat_storage ( + val total: Double, + val used: Double, +) + +@Serializable +data class DeviceListResponse ( + val devices: List = emptyList(), +) + +@Serializable +data class DeviceResponse ( + val device: Device, +) + +@Serializable +data class DeviceStatsResponse ( + val stats: List = emptyList(), +) + +@Serializable +data class DoorlockActivationResponse ( + val log: AuditLog, +) + +@Serializable +data class DoorlockLogEntry ( + val buttonPressed: Boolean, + val card: DoorlockLogEntry_card? = null, + val cardData: String? = null, + val cardId: String? = null, + val device: DoorlockLogEntry_device? = null, + val deviceId: String, + val id: Int, + val owner: DoorlockLogEntry_owner? = null, + val result: Boolean, + val timestamp: String, + val userId: String? = null, +) + +@Serializable +data class DoorlockLogEntry_card ( + val id: String, + val name: String, +) + +@Serializable +data class DoorlockLogEntry_device ( + val id: String, + val name: String, +) + +@Serializable +data class DoorlockLogEntry_owner ( + val email: String, + val id: String, + val name: String, + val nickname: String? = null, +) + +@Serializable +data class DoorlockLogListResponse ( + val logs: List = emptyList(), +) + +@Serializable +data class DoorlockStats ( + val doorOpenSeries: List = emptyList(), + val topUsers: List = emptyList(), + val totalCards: Int, + val totalDevices: Int, + val totalSuccessfulOpens: Int, +) + +@Serializable +data class DoorlockStatsResponse ( + val stats: DoorlockStats, +) + +@Serializable +data class DoorlockStats_doorOpenSeries ( + val count: Int, + val date: String, +) + +@Serializable +data class DoorlockStats_topUsers ( + val count: Int, + val id: String, + val name: String? = null, + val nickname: String? = null, +) + +@Serializable +data class DoorlockUser ( + val email: String, + val id: String, + val name: String, + val nickname: String? = null, +) + +@Serializable +data class DoorlockUserListResponse ( + val users: List = emptyList(), +) + +@Serializable +data class EnrichedLesson ( + val classrooms: List = emptyList(), + val day: DayDefinition, + val id: String, + val period: Period? = null, + val periodsPerWeek: Double, + val subject: Subject? = null, + val teachers: List = emptyList(), + val termDefinitionId: String? = null, + val weeksDefinitionId: String, +) + +@Serializable +data class MovedLesson ( + val date: String, + val id: String, + val room: String? = null, + val startingDay: String? = null, + val startingPeriod: String? = null, +) + +@Serializable +data class MovedLessonWithRelations ( + val classroom: Classroom? = null, + val dayDefinition: DayDefinition? = null, + val lessons: List = emptyList(), + val movedLesson: MovedLesson, + val period: Period? = null, +) + +@Serializable +data class Period ( + val endTime: String, + val id: String, + val period: Double, + val startTime: String, +) + +@Serializable +data class PingResponse ( + val message: String, +) + +@Serializable +data class Subject ( + val id: String, + val name: String, + val short: String, +) + +@Serializable +data class Substitution ( + val date: String, + val id: String, + val substituter: String? = null, +) + +@Serializable +data class SubstitutionWithRelations ( + val lessons: List = emptyList(), + val substitution: Substitution, + val teacher: Teacher? = null, +) + +@Serializable +data class SubstitutionsByCohort ( + val cohortId: String, + val substitutions: List = emptyList(), +) + +@Serializable +data class SystemMessage ( + val author: Author? = null, + val authorId: String, + val cohortIds: List = emptyList(), + val content: List = emptyList(), + val createdAt: String, + val id: String, + val title: String, + val updatedAt: String, + val validFrom: String, + val validUntil: String, +) + +@Serializable +data class Teacher ( + val firstName: String, + val gender: String? = null, + val id: String, + val lastName: String, + val short: String, + val userId: String? = null, +) + +@Serializable +data class TeacherSummary ( + val id: String, + val name: String, + val short: String, +) + +@Serializable +data class Timetable ( + val createdAt: String, + val id: String, + val name: String, + val updatedAt: String, + val validFrom: String? = null, +) + +@Serializable +data class UptimeResponse ( + val pretty: String, + val uptime_ms: Double, +) + +@Serializable +data class User ( + val cohortId: String? = null, + val createdAt: String, + val displayName: String, + val email: String, + val emailVerified: Boolean, + val id: String, + val image: String? = null, + val name: String, + val nickname: String? = null, + val permissions: List = emptyList(), + val roles: List = emptyList(), + val updatedAt: String, +) + +@Serializable +data class UserListResponse ( + val total: Int, + val users: List = emptyList(), +) + diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/HomeScreen.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/HomeScreen.kt new file mode 100644 index 0000000..d773221 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/HomeScreen.kt @@ -0,0 +1,114 @@ +package hu.petrik.filcapp.screens + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Home +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.unit.dp +import cafe.adriel.voyager.core.model.ScreenModel +import cafe.adriel.voyager.core.model.rememberScreenModel +import cafe.adriel.voyager.core.model.screenModelScope +import cafe.adriel.voyager.navigator.tab.Tab +import cafe.adriel.voyager.navigator.tab.TabOptions +import hu.petrik.filcapp.api.APIResult +import hu.petrik.filcapp.api.ClassroomApi +import hu.petrik.filcapp.api.CohortApi +import hu.petrik.filcapp.api.LessonApi +import hu.petrik.filcapp.api.MovedLessonApi +import hu.petrik.filcapp.api.NewsAnnouncementsApi +import hu.petrik.filcapp.api.NewsSystemMessagesApi +import hu.petrik.filcapp.api.SubstitutionApi +import hu.petrik.filcapp.api.TeacherApi +import hu.petrik.filcapp.api.client.APIClient +import hu.petrik.filcapp.components.DateView +import hu.petrik.filcapp.components.NoticesBanner +import hu.petrik.filcapp.components.NoticeState +import hu.petrik.filcapp.components.UpcomingClasses +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class HomeScreenModel( + private val announcementsApi: NewsAnnouncementsApi, + private val systemMessagesApi: NewsSystemMessagesApi, +) : ScreenModel { + fun loadNotices() { + if (NoticeState.loaded) return + screenModelScope.launch(Dispatchers.Default) { + val annResult = announcementsApi.getNewsAnnouncements() + val sysResult = systemMessagesApi.getNewsSystemMessages() + withContext(Dispatchers.Main) { + if (annResult is APIResult.Success) NoticeState.announcements = annResult.data + if (sysResult is APIResult.Success) NoticeState.systemMessages = sysResult.data + NoticeState.loaded = true + } + } + } +} + +@Composable +fun HomeScreen() { + val cohortId = TimetableState.selectedCohortId + + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(top = 16.dp), + ) { + DateView() + Spacer(Modifier.height(16.dp)) + NoticesBanner(cohortId = cohortId) + UpcomingClasses() + } +} + +object HomeTab : Tab { + override val options: TabOptions + @Composable + get() { + val title = "Home" + val icon = rememberVectorPainter(Icons.Default.Home) + return remember { + TabOptions( + index = 0u, + title = title, + icon = icon, + ) + } + } + + @Composable + override fun Content() { + val timetableModel = rememberScreenModel { + TimetableScreenModel( + LessonApi(APIClient), + CohortApi(APIClient), + TeacherApi(APIClient), + SubstitutionApi(APIClient), + MovedLessonApi(APIClient), + ClassroomApi(APIClient), + ) + } + val homeModel = rememberScreenModel { + HomeScreenModel( + NewsAnnouncementsApi(APIClient), + NewsSystemMessagesApi(APIClient), + ) + } + LaunchedEffect(Unit) { + timetableModel.loadCohorts() + homeModel.loadNotices() + } + LaunchedEffect(TimetableState.selectedCohortId) { + val id = TimetableState.selectedCohortId ?: return@LaunchedEffect + timetableModel.loadTimetable(id) + } + HomeScreen() + } +} diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/NewsScreen.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/NewsScreen.kt new file mode 100644 index 0000000..1e1d667 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/NewsScreen.kt @@ -0,0 +1,367 @@ +@file:OptIn(kotlin.time.ExperimentalTime::class) + +package hu.petrik.filcapp.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +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.Campaign +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import cafe.adriel.voyager.core.model.ScreenModel +import cafe.adriel.voyager.core.model.rememberScreenModel +import cafe.adriel.voyager.core.model.screenModelScope +import cafe.adriel.voyager.navigator.tab.Tab +import cafe.adriel.voyager.navigator.tab.TabOptions +import hu.petrik.filcapp.api.APIResult +import hu.petrik.filcapp.api.NewsBlogsApi +import hu.petrik.filcapp.api.client.APIClient +import hu.petrik.filcapp.models.BlogPost +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlin.time.Clock +import kotlinx.datetime.Instant +import kotlinx.serialization.json.* + +// ── Tab ─────────────────────────────────────────────────────────────────────── + +object NewsTab : Tab { + override val options: TabOptions + @Composable get() { + val icon = rememberVectorPainter(Icons.Default.Campaign) + return remember { TabOptions(index = 3u, title = "News", icon = icon) } + } + + @Composable + override fun Content() { + val model = rememberScreenModel { NewsScreenModel(NewsBlogsApi(APIClient)) } + LaunchedEffect(Unit) { model.load() } + + var selectedPost by remember { mutableStateOf(null) } + + val post = selectedPost + if (post != null) { + NewsDetailScreen(post = post, onBack = { selectedPost = null }) + } else { + NewsListScreen( + isLoading = model.isLoading, + posts = model.posts, + error = model.error, + onSelect = { selectedPost = it }, + ) + } + } +} + +// ── List screen ─────────────────────────────────────────────────────────────── + +@Composable +fun NewsListScreen( + isLoading: Boolean, + posts: List, + error: String?, + onSelect: (BlogPost) -> Unit, +) { + var query by remember { mutableStateOf("") } + val filtered = remember(posts, query) { + if (query.isBlank()) posts + else posts.filter { + it.title.contains(query, ignoreCase = true) || + it.author?.name?.contains(query, ignoreCase = true) == true + } + } + + Column(modifier = Modifier.fillMaxSize().padding(top = 16.dp)) { + // Search bar + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .clip(RoundedCornerShape(14.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(horizontal = 12.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + Spacer(Modifier.width(8.dp)) + TextField( + value = query, + onValueChange = { query = it }, + placeholder = { Text("Search news…", style = MaterialTheme.typography.bodyMedium) }, + singleLine = true, + modifier = Modifier.weight(1f), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ), + textStyle = MaterialTheme.typography.bodyMedium, + ) + if (query.isNotEmpty()) { + Icon( + Icons.Default.Close, + contentDescription = "Clear", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp).clickable { query = "" }, + ) + } + } + + Spacer(Modifier.height(16.dp)) + + when { + isLoading -> Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + error != null -> Box(Modifier.weight(1f).fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) { + Text(error, color = MaterialTheme.colorScheme.error) + } + else -> LazyColumn(modifier = Modifier.weight(1f), contentPadding = PaddingValues(horizontal = 16.dp, vertical = 4.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + items(filtered, key = { it.id }) { post -> + NewsCard(post = post, onClick = { onSelect(post) }) + } + } + } + } +} + +@Composable +fun NewsCard(post: BlogPost, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .clickable(onClick = onClick) + .padding(12.dp), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + // Thumbnail placeholder + Box( + modifier = Modifier + .size(width = 110.dp, height = 90.dp) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.outline.copy(alpha = 0.2f)), + contentAlignment = Alignment.Center, + ) { + Text("News pic", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + // Tags from title (extract #hashtags) + val tags = extractTags(post.title) + if (tags.isNotEmpty()) { + Text( + text = tags.joinToString(" ") { "#$it" }, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + } + Text( + text = post.title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = buildAuthorLine(post), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +// ── Detail screen ───────────────────────────────────────────────────────────── + +@Composable +fun NewsDetailScreen(post: BlogPost, onBack: () -> Unit) { + Column(modifier = Modifier.fillMaxSize()) { + // Top bar + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + Text( + text = "Detail News", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + ) + Spacer(Modifier.size(48.dp)) + } + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 32.dp), + ) { + item { + // Hero image placeholder + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .height(220.dp) + .clip(RoundedCornerShape(20.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center, + ) { + Text("News Pic", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + + Spacer(Modifier.height(16.dp)) + + // Author row + tags + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant), + ) + Spacer(Modifier.width(10.dp)) + Text( + text = post.author?.name ?: "Unknown", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(1f), + ) + val tags = extractTags(post.title) + if (tags.isNotEmpty()) { + Text( + text = tags.joinToString(" ") { "#$it" }, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + } + } + + Spacer(Modifier.height(12.dp)) + + Column(modifier = Modifier.padding(horizontal = 16.dp)) { + Text( + text = relativeTime(post.publishedAt ?: post.createdAt), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(6.dp)) + Text( + text = post.title, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.height(12.dp)) + post.content.forEach { block -> + val text = extractText(block) + if (text.isNotBlank()) { + Text( + text = text, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.85f), + ) + Spacer(Modifier.height(10.dp)) + } + } + } + } + } + } +} + +// ── ScreenModel ─────────────────────────────────────────────────────────────── + +class NewsScreenModel(private val api: NewsBlogsApi) : ScreenModel { + var posts by mutableStateOf>(emptyList()) + var isLoading by mutableStateOf(false) + var error by mutableStateOf(null) + + fun load() { + if (posts.isNotEmpty()) return + screenModelScope.launch(Dispatchers.Default) { + withContext(Dispatchers.Main) { isLoading = true; error = null } + when (val result = api.getNewsBlogs()) { + is APIResult.Success -> withContext(Dispatchers.Main) { + posts = result.data.sortedByDescending { it.publishedAt ?: it.createdAt } + } + is APIResult.Failure -> withContext(Dispatchers.Main) { error = result.error.toString() } + } + withContext(Dispatchers.Main) { isLoading = false } + } + } +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +private fun extractText(element: JsonElement): String = when (element) { + is JsonPrimitive -> element.contentOrNull ?: "" + is JsonObject -> { + val direct = element["content"] + if (direct != null) extractText(direct) + else element.values.joinToString(" ") { extractText(it) } + } + is JsonArray -> element.joinToString("\n") { extractText(it) } +} + +private fun extractTags(title: String): List = + Regex("""#(\w+)""").findAll(title).map { it.groupValues[1] }.toList() + +private fun buildAuthorLine(post: BlogPost): String { + val author = post.author?.name ?: return relativeTime(post.publishedAt ?: post.createdAt) + return "by $author · ${relativeTime(post.publishedAt ?: post.createdAt)}" +} + +private fun relativeTime(iso: String): String { + return try { + val then = Instant.parse(iso) + val now = Clock.System.now() + val totalSeconds = (now.epochSeconds - then.epochSeconds) + val minutes = totalSeconds / 60 + val hours = totalSeconds / 3600 + val days = totalSeconds / 86400 + when { + days >= 365 -> "${days / 365} year${if (days / 365 > 1L) "s" else ""} ago" + days >= 30 -> "${days / 30} month${if (days / 30 > 1L) "s" else ""} ago" + days >= 1 -> "$days day${if (days > 1L) "s" else ""} ago" + hours >= 1 -> "$hours hour${if (hours > 1L) "s" else ""} ago" + minutes >= 1 -> "$minutes minute${if (minutes > 1L) "s" else ""} ago" + else -> "just now" + } + } catch (_: Exception) { + iso.take(10) + } +} diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/SettingsScreen.kt new file mode 100644 index 0000000..6019386 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/SettingsScreen.kt @@ -0,0 +1,680 @@ +@file:OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) + +package hu.petrik.filcapp.screens + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Logout +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.CreditCard +import androidx.compose.material.icons.filled.DarkMode +import androidx.compose.material.icons.filled.LightMode +import androidx.compose.material.icons.filled.Palette +import androidx.compose.material.icons.filled.SettingsBrightness +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import hu.petrik.filcapp.AppSettings +import hu.petrik.filcapp.api.APIResult +import hu.petrik.filcapp.api.ApiErrorMessage +import hu.petrik.filcapp.api.DoorlockApi +import hu.petrik.filcapp.api.client.APIClient +import hu.petrik.filcapp.auth.AuthState +import hu.petrik.filcapp.auth.base64ToImageBitmap +import hu.petrik.filcapp.hslColor +import hu.petrik.filcapp.models.Card +import hu.petrik.filcapp.models.Card_authorizedDevices +import io.ktor.client.call.body +import io.ktor.client.request.* +import io.ktor.http.ContentType +import io.ktor.http.contentType +import io.ktor.http.isSuccess +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable + +@Serializable +private data class ActivateCardRequest(val deviceId: String? = null) + +// ── Settings sheet (opens when profile avatar is tapped) ────────────────────── + +@Composable +fun SettingsSheet(onDismiss: () -> Unit, onLogout: () -> Unit) { + val displayName = AuthState.displayName ?: "" + val profileImage = AuthState.profileImage + val avatar: ImageBitmap? = remember(profileImage) { + profileImage?.let { base64ToImageBitmap(it) } + } + var cardsOpen by remember { mutableStateOf(false) } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { + Column(modifier = Modifier.fillMaxSize()) { + // Profile header + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 20.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Box( + modifier = Modifier + .size(56.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center, + ) { + if (avatar != null) { + Image( + bitmap = avatar, + contentDescription = "Profile picture", + modifier = Modifier.fillMaxSize(), + ) + } else { + Text( + text = displayName.firstOrNull()?.uppercase() ?: "?", + style = MaterialTheme.typography.headlineSmall, + ) + } + } + Column { + Text( + text = displayName, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + Text( + text = "Petrik Lajos SZKI", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + HorizontalDivider() + + // Settings sections + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + // Account section + SettingsGroup(title = "Account") { + SettingsRow( + icon = Icons.Default.CreditCard, + label = "Cards", + onClick = { cardsOpen = true }, + ) + } + + // Appearance section + AppearanceSection() + } + + HorizontalDivider() + + // Logout + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onLogout() } + .padding(horizontal = 24.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Icon( + Icons.AutoMirrored.Filled.Logout, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + Text( + text = "Log out", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.error, + fontWeight = FontWeight.Medium, + ) + } + + Spacer(Modifier.height(16.dp)) + } + } + + if (cardsOpen) { + CardsSheet(onDismiss = { cardsOpen = false }) + } +} + +// ── Settings helpers ────────────────────────────────────────────────────────── + +@Composable +private fun SettingsGroup( + title: String? = null, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + Column(modifier = modifier.padding(horizontal = 16.dp)) { + if (title != null) { + Text( + text = title.uppercase(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 8.dp, bottom = 6.dp), + fontWeight = FontWeight.Medium, + ) + } + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + modifier = Modifier.fillMaxWidth(), + ) { + Column { content() } + } + } +} + +@Composable +private fun SettingsRow( + icon: ImageVector, + iconTint: Color = MaterialTheme.colorScheme.primary, + label: String, + onClick: () -> Unit = {}, + showChevron: Boolean = true, + trailing: @Composable (() -> Unit)? = null, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + Box( + modifier = Modifier + .size(30.dp) + .clip(RoundedCornerShape(7.dp)) + .background(iconTint), + contentAlignment = Alignment.Center, + ) { + Icon( + icon, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(18.dp), + ) + } + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f), + ) + if (trailing != null) { + trailing() + } else if (showChevron) { + Text( + text = "›", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +// ── Appearance section ──────────────────────────────────────────────────────── + +@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) +@Composable +private fun AppearanceSection() { + var pickerExpanded by remember { mutableStateOf(false) } + var localHue by remember { mutableFloatStateOf(AppSettings.accentHue) } + + SettingsGroup(title = "Appearance") { + // Dark mode + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + Box( + modifier = Modifier + .size(30.dp) + .clip(RoundedCornerShape(7.dp)) + .background(MaterialTheme.colorScheme.primary), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Default.SettingsBrightness, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(18.dp), + ) + } + Text("Dark Mode", style = MaterialTheme.typography.bodyLarge) + } + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + listOf("System" to Icons.Default.SettingsBrightness, "Light" to Icons.Default.LightMode, "Dark" to Icons.Default.DarkMode) + .forEachIndexed { index, (label, icon) -> + SegmentedButton( + selected = AppSettings.themeMode == index, + onClick = { AppSettings.themeMode = index }, + shape = SegmentedButtonDefaults.itemShape(index = index, count = 3), + icon = { Icon(icon, contentDescription = null, modifier = Modifier.size(16.dp)) }, + ) { + Text(label, style = MaterialTheme.typography.labelMedium) + } + } + } + } + + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp) + + // Accent color + SettingsRow( + icon = Icons.Default.Palette, + label = "Accent Color", + onClick = { pickerExpanded = !pickerExpanded }, + showChevron = false, + trailing = { + Box( + modifier = Modifier + .size(26.dp) + .clip(CircleShape) + .background(hslColor(AppSettings.accentHue, 0.65f, 0.38f)), + ) + }, + ) + + if (pickerExpanded) { + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + HueSlider( + hue = localHue, + onHueChange = { hue -> + localHue = hue + AppSettings.accentHue = hue + }, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + val presets = listOf(0f, 30f, 60f, 120f, 180f, 220f, 260f, 300f) + presets.forEach { preset -> + val isSelected = (localHue - preset) in -10f..10f + Box( + modifier = Modifier + .weight(1f) + .aspectRatio(1f) + .clip(CircleShape) + .background(hslColor(preset, 0.65f, 0.38f)) + .clickable { + localHue = preset + AppSettings.accentHue = preset + }, + contentAlignment = Alignment.Center, + ) { + if (isSelected) { + Box( + modifier = Modifier + .size(10.dp) + .clip(CircleShape) + .background(Color.White), + ) + } + } + } + } + } + } + } +} + +@Composable +private fun HueSlider(hue: Float, onHueChange: (Float) -> Unit) { + val trackHeight = 36.dp + val thumbRadius = 16.dp + val density = LocalDensity.current + + BoxWithConstraints( + modifier = Modifier + .fillMaxWidth() + .height(trackHeight), + ) { + val widthPx = with(density) { maxWidth.toPx() } + val thumbRadiusPx = with(density) { thumbRadius.toPx() } + + val hueGradient = remember { + Brush.horizontalGradient( + colors = listOf( + Color.Red, + Color(1f, 1f, 0f), + Color.Green, + Color.Cyan, + Color.Blue, + Color(1f, 0f, 1f), + Color.Red, + ), + ) + } + + Canvas( + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(50)) + .pointerInput(widthPx) { + detectTapGestures { offset -> + onHueChange((offset.x / widthPx * 360f).coerceIn(0f, 360f)) + } + } + .pointerInput(widthPx) { + detectDragGestures( + onDragStart = { offset -> + onHueChange((offset.x / widthPx * 360f).coerceIn(0f, 360f)) + }, + onDrag = { change, _ -> + onHueChange((change.position.x / widthPx * 360f).coerceIn(0f, 360f)) + }, + ) + }, + ) { + drawRect(brush = hueGradient) + + val thumbX = (hue / 360f) * size.width + val centerY = size.height / 2f + + drawCircle( + color = Color.White, + radius = thumbRadiusPx, + center = Offset(thumbX, centerY), + ) + drawCircle( + color = Color.Black.copy(alpha = 0.25f), + radius = thumbRadiusPx, + center = Offset(thumbX, centerY), + style = Stroke(width = 2f), + ) + } + } +} + +// ── Cards sheet ─────────────────────────────────────────────────────────────── + +@Composable +private fun CardsSheet(onDismiss: () -> Unit) { + val doorlockApi = remember { DoorlockApi(APIClient) } + val scope = rememberCoroutineScope() + var cards by remember { mutableStateOf>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } + var error by remember { mutableStateOf(null) } + var devicePickerCard by remember { mutableStateOf(null) } + var activatingCardId by remember { mutableStateOf(null) } + var resultDialog by remember { mutableStateOf?>(null) } + + LaunchedEffect(Unit) { + when (val result = doorlockApi.getDoorlockSelfCards()) { + is APIResult.Success -> { cards = result.data.cards; isLoading = false } + is APIResult.Failure -> { error = result.error.toString(); isLoading = false } + } + } + + suspend fun activateCard(cardId: String, deviceId: String?) { + activatingCardId = cardId + try { + val response = APIClient.post { + url("/doorlock/self/cards/$cardId/activate") + contentType(ContentType.Application.Json) + setBody(ActivateCardRequest(deviceId = deviceId)) + } + if (response.status.isSuccess()) { + resultDialog = true to "Door opened!" + } else { + val err = response.body() + resultDialog = false to err.message + } + } catch (e: Exception) { + resultDialog = false to (e.message ?: "Unknown error") + } finally { + activatingCardId = null + } + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { + Column(modifier = Modifier.fillMaxSize()) { + // Header + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .padding(bottom = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Cards", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + IconButton(onClick = onDismiss) { + Icon(Icons.Default.Close, contentDescription = "Close") + } + } + + when { + isLoading -> Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { CircularProgressIndicator() } + + error != null -> Box( + modifier = Modifier.fillMaxSize().padding(24.dp), + contentAlignment = Alignment.Center, + ) { Text(error!!, color = MaterialTheme.colorScheme.error) } + + cards.isEmpty() -> Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { Text("No cards found", color = MaterialTheme.colorScheme.onSurfaceVariant) } + + else -> LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 32.dp), + ) { + items(cards, key = { it.id }) { card -> + CardRow( + card = card, + isActivating = activatingCardId == card.id, + onUse = { + val devices = card.authorizedDevices + when { + devices.isEmpty() -> resultDialog = false to "Card is not authorized on any devices" + devices.size == 1 -> scope.launch { activateCard(card.id, devices[0].id) } + else -> devicePickerCard = card + } + }, + ) + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp) + } + } + } + } + } + + // Device picker dialog + devicePickerCard?.let { card -> + DevicePickerDialog( + devices = card.authorizedDevices, + onDismiss = { devicePickerCard = null }, + onSelect = { device -> + devicePickerCard = null + scope.launch { activateCard(card.id, device.id) } + }, + ) + } + + // Result dialog + resultDialog?.let { (success, message) -> + AlertDialog( + onDismissRequest = { resultDialog = null }, + title = { Text(if (success) "Success" else "Error") }, + text = { Text(message) }, + confirmButton = { + TextButton(onClick = { resultDialog = null }) { Text("OK") } + }, + containerColor = if (success) + MaterialTheme.colorScheme.primaryContainer + else + MaterialTheme.colorScheme.errorContainer, + ) + } +} + +// ── Card row ────────────────────────────────────────────────────────────────── + +@Composable +private fun CardRow( + card: Card, + isActivating: Boolean, + onUse: () -> Unit, +) { + val canUse = card.enabled && !card.frozen + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = card.name, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + color = if (canUse) MaterialTheme.colorScheme.onSurface + else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f), + ) + Spacer(Modifier.height(4.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + if (!card.enabled) StatusChip(label = "Disabled", color = MaterialTheme.colorScheme.error) + if (card.frozen) StatusChip(label = "Frozen", color = Color(0xFF2196F3)) + if (canUse) StatusChip(label = "Active", color = Color(0xFF4CAF50)) + } + if (card.authorizedDevices.isNotEmpty()) { + Spacer(Modifier.height(4.dp)) + Text( + text = card.authorizedDevices.joinToString(", ") { it.name }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Spacer(Modifier.width(12.dp)) + + if (isActivating) { + CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp) + } else { + Button( + onClick = onUse, + enabled = canUse, + shape = RoundedCornerShape(10.dp), + ) { + Text("Use") + } + } + } +} + +@Composable +private fun StatusChip(label: String, color: Color) { + Surface( + shape = RoundedCornerShape(4.dp), + color = color.copy(alpha = 0.15f), + ) { + Text( + text = label, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + style = MaterialTheme.typography.labelSmall, + color = color, + fontWeight = FontWeight.SemiBold, + ) + } +} + +// ── Device picker dialog ────────────────────────────────────────────────────── + +@Composable +private fun DevicePickerDialog( + devices: List, + onDismiss: () -> Unit, + onSelect: (Card_authorizedDevices) -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Select Device") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + devices.forEach { device -> + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable { onSelect(device) } + .padding(horizontal = 8.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Default.CreditCard, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(10.dp)) + Text(device.name, style = MaterialTheme.typography.bodyMedium) + } + } + } + }, + confirmButton = {}, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + }, + ) +} diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/SplashScreen.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/SplashScreen.kt new file mode 100644 index 0000000..f55721b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/SplashScreen.kt @@ -0,0 +1,48 @@ +package hu.petrik.filcapp.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import hu.petrik.filcapp.auth.AuthState +import kotlinx.coroutines.delay + +@Composable +fun SplashScreen(onAuthResolved: (loggedIn: Boolean) -> Unit) { + LaunchedEffect(Unit) { + AuthState.init() + val loggedIn = if (AuthState.isLoggedIn) AuthState.fetchSession() else false + delay(1200) + onAuthResolved(loggedIn) + } + + Box( + modifier = Modifier.fillMaxSize().background(Color.White), + contentAlignment = Alignment.Center, + ) { + // Logo — swap for actual drawable when assets are ready + Text( + text = "\uD83C\uDF3F", + fontSize = 96.sp, + color = Color(0xFFD0D0D0), + ) + + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter) { + Text( + text = "Petrik App", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + color = Color(0xFFB0B0B0), + modifier = Modifier.padding(bottom = 48.dp), + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/SubstitutionScreen.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/SubstitutionScreen.kt new file mode 100644 index 0000000..508352f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/SubstitutionScreen.kt @@ -0,0 +1,58 @@ +package hu.petrik.filcapp.screens + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.SwapCalls +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.unit.dp +import cafe.adriel.voyager.navigator.tab.Tab +import cafe.adriel.voyager.navigator.tab.TabOptions +import filcapp.composeapp.generated.resources.Res +import filcapp.composeapp.generated.resources.compose_multiplatform +import org.jetbrains.compose.resources.painterResource + +@Composable +fun SubstitutionScreen() { + Box( + modifier = + Modifier + .fillMaxSize() + .padding(top = 16.dp), + contentAlignment = Alignment.TopCenter, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image(painterResource(Res.drawable.compose_multiplatform), null) + Text( + "Placeholder screen", + style = MaterialTheme.typography.titleLarge, + ) + } + } +} + +object SubstitutionTab : Tab { + override val options: TabOptions + @Composable + get() { + val title = "Substitution" + val icon = rememberVectorPainter(Icons.Default.SwapCalls) + return remember { + TabOptions(index = 1u, title = title, icon = icon) + } + } + + @Composable + override fun Content() { + SubstitutionScreen() + } +} diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/TimetableScreen.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/TimetableScreen.kt new file mode 100644 index 0000000..835d038 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/TimetableScreen.kt @@ -0,0 +1,1072 @@ +@file:OptIn(kotlin.time.ExperimentalTime::class, androidx.compose.material3.ExperimentalMaterial3Api::class) + +package hu.petrik.filcapp.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CalendarMonth +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.* +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.* +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.Color +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cafe.adriel.voyager.core.model.ScreenModel +import cafe.adriel.voyager.core.model.rememberScreenModel +import cafe.adriel.voyager.core.model.screenModelScope +import cafe.adriel.voyager.navigator.tab.Tab +import cafe.adriel.voyager.navigator.tab.TabOptions +import hu.petrik.filcapp.api.APIResult +import hu.petrik.filcapp.api.ClassroomApi +import hu.petrik.filcapp.api.CohortApi +import hu.petrik.filcapp.api.LessonApi +import hu.petrik.filcapp.api.MovedLessonApi +import hu.petrik.filcapp.api.SubstitutionApi +import hu.petrik.filcapp.api.TeacherApi +import hu.petrik.filcapp.api.client.APIClient +import hu.petrik.filcapp.auth.AuthState +import hu.petrik.filcapp.models.Classroom +import hu.petrik.filcapp.models.Cohort +import hu.petrik.filcapp.models.EnrichedLesson +import hu.petrik.filcapp.models.MovedLessonWithRelations +import hu.petrik.filcapp.models.SubstitutionWithRelations +import hu.petrik.filcapp.models.Teacher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlin.time.Clock +import kotlinx.datetime.* + +// ── Shared timetable state (readable by TopBar) ─────────────────────────────── + +enum class TimetableMode { Class, Room, Teacher } + +object TimetableState { + var cohorts by mutableStateOf>(emptyList()) + var cohortsLoaded by mutableStateOf(false) + var cohortsError by mutableStateOf(null) + var selectedCohortId by mutableStateOf(null) + var ownCohortId by mutableStateOf(null) + var lessons by mutableStateOf>(emptyList()) + var substitutions by mutableStateOf>(emptyList()) + var movedLessons by mutableStateOf>(emptyList()) + var teacherMap by mutableStateOf>(emptyMap()) + var timetableLoaded by mutableStateOf(false) + // Room / Teacher mode + var timetableMode by mutableStateOf(TimetableMode.Class) + var classrooms by mutableStateOf>(emptyList()) + var classroomsLoaded by mutableStateOf(false) + var teacherList by mutableStateOf>(emptyList()) + var selectedRoomId by mutableStateOf(null) + var selectedTeacherId by mutableStateOf(null) +} + +// ── Tab entry point ────────────────────────────────────────────────────────── + +object TimetableTab : Tab { + override val options: TabOptions + @Composable get() { + val icon = rememberVectorPainter(Icons.Default.CalendarMonth) + return remember { TabOptions(index = 1u, title = "Timetable", icon = icon) } + } + + @Composable + override fun Content() { + val model = rememberScreenModel { + TimetableScreenModel( + LessonApi(APIClient), + CohortApi(APIClient), + TeacherApi(APIClient), + SubstitutionApi(APIClient), + MovedLessonApi(APIClient), + ClassroomApi(APIClient), + ) + } + + LaunchedEffect(Unit) { model.loadCohorts() } + + LaunchedEffect(TimetableState.selectedCohortId) { + val id = TimetableState.selectedCohortId + if (id != null) model.loadTimetable(id) + } + + LaunchedEffect(TimetableState.timetableMode) { + if (TimetableState.timetableMode == TimetableMode.Room && !TimetableState.classroomsLoaded) { + model.loadClassrooms() + } + } + + LaunchedEffect(TimetableState.selectedRoomId) { + val id = TimetableState.selectedRoomId ?: return@LaunchedEffect + model.loadRoomTimetable(id) + } + + LaunchedEffect(TimetableState.selectedTeacherId) { + val id = TimetableState.selectedTeacherId ?: return@LaunchedEffect + model.loadTeacherTimetable(id) + } + + val mode = TimetableState.timetableMode + val lessons = when (mode) { + TimetableMode.Class -> if (model.isViewingOwnCohort) TimetableState.lessons else model.localLessons + TimetableMode.Room -> model.localRoomLessons + TimetableMode.Teacher -> model.localTeacherLessons + } + val substitutions = if (mode == TimetableMode.Class) { + if (model.isViewingOwnCohort) TimetableState.substitutions else model.localSubstitutions + } else emptyList() + val movedLessons = if (mode == TimetableMode.Class) { + if (model.isViewingOwnCohort) TimetableState.movedLessons else model.localMovedLessons + } else emptyList() + + val error = when { + mode == TimetableMode.Class && TimetableState.selectedCohortId == null && TimetableState.cohortsLoaded -> + "No cohort assigned to your account" + mode == TimetableMode.Room && TimetableState.selectedRoomId == null -> + "Select a room from the top bar" + mode == TimetableMode.Teacher && TimetableState.selectedTeacherId == null -> + "Select a teacher from the top bar" + else -> model.error + } + + TimetableScreen( + mode = mode, + isLoading = model.isLoading, + lessons = lessons, + substitutions = substitutions, + movedLessons = movedLessons, + teacherMap = TimetableState.teacherMap, + error = error, + onRefresh = { + when (mode) { + TimetableMode.Class -> TimetableState.selectedCohortId?.let { model.loadTimetable(it) } + TimetableMode.Room -> TimetableState.selectedRoomId?.let { model.loadRoomTimetable(it) } + TimetableMode.Teacher -> TimetableState.selectedTeacherId?.let { model.loadTeacherTimetable(it) } + } + }, + ) + } +} + +// ── Screen ─────────────────────────────────────────────────────────────────── + +@Composable +fun TimetableScreen( + mode: TimetableMode = TimetableMode.Class, + isLoading: Boolean, + lessons: List, + substitutions: List, + movedLessons: List, + teacherMap: Map, + error: String?, + onRefresh: () -> Unit = {}, +) { + val today = remember { Clock.System.todayIn(TimeZone.currentSystemDefault()) } + var selectedDate by remember { mutableStateOf(today) } + val weekDays = remember { weekOf(today) } + var expandedLessonId by remember { mutableStateOf(null) } + var cooldown by remember { mutableStateOf(0) } + var currentTime by remember { + mutableStateOf(Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).time) + } + + LaunchedEffect(cooldown) { + if (cooldown > 0) { + delay(1000) + cooldown-- + } + } + + LaunchedEffect(Unit) { + while (true) { + delay(30_000) + currentTime = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).time + } + } + + val activeSubstitutions = remember(substitutions, selectedDate) { + substitutions + .filter { it.substitution.date.startsWith(selectedDate.toString()) } + .flatMap { sub -> sub.lessons.map { lessonId -> lessonId to sub } } + .toMap() + } + + // movedToday: lessons moved TO this specific date (may not normally appear today) + val movedToday = remember(movedLessons, selectedDate) { + movedLessons + .filter { it.movedLesson.date.startsWith(selectedDate.toString()) } + .flatMap { ml -> ml.lessons.map { id -> id to ml } } + .toMap() + } + // movedAway: lessons originally on today's day that were moved to a different date + val movedAway = remember(movedLessons, selectedDate) { + val todayIsoDay = selectedDate.dayOfWeek.isoDayNumber.toString() + movedLessons + .filter { ml -> + !ml.movedLesson.date.startsWith(selectedDate.toString()) && + ml.dayDefinition?.days?.contains(todayIsoDay) == true + } + .flatMap { ml -> ml.lessons.map { id -> id to ml } } + .toMap() + } + + val lessonById = remember(lessons) { lessons.associateBy { it.id } } + + // Base timetable lessons for today + extra lessons moved to today from another day + val displayLessons = remember(lessons, selectedDate, movedToday, movedAway) { + val base = lessonsForDate(lessons, selectedDate) + val extra = movedToday.keys + .mapNotNull { id -> lessonById[id] } + .filter { l -> !base.any { it.id == l.id } } + (base + extra).sortedBy { l -> + movedToday[l.id]?.period?.startTime ?: l.period?.startTime ?: "" + } + } + + Column(modifier = Modifier.fillMaxSize()) { + // ── Mode toggle ────────────────────────────────────────────────────── + SingleChoiceSegmentedButtonRow( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + TimetableMode.entries.forEachIndexed { index, m -> + SegmentedButton( + selected = mode == m, + onClick = { TimetableState.timetableMode = m }, + shape = SegmentedButtonDefaults.itemShape(index, TimetableMode.entries.size), + label = { Text(m.name) }, + ) + } + } + + // ── Lesson area ────────────────────────────────────────────────────── + Box(modifier = Modifier.weight(1f).fillMaxWidth()) { + PullToRefreshBox( + isRefreshing = isLoading, + onRefresh = { + if (cooldown == 0) { + cooldown = 5 + onRefresh() + } + }, + modifier = Modifier.fillMaxSize(), + ) { + LazyColumn(modifier = Modifier.fillMaxSize()) { + when { + error != null -> item { + Box(modifier = Modifier.fillParentMaxSize().padding(16.dp), contentAlignment = Alignment.Center) { + Text(error, color = MaterialTheme.colorScheme.error) + } + } + displayLessons.isEmpty() && !isLoading -> item { + Box(modifier = Modifier.fillParentMaxSize(), contentAlignment = Alignment.Center) { + Text("No lessons today", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + else -> items(displayLessons, key = { it.id }) { lesson -> + val period = if (movedToday.containsKey(lesson.id)) + movedToday[lesson.id]?.period ?: lesson.period + else lesson.period + val isActive = selectedDate == today && isLessonActive(period, currentTime) + LessonCard( + lesson = lesson, + substitution = activeSubstitutions[lesson.id], + movedLesson = movedToday[lesson.id] ?: movedAway[lesson.id], + isMovedHere = movedToday.containsKey(lesson.id), + isActive = isActive, + teacherMap = teacherMap, + onClick = { expandedLessonId = if (expandedLessonId == lesson.id) null else lesson.id }, + ) + } + } + } + } + + if (cooldown > 0) { + Surface( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 16.dp), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.secondaryContainer, + shadowElevation = 4.dp, + ) { + Text( + text = "Refresh in ${cooldown}s", + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + fontWeight = FontWeight.Medium, + ) + } + } + } + + // ── Lesson detail sheet ────────────────────────────────────────────── + val expandedLesson = expandedLessonId?.let { id -> displayLessons.find { it.id == id } } + if (expandedLesson != null) { + ModalBottomSheet( + onDismissRequest = { expandedLessonId = null }, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { + LessonDetailSheet( + lesson = expandedLesson, + substitution = activeSubstitutions[expandedLesson.id], + movedLesson = movedToday[expandedLesson.id] ?: movedAway[expandedLesson.id], + isMovedHere = movedToday.containsKey(expandedLesson.id), + teacherMap = teacherMap, + ) + } + } + + // ── Week strip ─────────────────────────────────────────────────────── + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceAround, + ) { + weekDays.forEach { date -> + val isSelected = date == selectedDate + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .background(if (isSelected) MaterialTheme.colorScheme.onSurface.copy(alpha = 0.15f) else Color.Transparent) + .clickable { selectedDate = date } + .padding(horizontal = 6.dp, vertical = 6.dp), + ) { + Text( + text = date.dayOfWeek.name.take(2).lowercase().replaceFirstChar { it.uppercase() }, + style = MaterialTheme.typography.labelSmall, + color = if (isSelected) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = date.dayOfMonth.toString(), + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + color = if (isSelected) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } +} + +// ── Cohort switcher (used in TopBar when timetable tab is active) ───────────── + +@Composable +fun CohortSwitcher() { + val cohorts = TimetableState.cohorts + val selectedCohortId = TimetableState.selectedCohortId + val selectedCohort = cohorts.find { it.id == selectedCohortId } + var sheetOpen by remember { mutableStateOf(false) } + + val label = when { + selectedCohort != null -> selectedCohort.name + !TimetableState.cohortsLoaded -> "Loading…" + TimetableState.cohortsError != null -> "Error" + else -> "Select class" + } + + SwitcherTapTarget( + label = "Class", + value = label, + hasSelection = selectedCohort != null, + onClick = { if (cohorts.isNotEmpty()) sheetOpen = true }, + ) + + if (sheetOpen) { + SearchPickerSheet( + title = "Select Class", + items = cohorts, + selectedId = selectedCohortId, + itemId = { it.id }, + itemPrimary = { it.name }, + itemSecondary = { it.short }, + filterItem = { cohort, q -> + cohort.name.contains(q, ignoreCase = true) || cohort.short.contains(q, ignoreCase = true) + }, + onSelect = { TimetableState.selectedCohortId = it.id }, + onDismiss = { sheetOpen = false }, + ) + } +} + +// ── Room switcher (tap → bottom sheet with search) ────────────────────────── + +@Composable +fun RoomSwitcher() { + val classrooms = TimetableState.classrooms + val selectedRoomId = TimetableState.selectedRoomId + val selectedRoom = classrooms.find { it.id == selectedRoomId } + var sheetOpen by remember { mutableStateOf(false) } + + SwitcherTapTarget( + label = "Room", + value = selectedRoom?.name ?: "Select room", + hasSelection = selectedRoom != null, + onClick = { sheetOpen = true }, + ) + + if (sheetOpen) { + SearchPickerSheet( + title = "Select Room", + items = classrooms, + selectedId = selectedRoomId, + itemId = { it.id }, + itemPrimary = { it.name }, + itemSecondary = { it.short }, + filterItem = { room, q -> room.name.contains(q, ignoreCase = true) || room.short.contains(q, ignoreCase = true) }, + onSelect = { TimetableState.selectedRoomId = it.id }, + onDismiss = { sheetOpen = false }, + ) + } +} + +// ── Teacher switcher (tap → bottom sheet with search) ──────────────────────── + +@Composable +fun TeacherSwitcher() { + val teachers = TimetableState.teacherList + val selectedTeacherId = TimetableState.selectedTeacherId + val selectedTeacher = teachers.find { it.id == selectedTeacherId } + var sheetOpen by remember { mutableStateOf(false) } + + SwitcherTapTarget( + label = "Teacher", + value = selectedTeacher?.let { "${it.lastName} ${it.firstName}" } ?: "Select teacher", + hasSelection = selectedTeacher != null, + onClick = { sheetOpen = true }, + ) + + if (sheetOpen) { + SearchPickerSheet( + title = "Select Teacher", + items = teachers, + selectedId = selectedTeacherId, + itemId = { it.id }, + itemPrimary = { "${it.lastName} ${it.firstName}" }, + itemSecondary = { it.short }, + filterItem = { t, q -> + "${t.lastName} ${t.firstName}".contains(q, ignoreCase = true) || + t.short.contains(q, ignoreCase = true) + }, + onSelect = { TimetableState.selectedTeacherId = it.id }, + onDismiss = { sheetOpen = false }, + ) + } +} + +// ── Shared tap target (matches CohortSwitcher style) ───────────────────────── + +@Composable +private fun SwitcherTapTarget( + label: String, + value: String, + hasSelection: Boolean, + onClick: () -> Unit, +) { + Column(modifier = Modifier.clickable(onClick = onClick)) { + Text( + text = label, + style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Normal), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = value, + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + color = if (hasSelection) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant, + ) + Icon( + Icons.Default.KeyboardArrowDown, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +// ── Generic search picker bottom sheet ─────────────────────────────────────── + +@Composable +private fun SearchPickerSheet( + title: String, + items: List, + selectedId: String?, + itemId: (T) -> String, + itemPrimary: (T) -> String, + itemSecondary: (T) -> String, + filterItem: (T, String) -> Boolean, + onSelect: (T) -> Unit, + onDismiss: () -> Unit, +) { + var query by remember { mutableStateOf("") } + val filtered = remember(items, query) { + if (query.isBlank()) items else items.filter { filterItem(it, query) } + } + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + delay(300) + try { focusRequester.requestFocus() } catch (_: Exception) {} + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { + Column(modifier = Modifier.fillMaxSize()) { + // Header + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .padding(bottom = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + IconButton(onClick = onDismiss) { + Icon(Icons.Default.Close, contentDescription = "Close") + } + } + + // Search field + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 12.dp) + .clip(RoundedCornerShape(14.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(horizontal = 14.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon( + Icons.Default.Search, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + TextField( + value = query, + onValueChange = { query = it }, + placeholder = { Text("Search…", style = MaterialTheme.typography.bodyMedium) }, + singleLine = true, + modifier = Modifier.weight(1f).focusRequester(focusRequester), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ), + textStyle = MaterialTheme.typography.bodyMedium, + ) + if (query.isNotEmpty()) { + Icon( + Icons.Default.Close, + contentDescription = "Clear", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp).clickable { query = "" }, + ) + } + } + + // Results + if (filtered.isEmpty()) { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center, + ) { + Text("No results", color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium) + } + } else { + LazyColumn( + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(bottom = 32.dp), + ) { + items(filtered, key = { itemId(it) }) { item -> + val isSelected = itemId(item) == selectedId + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + onSelect(item) + onDismiss() + } + .background( + if (isSelected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f) + else Color.Transparent + ) + .padding(horizontal = 20.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + Text( + itemPrimary(item), + style = MaterialTheme.typography.bodyLarge, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, + ) + val secondary = itemSecondary(item) + if (secondary.isNotEmpty()) { + Text( + secondary, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + if (isSelected) { + Surface( + shape = RoundedCornerShape(6.dp), + color = MaterialTheme.colorScheme.primary, + ) { + Text( + "Selected", + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimary, + fontWeight = FontWeight.SemiBold, + ) + } + } + } + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp) + } + } + } + } + } +} + +// ── Lesson card ─────────────────────────────────────────────────────────────── + +@Composable +fun LessonCard( + lesson: EnrichedLesson, + substitution: SubstitutionWithRelations? = null, + movedLesson: MovedLessonWithRelations? = null, + isMovedHere: Boolean = false, + isActive: Boolean = false, + teacherMap: Map = emptyMap(), + onClick: (() -> Unit)? = null, +) { + // When a lesson was moved to today, display its new time/room; otherwise use the timetable values + val displayPeriod = if (isMovedHere) movedLesson?.period ?: lesson.period else lesson.period + val startTime = displayPeriod?.startTime?.take(5) ?: "" + val endTime = displayPeriod?.endTime?.take(5) ?: "" + val subjectName = lesson.subject?.name ?: "Unknown" + val displayRoom = if (isMovedHere) movedLesson?.classroom?.name else lesson.classrooms.firstOrNull()?.name + val roomName = displayRoom?.let { "Room $it" } ?: "" + + val isCancelled = substitution != null && substitution.teacher == null + val isSubstituted = substitution != null && substitution.teacher != null + val isMovedAway = movedLesson != null && !isMovedHere + + val cancelledColor = MaterialTheme.colorScheme.error.copy(alpha = 0.75f) + val substitutedColor = Color(0xFFF59E0B) + val movedColor = MaterialTheme.colorScheme.primary + + val textColor = when { + isCancelled -> cancelledColor + isMovedAway -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f) + else -> Color.Unspecified + } + val squareBorderColor = when { + isCancelled -> cancelledColor + isMovedHere -> movedColor + else -> Color.Transparent + } + + val substituterTeacher = substitution?.substitution?.substituter?.let { id -> teacherMap[id] } + val displayTeacherName = substituterTeacher?.let { "${it.lastName} ${it.firstName}" } + val originalTeacherName = lesson.teachers.firstOrNull()?.name ?: "" + + val movedToDate = if (isMovedAway) movedLesson?.movedLesson?.date?.take(10) else null + + Row( + modifier = Modifier + .fillMaxWidth() + .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) + .padding(horizontal = 16.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Time column + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.width(44.dp), + ) { + Text(startTime, style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Medium, color = textColor) + Text(endTime, style = MaterialTheme.typography.labelSmall, color = if (isCancelled) cancelledColor else MaterialTheme.colorScheme.onSurfaceVariant) + } + + Spacer(Modifier.width(8.dp)) + + // Subject thumbnail + val thumbnailBackground = if (isActive) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant + val thumbnailTextColor = if (isActive) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant + Box( + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(10.dp)) + .border(1.5.dp, squareBorderColor, RoundedCornerShape(10.dp)) + .background(thumbnailBackground), + contentAlignment = Alignment.Center, + ) { + Text( + text = lesson.subject?.short ?: "?", + style = MaterialTheme.typography.labelMedium, + color = thumbnailTextColor, + maxLines = 1, + overflow = TextOverflow.Clip, + modifier = Modifier.padding(4.dp), + ) + } + + Spacer(Modifier.width(10.dp)) + + // Info column + Column(modifier = Modifier.weight(1f)) { + Text(subjectName, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = textColor, maxLines = 1, overflow = TextOverflow.Ellipsis) + val hasChips = isCancelled || isMovedAway + if (hasChips) { + Spacer(Modifier.height(2.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + if (isCancelled) Surface( + shape = RoundedCornerShape(4.dp), + color = MaterialTheme.colorScheme.errorContainer, + ) { + Text( + text = "Cancelled", + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onErrorContainer, + fontWeight = FontWeight.SemiBold, + ) + } + if (isMovedAway) Surface( + shape = RoundedCornerShape(4.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + Text( + text = if (movedToDate != null) "→ $movedToDate" else "Moved away", + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.SemiBold, + ) + } + } + } + if (isSubstituted && displayTeacherName != null) { + Text(displayTeacherName, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, color = substitutedColor) + } else if (originalTeacherName.isNotEmpty()) { + Text(originalTeacherName, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, color = textColor) + } + if (roomName.isNotEmpty()) { + val roomColor = when { + isMovedHere -> Color(0xFF9C27B0) + else -> textColor + } + Spacer(Modifier.height(2.dp)) + Text(roomName, style = MaterialTheme.typography.bodySmall, color = roomColor, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } +} + +// ── Lesson detail bottom sheet ──────────────────────────────────────────────── + +@Composable +fun LessonDetailSheet( + lesson: EnrichedLesson, + substitution: SubstitutionWithRelations? = null, + movedLesson: MovedLessonWithRelations? = null, + isMovedHere: Boolean = false, + teacherMap: Map = emptyMap(), +) { + val isCancelled = substitution != null && substitution.teacher == null + val isSubstituted = substitution != null && substitution.teacher != null + val isMovedAway = movedLesson != null && !isMovedHere + + val cancelledColor = MaterialTheme.colorScheme.error + val substitutedColor = Color(0xFFF59E0B) + val movedColor = MaterialTheme.colorScheme.primary + + val displayPeriod = if (isMovedHere) movedLesson?.period ?: lesson.period else lesson.period + val startTime = displayPeriod?.startTime?.take(5) ?: "" + val endTime = displayPeriod?.endTime?.take(5) ?: "" + val displayRoom = if (isMovedHere) movedLesson?.classroom?.name else lesson.classrooms.firstOrNull()?.name + + val substituterTeacher = substitution?.substitution?.substituter?.let { id -> teacherMap[id] } + val displayTeacherName = substituterTeacher?.let { "${it.lastName} ${it.firstName}" } + val originalTeacherName = lesson.teachers.firstOrNull()?.name ?: "" + + val movedToDate = if (isMovedAway) movedLesson?.movedLesson?.date?.take(10) else null + val movedFromDay = if (isMovedHere) lesson.day?.name else null + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(bottom = 32.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + // Subject name + Text( + text = lesson.subject?.name ?: "Unknown", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = when { + isCancelled -> cancelledColor + isMovedAway -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) + else -> Color.Unspecified + }, + ) + + // Status chips + val hasStatus = isCancelled || isMovedHere || isMovedAway || isSubstituted + if (hasStatus) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (isCancelled) Surface(shape = RoundedCornerShape(6.dp), color = MaterialTheme.colorScheme.errorContainer) { + Text("Cancelled", modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onErrorContainer, fontWeight = FontWeight.SemiBold) + } + if (isMovedHere) Surface(shape = RoundedCornerShape(6.dp), color = movedColor.copy(alpha = 0.15f)) { + Text("Moved here", modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), style = MaterialTheme.typography.labelMedium, color = movedColor, fontWeight = FontWeight.SemiBold) + } + if (isMovedAway) Surface(shape = RoundedCornerShape(6.dp), color = MaterialTheme.colorScheme.surfaceVariant) { + Text("Moved away", modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, fontWeight = FontWeight.SemiBold) + } + if (isSubstituted) Surface(shape = RoundedCornerShape(6.dp), color = Color(0xFFF59E0B).copy(alpha = 0.15f)) { + Text("Substituted", modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), style = MaterialTheme.typography.labelMedium, color = substitutedColor, fontWeight = FontWeight.SemiBold) + } + } + } + + HorizontalDivider() + + // Time + DetailRow(label = "Time", value = if (startTime.isNotEmpty()) "$startTime – $endTime" else "—") + + // Room + DetailRow(label = "Room", value = displayRoom ?: "—") + + // Subject short + lesson.subject?.short?.let { short -> + DetailRow(label = "Short", value = short) + } + + // Teacher + if (isSubstituted && displayTeacherName != null) { + DetailRow(label = "Teacher", value = displayTeacherName, valueColor = substitutedColor) + if (originalTeacherName.isNotEmpty()) { + DetailRow(label = "Was", value = originalTeacherName) + } + } else if (originalTeacherName.isNotEmpty()) { + DetailRow(label = "Teacher", value = originalTeacherName) + } + + // Move info + if (isMovedHere && movedFromDay != null) { + DetailRow(label = "Originally", value = movedFromDay, valueColor = movedColor) + } + if (isMovedAway && movedToDate != null) { + DetailRow(label = "Moved to", value = movedToDate, valueColor = MaterialTheme.colorScheme.onSurfaceVariant) + } + } +} + +@Composable +private fun DetailRow(label: String, value: String, valueColor: Color = Color.Unspecified) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(value, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, color = valueColor) + } +} + +// ── ScreenModel ─────────────────────────────────────────────────────────────── + +class TimetableScreenModel( + private val lessonApi: LessonApi, + private val cohortApi: CohortApi, + private val teacherApi: TeacherApi, + private val substitutionApi: SubstitutionApi, + private val movedLessonApi: MovedLessonApi, + private val classroomApi: ClassroomApi, +) : ScreenModel { + var localLessons by mutableStateOf>(emptyList()) + var localSubstitutions by mutableStateOf>(emptyList()) + var localMovedLessons by mutableStateOf>(emptyList()) + var localRoomLessons by mutableStateOf>(emptyList()) + var localTeacherLessons by mutableStateOf>(emptyList()) + var isViewingOwnCohort by mutableStateOf(true) + var error by mutableStateOf(null) + var isLoading by mutableStateOf(false) + + fun loadCohorts() { + if (TimetableState.cohortsLoaded) return + screenModelScope.launch(Dispatchers.Default) { + launch { + when (val result = teacherApi.getTimetableTeachersAll()) { + is APIResult.Success -> withContext(Dispatchers.Main) { + TimetableState.teacherMap = result.data.flatMap { t -> + listOfNotNull(t.id to t, t.userId?.let { uid -> uid to t }) + }.toMap() + TimetableState.teacherList = result.data.sortedBy { "${it.lastName} ${it.firstName}" } + } + is APIResult.Failure -> { /* non-fatal */ } + } + } + when (val result = cohortApi.getCohort()) { + is APIResult.Success -> withContext(Dispatchers.Main) { + TimetableState.cohorts = result.data + if (TimetableState.selectedCohortId == null) { + val own = AuthState.cohortId ?: result.data.firstOrNull()?.id + TimetableState.selectedCohortId = own + TimetableState.ownCohortId = own + } + TimetableState.cohortsLoaded = true + } + is APIResult.Failure -> withContext(Dispatchers.Main) { + TimetableState.cohortsError = result.error.toString() + TimetableState.cohortsLoaded = true + } + } + } + } + + fun loadTimetable(cohortId: String) { + val isOwn = cohortId == TimetableState.ownCohortId + screenModelScope.launch(Dispatchers.Default) { + withContext(Dispatchers.Main) { isLoading = true; error = null; isViewingOwnCohort = isOwn } + try { + launch { + when (val result = substitutionApi.getTimetableSubstitutionsCohortByCohortId(cohortId)) { + is APIResult.Success -> withContext(Dispatchers.Main) { + if (isOwn) TimetableState.substitutions = result.data.substitutions + else localSubstitutions = result.data.substitutions + } + is APIResult.Failure -> { /* non-fatal */ } + } + } + launch { + when (val result = movedLessonApi.getTimetableMovedLessonsCohortByCohortId(cohortId)) { + is APIResult.Success -> withContext(Dispatchers.Main) { + if (isOwn) TimetableState.movedLessons = result.data + else localMovedLessons = result.data + } + is APIResult.Failure -> { /* non-fatal */ } + } + } + when (val result = lessonApi.getTimetableLessonsForCohortByCohortId(cohortId)) { + is APIResult.Success -> withContext(Dispatchers.Main) { + if (isOwn) { TimetableState.lessons = result.data; TimetableState.timetableLoaded = true } + else localLessons = result.data + } + is APIResult.Failure -> withContext(Dispatchers.Main) { error = result.error.toString() } + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { error = e.message ?: "Unknown error" } + } finally { + withContext(Dispatchers.Main) { isLoading = false } + } + } + } + + fun loadClassrooms() { + if (TimetableState.classroomsLoaded) return + screenModelScope.launch(Dispatchers.Default) { + when (val result = classroomApi.getTimetableClassroomsAll()) { + is APIResult.Success -> withContext(Dispatchers.Main) { + TimetableState.classrooms = result.data.sortedBy { it.name } + TimetableState.classroomsLoaded = true + } + is APIResult.Failure -> { /* non-fatal */ } + } + } + } + + fun loadRoomTimetable(roomId: String) { + screenModelScope.launch(Dispatchers.Default) { + withContext(Dispatchers.Main) { isLoading = true; error = null } + when (val result = lessonApi.getTimetableLessonsForRoomByClassroomId(roomId)) { + is APIResult.Success -> withContext(Dispatchers.Main) { localRoomLessons = result.data } + is APIResult.Failure -> withContext(Dispatchers.Main) { error = result.error.toString() } + } + withContext(Dispatchers.Main) { isLoading = false } + } + } + + fun loadTeacherTimetable(teacherId: String) { + screenModelScope.launch(Dispatchers.Default) { + withContext(Dispatchers.Main) { isLoading = true; error = null } + when (val result = lessonApi.getTimetableLessonsForTeacherByTeacherId(teacherId)) { + is APIResult.Success -> withContext(Dispatchers.Main) { localTeacherLessons = result.data } + is APIResult.Failure -> withContext(Dispatchers.Main) { error = result.error.toString() } + } + withContext(Dispatchers.Main) { isLoading = false } + } + } +} + +// ── Date helpers ────────────────────────────────────────────────────────────── + +private fun weekOf(date: LocalDate): List { + return if (date.dayOfWeek.ordinal >= 5) { + val saturday = date.minus(date.dayOfWeek.ordinal - 5, DateTimeUnit.DAY) + (0..1).map { saturday.plus(it, DateTimeUnit.DAY) } + + (2..6).map { saturday.plus(it, DateTimeUnit.DAY) } + } else { + val monday = date.minus(date.dayOfWeek.ordinal, DateTimeUnit.DAY) + (0..6).map { monday.plus(it, DateTimeUnit.DAY) } + } +} + +internal fun isLessonActive(period: hu.petrik.filcapp.models.Period?, now: LocalTime): Boolean { + val start = period?.startTime?.take(5)?.let { runCatching { LocalTime.parse(it) }.getOrNull() } ?: return false + val end = period.endTime?.take(5)?.let { runCatching { LocalTime.parse(it) }.getOrNull() } ?: return false + return now >= start && now <= end +} + +private fun lessonsForDate(lessons: List, date: LocalDate): List { + val dayNum = date.dayOfWeek.isoDayNumber.toString() + return lessons + .filter { lesson -> + val days = lesson.day?.days?.filterNotNull() ?: return@filter false + days.any { it == dayNum } + } + .sortedBy { it.period?.startTime ?: "" } +} diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/WelcomeScreen.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/WelcomeScreen.kt new file mode 100644 index 0000000..aa8b576 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/WelcomeScreen.kt @@ -0,0 +1,131 @@ +package hu.petrik.filcapp.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import hu.petrik.filcapp.apiBaseUrl +import hu.petrik.filcapp.auth.AuthState +import hu.petrik.filcapp.auth.AuthWebView +import kotlinx.coroutines.launch + +private data class OnboardingPage(val title: String, val subtitle: String, val illustrationLabel: String) + +private val pages = listOf( + OnboardingPage( + title = "Stay informed", + subtitle = "Keep track of substitutions and timetables with the Petrik app.", + illustrationLabel = "Illustration or lottie", + ), + OnboardingPage( + title = "Follow Petrik News", + subtitle = "Follow the most important events at Petrik and don't miss a thing.", + illustrationLabel = "Other Illustration or lottie", + ), +) + +@Composable +fun WelcomeScreen(onLoggedIn: () -> Unit) { + val scope = rememberCoroutineScope() + var showWebView by remember { mutableStateOf(false) } + val pagerState = rememberPagerState { pages.size } + + if (showWebView) { + AuthWebView( + apiBaseUrl = apiBaseUrl, + onSessionAcquired = { token -> + AuthState.login(token) + showWebView = false + scope.launch { + AuthState.fetchSession() + onLoggedIn() + } + }, + onDismiss = { showWebView = false }, + ) + return + } + + Column( + modifier = Modifier.fillMaxSize().background(Color.White), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + HorizontalPager(state = pagerState, modifier = Modifier.weight(1f)) { page -> + val p = pages[page] + Column( + modifier = Modifier.fillMaxSize().padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(Modifier.height(24.dp)) + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .clip(RoundedCornerShape(12.dp)) + .background(Color(0xFFE0E0E0)), + contentAlignment = Alignment.Center, + ) { + Text(p.illustrationLabel, color = Color(0xFF888888)) + } + Spacer(Modifier.height(32.dp)) + Text( + text = p.title, + style = MaterialTheme.typography.headlineLarge, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(12.dp)) + Text( + text = p.subtitle, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(24.dp)) + } + } + + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(bottom = 16.dp), + ) { + repeat(pages.size) { i -> + val selected = pagerState.currentPage == i + Box( + modifier = Modifier + .height(6.dp) + .width(if (selected) 24.dp else 6.dp) + .clip(CircleShape) + .background(if (selected) Color(0xFF888888) else Color(0xFFD0D0D0)), + ) + } + } + + Button( + onClick = { showWebView = true }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(bottom = 32.dp) + .height(56.dp), + shape = RoundedCornerShape(28.dp), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFF0F0F0)), + elevation = ButtonDefaults.buttonElevation(0.dp), + ) { + Box(modifier = Modifier.size(20.dp).background(Color(0xFFCCCCCC))) + Spacer(Modifier.width(12.dp)) + Text("Sign in with Microsoft", color = Color(0xFF333333), style = MaterialTheme.typography.bodyLarge) + } + } +} diff --git a/composeApp/src/desktopMain/kotlin/hu/petrik/filcapp/AppPreferences.desktop.kt b/composeApp/src/desktopMain/kotlin/hu/petrik/filcapp/AppPreferences.desktop.kt new file mode 100644 index 0000000..1c59283 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/hu/petrik/filcapp/AppPreferences.desktop.kt @@ -0,0 +1,12 @@ +package hu.petrik.filcapp + +import java.util.prefs.Preferences + +actual object AppPreferences { + private val prefs = Preferences.userRoot().node("hu/petrik/filcapp") + + actual fun getAccentHue(): Float = prefs.getFloat("accent_hue", 220f) + actual fun setAccentHue(hue: Float) { prefs.putFloat("accent_hue", hue) } + actual fun getThemeMode(): Int = prefs.getInt("theme_mode", 0) + actual fun setThemeMode(mode: Int) { prefs.putInt("theme_mode", mode) } +} diff --git a/composeApp/src/desktopMain/kotlin/hu/petrik/filcapp/Config.desktop.kt b/composeApp/src/desktopMain/kotlin/hu/petrik/filcapp/Config.desktop.kt new file mode 100644 index 0000000..6a06d30 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/hu/petrik/filcapp/Config.desktop.kt @@ -0,0 +1,6 @@ +package hu.petrik.filcapp + +actual val apiBaseUrl: String = + System.getProperty("API_BASE_URL") + ?: System.getenv("API_BASE_URL") + ?: "http://localhost:3000" diff --git a/composeApp/src/desktopMain/kotlin/hu/petrik/filcapp/Main.kt b/composeApp/src/desktopMain/kotlin/hu/petrik/filcapp/Main.kt new file mode 100644 index 0000000..9e95568 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/hu/petrik/filcapp/Main.kt @@ -0,0 +1,10 @@ +package hu.petrik.filcapp + +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.application + +fun main() = application { + Window(onCloseRequest = ::exitApplication, title = "Filcapp") { + App() + } +} diff --git a/composeApp/src/desktopMain/kotlin/hu/petrik/filcapp/Platform.desktop.kt b/composeApp/src/desktopMain/kotlin/hu/petrik/filcapp/Platform.desktop.kt new file mode 100644 index 0000000..8c61588 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/hu/petrik/filcapp/Platform.desktop.kt @@ -0,0 +1,7 @@ +package hu.petrik.filcapp + +class DesktopPlatform : Platform { + override val name: String = "Desktop" +} + +actual fun getPlatform(): Platform = DesktopPlatform() diff --git a/composeApp/src/desktopMain/kotlin/hu/petrik/filcapp/auth/AuthWebView.desktop.kt b/composeApp/src/desktopMain/kotlin/hu/petrik/filcapp/auth/AuthWebView.desktop.kt new file mode 100644 index 0000000..baf61e3 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/hu/petrik/filcapp/auth/AuthWebView.desktop.kt @@ -0,0 +1,51 @@ +package hu.petrik.filcapp.auth + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import java.awt.Desktop +import java.net.URI + +@Composable +actual fun AuthWebView(apiBaseUrl: String, onSessionAcquired: (String) -> Unit, onDismiss: () -> Unit) { + var tokenInput by remember { mutableStateOf("") } + + LaunchedEffect(Unit) { + runCatching { Desktop.getDesktop().browse(URI("$apiBaseUrl")) } + } + + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Card(shape = RoundedCornerShape(16.dp), modifier = Modifier.width(420.dp)) { + Column( + modifier = Modifier.padding(28.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text("Sign in (debug)", style = MaterialTheme.typography.headlineSmall) + Text( + "Sign in via the browser at $apiBaseUrl, then open DevTools " + + "(F12) → Application → Cookies → find filc.session_token and paste its value below.", + style = MaterialTheme.typography.bodyMedium, + ) + OutlinedTextField( + value = tokenInput, + onValueChange = { tokenInput = it }, + label = { Text("filc.session_token") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = onDismiss, modifier = Modifier.weight(1f)) { Text("Cancel") } + Button( + onClick = { if (tokenInput.isNotBlank()) onSessionAcquired(tokenInput.trim()) }, + modifier = Modifier.weight(1f), + enabled = tokenInput.isNotBlank(), + ) { Text("Continue") } + } + } + } + } +} diff --git a/composeApp/src/desktopMain/kotlin/hu/petrik/filcapp/auth/ProfileImageDecoder.desktop.kt b/composeApp/src/desktopMain/kotlin/hu/petrik/filcapp/auth/ProfileImageDecoder.desktop.kt new file mode 100644 index 0000000..8e3bff8 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/hu/petrik/filcapp/auth/ProfileImageDecoder.desktop.kt @@ -0,0 +1,16 @@ +package hu.petrik.filcapp.auth + +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.toComposeImageBitmap +import org.jetbrains.skia.Image +import java.util.Base64 + +actual fun base64ToImageBitmap(base64: String): ImageBitmap? { + val data = base64.substringAfter(",", base64) + return try { + val bytes = Base64.getDecoder().decode(data) + Image.makeFromEncoded(bytes).toComposeImageBitmap() + } catch (_: Exception) { + null + } +} diff --git a/composeApp/src/desktopMain/kotlin/hu/petrik/filcapp/auth/SessionStore.desktop.kt b/composeApp/src/desktopMain/kotlin/hu/petrik/filcapp/auth/SessionStore.desktop.kt new file mode 100644 index 0000000..7806313 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/hu/petrik/filcapp/auth/SessionStore.desktop.kt @@ -0,0 +1,11 @@ +package hu.petrik.filcapp.auth + +import java.util.prefs.Preferences + +actual object SessionStore { + private val prefs = Preferences.userRoot().node("hu/petrik/filcapp") + + actual fun get(): String? = prefs.get("session_token", null) + actual fun set(token: String) { prefs.put("session_token", token) } + actual fun clear() { prefs.remove("session_token") } +} diff --git a/composeApp/src/iosMain/kotlin/hu/petrik/filcapp/AppPreferences.ios.kt b/composeApp/src/iosMain/kotlin/hu/petrik/filcapp/AppPreferences.ios.kt new file mode 100644 index 0000000..88481de --- /dev/null +++ b/composeApp/src/iosMain/kotlin/hu/petrik/filcapp/AppPreferences.ios.kt @@ -0,0 +1,18 @@ +package hu.petrik.filcapp + +import platform.Foundation.NSUserDefaults + +actual object AppPreferences { + private val defaults = NSUserDefaults.standardUserDefaults + private const val KEY = "accent_hue" + + actual fun getAccentHue(): Float = + if (defaults.objectForKey(KEY) != null) defaults.floatForKey(KEY) else 220f + + actual fun setAccentHue(hue: Float) { defaults.setFloat(hue, forKey = KEY) } + + actual fun getThemeMode(): Int = + if (defaults.objectForKey("theme_mode") != null) defaults.integerForKey("theme_mode").toInt() else 0 + + actual fun setThemeMode(mode: Int) { defaults.setInteger(mode.toLong(), forKey = "theme_mode") } +} diff --git a/composeApp/src/iosMain/kotlin/hu/petrik/filcapp/Config.ios.kt b/composeApp/src/iosMain/kotlin/hu/petrik/filcapp/Config.ios.kt new file mode 100644 index 0000000..0c4e90d --- /dev/null +++ b/composeApp/src/iosMain/kotlin/hu/petrik/filcapp/Config.ios.kt @@ -0,0 +1,8 @@ +package hu.petrik.filcapp + +import kotlin.experimental.ExperimentalNativeApi +import kotlin.native.Platform + +@OptIn(ExperimentalNativeApi::class) +actual val apiBaseUrl: String + get() = if (Platform.isDebugBinary) "http://localhost:3000" else "https://filc.space" diff --git a/composeApp/src/iosMain/kotlin/hu/petrik/filcapp/auth/AuthWebView.ios.kt b/composeApp/src/iosMain/kotlin/hu/petrik/filcapp/auth/AuthWebView.ios.kt new file mode 100644 index 0000000..4acfa62 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/hu/petrik/filcapp/auth/AuthWebView.ios.kt @@ -0,0 +1,72 @@ +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) + +package hu.petrik.filcapp.auth + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.UIKitView +import platform.CoreGraphics.CGRectMake +import platform.Foundation.NSHTTPCookie +import platform.Foundation.NSURL +import platform.WebKit.WKNavigation +import platform.WebKit.WKNavigationDelegateProtocol +import platform.WebKit.WKWebView +import platform.WebKit.WKWebViewConfiguration +import platform.WebKit.WKWebsiteDataStore +import platform.darwin.NSObject + +// Runs inside the WebView so cookies from the POST response land in the WebView's jar, +// fixing the state_mismatch that occurs when the POST is made from Ktor instead. +private val AUTH_HTML = """ + +""".trimIndent() + +@Composable +actual fun AuthWebView(apiBaseUrl: String, onSessionAcquired: (String) -> Unit, onDismiss: () -> Unit) { + val baseUrl = remember(apiBaseUrl) { NSURL.URLWithString(apiBaseUrl) } + val delegate = remember { NavDelegate(onSessionAcquired) } + + Box(Modifier.fillMaxSize()) { + UIKitView( + factory = { + val config = WKWebViewConfiguration() + config.websiteDataStore = WKWebsiteDataStore.nonPersistentDataStore() + val webView = WKWebView(CGRectMake(0.0, 0.0, 0.0, 0.0), config) + webView.navigationDelegate = delegate + webView.loadHTMLString(AUTH_HTML, baseURL = baseUrl) + webView + }, + modifier = Modifier.fillMaxSize(), + ) + IconButton(onClick = onDismiss, modifier = Modifier.align(Alignment.TopStart)) { + Icon(Icons.Default.Close, contentDescription = "Close") + } + } +} + +private class NavDelegate( + private val onSessionAcquired: (String) -> Unit, +) : NSObject(), WKNavigationDelegateProtocol { + override fun webView(webView: WKWebView, didFinishNavigation: WKNavigation?) { + webView.configuration.websiteDataStore.httpCookieStore.getAllCookies { cookies -> + @Suppress("UNCHECKED_CAST") + val list = cookies as? List ?: return@getAllCookies + val token = list.firstOrNull { it.name == "filc.session_token" }?.value + if (token != null) onSessionAcquired(token) + } + } +} diff --git a/composeApp/src/iosMain/kotlin/hu/petrik/filcapp/auth/ProfileImageDecoder.ios.kt b/composeApp/src/iosMain/kotlin/hu/petrik/filcapp/auth/ProfileImageDecoder.ios.kt new file mode 100644 index 0000000..6feea7e --- /dev/null +++ b/composeApp/src/iosMain/kotlin/hu/petrik/filcapp/auth/ProfileImageDecoder.ios.kt @@ -0,0 +1,27 @@ +package hu.petrik.filcapp.auth + +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.toComposeImageBitmap +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.usePinned +import org.jetbrains.skia.Image +import platform.Foundation.NSData +import platform.Foundation.create +import platform.darwin.UInt8 + +@OptIn(ExperimentalForeignApi::class) +actual fun base64ToImageBitmap(base64: String): ImageBitmap? { + val data = base64.substringAfter(",", base64) + return try { + val nsData = NSData.create(base64Encoding = data) ?: return null + val length = nsData.length.toInt() + val bytes = ByteArray(length) + bytes.usePinned { pinned -> + platform.posix.memcpy(pinned.addressOf(0), nsData.bytes, length.toULong()) + } + Image.makeFromEncoded(bytes).toComposeImageBitmap() + } catch (_: Exception) { + null + } +} diff --git a/composeApp/src/iosMain/kotlin/hu/petrik/filcapp/auth/SessionStore.ios.kt b/composeApp/src/iosMain/kotlin/hu/petrik/filcapp/auth/SessionStore.ios.kt new file mode 100644 index 0000000..1815b6a --- /dev/null +++ b/composeApp/src/iosMain/kotlin/hu/petrik/filcapp/auth/SessionStore.ios.kt @@ -0,0 +1,12 @@ +package hu.petrik.filcapp.auth + +import platform.Foundation.NSUserDefaults + +actual object SessionStore { + private val defaults = NSUserDefaults.standardUserDefaults + private const val KEY = "session_token" + + actual fun get(): String? = defaults.stringForKey(KEY) + actual fun set(token: String) { defaults.setObject(token, KEY) } + actual fun clear() { defaults.removeObjectForKey(KEY) } +}