diff --git a/docs/superpowers/plans/2026-07-08-myice-kotlin-app.md b/docs/superpowers/plans/2026-07-08-myice-kotlin-app.md new file mode 100644 index 0000000..8dbd772 --- /dev/null +++ b/docs/superpowers/plans/2026-07-08-myice-kotlin-app.md @@ -0,0 +1,3649 @@ +# 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.6.1, AGP 2.1.3 + +## 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 = "2.1.3" +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" } +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.