Files
myice-kmp/docs/superpowers/plans/2026-07-08-myice-kotlin-app.md

120 KiB

MyIce Kotlin Multiplatform App Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Port the MyIce Flutter app (ice hockey schedule/convocation viewer) to Kotlin Multiplatform targeting Android, iOS, and Desktop using Compose Multiplatform.

Architecture: Single composeApp module with shared Compose UI across all three targets. Platform-specific code limited to OAuth glue (via interface + per-platform implementations). Ktor Client for networking, kotlinx.serialization for models, multiplatform-settings for caching, lifecycle-viewmodel + StateFlow for state.

Tech Stack: Kotlin 2.2.20, Compose Multiplatform 1.11.1, Ktor 3.5.1, kotlinx.serialization 1.11.0, multiplatform-settings 1.3.0, lifecycle-viewmodel-compose 2.10.0, Gradle 9.5.1, AGP 8.13.2

Global Constraints

  • Package: ch.parano.myice
  • Base URL: https://myice.parano.ch (hardcoded, no dev toggle)
  • OAuth callback scheme: myice://callback (Android/iOS), http://localhost:<port>/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

pluginManagement {
    repositories {
        google {
            mavenContent {
                releasesOnly()
            }
        }
        mavenCentral()
        gradlePluginPortal()
    }
}

dependencyResolutionManagement {
    repositories {
        google {
            mavenContent {
                releasesOnly()
            }
        }
        mavenCentral()
    }
}

rootProject.name = "myice_kotlin"
include(":composeApp")
  • Step 2: Create gradle.properties
kotlin.code.style=official
android.useAndroidX=true
android.nonTransitiveRClass=true
org.gradle.jvmargs=-Xmx4096m
  • Step 3: Create gradle/libs.versions.toml
[versions]
kotlin = "2.2.20"
compose-multiplatform = "1.11.1"
agp = "8.13.2"
ktor = "3.5.1"
kotlinx-serialization-json = "1.11.0"
kotlinx-coroutines = "1.11.0"
multiplatform-settings = "1.3.0"
lifecycle-viewmodel-compose = "2.10.0"
androidx-activity-compose = "1.13.0"

[libraries]
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" }
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
multiplatform-settings = { module = "com.russhwolf:multiplatform-settings", version.ref = "multiplatform-settings" }
multiplatform-settings-no-arg = { module = "com.russhwolf:multiplatform-settings-no-arg", version.ref = "multiplatform-settings" }
multiplatform-settings-test = { module = "com.russhwolf:multiplatform-settings-test", version.ref = "multiplatform-settings" }
lifecycle-viewmodel-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle-viewmodel-compose" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity-compose" }

[plugins]
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
android-application = { id = "com.android.application", version.ref = "agp" }
  • Step 4: Create root build.gradle.kts
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
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 version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

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

    <application
        android:label="MyIce"
        android:supportsRtl="true"
        android:theme="@android:style/Theme.Material.Light.NoActionBar">
        <activity
            android:name=".MainActivity"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

</manifest>
  • Step 7: Create placeholder source files

Create composeApp/src/commonMain/kotlin/ch/parano/myice/Placeholder.kt:

package ch.parano.myice

Create composeApp/src/androidMain/kotlin/ch/parano/myice/MainActivity.kt:

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:

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:

package ch.parano.myice

import androidx.compose.ui.window.ComposeUIViewController

fun MainViewController() = ComposeUIViewController {
    Text("MyIce")
    }
}
  • Step 8: Generate Gradle wrapper

Run:

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:

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:

./gradlew help

Expected: BUILD SUCCESSFUL

  • Step 10: Add .gitignore

Create .gitignore:

