From 6b4ec2fe20e9f011685a5119f8d85d419df9cb34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Susa=20Mil=C3=A1n=20Mih=C3=A1ly?= Date: Sat, 13 Dec 2025 12:21:15 +0100 Subject: [PATCH 01/23] Created app structure, tab nav, scaffolded some of the home screen. --- composeApp/build.gradle.kts | 24 +++- .../kotlin/hu/petrik/filcapp/App.kt | 72 ++++++---- .../hu/petrik/filcapp/components/DateView.kt | 123 ++++++++++++++++++ .../hu/petrik/filcapp/components/TopBar.kt | 58 +++++++++ .../hu/petrik/filcapp/screens/HomeScreen.kt | 63 +++++++++ .../hu/petrik/filcapp/screens/NewsScreen.kt | 67 ++++++++++ .../filcapp/screens/SubstitutionScreen.kt | 62 +++++++++ .../petrik/filcapp/screens/TimetableScreen.kt | 66 ++++++++++ 8 files changed, 509 insertions(+), 26 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/hu/petrik/filcapp/components/DateView.kt create mode 100644 composeApp/src/commonMain/kotlin/hu/petrik/filcapp/components/TopBar.kt create mode 100644 composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/HomeScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/NewsScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/SubstitutionScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/TimetableScreen.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 9a0c769..da01f63 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -8,15 +8,21 @@ plugins { alias(libs.plugins.composeCompiler) } +compose.resources { + publicResClass = true + generateResClass = always +} + kotlin { androidTarget { compilerOptions { jvmTarget.set(JvmTarget.JVM_11) } } listOf( + iosX64(), iosArm64(), iosSimulatorArm64(), ).forEach { iosTarget -> iosTarget.binaries.framework { - baseName = "ComposeApp" + baseName = "ComposeApp" // Change this to your module name isStatic = true } } @@ -35,8 +41,24 @@ kotlin { implementation(compose.components.uiToolingPreview) implementation(libs.androidx.lifecycle.viewmodelCompose) implementation(libs.androidx.lifecycle.runtimeCompose) + + implementation(compose.material3) + implementation(compose.materialIconsExtended) + implementation("cafe.adriel.voyager:voyager-navigator:1.0.0") + implementation("cafe.adriel.voyager:voyager-tab-navigator:1.0.0") + implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.7.1") } commonTest.dependencies { implementation(libs.kotlin.test) } + + val iosX64Main by getting + val iosArm64Main by getting + val iosSimulatorArm64Main by getting + val iosMain by creating { + dependsOn(commonMain.get()) + iosX64Main.dependsOn(this) + iosArm64Main.dependsOn(this) + iosSimulatorArm64Main.dependsOn(this) + } } } diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/App.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/App.kt index 544a8bd..0634055 100644 --- a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/App.kt +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/App.kt @@ -1,20 +1,32 @@ package hu.petrik.filcapp -import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.Image -import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column 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.material.icons.Icons +import androidx.compose.material.icons.filled.CalendarMonth +import androidx.compose.material.icons.filled.Campaign +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Settings +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.CurrentTab +import cafe.adriel.voyager.navigator.tab.Tab +import cafe.adriel.voyager.navigator.tab.TabNavigator +import cafe.adriel.voyager.navigator.tab.TabOptions import filcapp.composeapp.generated.resources.Res import filcapp.composeapp.generated.resources.compose_multiplatform +import hu.petrik.filcapp.screens.HomeTab +import hu.petrik.filcapp.screens.NewsTab +import hu.petrik.filcapp.screens.SubstitutionTab +import hu.petrik.filcapp.screens.TimetableTab import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.ui.tooling.preview.Preview @@ -22,26 +34,36 @@ import org.jetbrains.compose.ui.tooling.preview.Preview @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") + TabNavigator(HomeTab) { tabNavigator -> + Scaffold( + bottomBar = { BottomNavigationBar(tabNavigator) }, + modifier = Modifier.fillMaxSize(), + ) { paddingValues -> + Box(modifier = Modifier.padding(paddingValues).fillMaxSize()) { + CurrentTab() } } } } } + +@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/components/DateView.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/components/DateView.kt new file mode 100644 index 0000000..4ccdac5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/components/DateView.kt @@ -0,0 +1,123 @@ +package hu.petrik.filcapp.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +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.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.delay +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.Month +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Clock + +@OptIn(kotlin.time.ExperimentalTime::class) +@Composable +fun DateView(modifier: Modifier = Modifier) { + var currentTime by remember { + mutableStateOf( + Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()), + ) + } + + LaunchedEffect(Unit) { + while (true) { + currentTime = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()) + delay(1000L) + } + } + + val dayOfWeek = + when (currentTime.dayOfWeek) { + DayOfWeek.MONDAY -> "Monday" + DayOfWeek.TUESDAY -> "Tuesday" + DayOfWeek.WEDNESDAY -> "Wednesday" + DayOfWeek.THURSDAY -> "Thursday" + DayOfWeek.FRIDAY -> "Friday" + DayOfWeek.SATURDAY -> "Saturday" + DayOfWeek.SUNDAY -> "Sunday" + } + + val monthName = + when (currentTime.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 = currentTime.dayOfMonth.toString().padStart(2, '0') + val year = currentTime.year + val hour = currentTime.hour.toString().padStart(2, '0') + val minute = currentTime.minute.toString().padStart(2, '0') + + Row( + modifier = + modifier + .fillMaxWidth() + .padding(16.dp) + .height(IntrinsicSize.Min), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + 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 + .fillMaxHeight() + .weight(1f) + .aspectRatio(1f) + .padding(start = 16.dp) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(24.dp), + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = "Lottie or picture", + 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..ef9237a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/components/TopBar.kt @@ -0,0 +1,58 @@ +package hu.petrik.filcapp.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import cafe.adriel.voyager.navigator.tab.CurrentTab +import cafe.adriel.voyager.navigator.tab.Tab +import cafe.adriel.voyager.navigator.tab.TabNavigator +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 +import org.jetbrains.compose.ui.tooling.preview.Preview + +@Composable +fun TopBar(username: String = "Username") { + val topBarHeight = 64.dp + + Row( + modifier = + Modifier + .fillMaxWidth() + .height(topBarHeight) + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + Text( + text = "Good Morning", + style = + MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.Normal, + ), + ) + Text( + text = "Hi $username", + style = + MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + ), + ) + } + + Image(painterResource(Res.drawable.compose_multiplatform), contentDescription = null) + } +} 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..9d1acff --- /dev/null +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/HomeScreen.kt @@ -0,0 +1,63 @@ +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.CalendarMonth +import androidx.compose.material.icons.filled.Campaign +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Settings +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.CurrentTab +import cafe.adriel.voyager.navigator.tab.Tab +import cafe.adriel.voyager.navigator.tab.TabNavigator +import cafe.adriel.voyager.navigator.tab.TabOptions +import filcapp.composeapp.generated.resources.Res +import filcapp.composeapp.generated.resources.compose_multiplatform +import hu.petrik.filcapp.components.DateView +import hu.petrik.filcapp.components.TopBar +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.ui.tooling.preview.Preview + +@Composable +fun HomeScreen() { + Column( + modifier = + Modifier + .fillMaxSize() + .padding(top = 16.dp), + ) { + TopBar("John Doe") + DateView() + } +} + +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() { + 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..b169275 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/NewsScreen.kt @@ -0,0 +1,67 @@ +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.Campaign +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.CurrentTab +import cafe.adriel.voyager.navigator.tab.Tab +import cafe.adriel.voyager.navigator.tab.TabNavigator +import cafe.adriel.voyager.navigator.tab.TabOptions +import filcapp.composeapp.generated.resources.Res +import filcapp.composeapp.generated.resources.compose_multiplatform +import hu.petrik.filcapp.components.DateView +import hu.petrik.filcapp.components.TopBar +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.ui.tooling.preview.Preview + +@Composable +fun NewsScreen() { + Box( + modifier = + Modifier + .fillMaxSize() + .padding(top = 16.dp), + contentAlignment = Alignment.TopCenter, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image(painterResource(Res.drawable.compose_multiplatform), null) + Text( + "News placeholder", + style = MaterialTheme.typography.titleLarge, + ) + } + } +} + +object NewsTab : Tab { + override val options: TabOptions + @Composable + get() { + val title = "News" + val icon = rememberVectorPainter(Icons.Default.Campaign) + return remember { + TabOptions( + index = 2u, + title = title, + icon = icon, + ) + } + } + + @Composable + override fun Content() { + NewsScreen() + } +} 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..e273cfd --- /dev/null +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/SubstitutionScreen.kt @@ -0,0 +1,62 @@ +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.CurrentTab +import cafe.adriel.voyager.navigator.tab.Tab +import cafe.adriel.voyager.navigator.tab.TabNavigator +import cafe.adriel.voyager.navigator.tab.TabOptions +import filcapp.composeapp.generated.resources.Res +import filcapp.composeapp.generated.resources.compose_multiplatform +import hu.petrik.filcapp.components.TopBar +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.ui.tooling.preview.Preview + +@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..7f8887e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/TimetableScreen.kt @@ -0,0 +1,66 @@ +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.CalendarMonth +import androidx.compose.material.icons.filled.Campaign +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Settings +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.CurrentTab +import cafe.adriel.voyager.navigator.tab.Tab +import cafe.adriel.voyager.navigator.tab.TabNavigator +import cafe.adriel.voyager.navigator.tab.TabOptions +import filcapp.composeapp.generated.resources.Res +import filcapp.composeapp.generated.resources.compose_multiplatform +import hu.petrik.filcapp.components.TopBar +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.ui.tooling.preview.Preview + +object TimetableTab : Tab { + override val options: TabOptions + @Composable + get() { + val title = "Timetable" + val icon = rememberVectorPainter(Icons.Default.CalendarMonth) + return remember { + TabOptions(index = 1u, title = title, icon = icon) + } + } + + @Composable + override fun Content() { + TimetableScreen() + } +} + +@Composable +fun TimetableScreen() { + Box( + modifier = + Modifier + .fillMaxSize() + .padding(top = 16.dp), + contentAlignment = Alignment.TopCenter, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image(painterResource(Res.drawable.compose_multiplatform), null) + Text( + "Timetable placeholder", + style = MaterialTheme.typography.titleLarge, + ) + } + } +} From 1c3a80d81e052988047554005def29e0672a2477 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Susa=20Mil=C3=A1n=20Mih=C3=A1ly?= Date: Sat, 13 Dec 2025 12:23:25 +0100 Subject: [PATCH 02/23] Removed useless comment made by LLM when making IOS targets work --- composeApp/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index da01f63..682ab77 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -22,7 +22,7 @@ kotlin { iosSimulatorArm64(), ).forEach { iosTarget -> iosTarget.binaries.framework { - baseName = "ComposeApp" // Change this to your module name + baseName = "ComposeApp" isStatic = true } } From 8b82323e87b80472ac2ec3e1c2f7dfdab76fdc37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Susa=20Mil=C3=A1n=20Mih=C3=A1ly?= Date: Fri, 17 Apr 2026 14:24:23 +0200 Subject: [PATCH 03/23] feat(build): add desktop target and fix Ktor engine source sets --- composeApp/build.gradle.kts | 54 +++++++++++++++---- .../src/androidMain/AndroidManifest.xml | 2 + .../hu/petrik/filcapp/Config.desktop.kt | 6 +++ .../kotlin/hu/petrik/filcapp/Main.kt | 10 ++++ .../hu/petrik/filcapp/Platform.desktop.kt | 7 +++ 5 files changed, 68 insertions(+), 11 deletions(-) create mode 100644 composeApp/src/desktopMain/kotlin/hu/petrik/filcapp/Config.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/hu/petrik/filcapp/Main.kt create mode 100644 composeApp/src/desktopMain/kotlin/hu/petrik/filcapp/Platform.desktop.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 682ab77..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,6 +6,8 @@ plugins { alias(libs.plugins.androidApplication) alias(libs.plugins.composeMultiplatform) alias(libs.plugins.composeCompiler) + + kotlin("plugin.serialization") version "2.1.0" } compose.resources { @@ -16,6 +18,8 @@ compose.resources { kotlin { androidTarget { compilerOptions { jvmTarget.set(JvmTarget.JVM_11) } } + jvm("desktop") + listOf( iosX64(), iosArm64(), @@ -28,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) @@ -42,22 +49,27 @@ kotlin { implementation(libs.androidx.lifecycle.viewmodelCompose) implementation(libs.androidx.lifecycle.runtimeCompose) - implementation(compose.material3) 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) } - val iosX64Main by getting - val iosArm64Main by getting - val iosSimulatorArm64Main by getting - val iosMain by creating { - dependsOn(commonMain.get()) - iosX64Main.dependsOn(this) - iosArm64Main.dependsOn(this) - iosSimulatorArm64Main.dependsOn(this) + 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") } } } @@ -83,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 @@ -91,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 @@ + + Date: Fri, 17 Apr 2026 14:24:23 +0200 Subject: [PATCH 04/23] feat(config): add per-platform API base URL --- .../kotlin/hu/petrik/filcapp/Config.android.kt | 3 +++ .../src/commonMain/kotlin/hu/petrik/filcapp/Config.kt | 3 +++ .../src/iosMain/kotlin/hu/petrik/filcapp/Config.ios.kt | 8 ++++++++ 3 files changed, 14 insertions(+) create mode 100644 composeApp/src/androidMain/kotlin/hu/petrik/filcapp/Config.android.kt create mode 100644 composeApp/src/commonMain/kotlin/hu/petrik/filcapp/Config.kt create mode 100644 composeApp/src/iosMain/kotlin/hu/petrik/filcapp/Config.ios.kt diff --git a/composeApp/src/androidMain/kotlin/hu/petrik/filcapp/Config.android.kt b/composeApp/src/androidMain/kotlin/hu/petrik/filcapp/Config.android.kt new file mode 100644 index 0000000..9053113 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/hu/petrik/filcapp/Config.android.kt @@ -0,0 +1,3 @@ +package hu.petrik.filcapp + +actual val apiBaseUrl: String = BuildConfig.API_BASE_URL 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/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" From 262ab37a13e2bd0297baaf86f40593065137416b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Susa=20Mil=C3=A1n=20Mih=C3=A1ly?= Date: Fri, 17 Apr 2026 14:24:23 +0200 Subject: [PATCH 05/23] feat(api): add open2ktor generated Ktor client bindings --- .../hu/petrik/filcapp/api/ClassroomApi.kt | 30 ++ .../kotlin/hu/petrik/filcapp/api/Client.kt | 29 ++ .../kotlin/hu/petrik/filcapp/api/CohortApi.kt | 48 +++ .../hu/petrik/filcapp/api/DoorlockApi.kt | 303 ++++++++++++++++ .../kotlin/hu/petrik/filcapp/api/LessonApi.kt | 64 ++++ .../hu/petrik/filcapp/api/MovedLessonApi.kt | 140 ++++++++ .../kotlin/hu/petrik/filcapp/api/PingApi.kt | 48 +++ .../kotlin/hu/petrik/filcapp/api/Result.kt | 79 +++++ .../hu/petrik/filcapp/api/SubstitutionApi.kt | 127 +++++++ .../hu/petrik/filcapp/api/TeacherApi.kt | 30 ++ .../hu/petrik/filcapp/api/TimetableApi.kt | 87 +++++ .../kotlin/hu/petrik/filcapp/api/UsersApi.kt | 52 +++ .../kotlin/hu/petrik/filcapp/models/Models.kt | 325 ++++++++++++++++++ 13 files changed, 1362 insertions(+) create mode 100644 composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/ClassroomApi.kt create mode 100644 composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/Client.kt create mode 100644 composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/CohortApi.kt create mode 100644 composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/DoorlockApi.kt create mode 100644 composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/LessonApi.kt create mode 100644 composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/MovedLessonApi.kt create mode 100644 composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/PingApi.kt create mode 100644 composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/Result.kt create mode 100644 composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/SubstitutionApi.kt create mode 100644 composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/TeacherApi.kt create mode 100644 composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/TimetableApi.kt create mode 100644 composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/UsersApi.kt create mode 100644 composeApp/src/commonMain/kotlin/hu/petrik/filcapp/models/Models.kt 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..5a499fd --- /dev/null +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/Client.kt @@ -0,0 +1,29 @@ +package hu.petrik.filcapp.api.client + +import hu.petrik.filcapp.apiBaseUrl +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 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 }) + } + + defaultRequest { + url("$apiBaseUrl/") + } +} 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..012980c --- /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 getTimetableCohortsAllForTimetable(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..cbc69ac --- /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 putDoorlockCards(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 deleteDoorlockCards(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 putDoorlockDevices(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 deleteDoorlockDevices(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 getDoorlockDevicesIdStats(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 postDoorlockSelfCardsIdActivate(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 putDoorlockSelfCardsIdFrozen(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..4e16a4b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/LessonApi.kt @@ -0,0 +1,64 @@ +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 getTimetableLessonsForCohort(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 getTimetableLessonsForRoom(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 getTimetableLessonsForTeacher(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..ad17665 --- /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 getTimetableMovedLessonsCohort(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 getTimetableMovedLessonsCohortCohortIdRelevant(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 putTimetableMovedLessons(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 deleteTimetableMovedLessons(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/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..ea2cce2 --- /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 getTimetableSubstitutionsCohort(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 putTimetableSubstitutions(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 deleteTimetableSubstitutions(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..02e73df --- /dev/null +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/TimetableApi.kt @@ -0,0 +1,87 @@ +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 postTimetableImport(body: Boolean): APIResult { + return try { + val response = client.post { + url("/timetable/import") + 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 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..8f48243 --- /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 patchUsers(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/models/Models.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/models/Models.kt new file mode 100644 index 0000000..3253fa4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/models/Models.kt @@ -0,0 +1,325 @@ +package hu.petrik.filcapp.models + +import kotlinx.serialization.Serializable + +@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 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: String? = null, + val updatedAt: String, + val userId: String, +) + +@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 Classroom ( + val buildingId: String, + val capacity: String? = 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: String? = null, + val cardData: String? = null, + val cardId: String? = null, + val device: String? = null, + val deviceId: String, + val id: Int, + val owner: String? = null, + val result: Boolean, + val timestamp: String, + val userId: 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? = null, + val id: String, + val name: String? = null, + 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, + val periodsPerWeek: Double, + val subject: Subject, + 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, + val dayDefinition: DayDefinition, + val lessons: List = emptyList(), + val movedLesson: MovedLesson, + val period: Period, +) + +@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, +) + +@Serializable +data class SubstitutionsByCohort ( + val cohortId: String, + val substitutions: List = emptyList(), +) + +@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(), +) + From 3195ef6c99da32483312275070d2ae15794b79c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Susa=20Mil=C3=A1n=20Mih=C3=A1ly?= Date: Fri, 17 Apr 2026 14:24:23 +0200 Subject: [PATCH 06/23] feat(timetable): implement timetable screen from wireframe --- .../petrik/filcapp/screens/TimetableScreen.kt | 308 ++++++++++++++++-- 1 file changed, 273 insertions(+), 35 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/TimetableScreen.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/TimetableScreen.kt index 7f8887e..4c22bcb 100644 --- a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/TimetableScreen.kt +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/TimetableScreen.kt @@ -1,66 +1,304 @@ +@file:OptIn(kotlin.time.ExperimentalTime::class) + 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.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.filled.CalendarMonth -import androidx.compose.material.icons.filled.Campaign -import androidx.compose.material.icons.filled.Home -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material.icons.filled.SwapCalls +import androidx.compose.material.icons.filled.KeyboardArrowDown 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.unit.dp -import cafe.adriel.voyager.navigator.tab.CurrentTab +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.TabNavigator import cafe.adriel.voyager.navigator.tab.TabOptions -import filcapp.composeapp.generated.resources.Res -import filcapp.composeapp.generated.resources.compose_multiplatform -import hu.petrik.filcapp.components.TopBar -import org.jetbrains.compose.resources.painterResource -import org.jetbrains.compose.ui.tooling.preview.Preview +import hu.petrik.filcapp.api.APIResult +import hu.petrik.filcapp.api.LessonApi +import hu.petrik.filcapp.api.client.APIClient +import hu.petrik.filcapp.models.EnrichedLesson +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlin.time.Clock +import kotlinx.datetime.* + +// ── Tab entry point ────────────────────────────────────────────────────────── object TimetableTab : Tab { override val options: TabOptions - @Composable - get() { - val title = "Timetable" + @Composable get() { val icon = rememberVectorPainter(Icons.Default.CalendarMonth) - return remember { - TabOptions(index = 1u, title = title, icon = icon) - } + return remember { TabOptions(index = 1u, title = "Timetable", icon = icon) } } @Composable override fun Content() { - TimetableScreen() + val model = rememberScreenModel { TimetableScreenModel(LessonApi(APIClient)) } + val cohortId = "744bfc41-7f89-4b23-afa4-04acc2789fc3" + val cohortName = "13.E" + + LaunchedEffect(Unit) { model.loadTimetable(cohortId) } + + TimetableScreen( + isLoading = model.isLoading, + lessons = model.lessons, + error = model.error, + cohortName = cohortName, + ) + } +} + +// ── Screen ─────────────────────────────────────────────────────────────────── + +@Composable +fun TimetableScreen( + isLoading: Boolean, + lessons: List, + error: String?, + cohortName: String, +) { + var selectedDate by remember { mutableStateOf(Clock.System.todayIn(TimeZone.currentSystemDefault())) } + val weekDays = remember(selectedDate) { weekOf(selectedDate) } + val filtered = remember(lessons, selectedDate) { lessonsForDate(lessons, selectedDate) } + + Column(modifier = Modifier.fillMaxSize()) { + // ── Header ─────────────────────────────────────────────────────────── + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = selectedDate.month.name.lowercase().replaceFirstChar { it.uppercase() }, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + ) + Icon(Icons.Default.KeyboardArrowDown, contentDescription = null) + } + Text( + text = selectedDate.year.toString(), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + ) + } + Box( + modifier = Modifier + .size(56.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center, + ) { + Text("?", style = MaterialTheme.typography.titleMedium) + } + } + + // ── 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, + ) + } + } + } + } + + // ── Lesson area ────────────────────────────────────────────────────── + if (isLoading) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } else if (error != null) { + Box(modifier = Modifier.fillMaxSize().padding(16.dp), contentAlignment = Alignment.Center) { + Text(error, color = MaterialTheme.colorScheme.error) + } + } else { + LazyColumn(modifier = Modifier.fillMaxSize()) { + item { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.End, + ) { + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + Text( + text = cohortName, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + ) + } + } + } + + if (filtered.isEmpty()) { + item { + Box(modifier = Modifier.fillParentMaxSize(), contentAlignment = Alignment.Center) { + Text("No lessons today", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } else { + items(filtered, key = { it.id }) { lesson -> + LessonCard(lesson = lesson) + } + } + } + } } } +// ── Lesson card ─────────────────────────────────────────────────────────────── + @Composable -fun TimetableScreen() { - Box( - modifier = - Modifier - .fillMaxSize() - .padding(top = 16.dp), - contentAlignment = Alignment.TopCenter, +fun LessonCard(lesson: EnrichedLesson) { + val startTime = lesson.period?.startTime?.take(5) ?: "" + val endTime = lesson.period?.endTime?.take(5) ?: "" + val subjectName = lesson.subject?.name ?: "Unknown" + val teacherName = lesson.teachers.firstOrNull()?.name ?: "" + val roomName = lesson.classrooms.firstOrNull()?.name?.let { "Room $it" } ?: "" + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, ) { + // Time column Column( horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.width(52.dp), + ) { + Text(startTime, style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Medium) + Spacer(Modifier.height(4.dp)) + repeat(5) { + Box( + modifier = Modifier + .width(14.dp) + .height(2.dp) + .background(MaterialTheme.colorScheme.outlineVariant), + ) + Spacer(Modifier.height(4.dp)) + } + Text(endTime, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + + Spacer(Modifier.width(10.dp)) + + // Image placeholder + Box( + modifier = Modifier + .size(width = 100.dp, height = 110.dp) + .clip(RoundedCornerShape(14.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center, ) { - Image(painterResource(Res.drawable.compose_multiplatform), null) - Text( - "Timetable placeholder", - style = MaterialTheme.typography.titleLarge, - ) + Text(lesson.subject?.short ?: "?", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + + Spacer(Modifier.width(14.dp)) + + // Info column + Column(modifier = Modifier.weight(1f)) { + Text(subjectName, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + if (teacherName.isNotEmpty()) { + Text("Teacher", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.height(4.dp)) + Text(teacherName, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) + } + if (roomName.isNotEmpty()) { + Spacer(Modifier.height(2.dp)) + Text(roomName, style = MaterialTheme.typography.bodySmall) + } } } } + +// ── ScreenModel ─────────────────────────────────────────────────────────────── + +class TimetableScreenModel(private val lessonApi: LessonApi) : ScreenModel { + var lessons by mutableStateOf>(emptyList()) + var error by mutableStateOf(null) + var isLoading by mutableStateOf(false) + + fun loadTimetable(cohortId: String) { + screenModelScope.launch(Dispatchers.Default) { + withContext(Dispatchers.Main) { isLoading = true; error = null } + try { + when (val result = lessonApi.getTimetableLessonsForCohort(cohortId)) { + is APIResult.Success -> withContext(Dispatchers.Main) { lessons = 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 } + } + } + } +} + +// ── Date helpers ────────────────────────────────────────────────────────────── + +private fun weekOf(date: LocalDate): List { + val monday = date.minus(date.dayOfWeek.ordinal, DateTimeUnit.DAY) + return (0..6).map { monday.plus(it, DateTimeUnit.DAY) } +} + +private fun lessonsForDate(lessons: List, date: LocalDate): List { + val dateStr = date.toString() // ISO: "2025-01-09" + return lessons + .filter { lesson -> + val days = lesson.day?.days?.filterNotNull() ?: return@filter false + days.any { it == dateStr } + } + .sortedBy { it.period?.startTime ?: "" } +} From 2b95944bc7df4639d22813b0775717a2d776f89b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Susa=20Mil=C3=A1n=20Mih=C3=A1ly?= Date: Fri, 17 Apr 2026 14:38:37 +0200 Subject: [PATCH 07/23] fix(timetable): correct day filtering to use ISO day number --- .../kotlin/hu/petrik/filcapp/screens/TimetableScreen.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/TimetableScreen.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/TimetableScreen.kt index 4c22bcb..e8289e3 100644 --- a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/TimetableScreen.kt +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/TimetableScreen.kt @@ -294,11 +294,11 @@ private fun weekOf(date: LocalDate): List { } private fun lessonsForDate(lessons: List, date: LocalDate): List { - val dateStr = date.toString() // ISO: "2025-01-09" + val dayNum = date.dayOfWeek.isoDayNumber.toString() // "1"=Mon ... "7"=Sun return lessons .filter { lesson -> val days = lesson.day?.days?.filterNotNull() ?: return@filter false - days.any { it == dateStr } + days.any { it == dayNum } } .sortedBy { it.period?.startTime ?: "" } } From 1f4cfb5b45ac8e0c0b8c3f0ee81aa75a1365259b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Susa=20Mil=C3=A1n=20Mih=C3=A1ly?= Date: Sat, 18 Apr 2026 19:24:35 +0200 Subject: [PATCH 08/23] Auth and welcome --- .../filcapp/auth/AuthWebView.android.kt | 73 ++++++++++ .../auth/ProfileImageDecoder.android.kt | 16 +++ .../filcapp/auth/SessionStore.android.kt | 11 ++ .../kotlin/hu/petrik/filcapp/auth/AuthApi.kt | 39 ++++++ .../hu/petrik/filcapp/auth/AuthState.kt | 47 +++++++ .../hu/petrik/filcapp/auth/AuthWebView.kt | 10 ++ .../filcapp/auth/ProfileImageDecoder.kt | 5 + .../hu/petrik/filcapp/auth/SessionStore.kt | 7 + .../hu/petrik/filcapp/screens/SplashScreen.kt | 48 +++++++ .../petrik/filcapp/screens/WelcomeScreen.kt | 131 ++++++++++++++++++ .../filcapp/auth/AuthWebView.desktop.kt | 51 +++++++ .../auth/ProfileImageDecoder.desktop.kt | 16 +++ .../filcapp/auth/SessionStore.desktop.kt | 11 ++ .../hu/petrik/filcapp/auth/AuthWebView.ios.kt | 66 +++++++++ .../filcapp/auth/ProfileImageDecoder.ios.kt | 27 ++++ .../petrik/filcapp/auth/SessionStore.ios.kt | 12 ++ 16 files changed, 570 insertions(+) create mode 100644 composeApp/src/androidMain/kotlin/hu/petrik/filcapp/auth/AuthWebView.android.kt create mode 100644 composeApp/src/androidMain/kotlin/hu/petrik/filcapp/auth/ProfileImageDecoder.android.kt create mode 100644 composeApp/src/androidMain/kotlin/hu/petrik/filcapp/auth/SessionStore.android.kt create mode 100644 composeApp/src/commonMain/kotlin/hu/petrik/filcapp/auth/AuthApi.kt create mode 100644 composeApp/src/commonMain/kotlin/hu/petrik/filcapp/auth/AuthState.kt create mode 100644 composeApp/src/commonMain/kotlin/hu/petrik/filcapp/auth/AuthWebView.kt create mode 100644 composeApp/src/commonMain/kotlin/hu/petrik/filcapp/auth/ProfileImageDecoder.kt create mode 100644 composeApp/src/commonMain/kotlin/hu/petrik/filcapp/auth/SessionStore.kt create mode 100644 composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/SplashScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/WelcomeScreen.kt create mode 100644 composeApp/src/desktopMain/kotlin/hu/petrik/filcapp/auth/AuthWebView.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/hu/petrik/filcapp/auth/ProfileImageDecoder.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/hu/petrik/filcapp/auth/SessionStore.desktop.kt create mode 100644 composeApp/src/iosMain/kotlin/hu/petrik/filcapp/auth/AuthWebView.ios.kt create mode 100644 composeApp/src/iosMain/kotlin/hu/petrik/filcapp/auth/ProfileImageDecoder.ios.kt create mode 100644 composeApp/src/iosMain/kotlin/hu/petrik/filcapp/auth/SessionStore.ios.kt diff --git a/composeApp/src/androidMain/kotlin/hu/petrik/filcapp/auth/AuthWebView.android.kt b/composeApp/src/androidMain/kotlin/hu/petrik/filcapp/auth/AuthWebView.android.kt new file mode 100644 index 0000000..ec2c12a --- /dev/null +++ b/composeApp/src/androidMain/kotlin/hu/petrik/filcapp/auth/AuthWebView.android.kt @@ -0,0 +1,73 @@ +package hu.petrik.filcapp.auth + +import android.webkit.CookieManager +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.AndroidView + +// 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) { + Box(Modifier.fillMaxSize()) { + AndroidView( + factory = { ctx -> + WebView(ctx).apply { + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + 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/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/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/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/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/auth/AuthWebView.ios.kt b/composeApp/src/iosMain/kotlin/hu/petrik/filcapp/auth/AuthWebView.ios.kt new file mode 100644 index 0000000..3ba01fa --- /dev/null +++ b/composeApp/src/iosMain/kotlin/hu/petrik/filcapp/auth/AuthWebView.ios.kt @@ -0,0 +1,66 @@ +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.Foundation.NSHTTPCookie +import platform.Foundation.NSURL +import platform.WebKit.WKNavigation +import platform.WebKit.WKNavigationDelegateProtocol +import platform.WebKit.WKWebView +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 webView = WKWebView() + 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?) { + WKWebsiteDataStore.defaultDataStore().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) } +} From 74f6e4cef2b6146ca438de42bc49612b6f614231 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Susa=20Mil=C3=A1n=20Mih=C3=A1ly?= Date: Sat, 18 Apr 2026 19:25:17 +0200 Subject: [PATCH 09/23] General layout work and main timetable screen --- .../kotlin/hu/petrik/filcapp/MainActivity.kt | 4 +- .../kotlin/hu/petrik/filcapp/App.kt | 58 ++-- .../kotlin/hu/petrik/filcapp/api/Client.kt | 18 +- .../kotlin/hu/petrik/filcapp/api/Result.kt | 3 +- .../hu/petrik/filcapp/components/TopBar.kt | 78 ++--- .../kotlin/hu/petrik/filcapp/models/Models.kt | 2 +- .../hu/petrik/filcapp/screens/HomeScreen.kt | 15 - .../hu/petrik/filcapp/screens/NewsScreen.kt | 5 - .../filcapp/screens/SubstitutionScreen.kt | 4 - .../petrik/filcapp/screens/TimetableScreen.kt | 266 +++++++++++------- 10 files changed, 258 insertions(+), 195 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/hu/petrik/filcapp/MainActivity.kt b/composeApp/src/androidMain/kotlin/hu/petrik/filcapp/MainActivity.kt index a721a98..30c824f 100644 --- a/composeApp/src/androidMain/kotlin/hu/petrik/filcapp/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/hu/petrik/filcapp/MainActivity.kt @@ -1,17 +1,19 @@ package hu.petrik.filcapp +import android.content.Context import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview +import hu.petrik.filcapp.auth.SessionStore class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) - + SessionStore.prefs = getSharedPreferences("filcapp", Context.MODE_PRIVATE) setContent { App() } diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/App.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/App.kt index 0634055..fe75141 100644 --- a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/App.kt +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/App.kt @@ -1,47 +1,57 @@ package hu.petrik.filcapp -import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.CalendarMonth -import androidx.compose.material.icons.filled.Campaign -import androidx.compose.material.icons.filled.Home -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material.icons.filled.SwapCalls +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 androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.unit.dp import cafe.adriel.voyager.navigator.tab.CurrentTab -import cafe.adriel.voyager.navigator.tab.Tab import cafe.adriel.voyager.navigator.tab.TabNavigator -import cafe.adriel.voyager.navigator.tab.TabOptions -import filcapp.composeapp.generated.resources.Res -import filcapp.composeapp.generated.resources.compose_multiplatform +import hu.petrik.filcapp.components.TopBar import hu.petrik.filcapp.screens.HomeTab import hu.petrik.filcapp.screens.NewsTab +import hu.petrik.filcapp.screens.SplashScreen import hu.petrik.filcapp.screens.SubstitutionTab import hu.petrik.filcapp.screens.TimetableTab -import org.jetbrains.compose.resources.painterResource +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() { + var screen by remember { mutableStateOf(AppScreen.Splash) } + MaterialTheme { - TabNavigator(HomeTab) { tabNavigator -> - Scaffold( - bottomBar = { BottomNavigationBar(tabNavigator) }, - modifier = Modifier.fillMaxSize(), - ) { paddingValues -> - Box(modifier = Modifier.padding(paddingValues).fillMaxSize()) { - CurrentTab() - } + 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() + } + } +} + +@Composable +private fun MainContent() { + TabNavigator(HomeTab) { tabNavigator -> + Scaffold( + topBar = { TopBar() }, + bottomBar = { BottomNavigationBar(tabNavigator) }, + modifier = Modifier.fillMaxSize(), + contentWindowInsets = WindowInsets.systemBars, + ) { paddingValues -> + Box(modifier = Modifier.padding(paddingValues).fillMaxSize()) { + CurrentTab() } } } diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/Client.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/Client.kt index 5a499fd..69b9844 100644 --- a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/Client.kt +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/Client.kt @@ -1,12 +1,14 @@ 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 { @@ -23,7 +25,21 @@ val APIClient = HttpClient { json(Json { ignoreUnknownKeys = true; isLenient = 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/") + 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/Result.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/Result.kt index 2f4d536..aab9680 100644 --- a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/Result.kt +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/Result.kt @@ -1,5 +1,6 @@ package hu.petrik.filcapp.api +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Target(AnnotationTarget.FUNCTION) @@ -9,7 +10,7 @@ annotation class RequiresAuth @Serializable public data class ApiErrorMessage( val success: Boolean, - val message: String + @SerialName("error") val message: String = "", ) @Serializable diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/components/TopBar.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/components/TopBar.kt index ef9237a..73824b1 100644 --- a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/components/TopBar.kt +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/components/TopBar.kt @@ -1,58 +1,68 @@ package hu.petrik.filcapp.components import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.* +import androidx.compose.foundation.background +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 cafe.adriel.voyager.navigator.tab.CurrentTab -import cafe.adriel.voyager.navigator.tab.Tab -import cafe.adriel.voyager.navigator.tab.TabNavigator -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 -import org.jetbrains.compose.ui.tooling.preview.Preview +import hu.petrik.filcapp.auth.AuthState +import hu.petrik.filcapp.auth.base64ToImageBitmap @Composable -fun TopBar(username: String = "Username") { - val topBarHeight = 64.dp +fun TopBar() { + val displayName = AuthState.displayName ?: "" + val profileImage = AuthState.profileImage + val avatar: ImageBitmap? = remember(profileImage) { + profileImage?.let { base64ToImageBitmap(it) } + } Row( - modifier = - Modifier - .fillMaxWidth() - .height(topBarHeight) - .padding(horizontal = 16.dp), + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.statusBars) + .padding(horizontal = 20.dp, vertical = 16.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { Column { Text( - text = "Good Morning", - style = - MaterialTheme.typography.titleSmall.copy( - fontWeight = FontWeight.Normal, - ), + text = "Petrik", + style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Normal), + color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( - text = "Hi $username", - style = - MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.Bold, - ), + text = displayName, + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), ) } - Image(painterResource(Res.drawable.compose_multiplatform), contentDescription = null) + Box( + modifier = Modifier + .size(44.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.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 index 3253fa4..bb897d1 100644 --- a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/models/Models.kt +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/models/Models.kt @@ -46,7 +46,7 @@ data class Card_authorizedDevices ( @Serializable data class Classroom ( - val buildingId: String, + val buildingId: String? = null, val capacity: String? = null, val id: String, val name: String, diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/HomeScreen.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/HomeScreen.kt index 9d1acff..d958692 100644 --- a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/HomeScreen.kt @@ -1,32 +1,18 @@ 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.CalendarMonth -import androidx.compose.material.icons.filled.Campaign import androidx.compose.material.icons.filled.Home -import androidx.compose.material.icons.filled.Settings -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.CurrentTab import cafe.adriel.voyager.navigator.tab.Tab -import cafe.adriel.voyager.navigator.tab.TabNavigator import cafe.adriel.voyager.navigator.tab.TabOptions -import filcapp.composeapp.generated.resources.Res -import filcapp.composeapp.generated.resources.compose_multiplatform import hu.petrik.filcapp.components.DateView -import hu.petrik.filcapp.components.TopBar -import org.jetbrains.compose.resources.painterResource -import org.jetbrains.compose.ui.tooling.preview.Preview @Composable fun HomeScreen() { @@ -36,7 +22,6 @@ fun HomeScreen() { .fillMaxSize() .padding(top = 16.dp), ) { - TopBar("John Doe") DateView() } } diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/NewsScreen.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/NewsScreen.kt index b169275..a023bed 100644 --- a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/NewsScreen.kt +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/NewsScreen.kt @@ -13,16 +13,11 @@ 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.CurrentTab import cafe.adriel.voyager.navigator.tab.Tab -import cafe.adriel.voyager.navigator.tab.TabNavigator import cafe.adriel.voyager.navigator.tab.TabOptions import filcapp.composeapp.generated.resources.Res import filcapp.composeapp.generated.resources.compose_multiplatform -import hu.petrik.filcapp.components.DateView -import hu.petrik.filcapp.components.TopBar import org.jetbrains.compose.resources.painterResource -import org.jetbrains.compose.ui.tooling.preview.Preview @Composable fun NewsScreen() { diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/SubstitutionScreen.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/SubstitutionScreen.kt index e273cfd..508352f 100644 --- a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/SubstitutionScreen.kt +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/SubstitutionScreen.kt @@ -13,15 +13,11 @@ 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.CurrentTab import cafe.adriel.voyager.navigator.tab.Tab -import cafe.adriel.voyager.navigator.tab.TabNavigator import cafe.adriel.voyager.navigator.tab.TabOptions import filcapp.composeapp.generated.resources.Res import filcapp.composeapp.generated.resources.compose_multiplatform -import hu.petrik.filcapp.components.TopBar import org.jetbrains.compose.resources.painterResource -import org.jetbrains.compose.ui.tooling.preview.Preview @Composable fun 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 index e8289e3..0c1eece 100644 --- a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/TimetableScreen.kt +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/TimetableScreen.kt @@ -7,7 +7,6 @@ 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.filled.CalendarMonth @@ -18,18 +17,21 @@ 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.graphicsLayer import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.text.font.FontWeight 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.CohortApi import hu.petrik.filcapp.api.LessonApi import hu.petrik.filcapp.api.client.APIClient +import hu.petrik.filcapp.auth.AuthState +import hu.petrik.filcapp.models.Cohort import hu.petrik.filcapp.models.EnrichedLesson import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -48,17 +50,17 @@ object TimetableTab : Tab { @Composable override fun Content() { - val model = rememberScreenModel { TimetableScreenModel(LessonApi(APIClient)) } - val cohortId = "744bfc41-7f89-4b23-afa4-04acc2789fc3" - val cohortName = "13.E" + val model = rememberScreenModel { TimetableScreenModel(LessonApi(APIClient), CohortApi(APIClient)) } - LaunchedEffect(Unit) { model.loadTimetable(cohortId) } + LaunchedEffect(Unit) { model.load() } TimetableScreen( isLoading = model.isLoading, lessons = model.lessons, - error = model.error, - cohortName = cohortName, + cohorts = model.cohorts, + selectedCohortId = model.selectedCohortId, + onCohortSelected = { model.selectCohort(it) }, + error = if (model.selectedCohortId == null) "No cohort assigned to your account" else model.error, ) } } @@ -69,117 +71,27 @@ object TimetableTab : Tab { fun TimetableScreen( isLoading: Boolean, lessons: List, + cohorts: List, + selectedCohortId: String?, + onCohortSelected: (Cohort) -> Unit, error: String?, - cohortName: String, ) { var selectedDate by remember { mutableStateOf(Clock.System.todayIn(TimeZone.currentSystemDefault())) } val weekDays = remember(selectedDate) { weekOf(selectedDate) } val filtered = remember(lessons, selectedDate) { lessonsForDate(lessons, selectedDate) } Column(modifier = Modifier.fillMaxSize()) { - // ── Header ─────────────────────────────────────────────────────────── - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = selectedDate.month.name.lowercase().replaceFirstChar { it.uppercase() }, - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold, - ) - Icon(Icons.Default.KeyboardArrowDown, contentDescription = null) - } - Text( - text = selectedDate.year.toString(), - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold, - ) - } - Box( - modifier = Modifier - .size(56.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.surfaceVariant), - contentAlignment = Alignment.Center, - ) { - Text("?", style = MaterialTheme.typography.titleMedium) - } - } - - // ── 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, - ) - } - } - } - } - // ── Lesson area ────────────────────────────────────────────────────── if (isLoading) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Box(modifier = Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } } else if (error != null) { - Box(modifier = Modifier.fillMaxSize().padding(16.dp), contentAlignment = Alignment.Center) { + Box(modifier = Modifier.weight(1f).fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) { Text(error, color = MaterialTheme.colorScheme.error) } } else { - LazyColumn(modifier = Modifier.fillMaxSize()) { - item { - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp), - horizontalArrangement = Arrangement.End, - ) { - Surface( - shape = RoundedCornerShape(8.dp), - color = MaterialTheme.colorScheme.surfaceVariant, - ) { - Text( - text = cohortName, - modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold, - ) - } - } - } - + LazyColumn(modifier = Modifier.weight(1f).fillMaxWidth()) { if (filtered.isEmpty()) { item { Box(modifier = Modifier.fillParentMaxSize(), contentAlignment = Alignment.Center) { @@ -193,6 +105,113 @@ fun TimetableScreen( } } } + + // ── Bottom panel (cohort switcher + week strip) ─────────────────────── + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp), + ) { + Column { + CohortSwitcher( + cohorts = cohorts, + selectedCohortId = selectedCohortId, + onCohortSelected = onCohortSelected, + ) + + HorizontalDivider( + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f), + ) + + 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 ─────────────────────────────────────────────────────────── + +@Composable +fun CohortSwitcher( + cohorts: List, + selectedCohortId: String?, + onCohortSelected: (Cohort) -> Unit, +) { + val selectedCohort = cohorts.find { it.id == selectedCohortId } + var expanded by remember { mutableStateOf(false) } + + Box(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = cohorts.isNotEmpty()) { expanded = true } + .padding(horizontal = 16.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = selectedCohort?.name ?: if (cohorts.isEmpty()) "Loading…" else "Select class", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + ) + Icon( + Icons.Default.KeyboardArrowDown, + contentDescription = "Switch class", + modifier = Modifier.graphicsLayer { rotationZ = if (expanded) 180f else 0f }, + ) + } + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = Modifier.fillMaxWidth(), + ) { + cohorts.forEach { cohort -> + DropdownMenuItem( + text = { + Text( + cohort.name, + fontWeight = if (cohort.id == selectedCohortId) FontWeight.Bold else FontWeight.Normal, + ) + }, + onClick = { + onCohortSelected(cohort) + expanded = false + }, + ) + } + } } } @@ -218,7 +237,7 @@ fun LessonCard(lesson: EnrichedLesson) { modifier = Modifier.width(52.dp), ) { Text(startTime, style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Medium) - Spacer(Modifier.height(4.dp)) + Spacer(Modifier.height(6.dp)) repeat(5) { Box( modifier = Modifier @@ -226,7 +245,7 @@ fun LessonCard(lesson: EnrichedLesson) { .height(2.dp) .background(MaterialTheme.colorScheme.outlineVariant), ) - Spacer(Modifier.height(4.dp)) + Spacer(Modifier.height(6.dp)) } Text(endTime, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } @@ -264,16 +283,45 @@ fun LessonCard(lesson: EnrichedLesson) { // ── ScreenModel ─────────────────────────────────────────────────────────────── -class TimetableScreenModel(private val lessonApi: LessonApi) : ScreenModel { +class TimetableScreenModel( + private val lessonApi: LessonApi, + private val cohortApi: CohortApi, +) : ScreenModel { var lessons by mutableStateOf>(emptyList()) + var cohorts by mutableStateOf>(emptyList()) + var selectedCohortId by mutableStateOf(AuthState.cohortId) var error by mutableStateOf(null) var isLoading by mutableStateOf(false) - fun loadTimetable(cohortId: String) { + fun load() { + screenModelScope.launch(Dispatchers.Default) { + withContext(Dispatchers.Main) { isLoading = true; error = null } + try { + when (val result = cohortApi.getCohort()) { + is APIResult.Success -> withContext(Dispatchers.Main) { cohorts = result.data } + is APIResult.Failure -> { /* non-fatal — switcher just stays empty */ } + } + val cohortId = selectedCohortId + if (cohortId != null) { + when (val result = lessonApi.getTimetableLessonsForCohort(cohortId)) { + is APIResult.Success -> withContext(Dispatchers.Main) { lessons = 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 selectCohort(cohort: Cohort) { + selectedCohortId = cohort.id screenModelScope.launch(Dispatchers.Default) { withContext(Dispatchers.Main) { isLoading = true; error = null } try { - when (val result = lessonApi.getTimetableLessonsForCohort(cohortId)) { + when (val result = lessonApi.getTimetableLessonsForCohort(cohort.id)) { is APIResult.Success -> withContext(Dispatchers.Main) { lessons = result.data } is APIResult.Failure -> withContext(Dispatchers.Main) { error = result.error.toString() } } From 20c670d0413250f2460e8c500dd0d5faa348fc01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Susa=20Mil=C3=A1n=20Mih=C3=A1ly?= Date: Mon, 20 Apr 2026 14:23:07 +0200 Subject: [PATCH 10/23] Substitution and new top bar --- .../kotlin/hu/petrik/filcapp/App.kt | 6 +- .../kotlin/hu/petrik/filcapp/api/Client.kt | 2 +- .../kotlin/hu/petrik/filcapp/api/CohortApi.kt | 2 +- .../hu/petrik/filcapp/api/DoorlockApi.kt | 14 +- .../kotlin/hu/petrik/filcapp/api/LessonApi.kt | 23 +- .../hu/petrik/filcapp/api/MovedLessonApi.kt | 10 +- .../kotlin/hu/petrik/filcapp/api/Result.kt | 3 +- .../hu/petrik/filcapp/api/SubstitutionApi.kt | 8 +- .../hu/petrik/filcapp/api/TimetableApi.kt | 20 -- .../kotlin/hu/petrik/filcapp/api/UsersApi.kt | 2 +- .../hu/petrik/filcapp/components/TopBar.kt | 26 +- .../kotlin/hu/petrik/filcapp/models/Models.kt | 26 +- .../petrik/filcapp/screens/TimetableScreen.kt | 318 ++++++++++++------ 13 files changed, 279 insertions(+), 181 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/App.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/App.kt index fe75141..f72e863 100644 --- a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/App.kt +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/App.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.Modifier import cafe.adriel.voyager.navigator.tab.CurrentTab import cafe.adriel.voyager.navigator.tab.TabNavigator 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.SplashScreen @@ -45,7 +46,10 @@ fun App() { private fun MainContent() { TabNavigator(HomeTab) { tabNavigator -> Scaffold( - topBar = { TopBar() }, + topBar = { + val isTimetable = tabNavigator.current.options.index == TimetableTab.options.index + TopBar(leadingContent = if (isTimetable) ({ CohortSwitcher() }) else null) + }, bottomBar = { BottomNavigationBar(tabNavigator) }, modifier = Modifier.fillMaxSize(), contentWindowInsets = WindowInsets.systemBars, diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/Client.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/Client.kt index 69b9844..3d73148 100644 --- a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/Client.kt +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/Client.kt @@ -22,7 +22,7 @@ val APIClient = HttpClient { } install(ContentNegotiation) { - json(Json { ignoreUnknownKeys = true; isLenient = true }) + json(Json { ignoreUnknownKeys = true; isLenient = true; coerceInputValues = true }) } // Prefix every request path with /api so generated API files can use bare paths. diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/CohortApi.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/CohortApi.kt index 012980c..ac89ed8 100644 --- a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/CohortApi.kt +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/CohortApi.kt @@ -28,7 +28,7 @@ public class CohortApi(private val client: HttpClient) { } @RequiresAuth - suspend fun getTimetableCohortsAllForTimetable(timetableId: String): APIResult> { + suspend fun getTimetableCohortsAllForTimetableByTimetableId(timetableId: String): APIResult> { return try { val response = client.get { url("/timetable/cohorts/getAllForTimetable/${timetableId}") diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/DoorlockApi.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/DoorlockApi.kt index cbc69ac..f26a29a 100644 --- a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/DoorlockApi.kt +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/DoorlockApi.kt @@ -75,7 +75,7 @@ public class DoorlockApi(private val client: HttpClient) { } @RequiresAuth - suspend fun putDoorlockCards(id: String, body: CardResponse): APIResult { + suspend fun putDoorlockCardsById(id: String, body: CardResponse): APIResult { return try { val response = client.put { url("/doorlock/cards/${id}") @@ -95,7 +95,7 @@ public class DoorlockApi(private val client: HttpClient) { } @RequiresAuth - suspend fun deleteDoorlockCards(id: String): APIResult { + suspend fun deleteDoorlockCardsById(id: String): APIResult { return try { val response = client.delete { url("/doorlock/cards/${id}") @@ -151,7 +151,7 @@ public class DoorlockApi(private val client: HttpClient) { } @RequiresAuth - suspend fun putDoorlockDevices(id: String, body: DeviceResponse): APIResult { + suspend fun putDoorlockDevicesById(id: String, body: DeviceResponse): APIResult { return try { val response = client.put { url("/doorlock/devices/${id}") @@ -171,7 +171,7 @@ public class DoorlockApi(private val client: HttpClient) { } @RequiresAuth - suspend fun deleteDoorlockDevices(id: String): APIResult { + suspend fun deleteDoorlockDevicesById(id: String): APIResult { return try { val response = client.delete { url("/doorlock/devices/${id}") @@ -189,7 +189,7 @@ public class DoorlockApi(private val client: HttpClient) { } @RequiresAuth - suspend fun getDoorlockDevicesIdStats(id: String): APIResult { + suspend fun getDoorlockDevicesByIdStats(id: String): APIResult { return try { val response = client.get { url("/doorlock/devices/${id}/stats") @@ -243,7 +243,7 @@ public class DoorlockApi(private val client: HttpClient) { } @RequiresAuth - suspend fun postDoorlockSelfCardsIdActivate(id: String, body: DoorlockActivationResponse): APIResult { + suspend fun postDoorlockSelfCardsByIdActivate(id: String, body: DoorlockActivationResponse): APIResult { return try { val response = client.post { url("/doorlock/self/cards/${id}/activate") @@ -263,7 +263,7 @@ public class DoorlockApi(private val client: HttpClient) { } @RequiresAuth - suspend fun putDoorlockSelfCardsIdFrozen(id: String, body: CardResponse): APIResult { + suspend fun putDoorlockSelfCardsByIdFrozen(id: String, body: CardResponse): APIResult { return try { val response = client.put { url("/doorlock/self/cards/${id}/frozen") diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/LessonApi.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/LessonApi.kt index 4e16a4b..af9c8c9 100644 --- a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/LessonApi.kt +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/LessonApi.kt @@ -10,7 +10,7 @@ import io.ktor.http.isSuccess import hu.petrik.filcapp.models.EnrichedLesson public class LessonApi(private val client: HttpClient) { - suspend fun getTimetableLessonsForCohort(cohortId: String): APIResult> { + suspend fun getTimetableLessonsForCohortByCohortId(cohortId: String): APIResult> { return try { val response = client.get { url("/timetable/lessons/getForCohort/${cohortId}") @@ -27,7 +27,24 @@ public class LessonApi(private val client: HttpClient) { } } - suspend fun getTimetableLessonsForRoom(classroomId: String): APIResult> { + 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}") @@ -44,7 +61,7 @@ public class LessonApi(private val client: HttpClient) { } } - suspend fun getTimetableLessonsForTeacher(teacherId: String): APIResult> { + suspend fun getTimetableLessonsForTeacherByTeacherId(teacherId: String): APIResult> { return try { val response = client.get { url("/timetable/lessons/getForTeacher/${teacherId}") diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/MovedLessonApi.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/MovedLessonApi.kt index ad17665..8df047a 100644 --- a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/MovedLessonApi.kt +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/MovedLessonApi.kt @@ -48,7 +48,7 @@ public class MovedLessonApi(private val client: HttpClient) { } } - suspend fun getTimetableMovedLessonsCohort(cohortId: String): APIResult> { + suspend fun getTimetableMovedLessonsCohortByCohortId(cohortId: String): APIResult> { return try { val response = client.get { url("/timetable/movedLessons/cohort/${cohortId}") @@ -65,7 +65,7 @@ public class MovedLessonApi(private val client: HttpClient) { } } - suspend fun getTimetableMovedLessonsCohortCohortIdRelevant(cohortId: String): APIResult> { + suspend fun getTimetableMovedLessonsCohortByCohortIdRelevant(cohortId: String): APIResult> { return try { val response = client.get { url("/timetable/movedLessons/cohort/${cohortId}/relevant") @@ -100,7 +100,7 @@ public class MovedLessonApi(private val client: HttpClient) { } @RequiresAuth - suspend fun putTimetableMovedLessons(id: String, body: MovedLesson): APIResult { + suspend fun putTimetableMovedLessonsById(id: String, body: MovedLesson): APIResult { return try { val response = client.put { url("/timetable/movedLessons/${id}") @@ -120,13 +120,13 @@ public class MovedLessonApi(private val client: HttpClient) { } @RequiresAuth - suspend fun deleteTimetableMovedLessons(id: String): APIResult { + suspend fun deleteTimetableMovedLessonsById(id: String): APIResult { return try { val response = client.delete { url("/timetable/movedLessons/${id}") } if (response.status.isSuccess()) { - val envelope = response.body>() + val envelope = response.body>() APIResult.Success(envelope.data) } else { val errorBody = response.body() diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/Result.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/Result.kt index aab9680..2f4d536 100644 --- a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/Result.kt +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/Result.kt @@ -1,6 +1,5 @@ package hu.petrik.filcapp.api -import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Target(AnnotationTarget.FUNCTION) @@ -10,7 +9,7 @@ annotation class RequiresAuth @Serializable public data class ApiErrorMessage( val success: Boolean, - @SerialName("error") val message: String = "", + val message: String ) @Serializable diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/SubstitutionApi.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/SubstitutionApi.kt index ea2cce2..a00c0cf 100644 --- a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/SubstitutionApi.kt +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/SubstitutionApi.kt @@ -51,7 +51,7 @@ public class SubstitutionApi(private val client: HttpClient) { } @RequiresAuth - suspend fun getTimetableSubstitutionsCohort(cohortId: String): APIResult { + suspend fun getTimetableSubstitutionsCohortByCohortId(cohortId: String): APIResult { return try { val response = client.get { url("/timetable/substitutions/cohort/${cohortId}") @@ -87,7 +87,7 @@ public class SubstitutionApi(private val client: HttpClient) { } @RequiresAuth - suspend fun putTimetableSubstitutions(id: String, body: Substitution): APIResult { + suspend fun putTimetableSubstitutionsById(id: String, body: Substitution): APIResult { return try { val response = client.put { url("/timetable/substitutions/${id}") @@ -107,13 +107,13 @@ public class SubstitutionApi(private val client: HttpClient) { } @RequiresAuth - suspend fun deleteTimetableSubstitutions(id: String): APIResult { + suspend fun deleteTimetableSubstitutionsById(id: String): APIResult { return try { val response = client.delete { url("/timetable/substitutions/${id}") } if (response.status.isSuccess()) { - val envelope = response.body>() + val envelope = response.body>() APIResult.Success(envelope.data) } else { val errorBody = response.body() diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/TimetableApi.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/TimetableApi.kt index 02e73df..60f523f 100644 --- a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/TimetableApi.kt +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/TimetableApi.kt @@ -10,26 +10,6 @@ import io.ktor.http.isSuccess import hu.petrik.filcapp.models.Timetable public class TimetableApi(private val client: HttpClient) { - @RequiresAuth - suspend fun postTimetableImport(body: Boolean): APIResult { - return try { - val response = client.post { - url("/timetable/import") - 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 getTimetableTimetables(): APIResult> { return try { diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/UsersApi.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/UsersApi.kt index 8f48243..44240cd 100644 --- a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/UsersApi.kt +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/UsersApi.kt @@ -30,7 +30,7 @@ public class UsersApi(private val client: HttpClient) { } @RequiresAuth - suspend fun patchUsers(id: String, body: User): APIResult { + suspend fun patchUsersById(id: String, body: User): APIResult { return try { val response = client.patch { url("/users/${id}") diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/components/TopBar.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/components/TopBar.kt index 73824b1..4996afe 100644 --- a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/components/TopBar.kt +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/components/TopBar.kt @@ -17,7 +17,7 @@ import hu.petrik.filcapp.auth.AuthState import hu.petrik.filcapp.auth.base64ToImageBitmap @Composable -fun TopBar() { +fun TopBar(leadingContent: (@Composable () -> Unit)? = null) { val displayName = AuthState.displayName ?: "" val profileImage = AuthState.profileImage val avatar: ImageBitmap? = remember(profileImage) { @@ -32,16 +32,20 @@ fun TopBar() { horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - 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), - ) + 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( diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/models/Models.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/models/Models.kt index bb897d1..c33bd39 100644 --- a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/models/Models.kt +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/models/Models.kt @@ -25,7 +25,7 @@ data class Card ( val name: String, val owner: String? = null, val updatedAt: String, - val userId: String, + val userId: String? = null, ) @Serializable @@ -47,7 +47,7 @@ data class Card_authorizedDevices ( @Serializable data class Classroom ( val buildingId: String? = null, - val capacity: String? = null, + val capacity: Int? = null, val id: String, val name: String, val short: String, @@ -55,7 +55,7 @@ data class Classroom ( @Serializable data class Cohort ( - val classroomIds: List = emptyList(), + val classroomIds: List = emptyList(), val id: String, val name: String, val short: String, @@ -66,7 +66,7 @@ data class Cohort ( @Serializable data class DayDefinition ( val createdAt: String, - val days: List = emptyList(), + val days: List = emptyList(), val id: String, val name: String, val short: String, @@ -187,9 +187,9 @@ data class DoorlockStats_topUsers ( @Serializable data class DoorlockUser ( - val email: String? = null, + val email: String, val id: String, - val name: String? = null, + val name: String, val nickname: String? = null, ) @@ -203,9 +203,9 @@ data class EnrichedLesson ( val classrooms: List = emptyList(), val day: DayDefinition, val id: String, - val period: Period, + val period: Period? = null, val periodsPerWeek: Double, - val subject: Subject, + val subject: Subject? = null, val teachers: List = emptyList(), val termDefinitionId: String? = null, val weeksDefinitionId: String, @@ -222,11 +222,11 @@ data class MovedLesson ( @Serializable data class MovedLessonWithRelations ( - val classroom: Classroom, - val dayDefinition: DayDefinition, + val classroom: Classroom? = null, + val dayDefinition: DayDefinition? = null, val lessons: List = emptyList(), val movedLesson: MovedLesson, - val period: Period, + val period: Period? = null, ) @Serializable @@ -260,7 +260,7 @@ data class Substitution ( data class SubstitutionWithRelations ( val lessons: List = emptyList(), val substitution: Substitution, - val teacher: Teacher, + val teacher: Teacher? = null, ) @Serializable @@ -313,7 +313,7 @@ data class User ( val name: String, val nickname: String? = null, val permissions: List = emptyList(), - val roles: List = emptyList(), + val roles: List = emptyList(), val updatedAt: String, ) diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/TimetableScreen.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/TimetableScreen.kt index 0c1eece..9d9f320 100644 --- a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/TimetableScreen.kt +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/TimetableScreen.kt @@ -3,6 +3,7 @@ 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 @@ -17,10 +18,10 @@ 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.graphicsLayer import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.text.font.FontWeight 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 @@ -29,16 +30,29 @@ import cafe.adriel.voyager.navigator.tab.TabOptions import hu.petrik.filcapp.api.APIResult import hu.petrik.filcapp.api.CohortApi import hu.petrik.filcapp.api.LessonApi +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.Cohort import hu.petrik.filcapp.models.EnrichedLesson +import hu.petrik.filcapp.models.SubstitutionWithRelations +import hu.petrik.filcapp.models.Teacher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlin.time.Clock import kotlinx.datetime.* +// ── Shared timetable state (readable by TopBar) ─────────────────────────────── + +object TimetableState { + var cohorts by mutableStateOf>(emptyList()) + var cohortsLoaded by mutableStateOf(false) + var cohortsError by mutableStateOf(null) + var selectedCohortId by mutableStateOf(null) +} + // ── Tab entry point ────────────────────────────────────────────────────────── object TimetableTab : Tab { @@ -50,17 +64,32 @@ object TimetableTab : Tab { @Composable override fun Content() { - val model = rememberScreenModel { TimetableScreenModel(LessonApi(APIClient), CohortApi(APIClient)) } + val model = rememberScreenModel { + TimetableScreenModel( + LessonApi(APIClient), + CohortApi(APIClient), + TeacherApi(APIClient), + SubstitutionApi(APIClient), + ) + } + + LaunchedEffect(Unit) { model.loadCohorts() } - LaunchedEffect(Unit) { model.load() } + LaunchedEffect(TimetableState.selectedCohortId) { + val id = TimetableState.selectedCohortId + if (id != null) model.loadTimetable(id) + } TimetableScreen( isLoading = model.isLoading, lessons = model.lessons, - cohorts = model.cohorts, - selectedCohortId = model.selectedCohortId, - onCohortSelected = { model.selectCohort(it) }, - error = if (model.selectedCohortId == null) "No cohort assigned to your account" else model.error, + substitutions = model.substitutions, + teacherMap = model.teacherMap, + error = if (TimetableState.selectedCohortId == null && TimetableState.cohortsLoaded) { + "No cohort assigned to your account" + } else { + model.error + }, ) } } @@ -71,14 +100,20 @@ object TimetableTab : Tab { fun TimetableScreen( isLoading: Boolean, lessons: List, - cohorts: List, - selectedCohortId: String?, - onCohortSelected: (Cohort) -> Unit, + substitutions: List, + teacherMap: Map, error: String?, ) { - var selectedDate by remember { mutableStateOf(Clock.System.todayIn(TimeZone.currentSystemDefault())) } - val weekDays = remember(selectedDate) { weekOf(selectedDate) } + val today = remember { Clock.System.todayIn(TimeZone.currentSystemDefault()) } + var selectedDate by remember { mutableStateOf(today) } + val weekDays = remember { weekOf(today) } val filtered = remember(lessons, selectedDate) { lessonsForDate(lessons, selectedDate) } + val activeSubstitutions = remember(substitutions, selectedDate) { + substitutions + .filter { it.substitution.date.startsWith(selectedDate.toString()) } + .flatMap { sub -> sub.lessons.map { lessonId -> lessonId to sub } } + .toMap() + } Column(modifier = Modifier.fillMaxSize()) { // ── Lesson area ────────────────────────────────────────────────────── @@ -100,59 +135,50 @@ fun TimetableScreen( } } else { items(filtered, key = { it.id }) { lesson -> - LessonCard(lesson = lesson) + LessonCard( + lesson = lesson, + substitution = activeSubstitutions[lesson.id], + teacherMap = teacherMap, + ) } } } } - // ── Bottom panel (cohort switcher + week strip) ─────────────────────── + // ── Week strip ─────────────────────────────────────────────────────── Surface( modifier = Modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp), ) { - Column { - CohortSwitcher( - cohorts = cohorts, - selectedCohortId = selectedCohortId, - onCohortSelected = onCohortSelected, - ) - - HorizontalDivider( - modifier = Modifier.padding(horizontal = 16.dp), - color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f), - ) - - 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, - ) - } + 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, + ) } } } @@ -160,42 +186,47 @@ fun TimetableScreen( } } -// ── Cohort switcher ─────────────────────────────────────────────────────────── +// ── Cohort switcher (used in TopBar when timetable tab is active) ───────────── @Composable -fun CohortSwitcher( - cohorts: List, - selectedCohortId: String?, - onCohortSelected: (Cohort) -> Unit, -) { +fun CohortSwitcher() { + val cohorts = TimetableState.cohorts + val selectedCohortId = TimetableState.selectedCohortId val selectedCohort = cohorts.find { it.id == selectedCohortId } var expanded by remember { mutableStateOf(false) } - Box(modifier = Modifier.fillMaxWidth()) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(enabled = cohorts.isNotEmpty()) { expanded = true } - .padding(horizontal = 16.dp, vertical = 14.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + val label = when { + selectedCohort != null -> selectedCohort.name + !TimetableState.cohortsLoaded -> "Loading…" + TimetableState.cohortsError != null -> "Error" + else -> "Select class" + } + + Box { + Column( + modifier = Modifier.clickable(enabled = cohorts.isNotEmpty()) { expanded = true }, ) { Text( - text = selectedCohort?.name ?: if (cohorts.isEmpty()) "Loading…" else "Select class", - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold, - ) - Icon( - Icons.Default.KeyboardArrowDown, - contentDescription = "Switch class", - modifier = Modifier.graphicsLayer { rotationZ = if (expanded) 180f else 0f }, + text = "Class", + style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Normal), + color = MaterialTheme.colorScheme.onSurfaceVariant, ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = label, + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + ) + Icon( + Icons.Default.KeyboardArrowDown, + contentDescription = "Switch class", + modifier = Modifier.size(18.dp), + ) + } } DropdownMenu( expanded = expanded, onDismissRequest = { expanded = false }, - modifier = Modifier.fillMaxWidth(), ) { cohorts.forEach { cohort -> DropdownMenuItem( @@ -206,7 +237,7 @@ fun CohortSwitcher( ) }, onClick = { - onCohortSelected(cohort) + TimetableState.selectedCohortId = cohort.id expanded = false }, ) @@ -218,13 +249,32 @@ fun CohortSwitcher( // ── Lesson card ─────────────────────────────────────────────────────────────── @Composable -fun LessonCard(lesson: EnrichedLesson) { +fun LessonCard( + lesson: EnrichedLesson, + substitution: SubstitutionWithRelations? = null, + teacherMap: Map = emptyMap(), +) { val startTime = lesson.period?.startTime?.take(5) ?: "" val endTime = lesson.period?.endTime?.take(5) ?: "" val subjectName = lesson.subject?.name ?: "Unknown" - val teacherName = lesson.teachers.firstOrNull()?.name ?: "" val roomName = lesson.classrooms.firstOrNull()?.name?.let { "Room $it" } ?: "" + val isCancelled = substitution != null && substitution.teacher == null + val isSubstituted = substitution != null && substitution.teacher != null + + val cancelledColor = MaterialTheme.colorScheme.error.copy(alpha = 0.75f) + val substitutedColor = Color(0xFFF59E0B) + + val textColor = when { + isCancelled -> cancelledColor + else -> Color.Unspecified + } + val squareBorderColor = if (isCancelled) cancelledColor 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 ?: "" + Row( modifier = Modifier .fillMaxWidth() @@ -236,7 +286,7 @@ fun LessonCard(lesson: EnrichedLesson) { horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.width(52.dp), ) { - Text(startTime, style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Medium) + Text(startTime, style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Medium, color = textColor) Spacer(Modifier.height(6.dp)) repeat(5) { Box( @@ -247,16 +297,17 @@ fun LessonCard(lesson: EnrichedLesson) { ) Spacer(Modifier.height(6.dp)) } - Text(endTime, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(endTime, style = MaterialTheme.typography.labelSmall, color = if (isCancelled) cancelledColor else MaterialTheme.colorScheme.onSurfaceVariant) } Spacer(Modifier.width(10.dp)) - // Image placeholder + // Subject thumbnail Box( modifier = Modifier - .size(width = 100.dp, height = 110.dp) + .size(80.dp) .clip(RoundedCornerShape(14.dp)) + .border(1.5.dp, squareBorderColor, RoundedCornerShape(14.dp)) .background(MaterialTheme.colorScheme.surfaceVariant), contentAlignment = Alignment.Center, ) { @@ -267,15 +318,36 @@ fun LessonCard(lesson: EnrichedLesson) { // Info column Column(modifier = Modifier.weight(1f)) { - Text(subjectName, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) - if (teacherName.isNotEmpty()) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Text(subjectName, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = textColor) + 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 (isSubstituted && displayTeacherName != null) { Text("Teacher", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) Spacer(Modifier.height(4.dp)) - Text(teacherName, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) + Text(displayTeacherName, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, color = substitutedColor) + if (originalTeacherName.isNotEmpty()) { + Text("was $originalTeacherName", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } else if (originalTeacherName.isNotEmpty()) { + Text("Teacher", style = MaterialTheme.typography.labelSmall, color = if (isCancelled) cancelledColor.copy(alpha = 0.7f) else MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.height(4.dp)) + Text(originalTeacherName, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, color = textColor) } if (roomName.isNotEmpty()) { Spacer(Modifier.height(2.dp)) - Text(roomName, style = MaterialTheme.typography.bodySmall) + Text(roomName, style = MaterialTheme.typography.bodySmall, color = textColor) } } } @@ -286,42 +358,58 @@ fun LessonCard(lesson: EnrichedLesson) { class TimetableScreenModel( private val lessonApi: LessonApi, private val cohortApi: CohortApi, + private val teacherApi: TeacherApi, + private val substitutionApi: SubstitutionApi, ) : ScreenModel { var lessons by mutableStateOf>(emptyList()) - var cohorts by mutableStateOf>(emptyList()) - var selectedCohortId by mutableStateOf(AuthState.cohortId) + var substitutions by mutableStateOf>(emptyList()) + var teacherMap by mutableStateOf>(emptyMap()) var error by mutableStateOf(null) var isLoading by mutableStateOf(false) - fun load() { + fun loadCohorts() { + if (TimetableState.cohortsLoaded) return screenModelScope.launch(Dispatchers.Default) { - withContext(Dispatchers.Main) { isLoading = true; error = null } - try { - when (val result = cohortApi.getCohort()) { - is APIResult.Success -> withContext(Dispatchers.Main) { cohorts = result.data } - is APIResult.Failure -> { /* non-fatal — switcher just stays empty */ } + launch { + when (val result = teacherApi.getTimetableTeachersAll()) { + is APIResult.Success -> withContext(Dispatchers.Main) { + teacherMap = result.data.flatMap { t -> + listOfNotNull(t.id to t, t.userId?.let { uid -> uid to t }) + }.toMap() + } + is APIResult.Failure -> { /* non-fatal */ } } - val cohortId = selectedCohortId - if (cohortId != null) { - when (val result = lessonApi.getTimetableLessonsForCohort(cohortId)) { - is APIResult.Success -> withContext(Dispatchers.Main) { lessons = result.data } - is APIResult.Failure -> withContext(Dispatchers.Main) { error = result.error.toString() } + } + when (val result = cohortApi.getCohort()) { + is APIResult.Success -> withContext(Dispatchers.Main) { + TimetableState.cohorts = result.data + if (TimetableState.selectedCohortId == null) { + TimetableState.selectedCohortId = AuthState.cohortId + ?: result.data.firstOrNull()?.id } + TimetableState.cohortsLoaded = true + } + is APIResult.Failure -> withContext(Dispatchers.Main) { + TimetableState.cohortsError = result.error.toString() + TimetableState.cohortsLoaded = true } - } catch (e: Exception) { - withContext(Dispatchers.Main) { error = e.message ?: "Unknown error" } - } finally { - withContext(Dispatchers.Main) { isLoading = false } } } } - fun selectCohort(cohort: Cohort) { - selectedCohortId = cohort.id + fun loadTimetable(cohortId: String) { screenModelScope.launch(Dispatchers.Default) { withContext(Dispatchers.Main) { isLoading = true; error = null } try { - when (val result = lessonApi.getTimetableLessonsForCohort(cohort.id)) { + launch { + when (val result = substitutionApi.getTimetableSubstitutionsCohortByCohortId(cohortId)) { + is APIResult.Success -> withContext(Dispatchers.Main) { + substitutions = result.data.substitutions + } + is APIResult.Failure -> { /* non-fatal */ } + } + } + when (val result = lessonApi.getTimetableLessonsForCohortByCohortId(cohortId)) { is APIResult.Success -> withContext(Dispatchers.Main) { lessons = result.data } is APIResult.Failure -> withContext(Dispatchers.Main) { error = result.error.toString() } } @@ -337,12 +425,18 @@ class TimetableScreenModel( // ── Date helpers ────────────────────────────────────────────────────────────── private fun weekOf(date: LocalDate): List { - val monday = date.minus(date.dayOfWeek.ordinal, DateTimeUnit.DAY) - return (0..6).map { monday.plus(it, DateTimeUnit.DAY) } + 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) } + } } private fun lessonsForDate(lessons: List, date: LocalDate): List { - val dayNum = date.dayOfWeek.isoDayNumber.toString() // "1"=Mon ... "7"=Sun + val dayNum = date.dayOfWeek.isoDayNumber.toString() return lessons .filter { lesson -> val days = lesson.day?.days?.filterNotNull() ?: return@filter false From 4f7d4e18b9d10eb7ff9db103a2bfdeed74cbc5ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Susa=20Mil=C3=A1n=20Mih=C3=A1ly?= Date: Mon, 20 Apr 2026 14:41:13 +0200 Subject: [PATCH 11/23] moved lesson support --- .../petrik/filcapp/screens/TimetableScreen.kt | 146 +++++++++++++++--- 1 file changed, 125 insertions(+), 21 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/TimetableScreen.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/TimetableScreen.kt index 9d9f320..bf257dd 100644 --- a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/TimetableScreen.kt +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/TimetableScreen.kt @@ -30,12 +30,14 @@ import cafe.adriel.voyager.navigator.tab.TabOptions import hu.petrik.filcapp.api.APIResult 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.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 @@ -70,6 +72,7 @@ object TimetableTab : Tab { CohortApi(APIClient), TeacherApi(APIClient), SubstitutionApi(APIClient), + MovedLessonApi(APIClient), ) } @@ -84,6 +87,7 @@ object TimetableTab : Tab { isLoading = model.isLoading, lessons = model.lessons, substitutions = model.substitutions, + movedLessons = model.movedLessons, teacherMap = model.teacherMap, error = if (TimetableState.selectedCohortId == null && TimetableState.cohortsLoaded) { "No cohort assigned to your account" @@ -101,13 +105,14 @@ fun TimetableScreen( isLoading: Boolean, lessons: List, substitutions: List, + movedLessons: List, teacherMap: Map, error: String?, ) { val today = remember { Clock.System.todayIn(TimeZone.currentSystemDefault()) } var selectedDate by remember { mutableStateOf(today) } val weekDays = remember { weekOf(today) } - val filtered = remember(lessons, selectedDate) { lessonsForDate(lessons, selectedDate) } + val activeSubstitutions = remember(substitutions, selectedDate) { substitutions .filter { it.substitution.date.startsWith(selectedDate.toString()) } @@ -115,6 +120,38 @@ fun TimetableScreen( .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()) { // ── Lesson area ────────────────────────────────────────────────────── if (isLoading) { @@ -127,17 +164,19 @@ fun TimetableScreen( } } else { LazyColumn(modifier = Modifier.weight(1f).fillMaxWidth()) { - if (filtered.isEmpty()) { + if (displayLessons.isEmpty()) { item { Box(modifier = Modifier.fillParentMaxSize(), contentAlignment = Alignment.Center) { Text("No lessons today", color = MaterialTheme.colorScheme.onSurfaceVariant) } } } else { - items(filtered, key = { it.id }) { lesson -> + items(displayLessons, key = { it.id }) { lesson -> LessonCard( lesson = lesson, substitution = activeSubstitutions[lesson.id], + movedLesson = movedToday[lesson.id] ?: movedAway[lesson.id], + isMovedHere = movedToday.containsKey(lesson.id), teacherMap = teacherMap, ) } @@ -252,29 +291,52 @@ fun CohortSwitcher() { fun LessonCard( lesson: EnrichedLesson, substitution: SubstitutionWithRelations? = null, + movedLesson: MovedLessonWithRelations? = null, + isMovedHere: Boolean = false, teacherMap: Map = emptyMap(), ) { - val startTime = lesson.period?.startTime?.take(5) ?: "" - val endTime = lesson.period?.endTime?.take(5) ?: "" + // 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 roomName = lesson.classrooms.firstOrNull()?.name?.let { "Room $it" } ?: "" + 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 = if (isCancelled) cancelledColor else Color.Transparent + 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 ?: "" + // Moved-away target date (e.g. "Apr 22") and moved-here source day name + val movedToDate = if (isMovedAway) { + movedLesson?.movedLesson?.date?.take(10) + } else null + val movedFromDay = if (isMovedHere) { + movedLesson?.dayDefinition?.let { dd -> + // startingDay is the original day def id; use the lesson's own day name as source + lesson.day?.name + } + } else null + Row( modifier = Modifier .fillMaxWidth() @@ -318,21 +380,53 @@ fun LessonCard( // Info column Column(modifier = Modifier.weight(1f)) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { - Text(subjectName, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = textColor) - 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, - ) + Text(subjectName, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = textColor) + val hasChips = isCancelled || isMovedHere || isMovedAway + if (hasChips) { + Spacer(Modifier.height(3.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 (isMovedHere) Surface( + shape = RoundedCornerShape(4.dp), + color = movedColor.copy(alpha = 0.15f), + ) { + Text( + text = "Moved", + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + style = MaterialTheme.typography.labelSmall, + color = movedColor, + 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 (isMovedHere && movedFromDay != null) { + Spacer(Modifier.height(2.dp)) + Text("from $movedFromDay", style = MaterialTheme.typography.bodySmall, color = movedColor.copy(alpha = 0.8f)) + } if (isSubstituted && displayTeacherName != null) { Text("Teacher", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) Spacer(Modifier.height(4.dp)) @@ -347,7 +441,7 @@ fun LessonCard( } if (roomName.isNotEmpty()) { Spacer(Modifier.height(2.dp)) - Text(roomName, style = MaterialTheme.typography.bodySmall, color = textColor) + Text(roomName, style = MaterialTheme.typography.bodySmall, color = if (isMovedHere) movedColor.copy(alpha = 0.8f) else textColor) } } } @@ -360,9 +454,11 @@ class TimetableScreenModel( private val cohortApi: CohortApi, private val teacherApi: TeacherApi, private val substitutionApi: SubstitutionApi, + private val movedLessonApi: MovedLessonApi, ) : ScreenModel { var lessons by mutableStateOf>(emptyList()) var substitutions by mutableStateOf>(emptyList()) + var movedLessons by mutableStateOf>(emptyList()) var teacherMap by mutableStateOf>(emptyMap()) var error by mutableStateOf(null) var isLoading by mutableStateOf(false) @@ -409,6 +505,14 @@ class TimetableScreenModel( is APIResult.Failure -> { /* non-fatal */ } } } + launch { + when (val result = movedLessonApi.getTimetableMovedLessonsCohortByCohortId(cohortId)) { + is APIResult.Success -> withContext(Dispatchers.Main) { + movedLessons = result.data + } + is APIResult.Failure -> { /* non-fatal */ } + } + } when (val result = lessonApi.getTimetableLessonsForCohortByCohortId(cohortId)) { is APIResult.Success -> withContext(Dispatchers.Main) { lessons = result.data } is APIResult.Failure -> withContext(Dispatchers.Main) { error = result.error.toString() } From 69a3173c0a99dbb368caba05269badf0bb328259 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Susa=20Mil=C3=A1n=20Mih=C3=A1ly?= Date: Mon, 20 Apr 2026 15:08:04 +0200 Subject: [PATCH 12/23] More compact lesson view and detailed lesson view. --- .../petrik/filcapp/screens/TimetableScreen.kt | 179 +++++++++++++++--- 1 file changed, 150 insertions(+), 29 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/TimetableScreen.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/TimetableScreen.kt index bf257dd..3856734 100644 --- a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/TimetableScreen.kt +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/TimetableScreen.kt @@ -1,4 +1,4 @@ -@file:OptIn(kotlin.time.ExperimentalTime::class) +@file:OptIn(kotlin.time.ExperimentalTime::class, androidx.compose.material3.ExperimentalMaterial3Api::class) package hu.petrik.filcapp.screens @@ -20,6 +20,7 @@ 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 androidx.compose.ui.unit.sp import cafe.adriel.voyager.core.model.ScreenModel @@ -112,6 +113,7 @@ fun TimetableScreen( 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) } val activeSubstitutions = remember(substitutions, selectedDate) { substitutions @@ -178,12 +180,30 @@ fun TimetableScreen( movedLesson = movedToday[lesson.id] ?: movedAway[lesson.id], isMovedHere = movedToday.containsKey(lesson.id), teacherMap = teacherMap, + onClick = { expandedLessonId = if (expandedLessonId == lesson.id) null else lesson.id }, ) } } } } + // ── 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(), @@ -294,6 +314,7 @@ fun LessonCard( movedLesson: MovedLessonWithRelations? = null, isMovedHere: 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 @@ -340,50 +361,48 @@ fun LessonCard( Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 6.dp), + .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(52.dp), + modifier = Modifier.width(44.dp), ) { Text(startTime, style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Medium, color = textColor) - Spacer(Modifier.height(6.dp)) - repeat(5) { - Box( - modifier = Modifier - .width(14.dp) - .height(2.dp) - .background(MaterialTheme.colorScheme.outlineVariant), - ) - Spacer(Modifier.height(6.dp)) - } Text(endTime, style = MaterialTheme.typography.labelSmall, color = if (isCancelled) cancelledColor else MaterialTheme.colorScheme.onSurfaceVariant) } - Spacer(Modifier.width(10.dp)) + Spacer(Modifier.width(8.dp)) // Subject thumbnail Box( modifier = Modifier - .size(80.dp) - .clip(RoundedCornerShape(14.dp)) - .border(1.5.dp, squareBorderColor, RoundedCornerShape(14.dp)) + .size(48.dp) + .clip(RoundedCornerShape(10.dp)) + .border(1.5.dp, squareBorderColor, RoundedCornerShape(10.dp)) .background(MaterialTheme.colorScheme.surfaceVariant), contentAlignment = Alignment.Center, ) { - Text(lesson.subject?.short ?: "?", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text( + text = lesson.subject?.short ?: "?", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Clip, + modifier = Modifier.padding(4.dp), + ) } - Spacer(Modifier.width(14.dp)) + Spacer(Modifier.width(10.dp)) // Info column Column(modifier = Modifier.weight(1f)) { - Text(subjectName, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = textColor) + Text(subjectName, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = textColor, maxLines = 1, overflow = TextOverflow.Ellipsis) val hasChips = isCancelled || isMovedHere || isMovedAway if (hasChips) { - Spacer(Modifier.height(3.dp)) + Spacer(Modifier.height(2.dp)) Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { if (isCancelled) Surface( shape = RoundedCornerShape(4.dp), @@ -428,25 +447,127 @@ fun LessonCard( Text("from $movedFromDay", style = MaterialTheme.typography.bodySmall, color = movedColor.copy(alpha = 0.8f)) } if (isSubstituted && displayTeacherName != null) { - Text("Teacher", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - Spacer(Modifier.height(4.dp)) Text(displayTeacherName, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, color = substitutedColor) - if (originalTeacherName.isNotEmpty()) { - Text("was $originalTeacherName", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - } } else if (originalTeacherName.isNotEmpty()) { - Text("Teacher", style = MaterialTheme.typography.labelSmall, color = if (isCancelled) cancelledColor.copy(alpha = 0.7f) else MaterialTheme.colorScheme.onSurfaceVariant) - Spacer(Modifier.height(4.dp)) Text(originalTeacherName, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, color = textColor) } if (roomName.isNotEmpty()) { Spacer(Modifier.height(2.dp)) - Text(roomName, style = MaterialTheme.typography.bodySmall, color = if (isMovedHere) movedColor.copy(alpha = 0.8f) else textColor) + Text(roomName, style = MaterialTheme.typography.bodySmall, color = if (isMovedHere) movedColor.copy(alpha = 0.8f) else textColor, 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( From f363356c95c27bab2dc21c48774c821e9c695ef1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Susa=20Mil=C3=A1n=20Mih=C3=A1ly?= Date: Mon, 20 Apr 2026 15:19:38 +0200 Subject: [PATCH 13/23] Added refresh with a 5 second timeout --- .../petrik/filcapp/screens/TimetableScreen.kt | 88 +++++++++++++------ 1 file changed, 63 insertions(+), 25 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/TimetableScreen.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/TimetableScreen.kt index 3856734..6fc6fc1 100644 --- a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/TimetableScreen.kt +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/TimetableScreen.kt @@ -13,6 +13,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material3.* +import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -42,6 +43,7 @@ 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 @@ -95,6 +97,7 @@ object TimetableTab : Tab { } else { model.error }, + onRefresh = { TimetableState.selectedCohortId?.let { model.loadTimetable(it) } }, ) } } @@ -109,11 +112,20 @@ fun TimetableScreen( 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) } + + LaunchedEffect(cooldown) { + if (cooldown > 0) { + delay(1000) + cooldown-- + } + } val activeSubstitutions = remember(substitutions, selectedDate) { substitutions @@ -156,35 +168,61 @@ fun TimetableScreen( Column(modifier = Modifier.fillMaxSize()) { // ── Lesson area ────────────────────────────────────────────────────── - if (isLoading) { - Box(modifier = Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { - CircularProgressIndicator() - } - } else if (error != null) { - Box(modifier = Modifier.weight(1f).fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) { - Text(error, color = MaterialTheme.colorScheme.error) - } - } else { - LazyColumn(modifier = Modifier.weight(1f).fillMaxWidth()) { - if (displayLessons.isEmpty()) { - item { - Box(modifier = Modifier.fillParentMaxSize(), contentAlignment = Alignment.Center) { - Text("No lessons today", color = MaterialTheme.colorScheme.onSurfaceVariant) - } + Box(modifier = Modifier.weight(1f).fillMaxWidth()) { + PullToRefreshBox( + isRefreshing = isLoading, + onRefresh = { + if (cooldown == 0) { + cooldown = 5 + onRefresh() } - } else { - items(displayLessons, key = { it.id }) { lesson -> - LessonCard( - lesson = lesson, - substitution = activeSubstitutions[lesson.id], - movedLesson = movedToday[lesson.id] ?: movedAway[lesson.id], - isMovedHere = movedToday.containsKey(lesson.id), - teacherMap = teacherMap, - onClick = { expandedLessonId = if (expandedLessonId == lesson.id) null else lesson.id }, - ) + }, + 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 -> + LessonCard( + lesson = lesson, + substitution = activeSubstitutions[lesson.id], + movedLesson = movedToday[lesson.id] ?: movedAway[lesson.id], + isMovedHere = movedToday.containsKey(lesson.id), + 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 ────────────────────────────────────────────── From 1f8e2aab9311707eacbc883d57e40148609d4e4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Susa=20Mil=C3=A1n=20Mih=C3=A1ly?= Date: Mon, 20 Apr 2026 15:23:23 +0200 Subject: [PATCH 14/23] Color current lesson accent colored. --- .../petrik/filcapp/screens/TimetableScreen.kt | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/TimetableScreen.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/TimetableScreen.kt index 6fc6fc1..180fd3b 100644 --- a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/TimetableScreen.kt +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/TimetableScreen.kt @@ -119,6 +119,9 @@ fun TimetableScreen( 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) { @@ -127,6 +130,13 @@ fun TimetableScreen( } } + 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()) } @@ -192,11 +202,16 @@ fun TimetableScreen( } } 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 }, ) @@ -351,6 +366,7 @@ fun LessonCard( substitution: SubstitutionWithRelations? = null, movedLesson: MovedLessonWithRelations? = null, isMovedHere: Boolean = false, + isActive: Boolean = false, teacherMap: Map = emptyMap(), onClick: (() -> Unit)? = null, ) { @@ -415,18 +431,20 @@ fun LessonCard( 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(MaterialTheme.colorScheme.surfaceVariant), + .background(thumbnailBackground), contentAlignment = Alignment.Center, ) { Text( text = lesson.subject?.short ?: "?", style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = thumbnailTextColor, maxLines = 1, overflow = TextOverflow.Clip, modifier = Modifier.padding(4.dp), @@ -698,6 +716,12 @@ private fun weekOf(date: LocalDate): List { } } +private 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 From c484ade390967c8b22052bd523e9b5fe8e2866bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Susa=20Mil=C3=A1n=20Mih=C3=A1ly?= Date: Mon, 20 Apr 2026 19:38:51 +0200 Subject: [PATCH 15/23] Main screen, lesson queue. --- .../hu/petrik/filcapp/components/DateView.kt | 313 ++++++++++++++---- .../hu/petrik/filcapp/screens/HomeScreen.kt | 14 +- .../petrik/filcapp/screens/TimetableScreen.kt | 65 ++-- 3 files changed, 288 insertions(+), 104 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/components/DateView.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/components/DateView.kt index 4ccdac5..8a29cbb 100644 --- a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/components/DateView.kt +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/components/DateView.kt @@ -9,71 +9,54 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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 hu.petrik.filcapp.models.EnrichedLesson +import hu.petrik.filcapp.models.Period +import hu.petrik.filcapp.models.MovedLessonWithRelations +import hu.petrik.filcapp.screens.TimetableState import kotlinx.coroutines.delay -import kotlinx.datetime.DayOfWeek -import kotlinx.datetime.Month -import kotlinx.datetime.TimeZone -import kotlinx.datetime.toLocalDateTime +import kotlinx.datetime.* import kotlin.time.Clock @OptIn(kotlin.time.ExperimentalTime::class) @Composable fun DateView(modifier: Modifier = Modifier) { - var currentTime by remember { - mutableStateOf( - Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()), - ) + var currentDateTime by remember { + mutableStateOf(Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault())) } LaunchedEffect(Unit) { while (true) { - currentTime = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()) + currentDateTime = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()) delay(1000L) } } - val dayOfWeek = - when (currentTime.dayOfWeek) { - DayOfWeek.MONDAY -> "Monday" - DayOfWeek.TUESDAY -> "Tuesday" - DayOfWeek.WEDNESDAY -> "Wednesday" - DayOfWeek.THURSDAY -> "Thursday" - DayOfWeek.FRIDAY -> "Friday" - DayOfWeek.SATURDAY -> "Saturday" - DayOfWeek.SUNDAY -> "Sunday" - } + val dayOfWeek = currentDateTime.dayOfWeek.name + .lowercase().replaceFirstChar { it.uppercase() } - val monthName = - when (currentTime.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 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 day = currentTime.dayOfMonth.toString().padStart(2, '0') - val year = currentTime.year - val hour = currentTime.hour.toString().padStart(2, '0') - val minute = currentTime.minute.toString().padStart(2, '0') + val status = rememberLessonStatus(currentDateTime) Row( - modifier = - modifier - .fillMaxWidth() - .padding(16.dp) - .height(IntrinsicSize.Min), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.Top, ) { Column( modifier = Modifier.weight(1f), @@ -101,23 +84,241 @@ fun DateView(modifier: Modifier = Modifier) { } Box( - modifier = - Modifier - .fillMaxHeight() - .weight(1f) - .aspectRatio(1f) - .padding(start = 16.dp) - .background( - color = MaterialTheme.colorScheme.surfaceVariant, - shape = RoundedCornerShape(24.dp), - ), + 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), + ), 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, + ) + Text( + text = status.subject, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer, + textAlign = TextAlign.Center, + maxLines = 2, + ) + } + 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 UpcomingLesson(val subject: String, val startTime: String, val room: 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) + + 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 = "Lottie or picture", + 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)) + .padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = lesson.subject, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + maxLines = 1, + ) + Spacer(Modifier.width(8.dp)) + Column(horizontalAlignment = Alignment.End) { + Text( + text = lesson.startTime, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + ) + if (lesson.room != null) { + Text( + text = lesson.room, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } } } } + +// ── Status & upcoming helpers ───────────────────────────────────────────────── + +sealed interface LessonStatus { + data class InClass(val subject: String) : 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?, +) + +private fun buildTodaySlots( + lessons: List, + movedLessons: List, + 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 base = lessons.filter { lesson -> + val days = lesson.day?.days?.filterNotNull() ?: return@filter false + days.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 = p.startTime?.take(5)?.let { runCatching { LocalTime.parse(it) }.getOrNull() } ?: return@mapNotNull null + val end = p.endTime?.take(5)?.let { runCatching { LocalTime.parse(it) }.getOrNull() } ?: return@mapNotNull null + val subject = lesson.subject?.name ?: return@mapNotNull null + val room = (ml?.classroom?.name ?: lesson.classrooms.firstOrNull()?.name)?.let { "Room $it" } + Slot(start, end, subject, room) + }.sortedBy { it.start } +} + +@Composable +private fun rememberLessonStatus(now: LocalDateTime): LessonStatus { + val lessons = TimetableState.lessons + val movedLessons = TimetableState.movedLessons + if (!TimetableState.timetableLoaded || lessons.isEmpty()) return LessonStatus.NoData + + val slots = buildTodaySlots(lessons, movedLessons, now.date) + if (slots.isEmpty()) return LessonStatus.NoData + + val time = now.time + val current = slots.firstOrNull { time >= it.start && time <= it.end } + if (current != null) return LessonStatus.InClass(current.subject) + + 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 + if (!TimetableState.timetableLoaded || lessons.isEmpty()) return emptyList() + + val slots = buildTodaySlots(lessons, movedLessons, now.date) + val time = now.time + val future = slots.filter { it.start > time } + + // When status is Break/NotStarted the first future slot is already shown in the square — skip it + val inClass = slots.any { time >= it.start && time <= it.end } + val tail = if (inClass) future else future.drop(1) + + return tail.take(limit).map { UpcomingLesson(it.subject, it.start.toString().take(5), it.room) } +} diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/HomeScreen.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/HomeScreen.kt index d958692..03ae76c 100644 --- a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/HomeScreen.kt @@ -1,8 +1,6 @@ package hu.petrik.filcapp.screens -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Home import androidx.compose.material3.* @@ -13,16 +11,18 @@ import androidx.compose.ui.unit.dp import cafe.adriel.voyager.navigator.tab.Tab import cafe.adriel.voyager.navigator.tab.TabOptions import hu.petrik.filcapp.components.DateView +import hu.petrik.filcapp.components.UpcomingClasses @Composable fun HomeScreen() { Column( - modifier = - Modifier - .fillMaxSize() - .padding(top = 16.dp), + modifier = Modifier + .fillMaxSize() + .padding(top = 16.dp), ) { DateView() + Spacer(Modifier.height(16.dp)) + UpcomingClasses() } } diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/TimetableScreen.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/TimetableScreen.kt index 180fd3b..74230a6 100644 --- a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/TimetableScreen.kt +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/TimetableScreen.kt @@ -56,6 +56,11 @@ object TimetableState { var cohortsLoaded by mutableStateOf(false) var cohortsError by mutableStateOf(null) var selectedCohortId 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) } // ── Tab entry point ────────────────────────────────────────────────────────── @@ -88,10 +93,10 @@ object TimetableTab : Tab { TimetableScreen( isLoading = model.isLoading, - lessons = model.lessons, - substitutions = model.substitutions, - movedLessons = model.movedLessons, - teacherMap = model.teacherMap, + lessons = TimetableState.lessons, + substitutions = TimetableState.substitutions, + movedLessons = TimetableState.movedLessons, + teacherMap = TimetableState.teacherMap, error = if (TimetableState.selectedCohortId == null && TimetableState.cohortsLoaded) { "No cohort assigned to your account" } else { @@ -401,16 +406,7 @@ fun LessonCard( val displayTeacherName = substituterTeacher?.let { "${it.lastName} ${it.firstName}" } val originalTeacherName = lesson.teachers.firstOrNull()?.name ?: "" - // Moved-away target date (e.g. "Apr 22") and moved-here source day name - val movedToDate = if (isMovedAway) { - movedLesson?.movedLesson?.date?.take(10) - } else null - val movedFromDay = if (isMovedHere) { - movedLesson?.dayDefinition?.let { dd -> - // startingDay is the original day def id; use the lesson's own day name as source - lesson.day?.name - } - } else null + val movedToDate = if (isMovedAway) movedLesson?.movedLesson?.date?.take(10) else null Row( modifier = Modifier @@ -456,7 +452,7 @@ fun LessonCard( // 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 || isMovedHere || isMovedAway + val hasChips = isCancelled || isMovedAway if (hasChips) { Spacer(Modifier.height(2.dp)) Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { @@ -472,18 +468,6 @@ fun LessonCard( fontWeight = FontWeight.SemiBold, ) } - if (isMovedHere) Surface( - shape = RoundedCornerShape(4.dp), - color = movedColor.copy(alpha = 0.15f), - ) { - Text( - text = "Moved", - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), - style = MaterialTheme.typography.labelSmall, - color = movedColor, - fontWeight = FontWeight.SemiBold, - ) - } if (isMovedAway) Surface( shape = RoundedCornerShape(4.dp), color = MaterialTheme.colorScheme.surfaceVariant, @@ -498,18 +482,18 @@ fun LessonCard( } } } - if (isMovedHere && movedFromDay != null) { - Spacer(Modifier.height(2.dp)) - Text("from $movedFromDay", style = MaterialTheme.typography.bodySmall, color = movedColor.copy(alpha = 0.8f)) - } 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 = if (isMovedHere) movedColor.copy(alpha = 0.8f) else textColor, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(roomName, style = MaterialTheme.typography.bodySmall, color = roomColor, maxLines = 1, overflow = TextOverflow.Ellipsis) } } } @@ -633,10 +617,6 @@ class TimetableScreenModel( private val substitutionApi: SubstitutionApi, private val movedLessonApi: MovedLessonApi, ) : ScreenModel { - var lessons by mutableStateOf>(emptyList()) - var substitutions by mutableStateOf>(emptyList()) - var movedLessons by mutableStateOf>(emptyList()) - var teacherMap by mutableStateOf>(emptyMap()) var error by mutableStateOf(null) var isLoading by mutableStateOf(false) @@ -646,7 +626,7 @@ class TimetableScreenModel( launch { when (val result = teacherApi.getTimetableTeachersAll()) { is APIResult.Success -> withContext(Dispatchers.Main) { - teacherMap = result.data.flatMap { t -> + TimetableState.teacherMap = result.data.flatMap { t -> listOfNotNull(t.id to t, t.userId?.let { uid -> uid to t }) }.toMap() } @@ -677,7 +657,7 @@ class TimetableScreenModel( launch { when (val result = substitutionApi.getTimetableSubstitutionsCohortByCohortId(cohortId)) { is APIResult.Success -> withContext(Dispatchers.Main) { - substitutions = result.data.substitutions + TimetableState.substitutions = result.data.substitutions } is APIResult.Failure -> { /* non-fatal */ } } @@ -685,13 +665,16 @@ class TimetableScreenModel( launch { when (val result = movedLessonApi.getTimetableMovedLessonsCohortByCohortId(cohortId)) { is APIResult.Success -> withContext(Dispatchers.Main) { - movedLessons = result.data + TimetableState.movedLessons = result.data } is APIResult.Failure -> { /* non-fatal */ } } } when (val result = lessonApi.getTimetableLessonsForCohortByCohortId(cohortId)) { - is APIResult.Success -> withContext(Dispatchers.Main) { lessons = result.data } + is APIResult.Success -> withContext(Dispatchers.Main) { + TimetableState.lessons = result.data + TimetableState.timetableLoaded = true + } is APIResult.Failure -> withContext(Dispatchers.Main) { error = result.error.toString() } } } catch (e: Exception) { @@ -716,7 +699,7 @@ private fun weekOf(date: LocalDate): List { } } -private fun isLessonActive(period: hu.petrik.filcapp.models.Period?, now: LocalTime): Boolean { +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 From f362f6c2c486525778afb8e3cefff970cde5578d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Susa=20Mil=C3=A1n=20Mih=C3=A1ly?= Date: Mon, 20 Apr 2026 20:30:34 +0200 Subject: [PATCH 16/23] Fix issue with shared timetable state --- .../petrik/filcapp/screens/TimetableScreen.kt | 29 ++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/TimetableScreen.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/TimetableScreen.kt index 74230a6..35be779 100644 --- a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/TimetableScreen.kt +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/TimetableScreen.kt @@ -56,6 +56,7 @@ object TimetableState { 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()) @@ -93,9 +94,9 @@ object TimetableTab : Tab { TimetableScreen( isLoading = model.isLoading, - lessons = TimetableState.lessons, - substitutions = TimetableState.substitutions, - movedLessons = TimetableState.movedLessons, + lessons = if (model.isViewingOwnCohort) TimetableState.lessons else model.localLessons, + substitutions = if (model.isViewingOwnCohort) TimetableState.substitutions else model.localSubstitutions, + movedLessons = if (model.isViewingOwnCohort) TimetableState.movedLessons else model.localMovedLessons, teacherMap = TimetableState.teacherMap, error = if (TimetableState.selectedCohortId == null && TimetableState.cohortsLoaded) { "No cohort assigned to your account" @@ -617,6 +618,10 @@ class TimetableScreenModel( private val substitutionApi: SubstitutionApi, private val movedLessonApi: MovedLessonApi, ) : ScreenModel { + var localLessons by mutableStateOf>(emptyList()) + var localSubstitutions by mutableStateOf>(emptyList()) + var localMovedLessons by mutableStateOf>(emptyList()) + var isViewingOwnCohort by mutableStateOf(true) var error by mutableStateOf(null) var isLoading by mutableStateOf(false) @@ -637,8 +642,9 @@ class TimetableScreenModel( is APIResult.Success -> withContext(Dispatchers.Main) { TimetableState.cohorts = result.data if (TimetableState.selectedCohortId == null) { - TimetableState.selectedCohortId = AuthState.cohortId - ?: result.data.firstOrNull()?.id + val own = AuthState.cohortId ?: result.data.firstOrNull()?.id + TimetableState.selectedCohortId = own + TimetableState.ownCohortId = own } TimetableState.cohortsLoaded = true } @@ -651,13 +657,15 @@ class TimetableScreenModel( } fun loadTimetable(cohortId: String) { + val isOwn = cohortId == TimetableState.ownCohortId screenModelScope.launch(Dispatchers.Default) { - withContext(Dispatchers.Main) { isLoading = true; error = null } + withContext(Dispatchers.Main) { isLoading = true; error = null; isViewingOwnCohort = isOwn } try { launch { when (val result = substitutionApi.getTimetableSubstitutionsCohortByCohortId(cohortId)) { is APIResult.Success -> withContext(Dispatchers.Main) { - TimetableState.substitutions = result.data.substitutions + if (isOwn) TimetableState.substitutions = result.data.substitutions + else localSubstitutions = result.data.substitutions } is APIResult.Failure -> { /* non-fatal */ } } @@ -665,15 +673,16 @@ class TimetableScreenModel( launch { when (val result = movedLessonApi.getTimetableMovedLessonsCohortByCohortId(cohortId)) { is APIResult.Success -> withContext(Dispatchers.Main) { - TimetableState.movedLessons = result.data + 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) { - TimetableState.lessons = result.data - TimetableState.timetableLoaded = true + if (isOwn) { TimetableState.lessons = result.data; TimetableState.timetableLoaded = true } + else localLessons = result.data } is APIResult.Failure -> withContext(Dispatchers.Main) { error = result.error.toString() } } From b4e8f35a8443e49b0b9f1b9366c117b854d29004 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Susa=20Mil=C3=A1n=20Mih=C3=A1ly?= Date: Mon, 20 Apr 2026 20:35:16 +0200 Subject: [PATCH 17/23] Generate news api bindings --- .../filcapp/api/NewsAnnouncementsApi.kt | 71 +++++++++++ .../hu/petrik/filcapp/api/NewsBlogsApi.kt | 111 ++++++++++++++++++ .../filcapp/api/NewsSystemMessagesApi.kt | 71 +++++++++++ .../kotlin/hu/petrik/filcapp/models/Models.kt | 39 ++++++ 4 files changed, 292 insertions(+) create mode 100644 composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/NewsAnnouncementsApi.kt create mode 100644 composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/NewsBlogsApi.kt create mode 100644 composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/NewsSystemMessagesApi.kt 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..44e904b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/NewsAnnouncementsApi.kt @@ -0,0 +1,71 @@ +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 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 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..aca6327 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/NewsBlogsApi.kt @@ -0,0 +1,111 @@ +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) { + @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 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)) + } + } + +} 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..eeba10d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/NewsSystemMessagesApi.kt @@ -0,0 +1,71 @@ +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 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 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/models/Models.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/models/Models.kt index c33bd39..b180fe2 100644 --- a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/models/Models.kt +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/models/Models.kt @@ -2,6 +2,19 @@ package hu.petrik.filcapp.models import kotlinx.serialization.Serializable +@Serializable +data class Announcement ( + 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, @@ -14,6 +27,19 @@ data class AuditLog ( val userId: String? = null, ) +@Serializable +data class BlogPost ( + 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(), @@ -269,6 +295,19 @@ data class SubstitutionsByCohort ( val substitutions: List = emptyList(), ) +@Serializable +data class SystemMessage ( + 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, From 5e9f6a8cf5d2bd1cd3ebe7f18ededda8c78841d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Susa=20Mil=C3=A1n=20Mih=C3=A1ly?= Date: Mon, 20 Apr 2026 21:13:12 +0200 Subject: [PATCH 18/23] Fixed bindings issue. Added news tab. --- .../filcapp/api/NewsAnnouncementsApi.kt | 36 ++ .../hu/petrik/filcapp/api/NewsBlogsApi.kt | 70 ++++ .../filcapp/api/NewsSystemMessagesApi.kt | 36 ++ .../kotlin/hu/petrik/filcapp/models/Models.kt | 17 +- .../hu/petrik/filcapp/screens/NewsScreen.kt | 375 ++++++++++++++++-- 5 files changed, 496 insertions(+), 38 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/NewsAnnouncementsApi.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/NewsAnnouncementsApi.kt index 44e904b..0711adc 100644 --- a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/NewsAnnouncementsApi.kt +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/NewsAnnouncementsApi.kt @@ -10,6 +10,24 @@ 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 { @@ -30,6 +48,24 @@ public class NewsAnnouncementsApi(private val client: HttpClient) { } } + @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 { diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/NewsBlogsApi.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/NewsBlogsApi.kt index aca6327..264cc51 100644 --- a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/NewsBlogsApi.kt +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/NewsBlogsApi.kt @@ -10,6 +10,23 @@ 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 { @@ -30,6 +47,42 @@ public class NewsBlogsApi(private val client: HttpClient) { } } + @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 { @@ -108,4 +161,21 @@ public class NewsBlogsApi(private val client: HttpClient) { } } + 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 index eeba10d..451fe53 100644 --- a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/NewsSystemMessagesApi.kt +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/api/NewsSystemMessagesApi.kt @@ -10,6 +10,24 @@ 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 { @@ -30,6 +48,24 @@ public class NewsSystemMessagesApi(private val client: HttpClient) { } } + @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 { diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/models/Models.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/models/Models.kt index b180fe2..d8bb23d 100644 --- a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/models/Models.kt +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/models/Models.kt @@ -1,12 +1,14 @@ 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 content: List = emptyList(), val createdAt: String, val id: String, val title: String, @@ -27,10 +29,18 @@ data class AuditLog ( 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 content: List = emptyList(), val createdAt: String, val id: String, val publishedAt: String? = null, @@ -297,9 +307,10 @@ data class SubstitutionsByCohort ( @Serializable data class SystemMessage ( + val author: Author? = null, val authorId: String, val cohortIds: List = emptyList(), - val content: List = emptyList(), + val content: List = emptyList(), val createdAt: String, val id: String, val title: String, diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/NewsScreen.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/NewsScreen.kt index a023bed..1e1d667 100644 --- a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/NewsScreen.kt +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/NewsScreen.kt @@ -1,62 +1,367 @@ +@file:OptIn(kotlin.time.ExperimentalTime::class) + 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.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 filcapp.composeapp.generated.resources.Res -import filcapp.composeapp.generated.resources.compose_multiplatform -import org.jetbrains.compose.resources.painterResource +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 NewsScreen() { - Box( - modifier = - Modifier - .fillMaxSize() - .padding(top = 16.dp), - contentAlignment = Alignment.TopCenter, +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), ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, + // 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, ) { - Image(painterResource(Res.drawable.compose_multiplatform), null) + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } Text( - "News placeholder", - style = MaterialTheme.typography.titleLarge, + 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)) + } + } + } + } } } } -object NewsTab : Tab { - override val options: TabOptions - @Composable - get() { - val title = "News" - val icon = rememberVectorPainter(Icons.Default.Campaign) - return remember { - TabOptions( - index = 2u, - title = title, - icon = icon, - ) +// ── 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 } } + } +} - @Composable - override fun Content() { - NewsScreen() +// ── 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) } } From 3f3d623a99e153bc7a648b6f09d432eb7ef343b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Susa=20Mil=C3=A1n=20Mih=C3=A1ly?= Date: Tue, 21 Apr 2026 10:53:41 +0200 Subject: [PATCH 19/23] Teacher and Room timetable views added --- .../kotlin/hu/petrik/filcapp/App.kt | 13 +- .../petrik/filcapp/screens/TimetableScreen.kt | 376 +++++++++++++++++- 2 files changed, 380 insertions(+), 9 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/App.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/App.kt index f72e863..e6c43ba 100644 --- a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/App.kt +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/App.kt @@ -14,8 +14,12 @@ 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.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 @@ -48,7 +52,14 @@ private fun MainContent() { Scaffold( topBar = { val isTimetable = tabNavigator.current.options.index == TimetableTab.options.index - TopBar(leadingContent = if (isTimetable) ({ CohortSwitcher() }) else null) + val timetableLeading: (@Composable () -> Unit)? = if (isTimetable) { + when (TimetableState.timetableMode) { + TimetableMode.Class -> { { CohortSwitcher() } } + TimetableMode.Room -> { { RoomSwitcher() } } + TimetableMode.Teacher -> { { TeacherSwitcher() } } + } + } else null + TopBar(leadingContent = timetableLeading) }, bottomBar = { BottomNavigationBar(tabNavigator) }, modifier = Modifier.fillMaxSize(), diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/TimetableScreen.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/TimetableScreen.kt index 35be779..e0a9a2b 100644 --- a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/TimetableScreen.kt +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/TimetableScreen.kt @@ -11,7 +11,9 @@ 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.* @@ -30,6 +32,7 @@ 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 @@ -37,6 +40,7 @@ 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 @@ -51,6 +55,8 @@ 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) @@ -62,6 +68,13 @@ object TimetableState { 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 ────────────────────────────────────────────────────────── @@ -82,6 +95,7 @@ object TimetableTab : Tab { TeacherApi(APIClient), SubstitutionApi(APIClient), MovedLessonApi(APIClient), + ClassroomApi(APIClient), ) } @@ -92,18 +106,60 @@ object TimetableTab : Tab { 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 = if (model.isViewingOwnCohort) TimetableState.lessons else model.localLessons, - substitutions = if (model.isViewingOwnCohort) TimetableState.substitutions else model.localSubstitutions, - movedLessons = if (model.isViewingOwnCohort) TimetableState.movedLessons else model.localMovedLessons, + lessons = lessons, + substitutions = substitutions, + movedLessons = movedLessons, teacherMap = TimetableState.teacherMap, - error = if (TimetableState.selectedCohortId == null && TimetableState.cohortsLoaded) { - "No cohort assigned to your account" - } else { - model.error + 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) } + } }, - onRefresh = { TimetableState.selectedCohortId?.let { model.loadTimetable(it) } }, ) } } @@ -112,6 +168,7 @@ object TimetableTab : Tab { @Composable fun TimetableScreen( + mode: TimetableMode = TimetableMode.Class, isLoading: Boolean, lessons: List, substitutions: List, @@ -183,6 +240,22 @@ fun TimetableScreen( } 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( @@ -364,6 +437,254 @@ fun CohortSwitcher() { } } +// ── 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) } + } + + 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), + 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 @@ -617,10 +938,13 @@ class TimetableScreenModel( 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) @@ -634,6 +958,7 @@ class TimetableScreenModel( 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 */ } } @@ -693,6 +1018,41 @@ class TimetableScreenModel( } } } + + 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 ────────────────────────────────────────────────────────────── From c2bd3d4d9f67c7d2511850f03ab9058b41f7b35d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Susa=20Mil=C3=A1n=20Mih=C3=A1ly?= Date: Tue, 21 Apr 2026 11:33:10 +0200 Subject: [PATCH 20/23] Refresh TT on open, settins tab, log out, cards --- .../filcapp/auth/AuthWebView.android.kt | 2 + .../kotlin/hu/petrik/filcapp/App.kt | 20 +- .../hu/petrik/filcapp/components/TopBar.kt | 6 +- .../kotlin/hu/petrik/filcapp/models/Models.kt | 36 +- .../hu/petrik/filcapp/screens/HomeScreen.kt | 23 + .../petrik/filcapp/screens/SettingsScreen.kt | 403 ++++++++++++++++++ .../hu/petrik/filcapp/auth/AuthWebView.ios.kt | 10 +- 7 files changed, 489 insertions(+), 11 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/SettingsScreen.kt diff --git a/composeApp/src/androidMain/kotlin/hu/petrik/filcapp/auth/AuthWebView.android.kt b/composeApp/src/androidMain/kotlin/hu/petrik/filcapp/auth/AuthWebView.android.kt index ec2c12a..6ef8205 100644 --- a/composeApp/src/androidMain/kotlin/hu/petrik/filcapp/auth/AuthWebView.android.kt +++ b/composeApp/src/androidMain/kotlin/hu/petrik/filcapp/auth/AuthWebView.android.kt @@ -35,6 +35,8 @@ actual fun AuthWebView(apiBaseUrl: String, onSessionAcquired: (String) -> Unit, 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() { diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/App.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/App.kt index e6c43ba..ac66409 100644 --- a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/App.kt +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/App.kt @@ -10,11 +10,13 @@ import androidx.compose.runtime.* import androidx.compose.ui.Modifier 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 @@ -41,13 +43,18 @@ fun App() { AppScreen.Welcome -> WelcomeScreen( onLoggedIn = { screen = AppScreen.Main }, ) - AppScreen.Main -> MainContent() + AppScreen.Main -> MainContent(onLogout = { + AuthState.logout() + screen = AppScreen.Welcome + }) } } } @Composable -private fun MainContent() { +private fun MainContent(onLogout: () -> Unit = {}) { + var settingsOpen by remember { mutableStateOf(false) } + TabNavigator(HomeTab) { tabNavigator -> Scaffold( topBar = { @@ -59,7 +66,7 @@ private fun MainContent() { TimetableMode.Teacher -> { { TeacherSwitcher() } } } } else null - TopBar(leadingContent = timetableLeading) + TopBar(leadingContent = timetableLeading, onProfileClick = { settingsOpen = true }) }, bottomBar = { BottomNavigationBar(tabNavigator) }, modifier = Modifier.fillMaxSize(), @@ -69,6 +76,13 @@ private fun MainContent() { CurrentTab() } } + + if (settingsOpen) { + SettingsSheet( + onDismiss = { settingsOpen = false }, + onLogout = { settingsOpen = false; onLogout() }, + ) + } } } diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/components/TopBar.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/components/TopBar.kt index 4996afe..09ea395 100644 --- a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/components/TopBar.kt +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/components/TopBar.kt @@ -2,6 +2,7 @@ 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 @@ -17,7 +18,7 @@ import hu.petrik.filcapp.auth.AuthState import hu.petrik.filcapp.auth.base64ToImageBitmap @Composable -fun TopBar(leadingContent: (@Composable () -> Unit)? = null) { +fun TopBar(leadingContent: (@Composable () -> Unit)? = null, onProfileClick: () -> Unit = {}) { val displayName = AuthState.displayName ?: "" val profileImage = AuthState.profileImage val avatar: ImageBitmap? = remember(profileImage) { @@ -52,7 +53,8 @@ fun TopBar(leadingContent: (@Composable () -> Unit)? = null) { modifier = Modifier .size(44.dp) .clip(CircleShape) - .background(MaterialTheme.colorScheme.surfaceVariant), + .background(MaterialTheme.colorScheme.surfaceVariant) + .clickable(onClick = onProfileClick), contentAlignment = Alignment.Center, ) { if (avatar != null) { diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/models/Models.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/models/Models.kt index d8bb23d..1c80a5e 100644 --- a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/models/Models.kt +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/models/Models.kt @@ -59,7 +59,7 @@ data class Card ( val frozen: Boolean, val id: String, val name: String, - val owner: String? = null, + val owner: Card_owner? = null, val updatedAt: String, val userId: String? = null, ) @@ -80,6 +80,14 @@ data class Card_authorizedDevices ( 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, @@ -176,18 +184,38 @@ data class DoorlockActivationResponse ( @Serializable data class DoorlockLogEntry ( val buttonPressed: Boolean, - val card: String? = null, + val card: DoorlockLogEntry_card? = null, val cardData: String? = null, val cardId: String? = null, - val device: String? = null, + val device: DoorlockLogEntry_device? = null, val deviceId: String, val id: Int, - val owner: String? = null, + 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(), diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/HomeScreen.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/HomeScreen.kt index 03ae76c..c2d3340 100644 --- a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/HomeScreen.kt @@ -8,8 +8,16 @@ 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.rememberScreenModel import cafe.adriel.voyager.navigator.tab.Tab import cafe.adriel.voyager.navigator.tab.TabOptions +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.components.DateView import hu.petrik.filcapp.components.UpcomingClasses @@ -43,6 +51,21 @@ object HomeTab : Tab { @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 ?: return@LaunchedEffect + model.loadTimetable(id) + } HomeScreen() } } 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..d74a505 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/SettingsScreen.kt @@ -0,0 +1,403 @@ +@file:OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) + +package hu.petrik.filcapp.screens + +import androidx.compose.foundation.Image +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.Logout +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.CreditCard +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.ImageBitmap +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import hu.petrik.filcapp.api.APIResult +import hu.petrik.filcapp.api.ApiEnvelope +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.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() + + // Cards row + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { cardsOpen = true } + .padding(horizontal = 24.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Icon( + Icons.Default.CreditCard, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = "Cards", + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f), + ) + } + + Spacer(Modifier.weight(1f)) + + 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 }) + } +} + +// ── 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/iosMain/kotlin/hu/petrik/filcapp/auth/AuthWebView.ios.kt b/composeApp/src/iosMain/kotlin/hu/petrik/filcapp/auth/AuthWebView.ios.kt index 3ba01fa..4acfa62 100644 --- a/composeApp/src/iosMain/kotlin/hu/petrik/filcapp/auth/AuthWebView.ios.kt +++ b/composeApp/src/iosMain/kotlin/hu/petrik/filcapp/auth/AuthWebView.ios.kt @@ -1,3 +1,5 @@ +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) + package hu.petrik.filcapp.auth import androidx.compose.foundation.layout.Box @@ -11,11 +13,13 @@ 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 @@ -39,7 +43,9 @@ actual fun AuthWebView(apiBaseUrl: String, onSessionAcquired: (String) -> Unit, Box(Modifier.fillMaxSize()) { UIKitView( factory = { - val webView = WKWebView() + 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 @@ -56,7 +62,7 @@ private class NavDelegate( private val onSessionAcquired: (String) -> Unit, ) : NSObject(), WKNavigationDelegateProtocol { override fun webView(webView: WKWebView, didFinishNavigation: WKNavigation?) { - WKWebsiteDataStore.defaultDataStore().httpCookieStore.getAllCookies { cookies -> + 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 From 1ae50a2a013abf41e515b3673d6004d1cc668b72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Susa=20Mil=C3=A1n=20Mih=C3=A1ly?= Date: Wed, 22 Apr 2026 09:18:24 +0200 Subject: [PATCH 21/23] System messages and announcements, split lessons handling on home screen, class search bar. --- .../hu/petrik/filcapp/components/DateView.kt | 251 +++++++++++++--- .../filcapp/components/NoticesBanner.kt | 279 ++++++++++++++++++ .../hu/petrik/filcapp/screens/HomeScreen.kt | 51 +++- .../petrik/filcapp/screens/TimetableScreen.kt | 71 ++--- 4 files changed, 563 insertions(+), 89 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/hu/petrik/filcapp/components/NoticesBanner.kt diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/components/DateView.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/components/DateView.kt index 8a29cbb..95151e1 100644 --- a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/components/DateView.kt +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/components/DateView.kt @@ -1,25 +1,37 @@ 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) { @@ -50,6 +62,7 @@ fun DateView(modifier: Modifier = Modifier) { val minute = currentDateTime.minute.toString().padStart(2, '0') val status = rememberLessonStatus(currentDateTime) + val tabNavigator = LocalTabNavigator.current Row( modifier = modifier @@ -95,6 +108,14 @@ fun DateView(modifier: Modifier = Modifier) { 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, ) { @@ -110,14 +131,45 @@ fun DateView(modifier: Modifier = Modifier) { style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onPrimaryContainer, ) - Text( - text = status.subject, - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onPrimaryContainer, - textAlign = TextAlign.Center, - maxLines = 2, - ) + 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( @@ -171,13 +223,21 @@ fun DateView(modifier: Modifier = Modifier) { // ── Upcoming classes ────────────────────────────────────────────────────────── -data class UpcomingLesson(val subject: String, val startTime: String, val room: String?) +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( @@ -200,34 +260,74 @@ fun UpcomingClasses(modifier: Modifier = Modifier) { 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, ) { - Text( - text = lesson.subject, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.weight(1f), - maxLines = 1, - ) - Spacer(Modifier.width(8.dp)) - Column(horizontalAlignment = Alignment.End) { - Text( - text = lesson.startTime, - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.primary, - ) - if (lesson.room != null) { + 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 = lesson.room, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + 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, + ) } } } @@ -238,7 +338,7 @@ fun UpcomingClasses(modifier: Modifier = Modifier) { // ── Status & upcoming helpers ───────────────────────────────────────────────── sealed interface LessonStatus { - data class InClass(val subject: String) : 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 @@ -250,11 +350,17 @@ private data class Slot( 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() @@ -266,35 +372,92 @@ private fun buildTodaySlots( } .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 -> - val days = lesson.day?.days?.filterNotNull() ?: return@filter false - days.any { it == dayNum } && lesson.id !in movedAwayIds + 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 = p.startTime?.take(5)?.let { runCatching { LocalTime.parse(it) }.getOrNull() } ?: return@mapNotNull null - val end = p.endTime?.take(5)?.let { runCatching { LocalTime.parse(it) }.getOrNull() } ?: 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" } - Slot(start, end, subject, room) + 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, now.date) + val slots = buildTodaySlots(lessons, movedLessons, substitutions, teacherMap, now.date) if (slots.isEmpty()) return LessonStatus.NoData val time = now.time - val current = slots.firstOrNull { time >= it.start && time <= it.end } - if (current != null) return LessonStatus.InClass(current.subject) + 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) { @@ -310,15 +473,17 @@ private fun rememberLessonStatus(now: LocalDateTime): LessonStatus { 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, now.date) + val slots = buildTodaySlots(lessons, movedLessons, substitutions, teacherMap, now.date) val time = now.time val future = slots.filter { it.start > time } - // When status is Break/NotStarted the first future slot is already shown in the square — skip it - val inClass = slots.any { time >= it.start && time <= it.end } - val tail = if (inClass) future else future.drop(1) + val groups = future.groupBy { it.start }.entries.sortedBy { it.key } - return tail.take(limit).map { UpcomingLesson(it.subject, it.start.toString().take(5), it.room) } + 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/screens/HomeScreen.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/HomeScreen.kt index c2d3340..d773221 100644 --- a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/HomeScreen.kt @@ -1,6 +1,8 @@ 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.* @@ -8,28 +10,60 @@ 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 - .fillMaxSize() + .fillMaxWidth() + .verticalScroll(rememberScrollState()) .padding(top = 16.dp), ) { DateView() Spacer(Modifier.height(16.dp)) + NoticesBanner(cohortId = cohortId) UpcomingClasses() } } @@ -51,7 +85,7 @@ object HomeTab : Tab { @Composable override fun Content() { - val model = rememberScreenModel { + val timetableModel = rememberScreenModel { TimetableScreenModel( LessonApi(APIClient), CohortApi(APIClient), @@ -61,10 +95,19 @@ object HomeTab : Tab { ClassroomApi(APIClient), ) } - LaunchedEffect(Unit) { model.loadCohorts() } + val homeModel = rememberScreenModel { + HomeScreenModel( + NewsAnnouncementsApi(APIClient), + NewsSystemMessagesApi(APIClient), + ) + } + LaunchedEffect(Unit) { + timetableModel.loadCohorts() + homeModel.loadNotices() + } LaunchedEffect(TimetableState.selectedCohortId) { val id = TimetableState.selectedCohortId ?: return@LaunchedEffect - model.loadTimetable(id) + timetableModel.loadTimetable(id) } HomeScreen() } diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/TimetableScreen.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/TimetableScreen.kt index e0a9a2b..835d038 100644 --- a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/TimetableScreen.kt +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/TimetableScreen.kt @@ -20,6 +20,8 @@ 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 @@ -384,7 +386,7 @@ fun CohortSwitcher() { val cohorts = TimetableState.cohorts val selectedCohortId = TimetableState.selectedCohortId val selectedCohort = cohorts.find { it.id == selectedCohortId } - var expanded by remember { mutableStateOf(false) } + var sheetOpen by remember { mutableStateOf(false) } val label = when { selectedCohort != null -> selectedCohort.name @@ -393,47 +395,27 @@ fun CohortSwitcher() { else -> "Select class" } - Box { - Column( - modifier = Modifier.clickable(enabled = cohorts.isNotEmpty()) { expanded = true }, - ) { - Text( - text = "Class", - style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Normal), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = label, - style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), - ) - Icon( - Icons.Default.KeyboardArrowDown, - contentDescription = "Switch class", - modifier = Modifier.size(18.dp), - ) - } - } + SwitcherTapTarget( + label = "Class", + value = label, + hasSelection = selectedCohort != null, + onClick = { if (cohorts.isNotEmpty()) sheetOpen = true }, + ) - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - ) { - cohorts.forEach { cohort -> - DropdownMenuItem( - text = { - Text( - cohort.name, - fontWeight = if (cohort.id == selectedCohortId) FontWeight.Bold else FontWeight.Normal, - ) - }, - onClick = { - TimetableState.selectedCohortId = cohort.id - expanded = false - }, - ) - } - } + 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 }, + ) } } @@ -551,6 +533,11 @@ private fun SearchPickerSheet( 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, @@ -595,7 +582,7 @@ private fun SearchPickerSheet( onValueChange = { query = it }, placeholder = { Text("Search…", style = MaterialTheme.typography.bodyMedium) }, singleLine = true, - modifier = Modifier.weight(1f), + modifier = Modifier.weight(1f).focusRequester(focusRequester), colors = TextFieldDefaults.colors( focusedContainerColor = Color.Transparent, unfocusedContainerColor = Color.Transparent, From 2aa054fbbc2013083e970f6b5587de8996c53400 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Susa=20Mil=C3=A1n=20Mih=C3=A1ly?= Date: Wed, 22 Apr 2026 09:33:30 +0200 Subject: [PATCH 22/23] Added accent color settinga --- .../petrik/filcapp/AppPreferences.android.kt | 8 + .../kotlin/hu/petrik/filcapp/App.kt | 2 +- .../hu/petrik/filcapp/AppPreferences.kt | 6 + .../kotlin/hu/petrik/filcapp/AppTheme.kt | 77 +++++ .../petrik/filcapp/screens/SettingsScreen.kt | 270 ++++++++++++++++-- .../petrik/filcapp/AppPreferences.desktop.kt | 10 + .../hu/petrik/filcapp/AppPreferences.ios.kt | 13 + 7 files changed, 365 insertions(+), 21 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/hu/petrik/filcapp/AppPreferences.android.kt create mode 100644 composeApp/src/commonMain/kotlin/hu/petrik/filcapp/AppPreferences.kt create mode 100644 composeApp/src/commonMain/kotlin/hu/petrik/filcapp/AppTheme.kt create mode 100644 composeApp/src/desktopMain/kotlin/hu/petrik/filcapp/AppPreferences.desktop.kt create mode 100644 composeApp/src/iosMain/kotlin/hu/petrik/filcapp/AppPreferences.ios.kt diff --git a/composeApp/src/androidMain/kotlin/hu/petrik/filcapp/AppPreferences.android.kt b/composeApp/src/androidMain/kotlin/hu/petrik/filcapp/AppPreferences.android.kt new file mode 100644 index 0000000..884c87e --- /dev/null +++ b/composeApp/src/androidMain/kotlin/hu/petrik/filcapp/AppPreferences.android.kt @@ -0,0 +1,8 @@ +package hu.petrik.filcapp + +import hu.petrik.filcapp.auth.SessionStore + +actual object AppPreferences { + actual fun getAccentHue(): Float = SessionStore.prefs.getFloat("accent_hue", 220f) + actual fun setAccentHue(hue: Float) { SessionStore.prefs.edit().putFloat("accent_hue", hue).apply() } +} diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/App.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/App.kt index ac66409..9bb5e74 100644 --- a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/App.kt +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/App.kt @@ -33,7 +33,7 @@ private enum class AppScreen { Splash, Welcome, Main } fun App() { var screen by remember { mutableStateOf(AppScreen.Splash) } - MaterialTheme { + AppTheme { when (screen) { AppScreen.Splash -> SplashScreen( onAuthResolved = { loggedIn -> 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..a71d317 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/AppPreferences.kt @@ -0,0 +1,6 @@ +package hu.petrik.filcapp + +expect object AppPreferences { + fun getAccentHue(): Float + fun setAccentHue(hue: Float) +} 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..6f2ad84 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/AppTheme.kt @@ -0,0 +1,77 @@ +package hu.petrik.filcapp + +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +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()) + + var accentHue: Float + get() = _accentHue.floatValue + set(value) { + _accentHue.floatValue = value + AppPreferences.setAccentHue(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 = Color(0xFFFDFCFF), + onBackground = Color(0xFF1B1B1F), + surface = Color(0xFFFDFCFF), + onSurface = Color(0xFF1B1B1F), + surfaceVariant = hslColor(hue, 0.18f, 0.88f), + onSurfaceVariant = hslColor(hue, 0.10f, 0.30f), + 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 = Color(0xFF313033), + inverseOnSurface = Color(0xFFF4EFF4), + inversePrimary = hslColor(hue, 0.75f, 0.78f), +) + +@Composable +fun AppTheme(content: @Composable () -> Unit) { + MaterialTheme(colorScheme = buildColorScheme(AppSettings.accentHue), content = content) +} diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/SettingsScreen.kt index d74a505..c7fa5b4 100644 --- a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/SettingsScreen.kt @@ -2,34 +2,47 @@ 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.Palette 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.ApiEnvelope 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 @@ -103,28 +116,26 @@ fun SettingsSheet(onDismiss: () -> Unit, onLogout: () -> Unit) { HorizontalDivider() - // Cards row - Row( + // Settings sections + Column( modifier = Modifier - .fillMaxWidth() - .clickable { cardsOpen = true } - .padding(horizontal = 24.dp, vertical = 16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), ) { - Icon( - Icons.Default.CreditCard, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = "Cards", - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.weight(1f), - ) - } + // Account section + SettingsGroup(title = "Account") { + SettingsRow( + icon = Icons.Default.CreditCard, + label = "Cards", + onClick = { cardsOpen = true }, + ) + } - Spacer(Modifier.weight(1f)) + // Appearance section + AppearanceSection() + } HorizontalDivider() @@ -159,6 +170,225 @@ fun SettingsSheet(onDismiss: () -> Unit, onLogout: () -> Unit) { } } +// ── 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 ──────────────────────────────────────────────────────── + +@Composable +private fun AppearanceSection() { + var pickerExpanded by remember { mutableStateOf(false) } + var localHue by remember { mutableFloatStateOf(AppSettings.accentHue) } + + SettingsGroup(title = "Appearance") { + 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 + }, + ) + + // Preview row of hue stops + 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 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..0d5c718 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/hu/petrik/filcapp/AppPreferences.desktop.kt @@ -0,0 +1,10 @@ +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) } +} 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..df0f63d --- /dev/null +++ b/composeApp/src/iosMain/kotlin/hu/petrik/filcapp/AppPreferences.ios.kt @@ -0,0 +1,13 @@ +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) } +} From afd965dae900d5ed96b0974ddf13e9f6fd37f96a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Susa=20Mil=C3=A1n=20Mih=C3=A1ly?= Date: Wed, 22 Apr 2026 09:56:18 +0200 Subject: [PATCH 23/23] Dark mode added --- .../petrik/filcapp/AppPreferences.android.kt | 2 + .../hu/petrik/filcapp/AppPreferences.kt | 2 + .../kotlin/hu/petrik/filcapp/AppTheme.kt | 77 +++++++++++++++++-- .../petrik/filcapp/screens/SettingsScreen.kt | 51 +++++++++++- .../petrik/filcapp/AppPreferences.desktop.kt | 2 + .../hu/petrik/filcapp/AppPreferences.ios.kt | 5 ++ 6 files changed, 130 insertions(+), 9 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/hu/petrik/filcapp/AppPreferences.android.kt b/composeApp/src/androidMain/kotlin/hu/petrik/filcapp/AppPreferences.android.kt index 884c87e..31f1ac7 100644 --- a/composeApp/src/androidMain/kotlin/hu/petrik/filcapp/AppPreferences.android.kt +++ b/composeApp/src/androidMain/kotlin/hu/petrik/filcapp/AppPreferences.android.kt @@ -5,4 +5,6 @@ import hu.petrik.filcapp.auth.SessionStore actual object AppPreferences { actual fun getAccentHue(): Float = SessionStore.prefs.getFloat("accent_hue", 220f) actual fun setAccentHue(hue: Float) { SessionStore.prefs.edit().putFloat("accent_hue", hue).apply() } + actual fun getThemeMode(): Int = SessionStore.prefs.getInt("theme_mode", 0) + actual fun setThemeMode(mode: Int) { SessionStore.prefs.edit().putInt("theme_mode", mode).apply() } } diff --git a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/AppPreferences.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/AppPreferences.kt index a71d317..db36237 100644 --- a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/AppPreferences.kt +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/AppPreferences.kt @@ -3,4 +3,6 @@ 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 index 6f2ad84..98c2c2a 100644 --- a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/AppTheme.kt +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/AppTheme.kt @@ -1,7 +1,9 @@ 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 @@ -11,6 +13,7 @@ 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 @@ -18,6 +21,14 @@ object AppSettings { _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 { @@ -53,12 +64,19 @@ fun buildColorScheme(hue: Float): ColorScheme = lightColorScheme( onTertiary = Color.White, tertiaryContainer = hslColor((hue + 80f) % 360f, 0.40f, 0.90f), onTertiaryContainer = hslColor((hue + 80f) % 360f, 0.35f, 0.15f), - background = Color(0xFFFDFCFF), - onBackground = Color(0xFF1B1B1F), - surface = Color(0xFFFDFCFF), - onSurface = Color(0xFF1B1B1F), + 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), @@ -66,12 +84,57 @@ fun buildColorScheme(hue: Float): ColorScheme = lightColorScheme( outline = hslColor(hue, 0.10f, 0.52f), outlineVariant = hslColor(hue, 0.14f, 0.80f), scrim = Color(0xFF000000), - inverseSurface = Color(0xFF313033), - inverseOnSurface = Color(0xFFF4EFF4), + 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) { - MaterialTheme(colorScheme = buildColorScheme(AppSettings.accentHue), content = content) + 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/screens/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/SettingsScreen.kt index c7fa5b4..6019386 100644 --- a/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/hu/petrik/filcapp/screens/SettingsScreen.kt @@ -19,7 +19,10 @@ 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 @@ -248,12 +251,58 @@ private fun SettingsRow( // ── 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", @@ -284,8 +333,6 @@ private fun AppearanceSection() { AppSettings.accentHue = hue }, ) - - // Preview row of hue stops Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), diff --git a/composeApp/src/desktopMain/kotlin/hu/petrik/filcapp/AppPreferences.desktop.kt b/composeApp/src/desktopMain/kotlin/hu/petrik/filcapp/AppPreferences.desktop.kt index 0d5c718..1c59283 100644 --- a/composeApp/src/desktopMain/kotlin/hu/petrik/filcapp/AppPreferences.desktop.kt +++ b/composeApp/src/desktopMain/kotlin/hu/petrik/filcapp/AppPreferences.desktop.kt @@ -7,4 +7,6 @@ actual object AppPreferences { 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/iosMain/kotlin/hu/petrik/filcapp/AppPreferences.ios.kt b/composeApp/src/iosMain/kotlin/hu/petrik/filcapp/AppPreferences.ios.kt index df0f63d..88481de 100644 --- a/composeApp/src/iosMain/kotlin/hu/petrik/filcapp/AppPreferences.ios.kt +++ b/composeApp/src/iosMain/kotlin/hu/petrik/filcapp/AppPreferences.ios.kt @@ -10,4 +10,9 @@ actual object AppPreferences { 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") } }