Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
6b4ec2f
Created app structure, tab nav, scaffolded some of the home screen.
busyLambda Dec 13, 2025
1c3a80d
Removed useless comment made by LLM when making IOS targets work
busyLambda Dec 13, 2025
8b82323
feat(build): add desktop target and fix Ktor engine source sets
busyLambda Apr 17, 2026
3b91eb2
feat(config): add per-platform API base URL
busyLambda Apr 17, 2026
262ab37
feat(api): add open2ktor generated Ktor client bindings
busyLambda Apr 17, 2026
3195ef6
feat(timetable): implement timetable screen from wireframe
busyLambda Apr 17, 2026
2b95944
fix(timetable): correct day filtering to use ISO day number
busyLambda Apr 17, 2026
1f4cfb5
Auth and welcome
busyLambda Apr 18, 2026
74f6e4c
General layout work and main timetable screen
busyLambda Apr 18, 2026
20c670d
Substitution and new top bar
busyLambda Apr 20, 2026
4f7d4e1
moved lesson support
busyLambda Apr 20, 2026
69a3173
More compact lesson view and detailed lesson view.
busyLambda Apr 20, 2026
f363356
Added refresh with a 5 second timeout
busyLambda Apr 20, 2026
1f8e2aa
Color current lesson accent colored.
busyLambda Apr 20, 2026
c484ade
Main screen, lesson queue.
busyLambda Apr 20, 2026
f362f6c
Fix issue with shared timetable state
busyLambda Apr 20, 2026
b4e8f35
Generate news api bindings
busyLambda Apr 20, 2026
5e9f6a8
Fixed bindings issue. Added news tab.
busyLambda Apr 20, 2026
3f3d623
Teacher and Room timetable views added
busyLambda Apr 21, 2026
c2bd3d4
Refresh TT on open, settins tab, log out, cards
busyLambda Apr 21, 2026
1ae50a2
System messages and announcements, split lessons handling on home scr…
busyLambda Apr 22, 2026
2aa054f
Added accent color settinga
busyLambda Apr 22, 2026
afd965d
Dark mode added
busyLambda Apr 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 56 additions & 2 deletions composeApp/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,17 +1,27 @@
// import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import org.jetbrains.kotlin.gradle.dsl.JvmTarget

plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidApplication)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)

kotlin("plugin.serialization") version "2.1.0"
}

compose.resources {
publicResClass = true
generateResClass = always
}

kotlin {
androidTarget { compilerOptions { jvmTarget.set(JvmTarget.JVM_11) } }

jvm("desktop")

listOf(
iosX64(),
iosArm64(),
iosSimulatorArm64(),
).forEach { iosTarget ->
Expand All @@ -22,9 +32,12 @@ kotlin {
}

sourceSets {
val desktopMain by getting

androidMain.dependencies {
implementation(compose.preview)
implementation(libs.androidx.activity.compose)
implementation("io.ktor:ktor-client-cio:3.3.3")
}
commonMain.dependencies {
implementation(compose.runtime)
Expand All @@ -35,8 +48,29 @@ kotlin {
implementation(compose.components.uiToolingPreview)
implementation(libs.androidx.lifecycle.viewmodelCompose)
implementation(libs.androidx.lifecycle.runtimeCompose)

implementation(compose.materialIconsExtended)
implementation("cafe.adriel.voyager:voyager-navigator:1.0.0")
implementation("cafe.adriel.voyager:voyager-tab-navigator:1.0.0")
implementation("cafe.adriel.voyager:voyager-screenmodel:1.0.0")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.7.1")

implementation("io.ktor:ktor-client-core:3.3.3")
implementation("io.ktor:ktor-client-content-negotiation:3.3.3")
implementation("io.ktor:ktor-serialization-kotlinx-json:3.3.3")
implementation("io.ktor:ktor-client-auth:3.3.3")
implementation("io.ktor:ktor-client-logging:3.3.3")
}
commonTest.dependencies { implementation(libs.kotlin.test) }

desktopMain.dependencies {
implementation(compose.desktop.currentOs)
implementation("io.ktor:ktor-client-cio:3.3.3")
}

iosMain.dependencies {
implementation("io.ktor:ktor-client-darwin:3.3.3")
}
}
}

Expand All @@ -61,11 +95,31 @@ 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
}
}

dependencies { debugImplementation(compose.uiTooling) }

compose.desktop {
application {
mainClass = "hu.petrik.filcapp.MainKt"
nativeDistributions {
targetFormats(TargetFormat.Dmg)
packageName = "Filcapp"
packageVersion = "1.0.0"
}
}
}
2 changes: 2 additions & 0 deletions composeApp/src/androidMain/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<uses-permission android:name="android.permission.INTERNET" />

<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
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() }
actual fun getThemeMode(): Int = SessionStore.prefs.getInt("theme_mode", 0)
actual fun setThemeMode(mode: Int) { SessionStore.prefs.edit().putInt("theme_mode", mode).apply() }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package hu.petrik.filcapp

