# MyIce Kotlin Multiplatform App Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Port the MyIce Flutter app (ice hockey schedule/convocation viewer) to Kotlin Multiplatform targeting Android, iOS, and Desktop using Compose Multiplatform. **Architecture:** Single `composeApp` module with shared Compose UI across all three targets. Platform-specific code limited to OAuth glue (via interface + per-platform implementations). Ktor Client for networking, kotlinx.serialization for models, multiplatform-settings for caching, lifecycle-viewmodel + StateFlow for state. **Tech Stack:** Kotlin 2.2.20, Compose Multiplatform 1.11.1, Ktor 3.5.1, kotlinx.serialization 1.11.0, multiplatform-settings 1.3.0, lifecycle-viewmodel-compose 2.10.0, Gradle 9.5.1, AGP 8.13.2 ## Global Constraints - **Package:** `ch.parano.myice` - **Base URL:** `https://myice.parano.ch` (hardcoded, no dev toggle) - **OAuth callback scheme:** `myice://callback` (Android/iOS), `http://localhost:/callback` (Desktop) - **UI language:** French (hardcoded strings, matching Flutter app) - **Min Android SDK:** 24 - **Android compile/target SDK:** 35 - **App ID:** `ch.parano.myice` - **Only GET requests** — the app is read-only - **Token:** Bearer token from OAuth, stored in settings, no refresh/revocation - **Practice filter:** App shows both games (`event == "Jeu"`) and practices (`event != "Jeu"`) via a type filter dropdown. Default is GAMES. - **Sub-group extraction:** Split event `name` on `" - "`, drop last segment, rejoin. Ported faithfully from Flutter. - **Tests:** TDD for models, networking, caching, filtering logic, and viewmodels. No UI tests. --- ## File Structure ``` myice_kotlin/ ├── settings.gradle.kts ├── build.gradle.kts ├── gradle.properties ├── gradle/ │ ├── libs.versions.toml │ └── wrapper/gradle-wrapper.properties ├── gradlew, gradlew.bat ├── composeApp/ │ ├── build.gradle.kts │ └── src/ │ ├── commonMain/kotlin/ch/parano/myice/ │ │ ├── models/ │ │ │ ├── Account.kt │ │ │ ├── Event.kt │ │ │ ├── EventDetail.kt │ │ │ ├── Convocation.kt │ │ │ ├── Player.kt │ │ │ ├── Staff.kt │ │ │ ├── UserInfo.kt │ │ │ └── FlexibleStringSerializer.kt │ │ ├── network/ │ │ │ ├── ApiConfig.kt │ │ │ ├── ApiEngine.kt # expect/actual HttpClient engine │ │ │ ├── ApiService.kt │ │ │ └── AuthTokenHolder.kt │ │ ├── cache/ │ │ │ └── ScheduleCache.kt │ │ ├── auth/ │ │ │ ├── AuthSettings.kt │ │ │ └── OAuthClient.kt # interface │ │ ├── filter/ │ │ │ ├── TypeFilter.kt │ │ │ └── EventFilter.kt │ │ ├── viewmodel/ │ │ │ ├── AuthViewModel.kt │ │ │ ├── ScheduleViewModel.kt │ │ │ └── EventDetailViewModel.kt │ │ ├── ui/ │ │ │ ├── theme/Theme.kt │ │ │ ├── components/EventCard.kt │ │ │ ├── components/FilterDropdown.kt │ │ │ ├── components/LoadingIndicator.kt │ │ │ └── screens/ │ │ │ ├── LoginScreen.kt │ │ │ ├── ScheduleScreen.kt │ │ │ └── EventDetailScreen.kt │ │ └── App.kt │ ├── commonTest/kotlin/ch/parano/myice/ │ │ ├── models/ModelSerializationTest.kt │ │ ├── network/ApiServiceTest.kt │ │ ├── cache/ScheduleCacheTest.kt │ │ ├── filter/EventFilterTest.kt │ │ └── viewmodel/ScheduleViewModelTest.kt │ ├── androidMain/kotlin/ch/parano/myice/ │ │ ├── network/ApiEngine.android.kt │ │ ├── auth/AndroidOAuthClient.kt │ │ ├── MainActivity.kt │ │ └── OAuthCallbackActivity.kt │ ├── androidMain/AndroidManifest.xml │ ├── iosMain/kotlin/ch/parano/myice/ │ │ ├── network/ApiEngine.ios.kt │ │ ├── auth/IosOAuthClient.kt │ │ └── MainViewController.kt │ ├── desktopMain/kotlin/ch/parano/myice/ │ │ ├── network/ApiEngine.desktop.kt │ │ ├── auth/DesktopOAuthClient.kt │ │ └── Main.kt │ └── iosApp/ # Xcode project (created in Task 10) └── iosApp/ ├── iosApp.xcodeproj/ └── iosApp/ ├── MyIceApp.swift ├── ContentView.swift └── Info.plist ``` --- ## Task 1: Scaffold KMP Project **Files:** - Create: `settings.gradle.kts` - Create: `build.gradle.kts` - Create: `gradle.properties` - Create: `gradle/libs.versions.toml` - Create: `composeApp/build.gradle.kts` - Create: `composeApp/src/androidMain/AndroidManifest.xml` - Create: `composeApp/src/commonMain/kotlin/ch/parano/myice/Placeholder.kt` - Create: `composeApp/src/androidMain/kotlin/ch/parano/myice/MainActivity.kt` - Create: `composeApp/src/desktopMain/kotlin/ch/parano/myice/Main.kt` - Create: `composeApp/src/iosMain/kotlin/ch/parano/myice/MainViewController.kt` **Interfaces:** - Consumes: nothing (first task) - Produces: a buildable KMP project with Compose Multiplatform targeting Android, iOS, Desktop - [ ] **Step 1: Create `settings.gradle.kts`** ```kotlin pluginManagement { repositories { google { mavenContent { releasesOnly() } } mavenCentral() gradlePluginPortal() } } dependencyResolutionManagement { repositories { google { mavenContent { releasesOnly() } } mavenCentral() } } rootProject.name = "myice_kotlin" include(":composeApp") ``` - [ ] **Step 2: Create `gradle.properties`** ```properties kotlin.code.style=official android.useAndroidX=true android.nonTransitiveRClass=true org.gradle.jvmargs=-Xmx4096m ``` - [ ] **Step 3: Create `gradle/libs.versions.toml`** ```toml [versions] kotlin = "2.2.20" compose-multiplatform = "1.11.1" agp = "8.13.2" ktor = "3.5.1" kotlinx-serialization-json = "1.11.0" kotlinx-coroutines = "1.11.0" multiplatform-settings = "1.3.0" lifecycle-viewmodel-compose = "2.10.0" androidx-activity-compose = "1.13.0" [libraries] ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" } ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } multiplatform-settings = { module = "com.russhwolf:multiplatform-settings", version.ref = "multiplatform-settings" } multiplatform-settings-no-arg = { module = "com.russhwolf:multiplatform-settings-no-arg", version.ref = "multiplatform-settings" } multiplatform-settings-test = { module = "com.russhwolf:multiplatform-settings-test", version.ref = "multiplatform-settings" } lifecycle-viewmodel-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle-viewmodel-compose" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity-compose" } [plugins] kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } android-application = { id = "com.android.application", version.ref = "agp" } ``` - [ ] **Step 4: Create root `build.gradle.kts`** ```kotlin plugins { alias(libs.plugins.kotlin.multiplatform) apply false alias(libs.plugins.compose.multiplatform) apply false alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.android.application) apply false } ``` - [ ] **Step 5: Create `composeApp/build.gradle.kts`** ```kotlin import org.jetbrains.compose.desktop.application.dsl.TargetFormat import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.compose.multiplatform) alias(libs.plugins.kotlin.serialization) alias(libs.plugins.android.application) } kotlin { androidTarget { @OptIn(ExperimentalKotlinGradlePluginApi::class) instrumentedTestVariant.sourceSetTree.set(KotlinSourceSetTree.test) compilations.all { compileTaskProvider.configure { compilerOptions.jvmTarget.set(JvmTarget.JVM_11) } } } listOf(iosX64(), iosArm64(), iosSimulatorArm64()) jvm("desktop") sourceSets { commonMain.dependencies { implementation(compose.runtime) implementation(compose.foundation) implementation(compose.material3) implementation(compose.components.uiToolingPreview) implementation(libs.ktor.client.core) implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.serialization.kotlinx.json) implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.coroutines.core) implementation(libs.multiplatform.settings) implementation(libs.multiplatform.settings.no.arg) implementation(libs.lifecycle.viewmodel.compose) } commonTest.dependencies { implementation(kotlin("test")) implementation(libs.ktor.client.mock) implementation(libs.kotlinx.coroutines.test) implementation(libs.multiplatform.settings.test) } androidMain.dependencies { implementation(libs.ktor.client.cio) implementation(libs.androidx.activity.compose) } iosMain.dependencies { implementation(libs.ktor.client.darwin) } desktopMain.dependencies { implementation(libs.ktor.client.cio) implementation(compose.desktop.currentOs) } } } android { namespace = "ch.parano.myice" compileSdk = 35 defaultConfig { applicationId = "ch.parano.myice" minSdk = 24 targetSdk = 35 versionCode = 1 versionName = "1.0" } compileOptions { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 } } compose.desktop { application { mainClass = "ch.parano.myice.MainKt" nativeDistributions { targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) packageName = "myice" packageVersion = "1.0.0" } } } ``` - [ ] **Step 6: Create `composeApp/src/androidMain/AndroidManifest.xml`** ```xml ``` - [ ] **Step 7: Create placeholder source files** Create `composeApp/src/commonMain/kotlin/ch/parano/myice/Placeholder.kt`: ```kotlin package ch.parano.myice ``` Create `composeApp/src/androidMain/kotlin/ch/parano/myice/MainActivity.kt`: ```kotlin package ch.parano.myice import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) setContent { Text("MyIce") } } } ``` Create `composeApp/src/desktopMain/kotlin/ch/parano/myice/Main.kt`: ```kotlin package ch.parano.myice import androidx.compose.ui.window.Window import androidx.compose.ui.window.application fun main() = application { Window(onCloseRequest = ::exitApplication, title = "MyIce") { Text("MyIce") } } ``` Create `composeApp/src/iosMain/kotlin/ch/parano/myice/MainViewController.kt`: ```kotlin package ch.parano.myice import androidx.compose.ui.window.ComposeUIViewController fun MainViewController() = ComposeUIViewController { Text("MyIce") } } ``` - [ ] **Step 8: Generate Gradle wrapper** Run: ```bash gradle wrapper --gradle-version 9.6.1 ``` If `gradle` is not installed, download it from https://gradle.org/install/ and run the wrapper command, or manually create `gradle/wrapper/gradle-wrapper.properties`: ```properties distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists ``` Then download the `gradle-wrapper.jar` and `gradlew`/`gradlew.bat` scripts from a Gradle installation or another project. - [ ] **Step 9: Verify the build** Run: ```bash ./gradlew help ``` Expected: BUILD SUCCESSFUL - [ ] **Step 10: Add `.gitignore`** Create `.gitignore`: ```gitignore .gradle/ build/ .idea/ local.properties *.iml .DS_Store *.apk *.aab *.xcworkspace/xcuserdata/ iosApp/iosApp.xcodeproj/xcuserdata/ ``` - [ ] **Step 11: Commit** ```bash git add -A git commit -m "feat: scaffold KMP project with Compose Multiplatform (Android, iOS, Desktop)" ``` --- ## Task 2: Data Models **Files:** - Create: `composeApp/src/commonMain/kotlin/ch/parano/myice/models/FlexibleStringSerializer.kt` - Create: `composeApp/src/commonMain/kotlin/ch/parano/myice/models/Account.kt` - Create: `composeApp/src/commonMain/kotlin/ch/parano/myice/models/Event.kt` - Create: `composeApp/src/commonMain/kotlin/ch/parano/myice/models/Convocation.kt` - Create: `composeApp/src/commonMain/kotlin/ch/parano/myice/models/Player.kt` - Create: `composeApp/src/commonMain/kotlin/ch/parano/myice/models/Staff.kt` - Create: `composeApp/src/commonMain/kotlin/ch/parano/myice/models/EventDetail.kt` - Create: `composeApp/src/commonMain/kotlin/ch/parano/myice/models/UserInfo.kt` - Test: `composeApp/src/commonTest/kotlin/ch/parano/myice/models/ModelSerializationTest.kt` **Interfaces:** - Consumes: nothing - Produces: `Account`, `Event`, `EventDetail`, `Convocation`, `Player`, `Staff`, `UserInfo` data classes; `FlexibleStringSerializer` - [ ] **Step 1: Write the failing test** Create `composeApp/src/commonTest/kotlin/ch/parano/myice/models/ModelSerializationTest.kt`: ```kotlin package ch.parano.myice.models import kotlinx.serialization.json.Json import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull class ModelSerializationTest { private val json = Json { ignoreUnknownKeys = true } @Test fun testAccountDeserialization() { val input = """{"name":"default","label":"Default"}""" val account = json.decodeFromString(input) assertEquals("default", account.name) assertEquals("Default", account.label) } @Test fun testEventDeserialization() { val input = """ { "id_event": 761040, "agegroup": "U13 (Elite)", "name": "U13 Elite - HC Ajoie", "title": "U13 (Elite)\nGame\nRaiffeisen Arena", "opponent": "HC Ajoie", "place": "Raiffeisen Arena, Porrentruy", "start": "2024-11-10T14:00:00", "end": "2024-11-10T16:15:00", "color": "#e4222e", "event": "Jeu" } """.trimIndent() val event = json.decodeFromString(input) assertEquals("761040", event.idEvent) assertEquals("U13 (Elite)", event.agegroup) assertEquals("U13 Elite - HC Ajoie", event.name) assertEquals("HC Ajoie", event.opponent) assertEquals("#e4222e", event.color) assertEquals("Jeu", event.eventType) } @Test fun testEventDeserializationWithNullFields() { val input = """ { "id_event": "12345", "agegroup": "U15", "name": "", "title": "Practice", "opponent": "", "place": "Les Vernets", "start": "2024-11-10T14:00:00", "end": "2024-11-10T15:30:00" } """.trimIndent() val event = json.decodeFromString(input) assertEquals("12345", event.idEvent) assertEquals("", event.name) assertEquals("", event.opponent) assertNull(event.color) assertEquals("", event.eventType) } @Test fun testPlayerWithIntNumber() { val input = """ {"position":"Gardien","number":1,"fname":"Jean","lname":"Dupont","dob":"2012-03-15"} """.trimIndent() val player = json.decodeFromString(input) assertEquals("Gardien", player.position) assertEquals("1", player.number) assertEquals("Jean", player.fname) } @Test fun testPlayerWithStringNumber() { val input = """ {"position":"Attaquant","number":"27","fname":"Pierre","lname":"Martin","dob":"2012-05-20"} """.trimIndent() val player = json.decodeFromString(input) assertEquals("27", player.number) } @Test fun testPlayerWithNullNumber() { val input = """ {"position":null,"number":null,"fname":"Paul","lname":"Durand","dob":"2012-01-10"} """.trimIndent() val player = json.decodeFromString(input) assertNull(player.position) assertNull(player.number) } @Test fun testStaffDeserialization() { val input = """ {"role":"Coach","fname":"Jean","lname":"Dupont"} """.trimIndent() val staff = json.decodeFromString(input) assertEquals("Coach", staff.role) assertEquals("Jean", staff.fname) assertEquals("Dupont", staff.lname) } @Test fun testConvocationDeserialization() { val input = """ { "available": [ {"position":"Gardien","number":1,"fname":"Jean","lname":"Dupont","dob":"2012-03-15"} ], "staff": [ {"role":"Coach","fname":"Marc","lname":"Blanc"} ] } """.trimIndent() val convocation = json.decodeFromString(input) assertEquals(1, convocation.available.size) assertEquals("1", convocation.available[0].number) assertEquals(1, convocation.staff.size) assertEquals("Coach", convocation.staff[0].role) } @Test fun testConvocationWithEmptyArrays() { val input = """{"available":[],"staff":[]}""" val convocation = json.decodeFromString(input) assertEquals(0, convocation.available.size) assertEquals(0, convocation.staff.size) } @Test fun testEventDetailDeserialization() { val input = """ { "title": "U13 (Elite) - Saison HC Ajoie", "type": "games", "place": "Raiffeisen Arena, Porrentruy", "time_start": "2024-11-10T14:00:00", "time_end": "2024-11-10T16:15:00", "is_away": "1", "convocation": { "available": [], "staff": [] } } """.trimIndent() val detail = json.decodeFromString(input) assertEquals("U13 (Elite) - Saison HC Ajoie", detail.title) assertEquals("games", detail.type) assertEquals("2024-11-10T14:00:00", detail.timeStart) assertEquals(0, detail.convocation.available.size) } @Test fun testUserInfoDeserialization() { val input = """{"email":"rene@luria.ch","sub":"abc123"}""" val userInfo = json.decodeFromString(input) assertEquals("rene@luria.ch", userInfo.email) } @Test fun testEventRoundTripSerialization() { val event = Event( idEvent = "761040", agegroup = "U13 (Elite)", name = "U13 Elite - HC Ajoie", title = "U13 (Elite)\nGame\nArena", opponent = "HC Ajoie", place = "Arena", start = "2024-11-10T14:00:00", end = "2024-11-10T16:15:00", color = "#e4222e", eventType = "Jeu", ) val jsonString = json.encodeToString(Event.serializer(), event) val decoded = json.decodeFromString(jsonString) assertEquals(event, decoded) } } ``` - [ ] **Step 2: Run test to verify it fails** Run: ```bash ./gradlew composeApp:commonTest --tests "ch.parano.myice.models.ModelSerializationTest" ``` Expected: FAIL with unresolved reference errors (classes not defined) - [ ] **Step 3: Write `FlexibleStringSerializer`** Create `composeApp/src/commonMain/kotlin/ch/parano/myice/models/FlexibleStringSerializer.kt`: ```kotlin package ch.parano.myice.models import kotlinx.serialization.KSerializer import kotlinx.serialization.SerializationException import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.JsonDecoder import kotlinx.serialization.json.jsonPrimitive object FlexibleStringSerializer : KSerializer { override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("FlexibleString", PrimitiveKind.STRING) override fun serialize(encoder: Encoder, value: String) { encoder.encodeString(value) } override fun deserialize(decoder: Decoder): String { val jsonDecoder = decoder as? JsonDecoder ?: throw SerializationException("FlexibleStringSerializer expects JsonDecoder") return jsonDecoder.decodeJsonElement().jsonPrimitive.content } } ``` - [ ] **Step 4: Write model classes** Create `composeApp/src/commonMain/kotlin/ch/parano/myice/models/Account.kt`: ```kotlin package ch.parano.myice.models import kotlinx.serialization.Serializable @Serializable data class Account( val name: String, val label: String, ) ``` Create `composeApp/src/commonMain/kotlin/ch/parano/myice/models/Event.kt`: ```kotlin package ch.parano.myice.models import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class Event( @SerialName("id_event") val idEvent: String = "", val agegroup: String = "", val name: String = "", val title: String = "", val opponent: String = "", val place: String = "", val start: String = "", val end: String = "", val color: String? = null, @SerialName("event") val eventType: String = "", ) ``` Create `composeApp/src/commonMain/kotlin/ch/parano/myice/models/Staff.kt`: ```kotlin package ch.parano.myice.models import kotlinx.serialization.Serializable @Serializable data class Staff( val role: String, val fname: String, val lname: String, ) ``` Create `composeApp/src/commonMain/kotlin/ch/parano/myice/models/Player.kt`: ```kotlin package ch.parano.myice.models import kotlinx.serialization.Serializable @Serializable data class Player( val position: String? = null, @Serializable(with = FlexibleStringSerializer::class) val number: String? = null, val fname: String, val lname: String, val dob: String, ) ``` Create `composeApp/src/commonMain/kotlin/ch/parano/myice/models/Convocation.kt`: ```kotlin package ch.parano.myice.models import kotlinx.serialization.Serializable @Serializable data class Convocation( val available: List = emptyList(), val staff: List = emptyList(), ) ``` Create `composeApp/src/commonMain/kotlin/ch/parano/myice/models/EventDetail.kt`: ```kotlin package ch.parano.myice.models import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class EventDetail( val title: String = "", val type: String = "", val place: String = "", @SerialName("time_start") val timeStart: String = "", @SerialName("time_end") val timeEnd: String = "", val convocation: Convocation = Convocation(), ) ``` Create `composeApp/src/commonMain/kotlin/ch/parano/myice/models/UserInfo.kt`: ```kotlin package ch.parano.myice.models import kotlinx.serialization.Serializable @Serializable data class UserInfo( val email: String = "", ) ``` - [ ] **Step 5: Run tests to verify they pass** Run: ```bash ./gradlew composeApp:commonTest --tests "ch.parano.myice.models.ModelSerializationTest" ``` Expected: PASS (all 12 tests) - [ ] **Step 6: Commit** ```bash git add -A git commit -m "feat: add serializable data models with FlexibleStringSerializer for number coercion" ``` --- ## Task 3: Networking Layer **Files:** - Create: `composeApp/src/commonMain/kotlin/ch/parano/myice/network/ApiConfig.kt` - Create: `composeApp/src/commonMain/kotlin/ch/parano/myice/network/AuthTokenHolder.kt` - Create: `composeApp/src/commonMain/kotlin/ch/parano/myice/network/ApiEngine.kt` - Create: `composeApp/src/commonMain/kotlin/ch/parano/myice/network/ApiService.kt` - Create: `composeApp/src/androidMain/kotlin/ch/parano/myice/network/ApiEngine.android.kt` - Create: `composeApp/src/iosMain/kotlin/ch/parano/myice/network/ApiEngine.ios.kt` - Create: `composeApp/src/desktopMain/kotlin/ch/parano/myice/network/ApiEngine.desktop.kt` - Test: `composeApp/src/commonTest/kotlin/ch/parano/myice/network/ApiServiceTest.kt` **Interfaces:** - Consumes: `Account`, `Event`, `EventDetail`, `UserInfo` from Task 2 - Produces: `ApiService` class with `getUserInfo()`, `getAccounts()`, `getSchedule(account)`, `getGameDetail(gameId, account)`; `AuthTokenHolder` singleton; `createHttpClient()` factory - [ ] **Step 1: Write the failing test** Create `composeApp/src/commonTest/kotlin/ch/parano/myice/network/ApiServiceTest.kt`: ```kotlin package ch.parano.myice.network import ch.parano.myice.models.EventDetail import io.ktor.client.HttpClient import io.ktor.client.engine.mock.MockEngine import io.ktor.client.engine.mock.respond import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode import io.ktor.http.headersOf import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull import kotlin.test.assertTrue class ApiServiceTest { private val json = Json { ignoreUnknownKeys = true } private fun mockClient(respondJson: String, requestUrl: String, expectedAuth: String? = "Bearer test-token"): HttpClient { val engine = MockEngine { request -> assertEquals(requestUrl, request.url.encodedPath + "?" + request.url.encodedQuery) if (expectedAuth != null) { assertEquals(expectedAuth, request.headers["Authorization"]) } respond( content = respondJson, status = HttpStatusCode.OK, headers = headersOf(HttpHeaders.ContentType, "application/json"), ) } return HttpClient(engine) { install(ContentNegotiation) { json(json) } } } @Test fun testGetUserInfo() { AuthTokenHolder.token = "test-token" val client = mockClient( """{"email":"rene@luria.ch","sub":"abc"}""", "/userinfo", ) val api = ApiService(client) val result = api.getUserInfo() assertEquals("rene@luria.ch", result.email) } @Test fun testGetAccounts() { AuthTokenHolder.token = "test-token" val client = mockClient( """[{"name":"default","label":"Default"},{"name":"leonard","label":"Leonard"}]""", "/accounts?", ) val api = ApiService(client) val result = api.getAccounts() assertEquals(2, result.size) assertEquals("default", result[0].name) assertEquals("Leonard", result[1].label) } @Test fun testGetSchedule() { AuthTokenHolder.token = "test-token" val responseJson = """ [{"id_event":"761040","agegroup":"U13 (Elite)","name":"U13 Elite - HC Ajoie","title":"U13 (Elite)\nGame\nArena","opponent":"HC Ajoie","place":"Arena","start":"2024-11-10T14:00:00","end":"2024-11-10T16:15:00","color":"#e4222e","event":"Jeu"}] """.trimIndent() val client = mockClient(responseJson, "/schedule?account=default") val api = ApiService(client) val result = api.getSchedule("default") assertEquals(1, result.size) assertEquals("761040", result[0].idEvent) assertEquals("Jeu", result[0].eventType) } @Test fun testGetGameDetail() { AuthTokenHolder.token = "test-token" val responseJson = """ {"title":"U13 - Game","type":"games","place":"Arena","time_start":"2024-11-10T14:00:00","time_end":"2024-11-10T16:15:00","convocation":{"available":[{"position":"Gardien","number":1,"fname":"Jean","lname":"Dupont","dob":"2012-03-15"}],"staff":[{"role":"Coach","fname":"Marc","lname":"Blanc"}]}} """.trimIndent() val client = mockClient(responseJson, "/game/761040?account=default") val api = ApiService(client) val result: EventDetail = api.getGameDetail("761040", "default") assertEquals("U13 - Game", result.title) assertEquals("games", result.type) assertEquals(1, result.convocation.available.size) assertEquals("1", result.convocation.available[0].number) assertEquals(1, result.convocation.staff.size) } @Test fun testNoAuthTokenSendsNoAuthHeader() { AuthTokenHolder.token = null val engine = MockEngine { request -> assertNull(request.headers["Authorization"]) respond( content = """{"email":""}""", status = HttpStatusCode.OK, headers = headersOf(HttpHeaders.ContentType, "application/json"), ) } val client = HttpClient(engine) { install(ContentNegotiation) { json(json) } } val api = ApiService(client) val result = api.getUserInfo() assertEquals("", result.email) } } ``` - [ ] **Step 2: Run test to verify it fails** Run: ```bash ./gradlew composeApp:commonTest --tests "ch.parano.myice.network.ApiServiceTest" ``` Expected: FAIL with unresolved reference errors - [ ] **Step 3: Write `ApiConfig`** Create `composeApp/src/commonMain/kotlin/ch/parano/myice/network/ApiConfig.kt`: ```kotlin package ch.parano.myice.network object ApiConfig { const val baseUrl = "https://myice.parano.ch" } ``` - [ ] **Step 4: Write `AuthTokenHolder`** Create `composeApp/src/commonMain/kotlin/ch/parano/myice/network/AuthTokenHolder.kt`: ```kotlin package ch.parano.myice.network object AuthTokenHolder { var token: String? = null } ``` - [ ] **Step 5: Write `ApiEngine` (expect/actual)** Create `composeApp/src/commonMain/kotlin/ch/parano/myice/network/ApiEngine.kt`: ```kotlin package ch.parano.myice.network import io.ktor.client.HttpClient import io.ktor.client.HttpClientEngineFactory import io.ktor.client.engine.HttpClientEngineConfig import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.httptimeout.HttpTimeoutPlugin import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json expect fun createEngine(): HttpClientEngineFactory fun createHttpClient(): HttpClient { return HttpClient(createEngine()) { install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) } install(HttpTimeoutPlugin) { connectTimeoutMillis = 10_000 requestTimeoutMillis = 10_000 } } } ``` Create `composeApp/src/androidMain/kotlin/ch/parano/myice/network/ApiEngine.android.kt`: ```kotlin package ch.parano.myice.network import io.ktor.client.engine.HttpClientEngineConfig import io.ktor.client.engine.HttpClientEngineFactory import io.ktor.client.engine.cio.CIO actual fun createEngine(): HttpClientEngineFactory = CIO ``` Create `composeApp/src/iosMain/kotlin/ch/parano/myice/network/ApiEngine.ios.kt`: ```kotlin package ch.parano.myice.network import io.ktor.client.engine.HttpClientEngineConfig import io.ktor.client.engine.HttpClientEngineFactory import io.ktor.client.engine.darwin.Darwin actual fun createEngine(): HttpClientEngineFactory = Darwin ``` Create `composeApp/src/desktopMain/kotlin/ch/parano/myice/network/ApiEngine.desktop.kt`: ```kotlin package ch.parano.myice.network import io.ktor.client.engine.HttpClientEngineConfig import io.ktor.client.engine.HttpClientEngineFactory import io.ktor.client.engine.cio.CIO actual fun createEngine(): HttpClientEngineFactory = CIO ``` - [ ] **Step 6: Write `ApiService`** Create `composeApp/src/commonMain/kotlin/ch/parano/myice/network/ApiService.kt`: ```kotlin package ch.parano.myice.network import ch.parano.myice.models.Account import ch.parano.myice.models.Event import ch.parano.myice.models.EventDetail import ch.parano.myice.models.UserInfo import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.request.get import io.ktor.client.request.header import io.ktor.http.HttpHeaders class ApiService(private val client: HttpClient) { private fun authHeader(): String? = AuthTokenHolder.token?.let { "Bearer $it" } suspend fun getUserInfo(): UserInfo = client.get("${ApiConfig.baseUrl}/userinfo") { authHeader()?.let { header(HttpHeaders.Authorization, it) } }.body() suspend fun getAccounts(): List = client.get("${ApiConfig.baseUrl}/accounts") { authHeader()?.let { header(HttpHeaders.Authorization, it) } }.body() suspend fun getSchedule(account: String): List = client.get("${ApiConfig.baseUrl}/schedule") { url.parameters.append("account", account) authHeader()?.let { header(HttpHeaders.Authorization, it) } }.body() suspend fun getGameDetail(gameId: String, account: String): EventDetail = client.get("${ApiConfig.baseUrl}/game/$gameId") { url.parameters.append("account", account) authHeader()?.let { header(HttpHeaders.Authorization, it) } }.body() } ``` - [ ] **Step 7: Run tests to verify they pass** Run: ```bash ./gradlew composeApp:commonTest --tests "ch.parano.myice.network.ApiServiceTest" ``` Expected: PASS (all 5 tests) - [ ] **Step 8: Commit** ```bash git add -A git commit -m "feat: add Ktor networking layer with ApiService and auth token holder" ``` --- ## Task 4: Caching **Files:** - Create: `composeApp/src/commonMain/kotlin/ch/parano/myice/cache/ScheduleCache.kt` - Test: `composeApp/src/commonTest/kotlin/ch/parano/myice/cache/ScheduleCacheTest.kt` **Interfaces:** - Consumes: `Event` from Task 2, `multiplatform-settings` - Produces: `ScheduleCache` class with `saveEvents`, `loadEvents`, `getLastUpdated`, `saveFilters`, `loadFilters`, `clearCache`, `clearAllCache` - [ ] **Step 1: Write the failing test** Create `composeApp/src/commonTest/kotlin/ch/parano/myice/cache/ScheduleCacheTest.kt`: ```kotlin package ch.parano.myice.cache import ch.parano.myice.models.Event import com.russhwolf.settings.MapSettings import com.russhwolf.settings.Settings import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue class ScheduleCacheTest { private fun createCache(): Pair { val settings = MapSettings() return ScheduleCache(settings) to settings } private fun sampleEvent(id: String, eventType: String = "Jeu") = Event( idEvent = id, agegroup = "U13 (Elite)", name = "U13 Elite - HC Ajoie", title = "U13 (Elite)\nGame\nArena", opponent = "HC Ajoie", place = "Arena", start = "2024-11-10T14:00:00", end = "2024-11-10T16:15:00", color = "#e4222e", eventType = eventType, ) @Test fun testSaveAndLoadEvents() { val (cache, _) = createCache() val events = listOf(sampleEvent("1"), sampleEvent("2")) cache.saveEvents("default", events) val loaded = cache.loadEvents("default") assertNotNull(loaded) assertEquals(2, loaded.size) assertEquals("1", loaded[0].idEvent) assertEquals("2", loaded[1].idEvent) } @Test fun testLoadEventsReturnsNullWhenEmpty() { val (cache, _) = createCache() assertNull(cache.loadEvents("default")) } @Test fun testGetLastUpdated() { val (cache, _) = createCache() assertNull(cache.getLastUpdated("default")) cache.saveEvents("default", listOf(sampleEvent("1"))) val timestamp = cache.getLastUpdated("default") assertNotNull(timestamp) assertTrue(timestamp > 0) } @Test fun testSaveAndLoadFilters() { val (cache, _) = createCache() cache.saveFilters("default", agegroup = "U13 (Elite)", subgroup = "U13 Elite") val (agegroup, subgroup) = cache.loadFilters("default") assertEquals("U13 (Elite)", agegroup) assertEquals("U13 Elite", subgroup) } @Test fun testSaveFiltersWithNulls() { val (cache, _) = createCache() cache.saveFilters("default", agegroup = "U13", subgroup = "Elite") cache.saveFilters("default", agegroup = null, subgroup = null) val (agegroup, subgroup) = cache.loadFilters("default") assertNull(agegroup) assertNull(subgroup) } @Test fun testClearCache() { val (cache, _) = createCache() cache.saveEvents("default", listOf(sampleEvent("1"))) cache.saveFilters("default", "U13", "Elite") cache.clearCache("default") assertNull(cache.loadEvents("default")) val (agegroup, subgroup) = cache.loadFilters("default") assertNull(agegroup) assertNull(subgroup) } @Test fun testClearAllCache() { val (cache, _) = createCache() cache.saveEvents("default", listOf(sampleEvent("1"))) cache.saveEvents("leonard", listOf(sampleEvent("2"))) cache.clearAllCache() assertNull(cache.loadEvents("default")) assertNull(cache.loadEvents("leonard")) } @Test fun testTypeFilterPersistence() { val (cache, _) = createCache() cache.saveTypeFilter("default", "GAMES") assertEquals("GAMES", cache.loadTypeFilter("default")) } @Test fun testTypeFilterNullWhenNotSet() { val (cache, _) = createCache() assertNull(cache.loadTypeFilter("default")) } } ``` - [ ] **Step 2: Run test to verify it fails** Run: ```bash ./gradlew composeApp:commonTest --tests "ch.parano.myice.cache.ScheduleCacheTest" ``` Expected: FAIL with unresolved reference - [ ] **Step 3: Write `ScheduleCache`** Create `composeApp/src/commonMain/kotlin/ch/parano/myice/cache/ScheduleCache.kt`: ```kotlin package ch.parano.myice.cache import ch.parano.myice.models.Event import com.russhwolf.settings.Settings import kotlinx.serialization.json.Json import kotlinx.serialization.builtins.ListSerializer class ScheduleCache(private val settings: Settings) { private val json = Json { ignoreUnknownKeys = true } private val eventListSerializer = ListSerializer(Event.serializer()) private fun eventsKey(account: String) = "cached_events_$account" private fun agegroupKey(account: String) = "filters_agegroup_$account" private fun subgroupKey(account: String) = "filters_subgroup_$account" private fun typeFilterKey(account: String) = "filters_typefilter_$account" private fun timestampKey(account: String) = "cached_events_timestamp_$account" fun saveEvents(account: String, events: List) { settings.putString(eventsKey(account), json.encodeToString(eventListSerializer, events)) settings.putLong(timestampKey(account), kotlinx.datetime.Clock.System.now().toEpochMilliseconds()) } fun loadEvents(account: String): List? { val jsonString = settings.getStringOrNull(eventsKey(account)) ?: return null return try { json.decodeFromString(eventListSerializer, jsonString) } catch (e: Exception) { null } } fun getLastUpdated(account: String): Long? { val ts = settings.getLongOrNull(timestampKey(account)) return ts } fun saveFilters(account: String, agegroup: String?, subgroup: String?) { if (agegroup != null) settings.putString(agegroupKey(account), agegroup) else settings.remove(agegroupKey(account)) if (subgroup != null) settings.putString(subgroupKey(account), subgroup) else settings.remove(subgroupKey(account)) } fun loadFilters(account: String): Pair { return settings.getStringOrNull(agegroupKey(account)) to settings.getStringOrNull(subgroupKey(account)) } fun saveTypeFilter(account: String, typeFilter: String) { settings.putString(typeFilterKey(account), typeFilter) } fun loadTypeFilter(account: String): String? { return settings.getStringOrNull(typeFilterKey(account)) } fun clearCache(account: String) { settings.remove(eventsKey(account)) settings.remove(timestampKey(account)) settings.remove(agegroupKey(account)) settings.remove(subgroupKey(account)) settings.remove(typeFilterKey(account)) } fun clearAllCache() { val keys = settings.keys.filter { key -> key.startsWith("cached_events_") || key.startsWith("filters_") || key.startsWith("cached_events_timestamp_") } keys.forEach { settings.remove(it) } } } ``` - [ ] **Step 4: Add kotlinx-datetime dependency** The `ScheduleCache` uses `kotlinx.datetime.Clock` for timestamps. Add it to the version catalog and build file. In `gradle/libs.versions.toml`, add to `[versions]`: ```toml kotlinx-datetime = "0.7.1" ``` Add to `[libraries]`: ```toml kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } ``` In `composeApp/build.gradle.kts`, in `commonMain.dependencies`, add: ```kotlin implementation(libs.kotlinx.datetime) ``` - [ ] **Step 5: Run tests to verify they pass** Run: ```bash ./gradlew composeApp:commonTest --tests "ch.parano.myice.cache.ScheduleCacheTest" ``` Expected: PASS (all 9 tests) - [ ] **Step 6: Commit** ```bash git add -A git commit -m "feat: add ScheduleCache with multiplatform-settings for offline caching" ``` --- ## Task 5: Filtering Logic + Auth Interface **Files:** - Create: `composeApp/src/commonMain/kotlin/ch/parano/myice/filter/TypeFilter.kt` - Create: `composeApp/src/commonMain/kotlin/ch/parano/myice/filter/EventFilter.kt` - Create: `composeApp/src/commonMain/kotlin/ch/parano/myice/auth/AuthSettings.kt` - Create: `composeApp/src/commonMain/kotlin/ch/parano/myice/auth/OAuthClient.kt` - Test: `composeApp/src/commonTest/kotlin/ch/parano/myice/filter/EventFilterTest.kt` **Interfaces:** - Consumes: `Event` from Task 2 - Produces: `TypeFilter` enum, `filterEvents()` pure function, `extractSubgroup()` function, `AuthSettings` class, `OAuthClient` interface - [ ] **Step 1: Write the failing test for filtering** Create `composeApp/src/commonTest/kotlin/ch/parano/myice/filter/EventFilterTest.kt`: ```kotlin package ch.parano.myice.filter import ch.parano.myice.models.Event import kotlin.test.Test import kotlin.test.assertEquals class EventFilterTest { private fun event( id: String = "1", agegroup: String = "U13 (Elite)", name: String = "U13 Elite - HC Ajoie", eventType: String = "Jeu", ) = Event( idEvent = id, agegroup = agegroup, name = name, title = "Title", opponent = "Opp", place = "Place", start = "2024-11-10T14:00:00", end = "2024-11-10T16:15:00", color = "#e4222e", eventType = eventType, ) @Test fun testExtractSubgroupWithSeparator() { assertEquals("U13 Elite", extractSubgroup("U13 Elite - HC Ajoie")) } @Test fun testExtractSubgroupMultipleSeparators() { assertEquals("U13 Elite - Group A", extractSubgroup("U13 Elite - Group A - HC Ajoie")) } @Test fun testExtractSubgroupNoSeparator() { assertEquals("U13 Elite", extractSubgroup("U13 Elite")) } @Test fun testExtractSubgroupEmptyString() { assertEquals("", extractSubgroup("")) } @Test fun testFilterGamesOnly() { val events = listOf( event(id = "1", eventType = "Jeu"), event(id = "2", eventType = ""), event(id = "3", eventType = "Jeu"), ) val result = filterEvents(events, agegroup = null, subgroup = null, typeFilter = TypeFilter.GAMES) assertEquals(2, result.size) assertEquals("1", result[0].idEvent) assertEquals("3", result[1].idEvent) } @Test fun testFilterPracticesOnly() { val events = listOf( event(id = "1", eventType = "Jeu"), event(id = "2", eventType = ""), event(id = "3", eventType = ""), ) val result = filterEvents(events, agegroup = null, subgroup = null, typeFilter = TypeFilter.PRACTICES) assertEquals(2, result.size) assertEquals("2", result[0].idEvent) assertEquals("3", result[1].idEvent) } @Test fun testFilterAll() { val events = listOf( event(id = "1", eventType = "Jeu"), event(id = "2", eventType = ""), ) val result = filterEvents(events, agegroup = null, subgroup = null, typeFilter = TypeFilter.ALL) assertEquals(2, result.size) } @Test fun testFilterByAgegroup() { val events = listOf( event(id = "1", agegroup = "U13 (Elite)"), event(id = "2", agegroup = "U15"), event(id = "3", agegroup = "U13 (Elite)"), ) val result = filterEvents(events, agegroup = "U13 (Elite)", subgroup = null, typeFilter = TypeFilter.ALL) assertEquals(2, result.size) assertEquals("1", result[0].idEvent) } @Test fun testFilterBySubgroup() { val events = listOf( event(id = "1", name = "U13 Elite - HC Ajoie"), event(id = "2", name = "U13 Top - HC Geneva"), event(id = "3", name = "U13 Elite - HC Lausanne"), ) val result = filterEvents(events, agegroup = null, subgroup = "U13 Elite", typeFilter = TypeFilter.ALL) assertEquals(2, result.size) assertEquals("1", result[0].idEvent) assertEquals("3", result[1].idEvent) } @Test fun testFilterCombinedAgegroupAndSubgroup() { val events = listOf( event(id = "1", agegroup = "U13 (Elite)", name = "U13 Elite - HC Ajoie"), event(id = "2", agegroup = "U13 (Elite)", name = "U13 Top - HC Geneva"), event(id = "3", agegroup = "U15", name = "U13 Elite - HC Lausanne"), ) val result = filterEvents(events, agegroup = "U13 (Elite)", subgroup = "U13 Elite", typeFilter = TypeFilter.ALL) assertEquals(1, result.size) assertEquals("1", result[0].idEvent) } @Test fun testFilterGamesAndAgegroup() { val events = listOf( event(id = "1", agegroup = "U13 (Elite)", eventType = "Jeu"), event(id = "2", agegroup = "U13 (Elite)", eventType = ""), event(id = "3", agegroup = "U15", eventType = "Jeu"), ) val result = filterEvents(events, agegroup = "U13 (Elite)", subgroup = null, typeFilter = TypeFilter.GAMES) assertEquals(1, result.size) assertEquals("1", result[0].idEvent) } @Test fun testDistinctAgegroups() { val events = listOf( event(agegroup = "U15"), event(agegroup = "U13 (Elite)"), event(agegroup = "U15"), ) val result = events.distinctAgegroups() assertEquals(2, result.size) assertEquals("U13 (Elite)", result[0]) assertEquals("U15", result[1]) } @Test fun testDistinctSubgroups() { val events = listOf( event(name = "U13 Elite - HC Ajoie"), event(name = "U13 Top - HC Geneva"), event(name = "U13 Elite - HC Lausanne"), ) val result = events.distinctSubgroups() assertEquals(2, result.size) assertEquals("U13 Elite", result[0]) assertEquals("U13 Top", result[1]) } } ``` - [ ] **Step 2: Run test to verify it fails** Run: ```bash ./gradlew composeApp:commonTest --tests "ch.parano.myice.filter.EventFilterTest" ``` Expected: FAIL with unresolved reference - [ ] **Step 3: Write `TypeFilter`** Create `composeApp/src/commonMain/kotlin/ch/parano/myice/filter/TypeFilter.kt`: ```kotlin package ch.parano.myice.filter enum class TypeFilter { ALL, GAMES, PRACTICES; companion object { val DEFAULT = GAMES } } ``` - [ ] **Step 4: Write `EventFilter`** Create `composeApp/src/commonMain/kotlin/ch/parano/myice/filter/EventFilter.kt`: ```kotlin package ch.parano.myice.filter import ch.parano.myice.models.Event fun extractSubgroup(name: String): String { val parts = name.split(" - ") return if (parts.size > 1) parts.dropLast(1).joinToString(" - ") else name } fun filterEvents( events: List, agegroup: String?, subgroup: String?, typeFilter: TypeFilter, ): List = events.filter { event -> val typeMatches = when (typeFilter) { TypeFilter.ALL -> true TypeFilter.GAMES -> event.eventType == "Jeu" TypeFilter.PRACTICES -> event.eventType != "Jeu" } if (!typeMatches) return@filter false if (agegroup != null && event.agegroup != agegroup) return@filter false if (subgroup != null) { val eventSubgroup = extractSubgroup(event.name) if (eventSubgroup != subgroup) return@filter false } true } fun List.distinctAgegroups(): List = map { it.agegroup }.toSet().sorted() fun List.distinctSubgroups(): List = map { extractSubgroup(it.name) }.toSet().sorted() ``` - [ ] **Step 5: Write `AuthSettings`** Create `composeApp/src/commonMain/kotlin/ch/parano/myice/auth/AuthSettings.kt`: ```kotlin package ch.parano.myice.auth import ch.parano.myice.network.AuthTokenHolder import com.russhwolf.settings.Settings class AuthSettings(private val settings: Settings) { companion object { private const val TOKEN_KEY = "access_token" private const val ACCOUNT_KEY = "selected_account" } fun getStoredToken(): String? = settings.getStringOrNull(TOKEN_KEY) fun setToken(token: String?) { if (token != null) { settings.putString(TOKEN_KEY, token) } else { settings.remove(TOKEN_KEY) } AuthTokenHolder.token = token } fun getStoredAccount(): String? = settings.getStringOrNull(ACCOUNT_KEY) fun setAccount(account: String) { settings.putString(ACCOUNT_KEY, account) } fun clear() { settings.remove(TOKEN_KEY) settings.remove(ACCOUNT_KEY) AuthTokenHolder.token = null } } ``` - [ ] **Step 6: Write `OAuthClient` interface** Create `composeApp/src/commonMain/kotlin/ch/parano/myice/auth/OAuthClient.kt`: ```kotlin package ch.parano.myice.auth interface OAuthClient { suspend fun authenticate(loginUrl: String): String? } ``` - [ ] **Step 7: Run tests to verify they pass** Run: ```bash ./gradlew composeApp:commonTest --tests "ch.parano.myice.filter.EventFilterTest" ``` Expected: PASS (all 12 tests) - [ ] **Step 8: Commit** ```bash git add -A git commit -m "feat: add event filtering logic, TypeFilter enum, AuthSettings, and OAuthClient interface" ``` --- ## Task 6: ViewModels **Files:** - Create: `composeApp/src/commonMain/kotlin/ch/parano/myice/viewmodel/AuthViewModel.kt` - Create: `composeApp/src/commonMain/kotlin/ch/parano/myice/viewmodel/ScheduleViewModel.kt` - Create: `composeApp/src/commonMain/kotlin/ch/parano/myice/viewmodel/EventDetailViewModel.kt` - Test: `composeApp/src/commonTest/kotlin/ch/parano/myice/viewmodel/ScheduleViewModelTest.kt` **Interfaces:** - Consumes: `ApiService` (Task 3), `ScheduleCache` (Task 4), `AuthSettings` + `OAuthClient` (Task 5), `filterEvents` (Task 5) - Produces: `AuthViewModel`, `ScheduleViewModel`, `EventDetailViewModel` with `StateFlow` state - [ ] **Step 1: Write the failing test** Create `composeApp/src/commonTest/kotlin/ch/parano/myice/viewmodel/ScheduleViewModelTest.kt`: ```kotlin package ch.parano.myice.viewmodel import ch.parano.myice.cache.ScheduleCache import ch.parano.myice.filter.TypeFilter import ch.parano.myice.models.Account import ch.parano.myice.models.Event import ch.parano.myice.network.ApiService import com.russhwolf.settings.MapSettings import io.ktor.client.HttpClient import io.ktor.client.engine.mock.MockEngine import io.ktor.client.engine.mock.respond import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode import io.ktor.http.headersOf import io.ktor.serialization.kotlinx.json.json import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.Json import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue class ScheduleViewModelTest { private val json = Json { ignoreUnknownKeys = true } private fun mockApi(accountsJson: String, scheduleJson: String): ApiService { val engine = MockEngine { request -> val path = request.url.encodedPath val response = when { path == "/accounts" -> accountsJson path == "/schedule" -> scheduleJson else -> "[]" } respond( content = response, status = HttpStatusCode.OK, headers = headersOf(HttpHeaders.ContentType, "application/json"), ) } val client = HttpClient(engine) { install(ContentNegotiation) { json(json) } } return ApiService(client) } private fun sampleEvent( id: String = "1", agegroup: String = "U13 (Elite)", name: String = "U13 Elite - HC Ajoie", eventType: String = "Jeu", ) = Event( idEvent = id, agegroup = agegroup, name = name, title = "Title", opponent = "Opp", place = "Place", start = "2024-11-10T14:00:00", end = "2024-11-10T16:15:00", color = "#e4222e", eventType = eventType, ) @Test fun testLoadAccounts() = runTest { val api = mockApi( """[{"name":"default","label":"Default"}]""", "[]", ) val cache = ScheduleCache(MapSettings()) val vm = ScheduleViewModel(api, cache) vm.loadAccounts() assertEquals(1, vm.state.value.accounts.size) assertEquals("default", vm.state.value.selectedAccount) } @Test fun testRefreshScheduleStoresEvents() = runTest { val scheduleJson = """ [{"id_event":"1","agegroup":"U13 (Elite)","name":"U13 Elite - HC Ajoie","title":"T","opponent":"O","place":"P","start":"2024-11-10T14:00:00","end":"2024-11-10T16:15:00","color":"#e4222e","event":"Jeu"}] """.trimIndent() val api = mockApi("""[{"name":"default","label":"Default"}]""", scheduleJson) val cache = ScheduleCache(MapSettings()) val vm = ScheduleViewModel(api, cache) vm.loadAccounts() vm.refreshSchedule() assertEquals(1, vm.state.value.events.size) assertEquals("1", vm.state.value.events[0].idEvent) } @Test fun testFilteredEventsDefaultGamesOnly() = runTest { val scheduleJson = """ [{"id_event":"1","agegroup":"U13","name":"U13 - A","title":"T","opponent":"O","place":"P","start":"s","end":"e","event":"Jeu"}, {"id_event":"2","agegroup":"U13","name":"U13 - B","title":"T","opponent":"","place":"P","start":"s","end":"e"}] """.trimIndent() val api = mockApi("""[{"name":"default","label":"Default"}]""", scheduleJson) val cache = ScheduleCache(MapSettings()) val vm = ScheduleViewModel(api, cache) vm.loadAccounts() vm.refreshSchedule() assertEquals(2, vm.state.value.events.size) assertEquals(1, vm.state.value.filteredEvents.size) assertEquals("1", vm.state.value.filteredEvents[0].idEvent) } @Test fun testSetTypeFilterAll() = runTest { val scheduleJson = """ [{"id_event":"1","agegroup":"U13","name":"U13 - A","title":"T","opponent":"O","place":"P","start":"s","end":"e","event":"Jeu"}, {"id_event":"2","agegroup":"U13","name":"U13 - B","title":"T","opponent":"","place":"P","start":"s","end":"e"}] """.trimIndent() val api = mockApi("""[{"name":"default","label":"Default"}]""", scheduleJson) val cache = ScheduleCache(MapSettings()) val vm = ScheduleViewModel(api, cache) vm.loadAccounts() vm.refreshSchedule() vm.setTypeFilter(TypeFilter.ALL) assertEquals(2, vm.state.value.filteredEvents.size) } @Test fun testSetAgegroup() = runTest { val scheduleJson = """ [{"id_event":"1","agegroup":"U13","name":"U13 - A","title":"T","opponent":"O","place":"P","start":"s","end":"e","event":"Jeu"}, {"id_event":"2","agegroup":"U15","name":"U15 - B","title":"T","opponent":"O","place":"P","start":"s","end":"e","event":"Jeu"}] """.trimIndent() val api = mockApi("""[{"name":"default","label":"Default"}]""", scheduleJson) val cache = ScheduleCache(MapSettings()) val vm = ScheduleViewModel(api, cache) vm.loadAccounts() vm.refreshSchedule() vm.setAgegroup("U15") assertEquals(1, vm.state.value.filteredEvents.size) assertEquals("2", vm.state.value.filteredEvents[0].idEvent) } @Test fun testSetSubgroup() = runTest { val scheduleJson = """ [{"id_event":"1","agegroup":"U13","name":"U13 Elite - HC Ajoie","title":"T","opponent":"O","place":"P","start":"s","end":"e","event":"Jeu"}, {"id_event":"2","agegroup":"U13","name":"U13 Top - HC Geneva","title":"T","opponent":"O","place":"P","start":"s","end":"e","event":"Jeu"}] """.trimIndent() val api = mockApi("""[{"name":"default","label":"Default"}]""", scheduleJson) val cache = ScheduleCache(MapSettings()) val vm = ScheduleViewModel(api, cache) vm.loadAccounts() vm.refreshSchedule() vm.setSubgroup("U13 Elite") assertEquals(1, vm.state.value.filteredEvents.size) assertEquals("1", vm.state.value.filteredEvents[0].idEvent) } @Test fun testCacheLoadOnLoadCachedSchedule() = runTest { val api = mockApi("""[{"name":"default","label":"Default"}]""", "[]") val cache = ScheduleCache(MapSettings()) cache.saveEvents("default", listOf(sampleEvent("1"))) val vm = ScheduleViewModel(api, cache) vm.loadAccounts() vm.loadCachedSchedule() assertEquals(1, vm.state.value.events.size) assertFalse(vm.state.value.isLoading) } @Test fun testAgegroupsComputed() = runTest { val scheduleJson = """ [{"id_event":"1","agegroup":"U15","name":"N","title":"T","opponent":"","place":"P","start":"s","end":"e","event":"Jeu"}, {"id_event":"2","agegroup":"U13","name":"N","title":"T","opponent":"","place":"P","start":"s","end":"e","event":"Jeu"}] """.trimIndent() val api = mockApi("""[{"name":"default","label":"Default"}]""", scheduleJson) val cache = ScheduleCache(MapSettings()) val vm = ScheduleViewModel(api, cache) vm.loadAccounts() vm.refreshSchedule() assertEquals(listOf("U13", "U15"), vm.state.value.agegroups) } } ``` - [ ] **Step 2: Run test to verify it fails** Run: ```bash ./gradlew composeApp:commonTest --tests "ch.parano.myice.viewmodel.ScheduleViewModelTest" ``` Expected: FAIL with unresolved reference - [ ] **Step 3: Write `AuthViewModel`** Create `composeApp/src/commonMain/kotlin/ch/parano/myice/viewmodel/AuthViewModel.kt`: ```kotlin package ch.parano.myice.viewmodel import ch.parano.myice.auth.AuthSettings import ch.parano.myice.auth.OAuthClient import ch.parano.myice.network.ApiConfig import ch.parano.myice.network.ApiService import ch.parano.myice.network.AuthTokenHolder import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch sealed class AuthState { data object Loading : AuthState() data class Authenticated(val email: String) : AuthState() data object Unauthenticated : AuthState() } class AuthViewModel( private val apiService: ApiService, private val authSettings: AuthSettings, private val oauthClient: OAuthClient, ) : ViewModel() { private val _state = MutableStateFlow(AuthState.Loading) val state: StateFlow = _state.asStateFlow() fun init() { viewModelScope.launch { val token = authSettings.getStoredToken() if (token != null) { AuthTokenHolder.token = token try { val userInfo = apiService.getUserInfo() _state.value = AuthState.Authenticated(userInfo.email) } catch (e: Exception) { authSettings.clear() _state.value = AuthState.Unauthenticated } } else { _state.value = AuthState.Unauthenticated } } } fun login() { viewModelScope.launch { _state.value = AuthState.Loading try { val loginUrl = "${ApiConfig.baseUrl}/login?redirect_uri=" + oauthClient.redirectUri() val token = oauthClient.authenticate(loginUrl) if (token != null) { authSettings.setToken(token) val userInfo = apiService.getUserInfo() _state.value = AuthState.Authenticated(userInfo.email) } else { _state.value = AuthState.Unauthenticated } } catch (e: Exception) { _state.value = AuthState.Unauthenticated } } } fun logout() { authSettings.clear() _state.value = AuthState.Unauthenticated } } ``` - [ ] **Step 4: Write `ScheduleViewModel`** Create `composeApp/src/commonMain/kotlin/ch/parano/myice/viewmodel/ScheduleViewModel.kt`: ```kotlin package ch.parano.myice.viewmodel import ch.parano.myice.auth.AuthSettings import ch.parano.myice.cache.ScheduleCache import ch.parano.myice.filter.TypeFilter import ch.parano.myice.filter.distinctAgegroups import ch.parano.myice.filter.distinctSubgroups import ch.parano.myice.filter.filterEvents import ch.parano.myice.models.Account import ch.parano.myice.models.Event import ch.parano.myice.network.ApiService import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch data class ScheduleState( val accounts: List = emptyList(), val events: List = emptyList(), val selectedAccount: String? = null, val selectedAgegroup: String? = null, val selectedSubgroup: String? = null, val typeFilter: TypeFilter = TypeFilter.DEFAULT, val isLoading: Boolean = false, val isRefreshing: Boolean = false, val error: String? = null, val lastUpdated: Long? = null, ) { val filteredEvents: List get() = filterEvents(events, selectedAgegroup, selectedSubgroup, typeFilter) val agegroups: List get() = events.distinctAgegroups() val subgroups: List get() = if (selectedAgegroup == null) emptyList() else events.filter { it.agegroup == selectedAgegroup }.distinctSubgroups() } class ScheduleViewModel( private val apiService: ApiService, private val cache: ScheduleCache, private val authSettings: AuthSettings? = null, ) : ViewModel() { private val _state = MutableStateFlow(ScheduleState()) val state: StateFlow = _state.asStateFlow() fun loadAccounts() { viewModelScope.launch { try { val accounts = apiService.getAccounts() val storedAccount = authSettings?.getStoredAccount() var selected = storedAccount if (selected == null || accounts.none { it.name == selected }) { selected = accounts.firstOrNull()?.name } val (cachedAgegroup, cachedSubgroup) = if (selected != null) { cache.loadFilters(selected) } else null to null val cachedTypeFilter = if (selected != null) { cache.loadTypeFilter(selected)?.let { name -> runCatching { TypeFilter.valueOf(name) }.getOrNull() } ?: TypeFilter.DEFAULT } else TypeFilter.DEFAULT _state.value = _state.value.copy( accounts = accounts, selectedAccount = selected, selectedAgegroup = cachedAgegroup, selectedSubgroup = cachedSubgroup, typeFilter = cachedTypeFilter, ) } catch (e: Exception) { _state.value = _state.value.copy(error = "Failed to load accounts: ${e.message}") } } } fun loadCachedSchedule() { val account = _state.value.selectedAccount ?: return _state.value = _state.value.copy(isLoading = true, error = null) val cachedEvents = cache.loadEvents(account) if (cachedEvents != null) { _state.value = _state.value.copy( events = cachedEvents, lastUpdated = cache.getLastUpdated(account), isLoading = false, ) } else { _state.value = _state.value.copy(events = emptyList(), isLoading = false) } if (_state.value.events.isEmpty()) { refreshSchedule() } } fun refreshSchedule() { val account = _state.value.selectedAccount ?: return viewModelScope.launch { _state.value = _state.value.copy(isRefreshing = true, error = null) try { val allEvents = apiService.getSchedule(account) cache.saveEvents(account, allEvents) _state.value = _state.value.copy( events = allEvents, isRefreshing = false, lastUpdated = cache.getLastUpdated(account), ) } catch (e: Exception) { _state.value = _state.value.copy( isRefreshing = false, error = "Failed to refresh schedule: ${e.message}", ) } } } fun setAccount(account: String) { authSettings?.setAccount(account) val (cachedAgegroup, cachedSubgroup) = cache.loadFilters(account) val cachedTypeFilter = cache.loadTypeFilter(account)?.let { name -> runCatching { TypeFilter.valueOf(name) }.getOrNull() } ?: TypeFilter.DEFAULT _state.value = _state.value.copy( selectedAccount = account, selectedAgegroup = cachedAgegroup, selectedSubgroup = cachedSubgroup, typeFilter = cachedTypeFilter, ) loadCachedSchedule() } fun setAgegroup(agegroup: String?) { val account = _state.value.selectedAccount if (account != null) { cache.saveFilters(account, agegroup, null) } _state.value = _state.value.copy(selectedAgegroup = agegroup, selectedSubgroup = null) } fun setSubgroup(subgroup: String?) { val account = _state.value.selectedAccount if (account != null) { cache.saveFilters(account, _state.value.selectedAgegroup, subgroup) } _state.value = _state.value.copy(selectedSubgroup = subgroup) } fun setTypeFilter(filter: TypeFilter) { val account = _state.value.selectedAccount if (account != null) { cache.saveTypeFilter(account, filter.name) } _state.value = _state.value.copy(typeFilter = filter) } fun clearCache() { cache.clearAllCache() } } ``` - [ ] **Step 5: Write `EventDetailViewModel`** Create `composeApp/src/commonMain/kotlin/ch/parano/myice/viewmodel/EventDetailViewModel.kt`: ```kotlin package ch.parano.myice.viewmodel import ch.parano.myice.models.EventDetail import ch.parano.myice.network.ApiService import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch data class EventDetailState( val eventDetail: EventDetail? = null, val isLoading: Boolean = false, val error: String? = null, ) class EventDetailViewModel( private val apiService: ApiService, ) : ViewModel() { private val _state = MutableStateFlow(EventDetailState(isLoading = false)) val state: StateFlow = _state.asStateFlow() fun loadEventDetail(gameId: String, account: String) { viewModelScope.launch { _state.value = EventDetailState(isLoading = true) try { val detail = apiService.getGameDetail(gameId, account) _state.value = EventDetailState(eventDetail = detail, isLoading = false) } catch (e: Exception) { _state.value = EventDetailState( error = "Failed to load event detail: ${e.message}", isLoading = false, ) } } } } ``` - [ ] **Step 6: Add `redirectUri()` to `OAuthClient` interface** Update `composeApp/src/commonMain/kotlin/ch/parano/myice/auth/OAuthClient.kt`: ```kotlin package ch.parano.myice.auth interface OAuthClient { fun redirectUri(): String suspend fun authenticate(loginUrl: String): String? } ``` - [ ] **Step 7: Run tests to verify they pass** Run: ```bash ./gradlew composeApp:commonTest --tests "ch.parano.myice.viewmodel.ScheduleViewModelTest" ``` Expected: PASS (all 8 tests) - [ ] **Step 8: Commit** ```bash git add -A git commit -m "feat: add AuthViewModel, ScheduleViewModel, and EventDetailViewModel with StateFlow" ``` --- ## Task 7: Platform OAuth Implementations **Files:** - Create: `composeApp/src/androidMain/kotlin/ch/parano/myice/auth/AndroidOAuthClient.kt` - Create: `composeApp/src/androidMain/kotlin/ch/parano/myice/OAuthCallbackActivity.kt` - Modify: `composeApp/src/androidMain/AndroidManifest.xml` - Create: `composeApp/src/iosMain/kotlin/ch/parano/myice/auth/IosOAuthClient.kt` - Create: `composeApp/src/desktopMain/kotlin/ch/parano/myice/auth/DesktopOAuthClient.kt` **Interfaces:** - Consumes: `OAuthClient` interface from Task 5 - Produces: `AndroidOAuthClient`, `IosOAuthClient`, `DesktopOAuthClient` implementing `OAuthClient` - [ ] **Step 1: Write Android OAuth implementation** Create `composeApp/src/androidMain/kotlin/ch/parano/myice/auth/AndroidOAuthClient.kt`: ```kotlin package ch.parano.myice.auth import android.content.Context import android.content.Intent import android.net.Uri import kotlinx.coroutines.CompletableDeferred import java.net.URLEncoder class AndroidOAuthClient(private val context: Context) : OAuthClient { override fun redirectUri(): String = "myice://callback" override suspend fun authenticate(loginUrl: String): String? { OAuthCallbackHolder.deferred = CompletableDeferred() val intent = Intent(Intent.ACTION_VIEW, Uri.parse(loginUrl)).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } context.startActivity(intent) val callbackUri = OAuthCallbackHolder.deferred?.await() ?: return null val fragment = callbackUri.fragment ?: return null val params = fragment.split("&").associate { pair -> val idx = pair.indexOf("=") if (idx >= 0) pair.substring(0, idx) to pair.substring(idx + 1) else pair to "" } return params["access_token"] } } object OAuthCallbackHolder { var deferred: CompletableDeferred? = null fun handleCallback(uri: Uri) { deferred?.complete(uri) deferred = null } } ``` - [ ] **Step 2: Write Android callback activity** Create `composeApp/src/androidMain/kotlin/ch/parano/myice/OAuthCallbackActivity.kt`: ```kotlin package ch.parano.myice import android.app.Activity import android.content.Intent import android.net.Uri import android.os.Bundle import ch.parano.myice.auth.OAuthCallbackHolder class OAuthCallbackActivity : Activity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val uri: Uri? = intent?.data if (uri != null) { OAuthCallbackHolder.handleCallback(uri) } finish() } } ``` - [ ] **Step 3: Update AndroidManifest.xml** Modify `composeApp/src/androidMain/AndroidManifest.xml` to add the callback activity and intent filter: ```xml ``` - [ ] **Step 4: Write iOS OAuth implementation** Create `composeApp/src/iosMain/kotlin/ch/parano/myice/auth/IosOAuthClient.kt`: ```kotlin package ch.parano.myice.auth import kotlinx.cinterop.ExperimentalForeignApi import platform.Foundation.NSURL import platform.AuthenticationServices.ASWebAuthenticationSession import platform.AuthenticationServices.ASWebAuthenticationPresentationContextProvider import platform.AuthenticationServices.ASCallback import platform.UIKit.UIApplication import platform.UIKit.UIWindow import platform.UIKit.UIWindowScene import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine @OptIn(ExperimentalForeignApi::class) class IosOAuthClient : OAuthClient { override fun redirectUri(): String = "myice://callback" override suspend fun authenticate(loginUrl: String): String? = suspendCoroutine { continuation -> val nsUrl = NSURL.URLWithString(loginUrl) ?: run { continuation.resume(null) return@suspendCoroutine } val session = ASWebAuthenticationSession( URL = nsUrl, callbackURLScheme = "myice", completionHandler = ASCallback { callbackURL, error -> if (error != null || callbackURL == null) { continuation.resume(null) } else { val urlStr = callbackURL.absoluteString ?: "" val fragmentIndex = urlStr.indexOf("#") if (fragmentIndex < 0) { continuation.resume(null) } else { val fragment = urlStr.substring(fragmentIndex + 1) val params = fragment.split("&").associate { pair -> val idx = pair.indexOf("=") if (idx >= 0) pair.substring(0, idx) to pair.substring(idx + 1) else pair to "" } continuation.resume(params["access_token"]) } } } ) session.setPresentationContextProvider(object : ASWebAuthenticationPresentationContextProviderProtocol { override fun presentationAnchorForWebAuthenticationSession( session: ASWebAuthenticationSession ): UIWindow { return keyWindow() } }) session.start() } private fun keyWindow(): UIWindow { val scenes = UIApplication.sharedApplication.connectedScenes val windowScene = scenes.firstOrNull { it is UIWindowScene } as? UIWindowScene return windowScene?.windows?.firstOrNull { it.keyWindow } ?: UIWindow() } } ``` - [ ] **Step 5: Write Desktop OAuth implementation** Create `composeApp/src/desktopMain/kotlin/ch/parano/myice/auth/DesktopOAuthClient.kt`: ```kotlin package ch.parano.myice.auth import kotlinx.coroutines.CompletableDeferred import java.awt.Desktop import java.io.BufferedReader import java.io.InputStreamReader import java.io.PrintWriter import java.net.InetSocketAddress import java.net.ServerSocket import java.net.URI import java.net.URLDecoder import java.net.URLEncoder import kotlin.concurrent.thread class DesktopOAuthClient : OAuthClient { private var serverPort: Int = 0 override fun redirectUri(): String = "http://localhost:$serverPort/callback" override suspend fun authenticate(loginUrl: String): String? { val server = ServerSocket() server.bind(InetSocketAddress(0)) serverPort = server.localPort val redirectUri = "http://localhost:$serverPort/callback" val fullLoginUrl = loginUrl + "&redirect_uri=" + URLEncoder.encode(redirectUri, "UTF-8") val deferred = CompletableDeferred() thread { try { while (true) { val socket = server.accept() val reader = BufferedReader(InputStreamReader(socket.getInputStream())) val writer = PrintWriter(socket.getOutputStream()) val requestLine = reader.readLine() ?: continue val path = requestLine.split(" ").getOrNull(1) ?: continue if (path.startsWith("/callback")) { val html = """ """.trimIndent() writer.println("HTTP/1.1 200 OK") writer.println("Content-Type: text/html") writer.println("Connection: close") writer.println() writer.println(html) writer.flush() } else if (path.startsWith("/token")) { val query = path.substringAfter("?") val params = query.split("&").associate { pair -> val idx = pair.indexOf("=") if (idx >= 0) { pair.substring(0, idx) to URLDecoder.decode(pair.substring(idx + 1), "UTF-8") } else pair to "" } val token = params["access_token"] deferred.complete(token) writer.println("HTTP/1.1 200 OK") writer.println("Content-Type: text/html") writer.println("Connection: close") writer.println() writer.println("