.gradle/
build/
.idea/
local.properties
*.iml
.DS_Store
*.apk
*.aab
*.xcworkspace/xcuserdata/
iosApp/iosApp.xcodeproj/xcuserdata/
  • Step 11: Commit
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:

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<Account>(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<Event>(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<Event>(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<Player>(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<Player>(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<Player>(input)
        assertNull(player.position)
        assertNull(player.number)
    }

    @Test
    fun testStaffDeserialization() {
        val input = """
            {"role":"Coach","fname":"Jean","lname":"Dupont"}
        """.trimIndent()
        val staff = json.decodeFromString<Staff>(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<Convocation>(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<Convocation>(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<EventDetail>(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<UserInfo>(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<Event>(jsonString)
        assertEquals(event, decoded)
    }
}
  • Step 2: Run test to verify it fails

Run:

./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:

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<String> {
    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:

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:

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:

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:

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:

package ch.parano.myice.models

import kotlinx.serialization.Serializable

@Serializable
data class Convocation(
    val available: List<Player> = emptyList(),
    val staff: List<Staff> = emptyList(),
)

Create composeApp/src/commonMain/kotlin/ch/parano/myice/models/EventDetail.kt:

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:

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:

./gradlew composeApp:commonTest --tests "ch.parano.myice.models.ModelSerializationTest"

Expected: PASS (all 12 tests)

  • Step 6: Commit
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:

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:

./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:

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:

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:

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<HttpClientEngineConfig>

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:

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<HttpClientEngineConfig> = CIO

Create composeApp/src/iosMain/kotlin/ch/parano/myice/network/ApiEngine.ios.kt:

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<HttpClientEngineConfig> = Darwin

Create composeApp/src/desktopMain/kotlin/ch/parano/myice/network/ApiEngine.desktop.kt:

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<HttpClientEngineConfig> = CIO
  • Step 6: Write ApiService

Create composeApp/src/commonMain/kotlin/ch/parano/myice/network/ApiService.kt:

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<Account> =
        client.get("${ApiConfig.baseUrl}/accounts") {
            authHeader()?.let { header(HttpHeaders.Authorization, it) }
        }.body()

    suspend fun getSchedule(account: String): List<Event> =
        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:

./gradlew composeApp:commonTest --tests "ch.parano.myice.network.ApiServiceTest"

Expected: PASS (all 5 tests)

  • Step 8: Commit
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:

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<ScheduleCache, Settings> {
        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:

./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:

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<Event>) {
        settings.putString(eventsKey(account), json.encodeToString(eventListSerializer, events))
        settings.putLong(timestampKey(account), kotlinx.datetime.Clock.System.now().toEpochMilliseconds())
    }

    fun loadEvents(account: String): List<Event>? {
        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<String?, String?> {
        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]:

kotlinx-datetime = "0.7.1"

Add to [libraries]:

kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" }

In composeApp/build.gradle.kts, in commonMain.dependencies, add:

implementation(libs.kotlinx.datetime)
  • Step 5: Run tests to verify they pass

Run:

./gradlew composeApp:commonTest --tests "ch.parano.myice.cache.ScheduleCacheTest"

Expected: PASS (all 9 tests)

  • Step 6: Commit
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:

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:

./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:

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:

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<Event>,
    agegroup: String?,
    subgroup: String?,
    typeFilter: TypeFilter,
): List<Event> = 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<Event>.distinctAgegroups(): List<String> =
    map { it.agegroup }.toSet().sorted()

fun List<Event>.distinctSubgroups(): List<String> =
    map { extractSubgroup(it.name) }.toSet().sorted()
  • Step 5: Write AuthSettings

Create composeApp/src/commonMain/kotlin/ch/parano/myice/auth/AuthSettings.kt:

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:

package ch.parano.myice.auth

interface OAuthClient {
    suspend fun authenticate(loginUrl: String): String?
}
  • Step 7: Run tests to verify they pass

Run:

./gradlew composeApp:commonTest --tests "ch.parano.myice.filter.EventFilterTest"

Expected: PASS (all 12 tests)

  • Step 8: Commit
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:

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:

./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:

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>(AuthState.Loading)
    val state: StateFlow<AuthState> = _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:

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<Account> = emptyList(),
    val events: List<Event> = 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<Event>
        get() = filterEvents(events, selectedAgegroup, selectedSubgroup, typeFilter)

    val agegroups: List<String>
        get() = events.distinctAgegroups()

    val subgroups: List<String>
        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<ScheduleState> = _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:

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<EventDetailState> = _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:

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:

./gradlew composeApp:commonTest --tests "ch.parano.myice.viewmodel.ScheduleViewModelTest"

Expected: PASS (all 8 tests)

  • Step 8: Commit
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:

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<Uri>? = 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:

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 version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

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

    <application
        android:label="MyIce"
        android:supportsRtl="true"
        android:theme="@android:style/Theme.Material.Light.NoActionBar">
        <activity
            android:name=".MainActivity"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
        <activity
            android:name=".OAuthCallbackActivity"
            android:exported="true"
            android:launchMode="singleTask">
            <intent-filter>
                <action android:name="android.intent.action.VIEW" />
                <category android:name="android.intent.category.DEFAULT" />
                <category android:name="android.intent.category.BROWSABLE" />
                <data android:scheme="myice" android:host="callback" />
            </intent-filter>
        </activity>
    </application>

</manifest>
  • Step 4: Write iOS OAuth implementation

Create composeApp/src/iosMain/kotlin/ch/parano/myice/auth/IosOAuthClient.kt:

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:

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<String?>()

        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 = """
                            <html><body>
                            <script>
                                var hash = window.location.hash.substring(1);
                                window.location.href = "/token?" + hash;
                            </script>
                            </body></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("<html><body><h1>Authentication complete. You can close this window.</h1></body></html>")
                        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:

./gradlew composeApp:compileKotlinDesktop composeApp:compileDebugKotlinAndroid

Expected: BUILD SUCCESSFUL

Note: iOS compilation requires Xcode. If not on macOS, verify Android + Desktop only:

./gradlew composeApp:compileKotlinDesktop composeApp:compileDebugKotlinAndroid
  • Step 7: Commit
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:

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:

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:

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<String>,
    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:

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:

./gradlew composeApp:compileKotlinDesktop

Expected: BUILD SUCCESSFUL

  • Step 6: Commit
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:

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:

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:

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:

./gradlew composeApp:compileKotlinDesktop

Expected: BUILD SUCCESSFUL

  • Step 5: Commit
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:

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>(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:

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:

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:

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:

import SwiftUI
import ComposeApp

@main
struct MyIceApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

Create iosApp/iosApp/ContentView.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 version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>CFBundleDevelopmentRegion</key>
    <string>$(DEVELOPMENT_LANGUAGE)</string>
    <key>CFBundleExecutable</key>
    <string>$(EXECUTABLE_NAME)</string>
    <key>CFBundleIdentifier</key>
    <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
    <key>CFBundleInfoDictionaryVersion</key>
    <string>6.0</string>
    <key>CFBundleName</key>
    <string>$(PRODUCT_NAME)</string>
    <key>CFBundlePackageType</key>
    <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
    <key>CFBundleShortVersionString</key>
    <string>1.0</string>
    <key>CFBundleVersion</key>
    <string>1</string>
    <key>LSRequiresIPhoneOS</key>
    <true/>
    <key>UIApplicationSceneManifest</key>
    <dict>
        <key>UIApplicationSupportsMultipleScenes</key>
        <false/>
    </dict>
    <key>UILaunchScreen</key>
    <dict/>
    <key>UISupportedInterfaceOrientations</key>
    <array>
        <string>UIInterfaceOrientationPortrait</string>
        <string>UIInterfaceOrientationLandscapeLeft</string>
        <string>UIInterfaceOrientationLandscapeRight</string>
    </array>
    <key>UISupportedInterfaceOrientations~ipad</key>
    <array>
        <string>UIInterfaceOrientationPortrait</string>
        <string>UIInterfaceOrientationPortraitUpsideDown</string>
        <string>UIInterfaceOrientationLandscapeLeft</string>
        <string>UIInterfaceOrientationLandscapeRight</string>
    </array>
    <key>CFBundleURLTypes</key>
    <array>
        <dict>
            <key>CFBundleURLName</key>
            <string>ch.parano.myice</string>
            <key>CFBundleURLSchemes</key>
            <array>
                <string>myice</string>
            </array>
        </dict>
    </array>
</dict>
</plist>
  • 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:

./gradlew composeApp:assembleDebug

Expected: BUILD SUCCESSFUL

  • Step 9: Verify Desktop build

Run:

./gradlew composeApp:run

Expected: A window opens showing the login screen

  • Step 10: Verify tests pass

Run:

./gradlew composeApp:allTests

Expected: All tests pass (models, networking, caching, filtering, viewmodels)

  • Step 11: Commit
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.