actual val apiBaseUrl: String = BuildConfig.API_BASE_URL
Original file line number Diff line number Diff line change
@@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
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 = """
<!DOCTYPE html><html><body><script>
fetch('/api/auth/sign-in/social', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({provider: 'microsoft', callbackURL: '/'})
}).then(r => r.json()).then(d => { window.location.href = d.url; });
</script></body></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().removeAllCookies(null)
CookieManager.getInstance().flush()
CookieManager.getInstance().setAcceptCookie(true)
CookieManager.getInstance().setAcceptThirdPartyCookies(this, true)
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
// Rewrite localhost → emulator host passthrough so the OAuth callback
// reaches the dev server and sends the correct cookies.
val url = request.url.toString()
val rewritten = url
.replace("http://localhost:", "http://10.0.2.2:")
.replace("https://localhost:", "https://10.0.2.2:")
if (rewritten != url) {
view.loadUrl(rewritten)
return true
}
return false
}

override fun onPageFinished(view: WebView, url: String) {
val raw = CookieManager.getInstance().getCookie(url) ?: return
val token = raw.split(";")
.map { it.trim() }
.firstOrNull { it.startsWith("filc.session_token=") }
?.removePrefix("filc.session_token=")
if (token != null) onSessionAcquired(token)
}
}
loadDataWithBaseURL(apiBaseUrl, AUTH_HTML, "text/html", "UTF-8", null)
}
},
modifier = Modifier.fillMaxSize(),
)
IconButton(onClick = onDismiss, modifier = Modifier.align(Alignment.TopStart)) {
Icon(Icons.Default.Close, contentDescription = "Close")
}
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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() }
}
125 changes: 93 additions & 32 deletions composeApp/src/commonMain/kotlin/hu/petrik/filcapp/App.kt
Original file line number Diff line number Diff line change
@@ -1,47 +1,108 @@
package hu.petrik.filcapp

import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.safeContentPadding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import filcapp.composeapp.generated.resources.Res
import filcapp.composeapp.generated.resources.compose_multiplatform
import org.jetbrains.compose.resources.painterResource
import cafe.adriel.voyager.navigator.tab.CurrentTab
import cafe.adriel.voyager.navigator.tab.TabNavigator
import hu.petrik.filcapp.auth.AuthState
import hu.petrik.filcapp.components.TopBar
import hu.petrik.filcapp.screens.CohortSwitcher
import hu.petrik.filcapp.screens.HomeTab
import hu.petrik.filcapp.screens.NewsTab
import hu.petrik.filcapp.screens.RoomSwitcher
import hu.petrik.filcapp.screens.SettingsSheet
import hu.petrik.filcapp.screens.SplashScreen
import hu.petrik.filcapp.screens.SubstitutionTab
import hu.petrik.filcapp.screens.TeacherSwitcher
import hu.petrik.filcapp.screens.TimetableMode
import hu.petrik.filcapp.screens.TimetableState
import hu.petrik.filcapp.screens.TimetableTab
import hu.petrik.filcapp.screens.WelcomeScreen
import org.jetbrains.compose.ui.tooling.preview.Preview

private enum class AppScreen { Splash, Welcome, Main }

@Composable
@Preview
fun App() {
MaterialTheme {
var showContent by remember { mutableStateOf(false) }
Column(
modifier =
Modifier
.background(MaterialTheme.colorScheme.primaryContainer)
.safeContentPadding()
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Button(onClick = { showContent = !showContent }) { Text("Click me!") }
AnimatedVisibility(showContent) {
val greeting = remember { Greeting().greet() }
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(painterResource(Res.drawable.compose_multiplatform), null)
Text("Compose: $greeting")
var screen by remember { mutableStateOf(AppScreen.Splash) }

AppTheme {
when (screen) {
AppScreen.Splash -> SplashScreen(
onAuthResolved = { loggedIn ->
screen = if (loggedIn) AppScreen.Main else AppScreen.Welcome
},
)
AppScreen.Welcome -> WelcomeScreen(
onLoggedIn = { screen = AppScreen.Main },
)
AppScreen.Main -> MainContent(onLogout = {
AuthState.logout()
screen = AppScreen.Welcome
})
}
}
}

@Composable
private fun MainContent(onLogout: () -> Unit = {}) {
var settingsOpen by remember { mutableStateOf(false) }

TabNavigator(HomeTab) { tabNavigator ->
Scaffold(
topBar = {
val isTimetable = tabNavigator.current.options.index == TimetableTab.options.index
val timetableLeading: (@Composable () -> Unit)? = if (isTimetable) {
when (TimetableState.timetableMode) {
TimetableMode.Class -> { { CohortSwitcher() } }
TimetableMode.Room -> { { RoomSwitcher() } }
TimetableMode.Teacher -> { { TeacherSwitcher() } }
}
} else null
TopBar(leadingContent = timetableLeading, onProfileClick = { settingsOpen = true })
},
bottomBar = { BottomNavigationBar(tabNavigator) },
modifier = Modifier.fillMaxSize(),
contentWindowInsets = WindowInsets.systemBars,
) { paddingValues ->
Box(modifier = Modifier.padding(paddingValues).fillMaxSize()) {
CurrentTab()
}
}

if (settingsOpen) {
SettingsSheet(
onDismiss = { settingsOpen = false },
onLogout = { settingsOpen = false; onLogout() },
)
}
}
}

@Composable
private fun BottomNavigationBar(tabNavigator: TabNavigator) {
NavigationBar(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
) {
listOf(HomeTab, TimetableTab, SubstitutionTab, NewsTab).forEach { tab ->
val isSelected = tabNavigator.current.options.index == tab.options.index
NavigationBarItem(
icon = {
tab.options.icon?.let { painter ->
Icon(painter, contentDescription = tab.options.title)
}
},
label = { Text(tab.options.title) },
selected = isSelected,
onClick = { tabNavigator.current = tab },
)
}
}
}
Loading
Loading