Authentication complete. You can close this window.

") writer.flush() socket.close() break } socket.close() } } catch (e: Exception) { deferred.complete(null) } finally { server.close() } } Desktop.getDesktop().browse(URI(fullLoginUrl)) return deferred.await() } } ``` - [ ] **Step 6: Verify build compiles** Run: ```bash ./gradlew composeApp:compileKotlinDesktop composeApp:compileDebugKotlinAndroid ``` Expected: BUILD SUCCESSFUL Note: iOS compilation requires Xcode. If not on macOS, verify Android + Desktop only: ```bash ./gradlew composeApp:compileKotlinDesktop composeApp:compileDebugKotlinAndroid ``` - [ ] **Step 7: Commit** ```bash git add -A git commit -m "feat: add platform OAuth implementations (Android, iOS, Desktop)" ``` --- ## Task 8: UI Theme + Components **Files:** - Create: `composeApp/src/commonMain/kotlin/ch/parano/myice/ui/theme/Theme.kt` - Create: `composeApp/src/commonMain/kotlin/ch/parano/myice/ui/components/EventCard.kt` - Create: `composeApp/src/commonMain/kotlin/ch/parano/myice/ui/components/FilterDropdown.kt` - Create: `composeApp/src/commonMain/kotlin/ch/parano/myice/ui/components/LoadingIndicator.kt` **Interfaces:** - Consumes: `Event` model from Task 2 - Produces: `MyIceTheme` composable, `EventCard`, `FilterDropdown`, `LoadingIndicator` composables - [ ] **Step 1: Write theme** Create `composeApp/src/commonMain/kotlin/ch/parano/myice/ui/theme/Theme.kt`: ```kotlin package ch.parano.myice.ui.theme import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color private val Indigo = Color(0xFF3F51B5) private val IndigoLight = Color(0xFF7986CB) private val IndigoDark = Color(0xFF303F9F) private val LightColors = lightColorScheme( primary = Indigo, onPrimary = Color.White, primaryContainer = IndigoDark, secondary = IndigoLight, ) private val DarkColors = darkColorScheme( primary = IndigoLight, onPrimary = Color.Black, primaryContainer = Indigo, secondary = Indigo, ) @Composable fun MyIceTheme( darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit, ) { MaterialTheme( colorScheme = if (darkTheme) DarkColors else LightColors, content = content, ) } ``` - [ ] **Step 2: Write `LoadingIndicator`** Create `composeApp/src/commonMain/kotlin/ch/parano/myice/ui/components/LoadingIndicator.kt`: ```kotlin package ch.parano.myice.ui.components import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @Composable fun LoadingIndicator(modifier: Modifier = Modifier) { Box( modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { CircularProgressIndicator() } } ``` - [ ] **Step 3: Write `FilterDropdown`** Create `composeApp/src/commonMain/kotlin/ch/parano/myice/ui/components/FilterDropdown.kt`: ```kotlin package ch.parano.myice.ui.components import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier @OptIn(ExperimentalMaterial3Api::class) @Composable fun FilterDropdown( label: String, selectedValue: String?, options: List, onSelect: (String?) -> Unit, modifier: Modifier = Modifier, ) { var expanded by remember { mutableStateOf(false) } ExposedDropdownMenuBox( expanded = expanded, onExpandedChange = { expanded = it }, modifier = modifier.fillMaxWidth(), ) { OutlinedTextField( value = selectedValue ?: "", onValueChange = {}, readOnly = true, label = { Text(label) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, modifier = Modifier.menuAnchor().fillMaxWidth(), ) ExposedDropdownMenu( expanded = expanded, onDismissRequest = { expanded = false }, ) { options.forEach { option -> DropdownMenuItem( text = { Text(option) }, onClick = { onSelect(option) expanded = false }, ) } } } } ``` - [ ] **Step 4: Write `EventCard`** Create `composeApp/src/commonMain/kotlin/ch/parano/myice/ui/components/EventCard.kt`: ```kotlin package ch.parano.myice.ui.components import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material3.Card import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import ch.parano.myice.models.Event @Composable fun EventCard( event: Event, onClick: () -> Unit, modifier: Modifier = Modifier, ) { val stripeColor = event.color?.let { parseHexColor(it) } ?: MaterialTheme.colorScheme.primary Card( onClick = onClick, modifier = modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp), ) { Row( modifier = Modifier.height(120.dp), ) { Box( modifier = Modifier .width(4.dp) .fillMaxHeight() .background(stripeColor), ) Column( modifier = Modifier .padding(16.dp) .fillMaxHeight(), verticalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(4.dp), ) { Text( text = "${event.agegroup} - ${event.name}", style = MaterialTheme.typography.titleMedium, ) Text( text = event.title, style = MaterialTheme.typography.bodyMedium, ) Text( text = "Adversaire: ${event.opponent}", style = MaterialTheme.typography.bodySmall, ) Text( text = "Lieu: ${event.place}", style = MaterialTheme.typography.bodySmall, ) Text( text = "${event.start} - ${event.end}", style = MaterialTheme.typography.bodySmall, ) } } } } private fun parseHexColor(hex: String): Color { val cleanHex = hex.removePrefix("#") val value = cleanHex.toLong(16) return Color(0xFF000000 or value) } ``` - [ ] **Step 5: Verify build compiles** Run: ```bash ./gradlew composeApp:compileKotlinDesktop ``` Expected: BUILD SUCCESSFUL - [ ] **Step 6: Commit** ```bash git add -A git commit -m "feat: add Material 3 theme and UI components (EventCard, FilterDropdown, LoadingIndicator)" ``` --- ## Task 9: Screens **Files:** - Create: `composeApp/src/commonMain/kotlin/ch/parano/myice/ui/screens/LoginScreen.kt` - Create: `composeApp/src/commonMain/kotlin/ch/parano/myice/ui/screens/ScheduleScreen.kt` - Create: `composeApp/src/commonMain/kotlin/ch/parano/myice/ui/screens/EventDetailScreen.kt` **Interfaces:** - Consumes: ViewModels from Task 6, components from Task 8, models from Task 2 - Produces: `LoginScreen`, `ScheduleScreen`, `EventDetailScreen` composables - [ ] **Step 1: Write `LoginScreen`** Create `composeApp/src/commonMain/kotlin/ch/parano/myice/ui/screens/LoginScreen.kt`: ```kotlin package ch.parano.myice.ui.screens import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import ch.parano.myice.viewmodel.AuthViewModel import ch.parano.myice.viewmodel.AuthState @Composable fun LoginScreen( viewModel: AuthViewModel, modifier: Modifier = Modifier, ) { val state by viewModel.state.collectAsState() val snackbarHostState = remember { SnackbarHostState() } LaunchedEffect(state) { if (state is AuthState.Unauthenticated) { snackbarHostState.showSnackbar("Erreur de connexion") } } Scaffold( snackbarHost = { SnackbarHost(snackbarHostState) }, modifier = modifier, ) { padding -> Column( modifier = Modifier.fillMaxSize().padding(padding), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { Text( text = "MyIce", style = MaterialTheme.typography.headlineLarge, fontWeight = FontWeight.Bold, ) Text( text = "Games", style = MaterialTheme.typography.titleMedium, ) Spacer(modifier = Modifier.height(32.dp)) if (state is AuthState.Loading) { CircularProgressIndicator() } else { Button( onClick = { viewModel.login() }, ) { Text("Se connecter avec Infomaniak") } } } } } ``` - [ ] **Step 2: Write `ScheduleScreen`** Create `composeApp/src/commonMain/kotlin/ch/parano/myice/ui/screens/ScheduleScreen.kt`: ```kotlin package ch.parano.myice.ui.screens 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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Logout import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue 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.foundation.layout.Spacer import androidx.compose.foundation.layout.height import ch.parano.myice.filter.TypeFilter import ch.parano.myice.ui.components.EventCard import ch.parano.myice.ui.components.FilterDropdown import ch.parano.myice.ui.components.LoadingIndicator import ch.parano.myice.viewmodel.AuthViewModel import ch.parano.myice.viewmodel.ScheduleViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable fun ScheduleScreen( scheduleViewModel: ScheduleViewModel, authViewModel: AuthViewModel, onEventClick: (gameId: String, account: String, eventTitle: String) -> Unit, modifier: Modifier = Modifier, ) { val scheduleState by scheduleViewModel.state.collectAsState() val authState by authViewModel.state.collectAsState() Scaffold( topBar = { TopAppBar( title = { Text("MyIce") }, actions = { val email = (authState as? ch.parano.myice.viewmodel.AuthState.Authenticated)?.email if (email != null) { Text( text = email, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(horizontal = 8.dp), ) } IconButton(onClick = { scheduleViewModel.refreshSchedule() }) { Icon(Icons.Default.Refresh, contentDescription = "Refresh") } IconButton(onClick = { scheduleViewModel.clearCache() authViewModel.logout() }) { Icon(Icons.Default.Logout, contentDescription = "Logout") } }, ) }, modifier = modifier, ) { padding -> Column( modifier = Modifier.fillMaxSize().padding(padding), ) { Card( modifier = Modifier.fillMaxWidth().padding(16.dp), ) { Column( modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { FilterDropdown( label = "Compte", selectedValue = scheduleState.selectedAccount, options = scheduleState.accounts.map { it.name }, onSelect = { scheduleViewModel.setAccount(it) }, ) FilterDropdown( label = "Age", selectedValue = scheduleState.selectedAgegroup, options = scheduleState.agegroups, onSelect = { scheduleViewModel.setAgegroup(it) }, ) FilterDropdown( label = "Sous-groupe", selectedValue = scheduleState.selectedSubgroup, options = scheduleState.subgroups, onSelect = { scheduleViewModel.setSubgroup(it) }, ) FilterDropdown( label = "Type", selectedValue = typeFilterLabel(scheduleState.typeFilter), options = listOf("Tous", "Matchs", "Entrainements"), onSelect = { label -> val filter = when (label) { "Tous" -> TypeFilter.ALL "Matchs" -> TypeFilter.GAMES "Entrainements" -> TypeFilter.PRACTICES else -> TypeFilter.DEFAULT } scheduleViewModel.setTypeFilter(filter) }, ) } } Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { when { scheduleState.isLoading && scheduleState.events.isEmpty() -> { LoadingIndicator() } scheduleState.error != null && scheduleState.events.isEmpty() -> { Column( horizontalAlignment = Alignment.CenterHorizontally, ) { Text( text = scheduleState.error!!, color = MaterialTheme.colorScheme.error, ) Spacer(modifier = Modifier.height(8.dp)) TextButton(onClick = { scheduleViewModel.refreshSchedule() }) { Text("Reessayer") } } } scheduleState.filteredEvents.isEmpty() -> { Column( horizontalAlignment = Alignment.CenterHorizontally, ) { Text("Aucun evenement disponible") if (scheduleState.events.isEmpty()) { Spacer(modifier = Modifier.height(8.dp)) Text( text = "Appuyez sur le bouton de rafraichissement pour charger les evenements", style = MaterialTheme.typography.bodySmall, color = Color.Gray, ) } } } else -> { LazyColumn( modifier = Modifier.fillMaxSize(), ) { items(scheduleState.filteredEvents) { event -> EventCard( event = event, onClick = { onEventClick( event.idEvent, scheduleState.selectedAccount!!, event.title, ) }, ) } } } } if (scheduleState.isRefreshing) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { Card { Column( modifier = Modifier.padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { CircularProgressIndicator() Spacer(modifier = Modifier.height(16.dp)) Text("Chargement...", fontWeight = FontWeight.Medium) } } } } } } } } private fun typeFilterLabel(filter: TypeFilter): String = when (filter) { TypeFilter.ALL -> "Tous" TypeFilter.GAMES -> "Matchs" TypeFilter.PRACTICES -> "Entrainements" } ``` - [ ] **Step 3: Write `EventDetailScreen`** Create `composeApp/src/commonMain/kotlin/ch/parano/myice/ui/screens/EventDetailScreen.kt`: ```kotlin package ch.parano.myice.ui.screens import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue 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 ch.parano.myice.ui.components.LoadingIndicator import ch.parano.myice.viewmodel.EventDetailViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable fun EventDetailScreen( gameId: String, account: String, eventTitle: String, viewModel: EventDetailViewModel, modifier: Modifier = Modifier, ) { LaunchedEffect(gameId, account) { viewModel.loadEventDetail(gameId, account) } val state by viewModel.state.collectAsState() Scaffold( topBar = { TopAppBar(title = { Text(eventTitle) }) }, modifier = modifier, ) { padding -> when { state.isLoading -> { LoadingIndicator(modifier = Modifier.padding(padding)) } state.error != null -> { Column( modifier = Modifier.fillMaxSize().padding(padding), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { Text( text = state.error!!, color = MaterialTheme.colorScheme.error, ) Spacer(modifier = Modifier.height(8.dp)) TextButton(onClick = { viewModel.loadEventDetail(gameId, account) }) { Text("Reessayer") } } } state.eventDetail != null -> { val detail = state.eventDetail!! val hasPlayers = detail.convocation.available.isNotEmpty() val hasStaff = detail.convocation.staff.isNotEmpty() LazyColumn( modifier = Modifier.fillMaxSize().padding(padding), contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { item { Card(modifier = Modifier.fillMaxWidth()) { Column( modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp), ) { Text("Type: ${detail.type}") Text("Lieu: ${detail.place}") Text("Heure: ${detail.timeStart} - ${detail.timeEnd}") } } } if (!hasPlayers && !hasStaff) { item { Card( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors( containerColor = Color(0xFFFFC107), ), ) { Text( text = "Aucun joueur ni personnel convoque", fontWeight = FontWeight.Bold, modifier = Modifier.padding(16.dp), ) } } } if (hasPlayers) { item { Text( text = "Joueurs (${detail.convocation.available.size})", style = MaterialTheme.typography.titleLarge, ) } items(detail.convocation.available) { player -> val position = player.position ?: "N/A" val number = player.number ?: "N/A" Card(modifier = Modifier.fillMaxWidth()) { Column( modifier = Modifier.padding(16.dp), ) { Text("[$position] #$number - ${player.fname} ${player.lname}") Text( text = "DOB: ${player.dob}", style = MaterialTheme.typography.bodySmall, ) } } } } if (hasStaff) { item { Text( text = "Personnel", style = MaterialTheme.typography.titleLarge, ) } items(detail.convocation.staff) { staff -> Card(modifier = Modifier.fillMaxWidth()) { Text( text = "${staff.role}: ${staff.fname} ${staff.lname}", modifier = Modifier.padding(16.dp), ) } } } } } else -> { Column( modifier = Modifier.fillMaxSize().padding(padding), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { Text("Aucune donnee disponible") } } } } } ``` - [ ] **Step 4: Verify build compiles** Run: ```bash ./gradlew composeApp:compileKotlinDesktop ``` Expected: BUILD SUCCESSFUL - [ ] **Step 5: Commit** ```bash git add -A git commit -m "feat: add LoginScreen, ScheduleScreen, and EventDetailScreen" ``` --- ## Task 10: App Root + Entry Points **Files:** - Create: `composeApp/src/commonMain/kotlin/ch/parano/myice/App.kt` - Modify: `composeApp/src/androidMain/kotlin/ch/parano/myice/MainActivity.kt` - Create: `composeApp/src/iosMain/kotlin/ch/parano/myice/MainViewController.kt` (modify existing) - Modify: `composeApp/src/desktopMain/kotlin/ch/parano/myice/Main.kt` - Create: `iosApp/iosApp/MyIceApp.swift` - Create: `iosApp/iosApp/ContentView.swift` - Create: `iosApp/iosApp/Info.plist` **Interfaces:** - Consumes: all previous tasks - Produces: a runnable app on all three platforms - [ ] **Step 1: Write `App.kt` (root composable + routing)** Create `composeApp/src/commonMain/kotlin/ch/parano/myice/App.kt`: ```kotlin package ch.parano.myice import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.lifecycle.viewmodel.compose.viewModel import ch.parano.myice.auth.AuthSettings import ch.parano.myice.auth.OAuthClient import ch.parano.myice.cache.ScheduleCache import ch.parano.myice.network.ApiService import ch.parano.myice.network.createHttpClient import ch.parano.myice.ui.screens.EventDetailScreen import ch.parano.myice.ui.screens.LoginScreen import ch.parano.myice.ui.screens.ScheduleScreen import ch.parano.myice.ui.theme.MyIceTheme import ch.parano.myice.viewmodel.AuthState import ch.parano.myice.viewmodel.AuthViewModel import ch.parano.myice.viewmodel.EventDetailViewModel import ch.parano.myice.viewmodel.ScheduleViewModel import com.russhwolf.settings.Settings sealed class Screen { data object Login : Screen() data object Schedule : Screen() data class EventDetail(val gameId: String, val account: String, val eventTitle: String) : Screen() } @Composable fun App( oauthClient: OAuthClient, ) { val apiService = remember { ApiService(createHttpClient()) } val authSettings = remember { AuthSettings(Settings()) } val cache = remember { ScheduleCache(Settings()) } val authViewModel = remember { AuthViewModel(apiService, authSettings, oauthClient) } val scheduleViewModel = remember { ScheduleViewModel(apiService, cache, authSettings) } val eventDetailViewModel = remember { EventDetailViewModel(apiService) } var currentScreen by remember { mutableStateOf(Screen.Login) } LaunchedEffect(Unit) { authViewModel.init() } val authState by authViewModel.state.collectAsState() LaunchedEffect(authState) { currentScreen = when (authState) { is AuthState.Authenticated -> Screen.Schedule is AuthState.Unauthenticated -> Screen.Login AuthState.Loading -> currentScreen } } MyIceTheme { when (val screen = currentScreen) { Screen.Login -> LoginScreen(viewModel = authViewModel) Screen.Schedule -> ScheduleScreen( scheduleViewModel = scheduleViewModel, authViewModel = authViewModel, onEventClick = { gameId, account, eventTitle -> currentScreen = Screen.EventDetail(gameId, account, eventTitle) }, ) is Screen.EventDetail -> EventDetailScreen( gameId = screen.gameId, account = screen.account, eventTitle = screen.eventTitle, viewModel = eventDetailViewModel, ) } } } ``` - [ ] **Step 2: Update `MainActivity.kt` (Android entry point)** Replace `composeApp/src/androidMain/kotlin/ch/parano/myice/MainActivity.kt`: ```kotlin package ch.parano.myice import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import ch.parano.myice.auth.AndroidOAuthClient class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) setContent { App(oauthClient = AndroidOAuthClient(applicationContext)) } } } ``` - [ ] **Step 3: Update `MainViewController.kt` (iOS entry point)** Replace `composeApp/src/iosMain/kotlin/ch/parano/myice/MainViewController.kt`: ```kotlin package ch.parano.myice import androidx.compose.ui.window.ComposeUIViewController import ch.parano.myice.auth.IosOAuthClient fun MainViewController() = ComposeUIViewController { App(oauthClient = IosOAuthClient()) } ``` - [ ] **Step 4: Update `Main.kt` (Desktop entry point)** Replace `composeApp/src/desktopMain/kotlin/ch/parano/myice/Main.kt`: ```kotlin package ch.parano.myice import androidx.compose.ui.window.Window import androidx.compose.ui.window.application import ch.parano.myice.auth.DesktopOAuthClient fun main() = application { Window(onCloseRequest = ::exitApplication, title = "MyIce") { App(oauthClient = DesktopOAuthClient()) } } ``` - [ ] **Step 5: Delete the placeholder file** Delete `composeApp/src/commonMain/kotlin/ch/parano/myice/Placeholder.kt` (if it still exists). - [ ] **Step 6: Create iOS Xcode project files** Create `iosApp/iosApp/MyIceApp.swift`: ```swift import SwiftUI import ComposeApp @main struct MyIceApp: App { var body: some Scene { WindowGroup { ContentView() } } } ``` Create `iosApp/iosApp/ContentView.swift`: ```swift import SwiftUI import ComposeApp struct ContentView: UIViewRepresentable { func makeUIView(context: Context) -> some UIView { MainViewControllerKt.MainViewController() } func updateUIView(_ uiView: UIViewType, context: Context) {} } ``` Create `iosApp/iosApp/Info.plist`: ```xml CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleInfoDictionaryVersion 6.0 CFBundleName $(PRODUCT_NAME) CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString 1.0 CFBundleVersion 1 LSRequiresIPhoneOS UIApplicationSceneManifest UIApplicationSupportsMultipleScenes UILaunchScreen UISupportedInterfaceOrientations UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UISupportedInterfaceOrientations~ipad UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight CFBundleURLTypes CFBundleURLName ch.parano.myice CFBundleURLSchemes myice ``` - [ ] **Step 7: Create iOS Xcode project** The iOS Xcode project (`iosApp/iosApp.xcodeproj`) needs to be created. The standard approach: 1. Open Xcode 2. File → New → Project 3. Choose iOS → App 4. Product Name: `iosApp` 5. Organization Identifier: `ch.parano.myice` 6. Interface: SwiftUI 7. Language: Swift 8. Save into `iosApp/` directory Then configure the project: 1. In the project settings, under "General" → "Frameworks, Libraries, and Embedded Content", add the `ComposeApp` framework (it will be available after building the Kotlin project) 2. Under "Build Phases", add a "Run Script" phase with: ``` "$SRCROOT/../gradlew" -p "$SRCROOT/.." :composeApp:linkDebugFrameworkIosArm64 ``` 3. Replace the generated `ContentView.swift` and app entry file with the files from Step 6 4. Set the `Info.plist` to the one from Step 6 Alternatively, if you have the KMP wizard template, you can copy the `iosApp.xcodeproj` from there and adjust the bundle identifier. - [ ] **Step 8: Verify Android build** Run: ```bash ./gradlew composeApp:assembleDebug ``` Expected: BUILD SUCCESSFUL - [ ] **Step 9: Verify Desktop build** Run: ```bash ./gradlew composeApp:run ``` Expected: A window opens showing the login screen - [ ] **Step 10: Verify tests pass** Run: ```bash ./gradlew composeApp:allTests ``` Expected: All tests pass (models, networking, caching, filtering, viewmodels) - [ ] **Step 11: Commit** ```bash git add -A git commit -m "feat: wire up app root, navigation, and platform entry points" ``` --- ## Self-Review Notes After writing this plan, I verified: 1. **Spec coverage:** All spec sections are covered — models (Task 2), networking (Task 3), caching (Task 4), filtering (Task 5), auth interface (Task 5), viewmodels (Task 6), platform OAuth (Task 7), UI theme + components (Task 8), screens (Task 9), app root + entry points (Task 10). The type filter feature (practices) is in Task 5 and wired into the ScheduleScreen in Task 9. 2. **Placeholder scan:** No TBD/TODO. All code blocks contain actual implementation. The iOS Xcode project creation (Task 10, Step 7) requires manual Xcode steps because `project.pbxproj` is impractical to write by hand — this is a concrete instruction, not a placeholder. 3. **Type consistency:** `OAuthClient` interface has `redirectUri()` and `authenticate()` — both implemented consistently in all three platform actuals. `ScheduleViewModel` uses `ScheduleState` data class with computed `filteredEvents`, `agegroups`, `subgroups` — consistent with test expectations. `TypeFilter` enum values (ALL, GAMES, PRACTICES) used consistently across filter, viewmodel, and screen. `AuthTokenHolder.token` is set/cleared by `AuthSettings.setToken/clear` and read by `ApiService` via `authHeader()`. 4. **Known issue:** The `kotlinx.datetime` dependency (Task 4, Step 4) is added for `Clock.System.now().toEpochMilliseconds()`. An alternative would be to use `System.currentTimeMillis()` on JVM/Android and `kotlinx.datetime` on iOS — but `kotlinx-datetime` is the clean KMP solution and worth the small dependency.