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

3651 lines
120 KiB
Markdown

# 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`**
```kotlin
pluginManagement {
repositories {
google {
mavenContent {
releasesOnly()
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositories {
google {
mavenContent {
releasesOnly()
}
}
mavenCentral()
}
}
rootProject.name = "myice_kotlin"
include(":composeApp")
```
- [ ] **Step 2: Create `gradle.properties`**
```properties
kotlin.code.style=official
android.useAndroidX=true
android.nonTransitiveRClass=true
org.gradle.jvmargs=-Xmx4096m
```
- [ ] **Step 3: Create `gradle/libs.versions.toml`**
```toml
[versions]
kotlin = "2.2.20"
compose-multiplatform = "1.11.1"
agp = "8.13.2"
ktor = "3.5.1"
kotlinx-serialization-json = "1.11.0"
kotlinx-coroutines = "1.11.0"
multiplatform-settings = "1.3.0"
lifecycle-viewmodel-compose = "2.10.0"
androidx-activity-compose = "1.13.0"
[libraries]
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" }
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
multiplatform-settings = { module = "com.russhwolf:multiplatform-settings", version.ref = "multiplatform-settings" }
multiplatform-settings-no-arg = { module = "com.russhwolf:multiplatform-settings-no-arg", version.ref = "multiplatform-settings" }
multiplatform-settings-test = { module = "com.russhwolf:multiplatform-settings-test", version.ref = "multiplatform-settings" }
lifecycle-viewmodel-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle-viewmodel-compose" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity-compose" }
[plugins]
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
android-application = { id = "com.android.application", version.ref = "agp" }
```
- [ ] **Step 4: Create root `build.gradle.kts`**
```kotlin
plugins {
alias(libs.plugins.kotlin.multiplatform) apply false
alias(libs.plugins.compose.multiplatform) apply false
alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.android.application) apply false
}
```
- [ ] **Step 5: Create `composeApp/build.gradle.kts`**
```kotlin
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.compose.multiplatform)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.android.application)
}
kotlin {
androidTarget {
@OptIn(ExperimentalKotlinGradlePluginApi::class)
instrumentedTestVariant.sourceSetTree.set(KotlinSourceSetTree.test)
compilations.all {
compileTaskProvider.configure {
compilerOptions.jvmTarget.set(JvmTarget.JVM_11)
}
}
}
listOf(iosX64(), iosArm64(), iosSimulatorArm64())
jvm("desktop")
sourceSets {
commonMain.dependencies {
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.components.uiToolingPreview)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.multiplatform.settings)
implementation(libs.multiplatform.settings.no.arg)
implementation(libs.lifecycle.viewmodel.compose)
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.ktor.client.mock)
implementation(libs.kotlinx.coroutines.test)
implementation(libs.multiplatform.settings.test)
}
androidMain.dependencies {
implementation(libs.ktor.client.cio)
implementation(libs.androidx.activity.compose)
}
iosMain.dependencies {
implementation(libs.ktor.client.darwin)
}
desktopMain.dependencies {
implementation(libs.ktor.client.cio)
implementation(compose.desktop.currentOs)
}
}
}
android {
namespace = "ch.parano.myice"
compileSdk = 35
defaultConfig {
applicationId = "ch.parano.myice"
minSdk = 24
targetSdk = 35
versionCode = 1
versionName = "1.0"
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
}
compose.desktop {
application {
mainClass = "ch.parano.myice.MainKt"
nativeDistributions {
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
packageName = "myice"
packageVersion = "1.0.0"
}
}
}
```
- [ ] **Step 6: Create `composeApp/src/androidMain/AndroidManifest.xml`**
```xml
<?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`:
```kotlin
package ch.parano.myice
```
Create `composeApp/src/androidMain/kotlin/ch/parano/myice/MainActivity.kt`:
```kotlin
package ch.parano.myice
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContent {
Text("MyIce")
}
}
}
```
Create `composeApp/src/desktopMain/kotlin/ch/parano/myice/Main.kt`:
```kotlin
package ch.parano.myice
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
fun main() = application {
Window(onCloseRequest = ::exitApplication, title = "MyIce") {
Text("MyIce")
}
}
```
Create `composeApp/src/iosMain/kotlin/ch/parano/myice/MainViewController.kt`:
```kotlin
package ch.parano.myice
import androidx.compose.ui.window.ComposeUIViewController
fun MainViewController() = ComposeUIViewController {
Text("MyIce")
}
}
```
- [ ] **Step 8: Generate Gradle wrapper**
Run:
```bash
gradle wrapper --gradle-version 9.6.1
```
If `gradle` is not installed, download it from https://gradle.org/install/ and run the wrapper command, or manually create `gradle/wrapper/gradle-wrapper.properties`:
```properties
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
```
Then download the `gradle-wrapper.jar` and `gradlew`/`gradlew.bat` scripts from a Gradle installation or another project.
- [ ] **Step 9: Verify the build**
Run:
```bash
./gradlew help
```
Expected: BUILD SUCCESSFUL
- [ ] **Step 10: Add `.gitignore`**
Create `.gitignore`:
```gitignore
.gradle/
build/
.idea/
local.properties
*.iml
.DS_Store
*.apk
*.aab
*.xcworkspace/xcuserdata/
iosApp/iosApp.xcodeproj/xcuserdata/
```
- [ ] **Step 11: Commit**
```bash
git add -A
git commit -m "feat: scaffold KMP project with Compose Multiplatform (Android, iOS, Desktop)"
```
---
## Task 2: Data Models
**Files:**
- Create: `composeApp/src/commonMain/kotlin/ch/parano/myice/models/FlexibleStringSerializer.kt`
- Create: `composeApp/src/commonMain/kotlin/ch/parano/myice/models/Account.kt`
- Create: `composeApp/src/commonMain/kotlin/ch/parano/myice/models/Event.kt`
- Create: `composeApp/src/commonMain/kotlin/ch/parano/myice/models/Convocation.kt`
- Create: `composeApp/src/commonMain/kotlin/ch/parano/myice/models/Player.kt`
- Create: `composeApp/src/commonMain/kotlin/ch/parano/myice/models/Staff.kt`
- Create: `composeApp/src/commonMain/kotlin/ch/parano/myice/models/EventDetail.kt`
- Create: `composeApp/src/commonMain/kotlin/ch/parano/myice/models/UserInfo.kt`
- Test: `composeApp/src/commonTest/kotlin/ch/parano/myice/models/ModelSerializationTest.kt`
**Interfaces:**
- Consumes: nothing
- Produces: `Account`, `Event`, `EventDetail`, `Convocation`, `Player`, `Staff`, `UserInfo` data classes; `FlexibleStringSerializer`
- [ ] **Step 1: Write the failing test**
Create `composeApp/src/commonTest/kotlin/ch/parano/myice/models/ModelSerializationTest.kt`:
```kotlin
package ch.parano.myice.models
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class ModelSerializationTest {
private val json = Json { ignoreUnknownKeys = true }
@Test
fun testAccountDeserialization() {
val input = """{"name":"default","label":"Default"}"""
val account = json.decodeFromString<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:
```bash
./gradlew composeApp:commonTest --tests "ch.parano.myice.models.ModelSerializationTest"
```
Expected: FAIL with unresolved reference errors (classes not defined)
- [ ] **Step 3: Write `FlexibleStringSerializer`**
Create `composeApp/src/commonMain/kotlin/ch/parano/myice/models/FlexibleStringSerializer.kt`:
```kotlin
package ch.parano.myice.models
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerializationException
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.jsonPrimitive
object FlexibleStringSerializer : KSerializer<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`:
```kotlin
package ch.parano.myice.models
import kotlinx.serialization.Serializable
@Serializable
data class Account(
val name: String,
val label: String,
)
```
Create `composeApp/src/commonMain/kotlin/ch/parano/myice/models/Event.kt`:
```kotlin
package ch.parano.myice.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class Event(
@SerialName("id_event") val idEvent: String = "",
val agegroup: String = "",
val name: String = "",
val title: String = "",
val opponent: String = "",
val place: String = "",
val start: String = "",
val end: String = "",
val color: String? = null,
@SerialName("event") val eventType: String = "",
)
```
Create `composeApp/src/commonMain/kotlin/ch/parano/myice/models/Staff.kt`:
```kotlin
package ch.parano.myice.models
import kotlinx.serialization.Serializable
@Serializable
data class Staff(
val role: String,
val fname: String,
val lname: String,
)
```
Create `composeApp/src/commonMain/kotlin/ch/parano/myice/models/Player.kt`:
```kotlin
package ch.parano.myice.models
import kotlinx.serialization.Serializable
@Serializable
data class Player(
val position: String? = null,
@Serializable(with = FlexibleStringSerializer::class) val number: String? = null,
val fname: String,
val lname: String,
val dob: String,
)
```
Create `composeApp/src/commonMain/kotlin/ch/parano/myice/models/Convocation.kt`:
```kotlin
package ch.parano.myice.models
import kotlinx.serialization.Serializable
@Serializable
data class Convocation(
val available: List<Player> = emptyList(),
val staff: List<Staff> = emptyList(),
)
```
Create `composeApp/src/commonMain/kotlin/ch/parano/myice/models/EventDetail.kt`:
```kotlin
package ch.parano.myice.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class EventDetail(
val title: String = "",
val type: String = "",
val place: String = "",
@SerialName("time_start") val timeStart: String = "",
@SerialName("time_end") val timeEnd: String = "",
val convocation: Convocation = Convocation(),
)
```
Create `composeApp/src/commonMain/kotlin/ch/parano/myice/models/UserInfo.kt`:
```kotlin
package ch.parano.myice.models
import kotlinx.serialization.Serializable
@Serializable
data class UserInfo(
val email: String = "",
)
```
- [ ] **Step 5: Run tests to verify they pass**
Run:
```bash
./gradlew composeApp:commonTest --tests "ch.parano.myice.models.ModelSerializationTest"
```
Expected: PASS (all 12 tests)
- [ ] **Step 6: Commit**
```bash
git add -A
git commit -m "feat: add serializable data models with FlexibleStringSerializer for number coercion"
```
---
## Task 3: Networking Layer
**Files:**
- Create: `composeApp/src/commonMain/kotlin/ch/parano/myice/network/ApiConfig.kt`
- Create: `composeApp/src/commonMain/kotlin/ch/parano/myice/network/AuthTokenHolder.kt`
- Create: `composeApp/src/commonMain/kotlin/ch/parano/myice/network/ApiEngine.kt`
- Create: `composeApp/src/commonMain/kotlin/ch/parano/myice/network/ApiService.kt`
- Create: `composeApp/src/androidMain/kotlin/ch/parano/myice/network/ApiEngine.android.kt`
- Create: `composeApp/src/iosMain/kotlin/ch/parano/myice/network/ApiEngine.ios.kt`
- Create: `composeApp/src/desktopMain/kotlin/ch/parano/myice/network/ApiEngine.desktop.kt`
- Test: `composeApp/src/commonTest/kotlin/ch/parano/myice/network/ApiServiceTest.kt`
**Interfaces:**
- Consumes: `Account`, `Event`, `EventDetail`, `UserInfo` from Task 2
- Produces: `ApiService` class with `getUserInfo()`, `getAccounts()`, `getSchedule(account)`, `getGameDetail(gameId, account)`; `AuthTokenHolder` singleton; `createHttpClient()` factory
- [ ] **Step 1: Write the failing test**
Create `composeApp/src/commonTest/kotlin/ch/parano/myice/network/ApiServiceTest.kt`:
```kotlin
package ch.parano.myice.network
import ch.parano.myice.models.EventDetail
import io.ktor.client.HttpClient
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.respond
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.headersOf
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class ApiServiceTest {
private val json = Json { ignoreUnknownKeys = true }
private fun mockClient(respondJson: String, requestUrl: String, expectedAuth: String? = "Bearer test-token"): HttpClient {
val engine = MockEngine { request ->
assertEquals(requestUrl, request.url.encodedPath + "?" + request.url.encodedQuery)
if (expectedAuth != null) {
assertEquals(expectedAuth, request.headers["Authorization"])
}
respond(
content = respondJson,
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, "application/json"),
)
}
return HttpClient(engine) {
install(ContentNegotiation) { json(json) }
}
}
@Test
fun testGetUserInfo() {
AuthTokenHolder.token = "test-token"
val client = mockClient(
"""{"email":"rene@luria.ch","sub":"abc"}""",
"/userinfo",
)
val api = ApiService(client)
val result = api.getUserInfo()
assertEquals("rene@luria.ch", result.email)
}
@Test
fun testGetAccounts() {
AuthTokenHolder.token = "test-token"
val client = mockClient(
"""[{"name":"default","label":"Default"},{"name":"leonard","label":"Leonard"}]""",
"/accounts?",
)
val api = ApiService(client)
val result = api.getAccounts()
assertEquals(2, result.size)
assertEquals("default", result[0].name)
assertEquals("Leonard", result[1].label)
}
@Test
fun testGetSchedule() {
AuthTokenHolder.token = "test-token"
val responseJson = """
[{"id_event":"761040","agegroup":"U13 (Elite)","name":"U13 Elite - HC Ajoie","title":"U13 (Elite)\nGame\nArena","opponent":"HC Ajoie","place":"Arena","start":"2024-11-10T14:00:00","end":"2024-11-10T16:15:00","color":"#e4222e","event":"Jeu"}]
""".trimIndent()
val client = mockClient(responseJson, "/schedule?account=default")
val api = ApiService(client)
val result = api.getSchedule("default")
assertEquals(1, result.size)
assertEquals("761040", result[0].idEvent)
assertEquals("Jeu", result[0].eventType)
}
@Test
fun testGetGameDetail() {
AuthTokenHolder.token = "test-token"
val responseJson = """
{"title":"U13 - Game","type":"games","place":"Arena","time_start":"2024-11-10T14:00:00","time_end":"2024-11-10T16:15:00","convocation":{"available":[{"position":"Gardien","number":1,"fname":"Jean","lname":"Dupont","dob":"2012-03-15"}],"staff":[{"role":"Coach","fname":"Marc","lname":"Blanc"}]}}
""".trimIndent()
val client = mockClient(responseJson, "/game/761040?account=default")
val api = ApiService(client)
val result: EventDetail = api.getGameDetail("761040", "default")
assertEquals("U13 - Game", result.title)
assertEquals("games", result.type)
assertEquals(1, result.convocation.available.size)
assertEquals("1", result.convocation.available[0].number)
assertEquals(1, result.convocation.staff.size)
}
@Test
fun testNoAuthTokenSendsNoAuthHeader() {
AuthTokenHolder.token = null
val engine = MockEngine { request ->
assertNull(request.headers["Authorization"])
respond(
content = """{"email":""}""",
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, "application/json"),
)
}
val client = HttpClient(engine) {
install(ContentNegotiation) { json(json) }
}
val api = ApiService(client)
val result = api.getUserInfo()
assertEquals("", result.email)
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
./gradlew composeApp:commonTest --tests "ch.parano.myice.network.ApiServiceTest"
```
Expected: FAIL with unresolved reference errors
- [ ] **Step 3: Write `ApiConfig`**
Create `composeApp/src/commonMain/kotlin/ch/parano/myice/network/ApiConfig.kt`:
```kotlin
package ch.parano.myice.network
object ApiConfig {
const val baseUrl = "https://myice.parano.ch"
}
```
- [ ] **Step 4: Write `AuthTokenHolder`**
Create `composeApp/src/commonMain/kotlin/ch/parano/myice/network/AuthTokenHolder.kt`:
```kotlin
package ch.parano.myice.network
object AuthTokenHolder {
var token: String? = null
}
```
- [ ] **Step 5: Write `ApiEngine` (expect/actual)**
Create `composeApp/src/commonMain/kotlin/ch/parano/myice/network/ApiEngine.kt`:
```kotlin
package ch.parano.myice.network
import io.ktor.client.HttpClient
import io.ktor.client.HttpClientEngineFactory
import io.ktor.client.engine.HttpClientEngineConfig
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.httptimeout.HttpTimeoutPlugin
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
expect fun createEngine(): HttpClientEngineFactory<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`:
```kotlin
package ch.parano.myice.network
import io.ktor.client.engine.HttpClientEngineConfig
import io.ktor.client.engine.HttpClientEngineFactory
import io.ktor.client.engine.cio.CIO
actual fun createEngine(): HttpClientEngineFactory<HttpClientEngineConfig> = CIO
```
Create `composeApp/src/iosMain/kotlin/ch/parano/myice/network/ApiEngine.ios.kt`:
```kotlin
package ch.parano.myice.network
import io.ktor.client.engine.HttpClientEngineConfig
import io.ktor.client.engine.HttpClientEngineFactory
import io.ktor.client.engine.darwin.Darwin
actual fun createEngine(): HttpClientEngineFactory<HttpClientEngineConfig> = Darwin
```
Create `composeApp/src/desktopMain/kotlin/ch/parano/myice/network/ApiEngine.desktop.kt`:
```kotlin
package ch.parano.myice.network
import io.ktor.client.engine.HttpClientEngineConfig
import io.ktor.client.engine.HttpClientEngineFactory
import io.ktor.client.engine.cio.CIO
actual fun createEngine(): HttpClientEngineFactory<HttpClientEngineConfig> = CIO
```
- [ ] **Step 6: Write `ApiService`**
Create `composeApp/src/commonMain/kotlin/ch/parano/myice/network/ApiService.kt`:
```kotlin
package ch.parano.myice.network
import ch.parano.myice.models.Account
import ch.parano.myice.models.Event
import ch.parano.myice.models.EventDetail
import ch.parano.myice.models.UserInfo
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.http.HttpHeaders
class ApiService(private val client: HttpClient) {
private fun authHeader(): String? = AuthTokenHolder.token?.let { "Bearer $it" }
suspend fun getUserInfo(): UserInfo =
client.get("${ApiConfig.baseUrl}/userinfo") {
authHeader()?.let { header(HttpHeaders.Authorization, it) }
}.body()
suspend fun getAccounts(): List<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:
```bash
./gradlew composeApp:commonTest --tests "ch.parano.myice.network.ApiServiceTest"
```
Expected: PASS (all 5 tests)
- [ ] **Step 8: Commit**
```bash
git add -A
git commit -m "feat: add Ktor networking layer with ApiService and auth token holder"
```
---
## Task 4: Caching
**Files:**
- Create: `composeApp/src/commonMain/kotlin/ch/parano/myice/cache/ScheduleCache.kt`
- Test: `composeApp/src/commonTest/kotlin/ch/parano/myice/cache/ScheduleCacheTest.kt`
**Interfaces:**
- Consumes: `Event` from Task 2, `multiplatform-settings`
- Produces: `ScheduleCache` class with `saveEvents`, `loadEvents`, `getLastUpdated`, `saveFilters`, `loadFilters`, `clearCache`, `clearAllCache`
- [ ] **Step 1: Write the failing test**
Create `composeApp/src/commonTest/kotlin/ch/parano/myice/cache/ScheduleCacheTest.kt`:
```kotlin
package ch.parano.myice.cache
import ch.parano.myice.models.Event
import com.russhwolf.settings.MapSettings
import com.russhwolf.settings.Settings
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class ScheduleCacheTest {
private fun createCache(): Pair<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:
```bash
./gradlew composeApp:commonTest --tests "ch.parano.myice.cache.ScheduleCacheTest"
```
Expected: FAIL with unresolved reference
- [ ] **Step 3: Write `ScheduleCache`**
Create `composeApp/src/commonMain/kotlin/ch/parano/myice/cache/ScheduleCache.kt`:
```kotlin
package ch.parano.myice.cache
import ch.parano.myice.models.Event
import com.russhwolf.settings.Settings
import kotlinx.serialization.json.Json
import kotlinx.serialization.builtins.ListSerializer
class ScheduleCache(private val settings: Settings) {
private val json = Json { ignoreUnknownKeys = true }
private val eventListSerializer = ListSerializer(Event.serializer())
private fun eventsKey(account: String) = "cached_events_$account"
private fun agegroupKey(account: String) = "filters_agegroup_$account"
private fun subgroupKey(account: String) = "filters_subgroup_$account"
private fun typeFilterKey(account: String) = "filters_typefilter_$account"
private fun timestampKey(account: String) = "cached_events_timestamp_$account"
fun saveEvents(account: String, events: List<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]`:
```toml
kotlinx-datetime = "0.7.1"
```
Add to `[libraries]`:
```toml
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" }
```
In `composeApp/build.gradle.kts`, in `commonMain.dependencies`, add:
```kotlin
implementation(libs.kotlinx.datetime)
```
- [ ] **Step 5: Run tests to verify they pass**
Run:
```bash
./gradlew composeApp:commonTest --tests "ch.parano.myice.cache.ScheduleCacheTest"
```
Expected: PASS (all 9 tests)
- [ ] **Step 6: Commit**
```bash
git add -A
git commit -m "feat: add ScheduleCache with multiplatform-settings for offline caching"
```
---
## Task 5: Filtering Logic + Auth Interface
**Files:**
- Create: `composeApp/src/commonMain/kotlin/ch/parano/myice/filter/TypeFilter.kt`
- Create: `composeApp/src/commonMain/kotlin/ch/parano/myice/filter/EventFilter.kt`
- Create: `composeApp/src/commonMain/kotlin/ch/parano/myice/auth/AuthSettings.kt`
- Create: `composeApp/src/commonMain/kotlin/ch/parano/myice/auth/OAuthClient.kt`
- Test: `composeApp/src/commonTest/kotlin/ch/parano/myice/filter/EventFilterTest.kt`
**Interfaces:**
- Consumes: `Event` from Task 2
- Produces: `TypeFilter` enum, `filterEvents()` pure function, `extractSubgroup()` function, `AuthSettings` class, `OAuthClient` interface
- [ ] **Step 1: Write the failing test for filtering**
Create `composeApp/src/commonTest/kotlin/ch/parano/myice/filter/EventFilterTest.kt`:
```kotlin
package ch.parano.myice.filter
import ch.parano.myice.models.Event
import kotlin.test.Test
import kotlin.test.assertEquals
class EventFilterTest {
private fun event(
id: String = "1",
agegroup: String = "U13 (Elite)",
name: String = "U13 Elite - HC Ajoie",
eventType: String = "Jeu",
) = Event(
idEvent = id,
agegroup = agegroup,
name = name,
title = "Title",
opponent = "Opp",
place = "Place",
start = "2024-11-10T14:00:00",
end = "2024-11-10T16:15:00",
color = "#e4222e",
eventType = eventType,
)
@Test
fun testExtractSubgroupWithSeparator() {
assertEquals("U13 Elite", extractSubgroup("U13 Elite - HC Ajoie"))
}
@Test
fun testExtractSubgroupMultipleSeparators() {
assertEquals("U13 Elite - Group A", extractSubgroup("U13 Elite - Group A - HC Ajoie"))
}
@Test
fun testExtractSubgroupNoSeparator() {
assertEquals("U13 Elite", extractSubgroup("U13 Elite"))
}
@Test
fun testExtractSubgroupEmptyString() {
assertEquals("", extractSubgroup(""))
}
@Test
fun testFilterGamesOnly() {
val events = listOf(
event(id = "1", eventType = "Jeu"),
event(id = "2", eventType = ""),
event(id = "3", eventType = "Jeu"),
)
val result = filterEvents(events, agegroup = null, subgroup = null, typeFilter = TypeFilter.GAMES)
assertEquals(2, result.size)
assertEquals("1", result[0].idEvent)
assertEquals("3", result[1].idEvent)
}
@Test
fun testFilterPracticesOnly() {
val events = listOf(
event(id = "1", eventType = "Jeu"),
event(id = "2", eventType = ""),
event(id = "3", eventType = ""),
)
val result = filterEvents(events, agegroup = null, subgroup = null, typeFilter = TypeFilter.PRACTICES)
assertEquals(2, result.size)
assertEquals("2", result[0].idEvent)
assertEquals("3", result[1].idEvent)
}
@Test
fun testFilterAll() {
val events = listOf(
event(id = "1", eventType = "Jeu"),
event(id = "2", eventType = ""),
)
val result = filterEvents(events, agegroup = null, subgroup = null, typeFilter = TypeFilter.ALL)
assertEquals(2, result.size)
}
@Test
fun testFilterByAgegroup() {
val events = listOf(
event(id = "1", agegroup = "U13 (Elite)"),
event(id = "2", agegroup = "U15"),
event(id = "3", agegroup = "U13 (Elite)"),
)
val result = filterEvents(events, agegroup = "U13 (Elite)", subgroup = null, typeFilter = TypeFilter.ALL)
assertEquals(2, result.size)
assertEquals("1", result[0].idEvent)
}
@Test
fun testFilterBySubgroup() {
val events = listOf(
event(id = "1", name = "U13 Elite - HC Ajoie"),
event(id = "2", name = "U13 Top - HC Geneva"),
event(id = "3", name = "U13 Elite - HC Lausanne"),
)
val result = filterEvents(events, agegroup = null, subgroup = "U13 Elite", typeFilter = TypeFilter.ALL)
assertEquals(2, result.size)
assertEquals("1", result[0].idEvent)
assertEquals("3", result[1].idEvent)
}
@Test
fun testFilterCombinedAgegroupAndSubgroup() {
val events = listOf(
event(id = "1", agegroup = "U13 (Elite)", name = "U13 Elite - HC Ajoie"),
event(id = "2", agegroup = "U13 (Elite)", name = "U13 Top - HC Geneva"),
event(id = "3", agegroup = "U15", name = "U13 Elite - HC Lausanne"),
)
val result = filterEvents(events, agegroup = "U13 (Elite)", subgroup = "U13 Elite", typeFilter = TypeFilter.ALL)
assertEquals(1, result.size)
assertEquals("1", result[0].idEvent)
}
@Test
fun testFilterGamesAndAgegroup() {
val events = listOf(
event(id = "1", agegroup = "U13 (Elite)", eventType = "Jeu"),
event(id = "2", agegroup = "U13 (Elite)", eventType = ""),
event(id = "3", agegroup = "U15", eventType = "Jeu"),
)
val result = filterEvents(events, agegroup = "U13 (Elite)", subgroup = null, typeFilter = TypeFilter.GAMES)
assertEquals(1, result.size)
assertEquals("1", result[0].idEvent)
}
@Test
fun testDistinctAgegroups() {
val events = listOf(
event(agegroup = "U15"),
event(agegroup = "U13 (Elite)"),
event(agegroup = "U15"),
)
val result = events.distinctAgegroups()
assertEquals(2, result.size)
assertEquals("U13 (Elite)", result[0])
assertEquals("U15", result[1])
}
@Test
fun testDistinctSubgroups() {
val events = listOf(
event(name = "U13 Elite - HC Ajoie"),
event(name = "U13 Top - HC Geneva"),
event(name = "U13 Elite - HC Lausanne"),
)
val result = events.distinctSubgroups()
assertEquals(2, result.size)
assertEquals("U13 Elite", result[0])
assertEquals("U13 Top", result[1])
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
./gradlew composeApp:commonTest --tests "ch.parano.myice.filter.EventFilterTest"
```
Expected: FAIL with unresolved reference
- [ ] **Step 3: Write `TypeFilter`**
Create `composeApp/src/commonMain/kotlin/ch/parano/myice/filter/TypeFilter.kt`:
```kotlin
package ch.parano.myice.filter
enum class TypeFilter {
ALL, GAMES, PRACTICES;
companion object {
val DEFAULT = GAMES
}
}
```
- [ ] **Step 4: Write `EventFilter`**
Create `composeApp/src/commonMain/kotlin/ch/parano/myice/filter/EventFilter.kt`:
```kotlin
package ch.parano.myice.filter
import ch.parano.myice.models.Event
fun extractSubgroup(name: String): String {
val parts = name.split(" - ")
return if (parts.size > 1) parts.dropLast(1).joinToString(" - ") else name
}
fun filterEvents(
events: List<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`:
```kotlin
package ch.parano.myice.auth
import ch.parano.myice.network.AuthTokenHolder
import com.russhwolf.settings.Settings
class AuthSettings(private val settings: Settings) {
companion object {
private const val TOKEN_KEY = "access_token"
private const val ACCOUNT_KEY = "selected_account"
}
fun getStoredToken(): String? = settings.getStringOrNull(TOKEN_KEY)
fun setToken(token: String?) {
if (token != null) {
settings.putString(TOKEN_KEY, token)
} else {
settings.remove(TOKEN_KEY)
}
AuthTokenHolder.token = token
}
fun getStoredAccount(): String? = settings.getStringOrNull(ACCOUNT_KEY)
fun setAccount(account: String) {
settings.putString(ACCOUNT_KEY, account)
}
fun clear() {
settings.remove(TOKEN_KEY)
settings.remove(ACCOUNT_KEY)
AuthTokenHolder.token = null
}
}
```
- [ ] **Step 6: Write `OAuthClient` interface**
Create `composeApp/src/commonMain/kotlin/ch/parano/myice/auth/OAuthClient.kt`:
```kotlin
package ch.parano.myice.auth
interface OAuthClient {
suspend fun authenticate(loginUrl: String): String?
}
```
- [ ] **Step 7: Run tests to verify they pass**
Run:
```bash
./gradlew composeApp:commonTest --tests "ch.parano.myice.filter.EventFilterTest"
```
Expected: PASS (all 12 tests)
- [ ] **Step 8: Commit**
```bash
git add -A
git commit -m "feat: add event filtering logic, TypeFilter enum, AuthSettings, and OAuthClient interface"
```
---
## Task 6: ViewModels
**Files:**
- Create: `composeApp/src/commonMain/kotlin/ch/parano/myice/viewmodel/AuthViewModel.kt`
- Create: `composeApp/src/commonMain/kotlin/ch/parano/myice/viewmodel/ScheduleViewModel.kt`
- Create: `composeApp/src/commonMain/kotlin/ch/parano/myice/viewmodel/EventDetailViewModel.kt`
- Test: `composeApp/src/commonTest/kotlin/ch/parano/myice/viewmodel/ScheduleViewModelTest.kt`
**Interfaces:**
- Consumes: `ApiService` (Task 3), `ScheduleCache` (Task 4), `AuthSettings` + `OAuthClient` (Task 5), `filterEvents` (Task 5)
- Produces: `AuthViewModel`, `ScheduleViewModel`, `EventDetailViewModel` with `StateFlow` state
- [ ] **Step 1: Write the failing test**
Create `composeApp/src/commonTest/kotlin/ch/parano/myice/viewmodel/ScheduleViewModelTest.kt`:
```kotlin
package ch.parano.myice.viewmodel
import ch.parano.myice.cache.ScheduleCache
import ch.parano.myice.filter.TypeFilter
import ch.parano.myice.models.Account
import ch.parano.myice.models.Event
import ch.parano.myice.network.ApiService
import com.russhwolf.settings.MapSettings
import io.ktor.client.HttpClient
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.respond
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.headersOf
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
class ScheduleViewModelTest {
private val json = Json { ignoreUnknownKeys = true }
private fun mockApi(accountsJson: String, scheduleJson: String): ApiService {
val engine = MockEngine { request ->
val path = request.url.encodedPath
val response = when {
path == "/accounts" -> accountsJson
path == "/schedule" -> scheduleJson
else -> "[]"
}
respond(
content = response,
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, "application/json"),
)
}
val client = HttpClient(engine) {
install(ContentNegotiation) { json(json) }
}
return ApiService(client)
}
private fun sampleEvent(
id: String = "1",
agegroup: String = "U13 (Elite)",
name: String = "U13 Elite - HC Ajoie",
eventType: String = "Jeu",
) = Event(
idEvent = id, agegroup = agegroup, name = name, title = "Title",
opponent = "Opp", place = "Place", start = "2024-11-10T14:00:00",
end = "2024-11-10T16:15:00", color = "#e4222e", eventType = eventType,
)
@Test
fun testLoadAccounts() = runTest {
val api = mockApi(
"""[{"name":"default","label":"Default"}]""",
"[]",
)
val cache = ScheduleCache(MapSettings())
val vm = ScheduleViewModel(api, cache)
vm.loadAccounts()
assertEquals(1, vm.state.value.accounts.size)
assertEquals("default", vm.state.value.selectedAccount)
}
@Test
fun testRefreshScheduleStoresEvents() = runTest {
val scheduleJson = """
[{"id_event":"1","agegroup":"U13 (Elite)","name":"U13 Elite - HC Ajoie","title":"T","opponent":"O","place":"P","start":"2024-11-10T14:00:00","end":"2024-11-10T16:15:00","color":"#e4222e","event":"Jeu"}]
""".trimIndent()
val api = mockApi("""[{"name":"default","label":"Default"}]""", scheduleJson)
val cache = ScheduleCache(MapSettings())
val vm = ScheduleViewModel(api, cache)
vm.loadAccounts()
vm.refreshSchedule()
assertEquals(1, vm.state.value.events.size)
assertEquals("1", vm.state.value.events[0].idEvent)
}
@Test
fun testFilteredEventsDefaultGamesOnly() = runTest {
val scheduleJson = """
[{"id_event":"1","agegroup":"U13","name":"U13 - A","title":"T","opponent":"O","place":"P","start":"s","end":"e","event":"Jeu"},
{"id_event":"2","agegroup":"U13","name":"U13 - B","title":"T","opponent":"","place":"P","start":"s","end":"e"}]
""".trimIndent()
val api = mockApi("""[{"name":"default","label":"Default"}]""", scheduleJson)
val cache = ScheduleCache(MapSettings())
val vm = ScheduleViewModel(api, cache)
vm.loadAccounts()
vm.refreshSchedule()
assertEquals(2, vm.state.value.events.size)
assertEquals(1, vm.state.value.filteredEvents.size)
assertEquals("1", vm.state.value.filteredEvents[0].idEvent)
}
@Test
fun testSetTypeFilterAll() = runTest {
val scheduleJson = """
[{"id_event":"1","agegroup":"U13","name":"U13 - A","title":"T","opponent":"O","place":"P","start":"s","end":"e","event":"Jeu"},
{"id_event":"2","agegroup":"U13","name":"U13 - B","title":"T","opponent":"","place":"P","start":"s","end":"e"}]
""".trimIndent()
val api = mockApi("""[{"name":"default","label":"Default"}]""", scheduleJson)
val cache = ScheduleCache(MapSettings())
val vm = ScheduleViewModel(api, cache)
vm.loadAccounts()
vm.refreshSchedule()
vm.setTypeFilter(TypeFilter.ALL)
assertEquals(2, vm.state.value.filteredEvents.size)
}
@Test
fun testSetAgegroup() = runTest {
val scheduleJson = """
[{"id_event":"1","agegroup":"U13","name":"U13 - A","title":"T","opponent":"O","place":"P","start":"s","end":"e","event":"Jeu"},
{"id_event":"2","agegroup":"U15","name":"U15 - B","title":"T","opponent":"O","place":"P","start":"s","end":"e","event":"Jeu"}]
""".trimIndent()
val api = mockApi("""[{"name":"default","label":"Default"}]""", scheduleJson)
val cache = ScheduleCache(MapSettings())
val vm = ScheduleViewModel(api, cache)
vm.loadAccounts()
vm.refreshSchedule()
vm.setAgegroup("U15")
assertEquals(1, vm.state.value.filteredEvents.size)
assertEquals("2", vm.state.value.filteredEvents[0].idEvent)
}
@Test
fun testSetSubgroup() = runTest {
val scheduleJson = """
[{"id_event":"1","agegroup":"U13","name":"U13 Elite - HC Ajoie","title":"T","opponent":"O","place":"P","start":"s","end":"e","event":"Jeu"},
{"id_event":"2","agegroup":"U13","name":"U13 Top - HC Geneva","title":"T","opponent":"O","place":"P","start":"s","end":"e","event":"Jeu"}]
""".trimIndent()
val api = mockApi("""[{"name":"default","label":"Default"}]""", scheduleJson)
val cache = ScheduleCache(MapSettings())
val vm = ScheduleViewModel(api, cache)
vm.loadAccounts()
vm.refreshSchedule()
vm.setSubgroup("U13 Elite")
assertEquals(1, vm.state.value.filteredEvents.size)
assertEquals("1", vm.state.value.filteredEvents[0].idEvent)
}
@Test
fun testCacheLoadOnLoadCachedSchedule() = runTest {
val api = mockApi("""[{"name":"default","label":"Default"}]""", "[]")
val cache = ScheduleCache(MapSettings())
cache.saveEvents("default", listOf(sampleEvent("1")))
val vm = ScheduleViewModel(api, cache)
vm.loadAccounts()
vm.loadCachedSchedule()
assertEquals(1, vm.state.value.events.size)
assertFalse(vm.state.value.isLoading)
}
@Test
fun testAgegroupsComputed() = runTest {
val scheduleJson = """
[{"id_event":"1","agegroup":"U15","name":"N","title":"T","opponent":"","place":"P","start":"s","end":"e","event":"Jeu"},
{"id_event":"2","agegroup":"U13","name":"N","title":"T","opponent":"","place":"P","start":"s","end":"e","event":"Jeu"}]
""".trimIndent()
val api = mockApi("""[{"name":"default","label":"Default"}]""", scheduleJson)
val cache = ScheduleCache(MapSettings())
val vm = ScheduleViewModel(api, cache)
vm.loadAccounts()
vm.refreshSchedule()
assertEquals(listOf("U13", "U15"), vm.state.value.agegroups)
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
./gradlew composeApp:commonTest --tests "ch.parano.myice.viewmodel.ScheduleViewModelTest"
```
Expected: FAIL with unresolved reference
- [ ] **Step 3: Write `AuthViewModel`**
Create `composeApp/src/commonMain/kotlin/ch/parano/myice/viewmodel/AuthViewModel.kt`:
```kotlin
package ch.parano.myice.viewmodel
import ch.parano.myice.auth.AuthSettings
import ch.parano.myice.auth.OAuthClient
import ch.parano.myice.network.ApiConfig
import ch.parano.myice.network.ApiService
import ch.parano.myice.network.AuthTokenHolder
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
sealed class AuthState {
data object Loading : AuthState()
data class Authenticated(val email: String) : AuthState()
data object Unauthenticated : AuthState()
}
class AuthViewModel(
private val apiService: ApiService,
private val authSettings: AuthSettings,
private val oauthClient: OAuthClient,
) : ViewModel() {
private val _state = MutableStateFlow<AuthState>(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`:
```kotlin
package ch.parano.myice.viewmodel
import ch.parano.myice.auth.AuthSettings
import ch.parano.myice.cache.ScheduleCache
import ch.parano.myice.filter.TypeFilter
import ch.parano.myice.filter.distinctAgegroups
import ch.parano.myice.filter.distinctSubgroups
import ch.parano.myice.filter.filterEvents
import ch.parano.myice.models.Account
import ch.parano.myice.models.Event
import ch.parano.myice.network.ApiService
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class ScheduleState(
val accounts: List<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`:
```kotlin
package ch.parano.myice.viewmodel
import ch.parano.myice.models.EventDetail
import ch.parano.myice.network.ApiService
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
data class EventDetailState(
val eventDetail: EventDetail? = null,
val isLoading: Boolean = false,
val error: String? = null,
)
class EventDetailViewModel(
private val apiService: ApiService,
) : ViewModel() {
private val _state = MutableStateFlow(EventDetailState(isLoading = false))
val state: StateFlow<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`:
```kotlin
package ch.parano.myice.auth
interface OAuthClient {
fun redirectUri(): String
suspend fun authenticate(loginUrl: String): String?
}
```
- [ ] **Step 7: Run tests to verify they pass**
Run:
```bash
./gradlew composeApp:commonTest --tests "ch.parano.myice.viewmodel.ScheduleViewModelTest"
```
Expected: PASS (all 8 tests)
- [ ] **Step 8: Commit**
```bash
git add -A
git commit -m "feat: add AuthViewModel, ScheduleViewModel, and EventDetailViewModel with StateFlow"
```
---
## Task 7: Platform OAuth Implementations
**Files:**
- Create: `composeApp/src/androidMain/kotlin/ch/parano/myice/auth/AndroidOAuthClient.kt`
- Create: `composeApp/src/androidMain/kotlin/ch/parano/myice/OAuthCallbackActivity.kt`
- Modify: `composeApp/src/androidMain/AndroidManifest.xml`
- Create: `composeApp/src/iosMain/kotlin/ch/parano/myice/auth/IosOAuthClient.kt`
- Create: `composeApp/src/desktopMain/kotlin/ch/parano/myice/auth/DesktopOAuthClient.kt`
**Interfaces:**
- Consumes: `OAuthClient` interface from Task 5
- Produces: `AndroidOAuthClient`, `IosOAuthClient`, `DesktopOAuthClient` implementing `OAuthClient`
- [ ] **Step 1: Write Android OAuth implementation**
Create `composeApp/src/androidMain/kotlin/ch/parano/myice/auth/AndroidOAuthClient.kt`:
```kotlin
package ch.parano.myice.auth
import android.content.Context
import android.content.Intent
import android.net.Uri
import kotlinx.coroutines.CompletableDeferred
import java.net.URLEncoder
class AndroidOAuthClient(private val context: Context) : OAuthClient {
override fun redirectUri(): String = "myice://callback"
override suspend fun authenticate(loginUrl: String): String? {
OAuthCallbackHolder.deferred = CompletableDeferred()
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(loginUrl)).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
val callbackUri = OAuthCallbackHolder.deferred?.await() ?: return null
val fragment = callbackUri.fragment ?: return null
val params = fragment.split("&").associate { pair ->
val idx = pair.indexOf("=")
if (idx >= 0) pair.substring(0, idx) to pair.substring(idx + 1)
else pair to ""
}
return params["access_token"]
}
}
object OAuthCallbackHolder {
var deferred: CompletableDeferred<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`:
```kotlin
package ch.parano.myice
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import ch.parano.myice.auth.OAuthCallbackHolder
class OAuthCallbackActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val uri: Uri? = intent?.data
if (uri != null) {
OAuthCallbackHolder.handleCallback(uri)
}
finish()
}
}
```
- [ ] **Step 3: Update AndroidManifest.xml**
Modify `composeApp/src/androidMain/AndroidManifest.xml` to add the callback activity and intent filter:
```xml
<?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`:
```kotlin
package ch.parano.myice.auth
import kotlinx.cinterop.ExperimentalForeignApi
import platform.Foundation.NSURL
import platform.AuthenticationServices.ASWebAuthenticationSession
import platform.AuthenticationServices.ASWebAuthenticationPresentationContextProvider
import platform.AuthenticationServices.ASCallback
import platform.UIKit.UIApplication
import platform.UIKit.UIWindow
import platform.UIKit.UIWindowScene
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
@OptIn(ExperimentalForeignApi::class)
class IosOAuthClient : OAuthClient {
override fun redirectUri(): String = "myice://callback"
override suspend fun authenticate(loginUrl: String): String? = suspendCoroutine { continuation ->
val nsUrl = NSURL.URLWithString(loginUrl) ?: run {
continuation.resume(null)
return@suspendCoroutine
}
val session = ASWebAuthenticationSession(
URL = nsUrl,
callbackURLScheme = "myice",
completionHandler = ASCallback { callbackURL, error ->
if (error != null || callbackURL == null) {
continuation.resume(null)
} else {
val urlStr = callbackURL.absoluteString ?: ""
val fragmentIndex = urlStr.indexOf("#")
if (fragmentIndex < 0) {
continuation.resume(null)
} else {
val fragment = urlStr.substring(fragmentIndex + 1)
val params = fragment.split("&").associate { pair ->
val idx = pair.indexOf("=")
if (idx >= 0) pair.substring(0, idx) to pair.substring(idx + 1)
else pair to ""
}
continuation.resume(params["access_token"])
}
}
}
)
session.setPresentationContextProvider(object : ASWebAuthenticationPresentationContextProviderProtocol {
override fun presentationAnchorForWebAuthenticationSession(
session: ASWebAuthenticationSession
): UIWindow {
return keyWindow()
}
})
session.start()
}
private fun keyWindow(): UIWindow {
val scenes = UIApplication.sharedApplication.connectedScenes
val windowScene = scenes.firstOrNull { it is UIWindowScene } as? UIWindowScene
return windowScene?.windows?.firstOrNull { it.keyWindow } ?: UIWindow()
}
}
```
- [ ] **Step 5: Write Desktop OAuth implementation**
Create `composeApp/src/desktopMain/kotlin/ch/parano/myice/auth/DesktopOAuthClient.kt`:
```kotlin
package ch.parano.myice.auth
import kotlinx.coroutines.CompletableDeferred
import java.awt.Desktop
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.PrintWriter
import java.net.InetSocketAddress
import java.net.ServerSocket
import java.net.URI
import java.net.URLDecoder
import java.net.URLEncoder
import kotlin.concurrent.thread
class DesktopOAuthClient : OAuthClient {
private var serverPort: Int = 0
override fun redirectUri(): String = "http://localhost:$serverPort/callback"
override suspend fun authenticate(loginUrl: String): String? {
val server = ServerSocket()
server.bind(InetSocketAddress(0))
serverPort = server.localPort
val redirectUri = "http://localhost:$serverPort/callback"
val fullLoginUrl = loginUrl + "&redirect_uri=" + URLEncoder.encode(redirectUri, "UTF-8")
val deferred = CompletableDeferred<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:
```bash
./gradlew composeApp:compileKotlinDesktop composeApp:compileDebugKotlinAndroid
```
Expected: BUILD SUCCESSFUL
Note: iOS compilation requires Xcode. If not on macOS, verify Android + Desktop only:
```bash
./gradlew composeApp:compileKotlinDesktop composeApp:compileDebugKotlinAndroid
```
- [ ] **Step 7: Commit**
```bash
git add -A
git commit -m "feat: add platform OAuth implementations (Android, iOS, Desktop)"
```
---
## Task 8: UI Theme + Components
**Files:**
- Create: `composeApp/src/commonMain/kotlin/ch/parano/myice/ui/theme/Theme.kt`
- Create: `composeApp/src/commonMain/kotlin/ch/parano/myice/ui/components/EventCard.kt`
- Create: `composeApp/src/commonMain/kotlin/ch/parano/myice/ui/components/FilterDropdown.kt`
- Create: `composeApp/src/commonMain/kotlin/ch/parano/myice/ui/components/LoadingIndicator.kt`
**Interfaces:**
- Consumes: `Event` model from Task 2
- Produces: `MyIceTheme` composable, `EventCard`, `FilterDropdown`, `LoadingIndicator` composables
- [ ] **Step 1: Write theme**
Create `composeApp/src/commonMain/kotlin/ch/parano/myice/ui/theme/Theme.kt`:
```kotlin
package ch.parano.myice.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
private val Indigo = Color(0xFF3F51B5)
private val IndigoLight = Color(0xFF7986CB)
private val IndigoDark = Color(0xFF303F9F)
private val LightColors = lightColorScheme(
primary = Indigo,
onPrimary = Color.White,
primaryContainer = IndigoDark,
secondary = IndigoLight,
)
private val DarkColors = darkColorScheme(
primary = IndigoLight,
onPrimary = Color.Black,
primaryContainer = Indigo,
secondary = Indigo,
)
@Composable
fun MyIceTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
MaterialTheme(
colorScheme = if (darkTheme) DarkColors else LightColors,
content = content,
)
}
```
- [ ] **Step 2: Write `LoadingIndicator`**
Create `composeApp/src/commonMain/kotlin/ch/parano/myice/ui/components/LoadingIndicator.kt`:
```kotlin
package ch.parano.myice.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@Composable
fun LoadingIndicator(modifier: Modifier = Modifier) {
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}
```
- [ ] **Step 3: Write `FilterDropdown`**
Create `composeApp/src/commonMain/kotlin/ch/parano/myice/ui/components/FilterDropdown.kt`:
```kotlin
package ch.parano.myice.ui.components
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FilterDropdown(
label: String,
selectedValue: String?,
options: List<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`:
```kotlin
package ch.parano.myice.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import ch.parano.myice.models.Event
@Composable
fun EventCard(
event: Event,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val stripeColor = event.color?.let { parseHexColor(it) }
?: MaterialTheme.colorScheme.primary
Card(
onClick = onClick,
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Row(
modifier = Modifier.height(120.dp),
) {
Box(
modifier = Modifier
.width(4.dp)
.fillMaxHeight()
.background(stripeColor),
)
Column(
modifier = Modifier
.padding(16.dp)
.fillMaxHeight(),
verticalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(4.dp),
) {
Text(
text = "${event.agegroup} - ${event.name}",
style = MaterialTheme.typography.titleMedium,
)
Text(
text = event.title,
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = "Adversaire: ${event.opponent}",
style = MaterialTheme.typography.bodySmall,
)
Text(
text = "Lieu: ${event.place}",
style = MaterialTheme.typography.bodySmall,
)
Text(
text = "${event.start} - ${event.end}",
style = MaterialTheme.typography.bodySmall,
)
}
}
}
}
private fun parseHexColor(hex: String): Color {
val cleanHex = hex.removePrefix("#")
val value = cleanHex.toLong(16)
return Color(0xFF000000 or value)
}
```
- [ ] **Step 5: Verify build compiles**
Run:
```bash
./gradlew composeApp:compileKotlinDesktop
```
Expected: BUILD SUCCESSFUL
- [ ] **Step 6: Commit**
```bash
git add -A
git commit -m "feat: add Material 3 theme and UI components (EventCard, FilterDropdown, LoadingIndicator)"
```
---
## Task 9: Screens
**Files:**
- Create: `composeApp/src/commonMain/kotlin/ch/parano/myice/ui/screens/LoginScreen.kt`
- Create: `composeApp/src/commonMain/kotlin/ch/parano/myice/ui/screens/ScheduleScreen.kt`
- Create: `composeApp/src/commonMain/kotlin/ch/parano/myice/ui/screens/EventDetailScreen.kt`
**Interfaces:**
- Consumes: ViewModels from Task 6, components from Task 8, models from Task 2
- Produces: `LoginScreen`, `ScheduleScreen`, `EventDetailScreen` composables
- [ ] **Step 1: Write `LoginScreen`**
Create `composeApp/src/commonMain/kotlin/ch/parano/myice/ui/screens/LoginScreen.kt`:
```kotlin
package ch.parano.myice.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import ch.parano.myice.viewmodel.AuthViewModel
import ch.parano.myice.viewmodel.AuthState
@Composable
fun LoginScreen(
viewModel: AuthViewModel,
modifier: Modifier = Modifier,
) {
val state by viewModel.state.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(state) {
if (state is AuthState.Unauthenticated) {
snackbarHostState.showSnackbar("Erreur de connexion")
}
}
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
modifier = modifier,
) { padding ->
Column(
modifier = Modifier.fillMaxSize().padding(padding),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = "MyIce",
style = MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.Bold,
)
Text(
text = "Games",
style = MaterialTheme.typography.titleMedium,
)
Spacer(modifier = Modifier.height(32.dp))
if (state is AuthState.Loading) {
CircularProgressIndicator()
} else {
Button(
onClick = { viewModel.login() },
) {
Text("Se connecter avec Infomaniak")
}
}
}
}
}
```
- [ ] **Step 2: Write `ScheduleScreen`**
Create `composeApp/src/commonMain/kotlin/ch/parano/myice/ui/screens/ScheduleScreen.kt`:
```kotlin
package ch.parano.myice.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Logout
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import ch.parano.myice.filter.TypeFilter
import ch.parano.myice.ui.components.EventCard
import ch.parano.myice.ui.components.FilterDropdown
import ch.parano.myice.ui.components.LoadingIndicator
import ch.parano.myice.viewmodel.AuthViewModel
import ch.parano.myice.viewmodel.ScheduleViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ScheduleScreen(
scheduleViewModel: ScheduleViewModel,
authViewModel: AuthViewModel,
onEventClick: (gameId: String, account: String, eventTitle: String) -> Unit,
modifier: Modifier = Modifier,
) {
val scheduleState by scheduleViewModel.state.collectAsState()
val authState by authViewModel.state.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = { Text("MyIce") },
actions = {
val email = (authState as? ch.parano.myice.viewmodel.AuthState.Authenticated)?.email
if (email != null) {
Text(
text = email,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = 8.dp),
)
}
IconButton(onClick = { scheduleViewModel.refreshSchedule() }) {
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
}
IconButton(onClick = {
scheduleViewModel.clearCache()
authViewModel.logout()
}) {
Icon(Icons.Default.Logout, contentDescription = "Logout")
}
},
)
},
modifier = modifier,
) { padding ->
Column(
modifier = Modifier.fillMaxSize().padding(padding),
) {
Card(
modifier = Modifier.fillMaxWidth().padding(16.dp),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
FilterDropdown(
label = "Compte",
selectedValue = scheduleState.selectedAccount,
options = scheduleState.accounts.map { it.name },
onSelect = { scheduleViewModel.setAccount(it) },
)
FilterDropdown(
label = "Age",
selectedValue = scheduleState.selectedAgegroup,
options = scheduleState.agegroups,
onSelect = { scheduleViewModel.setAgegroup(it) },
)
FilterDropdown(
label = "Sous-groupe",
selectedValue = scheduleState.selectedSubgroup,
options = scheduleState.subgroups,
onSelect = { scheduleViewModel.setSubgroup(it) },
)
FilterDropdown(
label = "Type",
selectedValue = typeFilterLabel(scheduleState.typeFilter),
options = listOf("Tous", "Matchs", "Entrainements"),
onSelect = { label ->
val filter = when (label) {
"Tous" -> TypeFilter.ALL
"Matchs" -> TypeFilter.GAMES
"Entrainements" -> TypeFilter.PRACTICES
else -> TypeFilter.DEFAULT
}
scheduleViewModel.setTypeFilter(filter)
},
)
}
}
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
when {
scheduleState.isLoading && scheduleState.events.isEmpty() -> {
LoadingIndicator()
}
scheduleState.error != null && scheduleState.events.isEmpty() -> {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = scheduleState.error!!,
color = MaterialTheme.colorScheme.error,
)
Spacer(modifier = Modifier.height(8.dp))
TextButton(onClick = { scheduleViewModel.refreshSchedule() }) {
Text("Reessayer")
}
}
}
scheduleState.filteredEvents.isEmpty() -> {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text("Aucun evenement disponible")
if (scheduleState.events.isEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Appuyez sur le bouton de rafraichissement pour charger les evenements",
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
)
}
}
}
else -> {
LazyColumn(
modifier = Modifier.fillMaxSize(),
) {
items(scheduleState.filteredEvents) { event ->
EventCard(
event = event,
onClick = {
onEventClick(
event.idEvent,
scheduleState.selectedAccount!!,
event.title,
)
},
)
}
}
}
}
if (scheduleState.isRefreshing) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Card {
Column(
modifier = Modifier.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(16.dp))
Text("Chargement...", fontWeight = FontWeight.Medium)
}
}
}
}
}
}
}
}
private fun typeFilterLabel(filter: TypeFilter): String = when (filter) {
TypeFilter.ALL -> "Tous"
TypeFilter.GAMES -> "Matchs"
TypeFilter.PRACTICES -> "Entrainements"
}
```
- [ ] **Step 3: Write `EventDetailScreen`**
Create `composeApp/src/commonMain/kotlin/ch/parano/myice/ui/screens/EventDetailScreen.kt`:
```kotlin
package ch.parano.myice.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import ch.parano.myice.ui.components.LoadingIndicator
import ch.parano.myice.viewmodel.EventDetailViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EventDetailScreen(
gameId: String,
account: String,
eventTitle: String,
viewModel: EventDetailViewModel,
modifier: Modifier = Modifier,
) {
LaunchedEffect(gameId, account) {
viewModel.loadEventDetail(gameId, account)
}
val state by viewModel.state.collectAsState()
Scaffold(
topBar = {
TopAppBar(title = { Text(eventTitle) })
},
modifier = modifier,
) { padding ->
when {
state.isLoading -> {
LoadingIndicator(modifier = Modifier.padding(padding))
}
state.error != null -> {
Column(
modifier = Modifier.fillMaxSize().padding(padding),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = state.error!!,
color = MaterialTheme.colorScheme.error,
)
Spacer(modifier = Modifier.height(8.dp))
TextButton(onClick = { viewModel.loadEventDetail(gameId, account) }) {
Text("Reessayer")
}
}
}
state.eventDetail != null -> {
val detail = state.eventDetail!!
val hasPlayers = detail.convocation.available.isNotEmpty()
val hasStaff = detail.convocation.staff.isNotEmpty()
LazyColumn(
modifier = Modifier.fillMaxSize().padding(padding),
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
item {
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text("Type: ${detail.type}")
Text("Lieu: ${detail.place}")
Text("Heure: ${detail.timeStart} - ${detail.timeEnd}")
}
}
}
if (!hasPlayers && !hasStaff) {
item {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = Color(0xFFFFC107),
),
) {
Text(
text = "Aucun joueur ni personnel convoque",
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(16.dp),
)
}
}
}
if (hasPlayers) {
item {
Text(
text = "Joueurs (${detail.convocation.available.size})",
style = MaterialTheme.typography.titleLarge,
)
}
items(detail.convocation.available) { player ->
val position = player.position ?: "N/A"
val number = player.number ?: "N/A"
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
) {
Text("[$position] #$number - ${player.fname} ${player.lname}")
Text(
text = "DOB: ${player.dob}",
style = MaterialTheme.typography.bodySmall,
)
}
}
}
}
if (hasStaff) {
item {
Text(
text = "Personnel",
style = MaterialTheme.typography.titleLarge,
)
}
items(detail.convocation.staff) { staff ->
Card(modifier = Modifier.fillMaxWidth()) {
Text(
text = "${staff.role}: ${staff.fname} ${staff.lname}",
modifier = Modifier.padding(16.dp),
)
}
}
}
}
}
else -> {
Column(
modifier = Modifier.fillMaxSize().padding(padding),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text("Aucune donnee disponible")
}
}
}
}
}
```
- [ ] **Step 4: Verify build compiles**
Run:
```bash
./gradlew composeApp:compileKotlinDesktop
```
Expected: BUILD SUCCESSFUL
- [ ] **Step 5: Commit**
```bash
git add -A
git commit -m "feat: add LoginScreen, ScheduleScreen, and EventDetailScreen"
```
---
## Task 10: App Root + Entry Points
**Files:**
- Create: `composeApp/src/commonMain/kotlin/ch/parano/myice/App.kt`
- Modify: `composeApp/src/androidMain/kotlin/ch/parano/myice/MainActivity.kt`
- Create: `composeApp/src/iosMain/kotlin/ch/parano/myice/MainViewController.kt` (modify existing)
- Modify: `composeApp/src/desktopMain/kotlin/ch/parano/myice/Main.kt`
- Create: `iosApp/iosApp/MyIceApp.swift`
- Create: `iosApp/iosApp/ContentView.swift`
- Create: `iosApp/iosApp/Info.plist`
**Interfaces:**
- Consumes: all previous tasks
- Produces: a runnable app on all three platforms
- [ ] **Step 1: Write `App.kt` (root composable + routing)**
Create `composeApp/src/commonMain/kotlin/ch/parano/myice/App.kt`:
```kotlin
package ch.parano.myice
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.lifecycle.viewmodel.compose.viewModel
import ch.parano.myice.auth.AuthSettings
import ch.parano.myice.auth.OAuthClient
import ch.parano.myice.cache.ScheduleCache
import ch.parano.myice.network.ApiService
import ch.parano.myice.network.createHttpClient
import ch.parano.myice.ui.screens.EventDetailScreen
import ch.parano.myice.ui.screens.LoginScreen
import ch.parano.myice.ui.screens.ScheduleScreen
import ch.parano.myice.ui.theme.MyIceTheme
import ch.parano.myice.viewmodel.AuthState
import ch.parano.myice.viewmodel.AuthViewModel
import ch.parano.myice.viewmodel.EventDetailViewModel
import ch.parano.myice.viewmodel.ScheduleViewModel
import com.russhwolf.settings.Settings
sealed class Screen {
data object Login : Screen()
data object Schedule : Screen()
data class EventDetail(val gameId: String, val account: String, val eventTitle: String) : Screen()
}
@Composable
fun App(
oauthClient: OAuthClient,
) {
val apiService = remember { ApiService(createHttpClient()) }
val authSettings = remember { AuthSettings(Settings()) }
val cache = remember { ScheduleCache(Settings()) }
val authViewModel = remember { AuthViewModel(apiService, authSettings, oauthClient) }
val scheduleViewModel = remember { ScheduleViewModel(apiService, cache, authSettings) }
val eventDetailViewModel = remember { EventDetailViewModel(apiService) }
var currentScreen by remember { mutableStateOf<Screen>(Screen.Login) }
LaunchedEffect(Unit) {
authViewModel.init()
}
val authState by authViewModel.state.collectAsState()
LaunchedEffect(authState) {
currentScreen = when (authState) {
is AuthState.Authenticated -> Screen.Schedule
is AuthState.Unauthenticated -> Screen.Login
AuthState.Loading -> currentScreen
}
}
MyIceTheme {
when (val screen = currentScreen) {
Screen.Login -> LoginScreen(viewModel = authViewModel)
Screen.Schedule -> ScheduleScreen(
scheduleViewModel = scheduleViewModel,
authViewModel = authViewModel,
onEventClick = { gameId, account, eventTitle ->
currentScreen = Screen.EventDetail(gameId, account, eventTitle)
},
)
is Screen.EventDetail -> EventDetailScreen(
gameId = screen.gameId,
account = screen.account,
eventTitle = screen.eventTitle,
viewModel = eventDetailViewModel,
)
}
}
}
```
- [ ] **Step 2: Update `MainActivity.kt` (Android entry point)**
Replace `composeApp/src/androidMain/kotlin/ch/parano/myice/MainActivity.kt`:
```kotlin
package ch.parano.myice
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import ch.parano.myice.auth.AndroidOAuthClient
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContent {
App(oauthClient = AndroidOAuthClient(applicationContext))
}
}
}
```
- [ ] **Step 3: Update `MainViewController.kt` (iOS entry point)**
Replace `composeApp/src/iosMain/kotlin/ch/parano/myice/MainViewController.kt`:
```kotlin
package ch.parano.myice
import androidx.compose.ui.window.ComposeUIViewController
import ch.parano.myice.auth.IosOAuthClient
fun MainViewController() = ComposeUIViewController {
App(oauthClient = IosOAuthClient())
}
```
- [ ] **Step 4: Update `Main.kt` (Desktop entry point)**
Replace `composeApp/src/desktopMain/kotlin/ch/parano/myice/Main.kt`:
```kotlin
package ch.parano.myice
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import ch.parano.myice.auth.DesktopOAuthClient
fun main() = application {
Window(onCloseRequest = ::exitApplication, title = "MyIce") {
App(oauthClient = DesktopOAuthClient())
}
}
```
- [ ] **Step 5: Delete the placeholder file**
Delete `composeApp/src/commonMain/kotlin/ch/parano/myice/Placeholder.kt` (if it still exists).
- [ ] **Step 6: Create iOS Xcode project files**
Create `iosApp/iosApp/MyIceApp.swift`:
```swift
import SwiftUI
import ComposeApp
@main
struct MyIceApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
```
Create `iosApp/iosApp/ContentView.swift`:
```swift
import SwiftUI
import ComposeApp
struct ContentView: UIViewRepresentable {
func makeUIView(context: Context) -> some UIView {
MainViewControllerKt.MainViewController()
}
func updateUIView(_ uiView: UIViewType, context: Context) {}
}
```
Create `iosApp/iosApp/Info.plist`:
```xml
<?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:
```bash
./gradlew composeApp:assembleDebug
```
Expected: BUILD SUCCESSFUL
- [ ] **Step 9: Verify Desktop build**
Run:
```bash
./gradlew composeApp:run
```
Expected: A window opens showing the login screen
- [ ] **Step 10: Verify tests pass**
Run:
```bash
./gradlew composeApp:allTests
```
Expected: All tests pass (models, networking, caching, filtering, viewmodels)
- [ ] **Step 11: Commit**
```bash
git add -A
git commit -m "feat: wire up app root, navigation, and platform entry points"
```
---
## Self-Review Notes
After writing this plan, I verified:
1. **Spec coverage:** All spec sections are covered — models (Task 2), networking (Task 3), caching (Task 4), filtering (Task 5), auth interface (Task 5), viewmodels (Task 6), platform OAuth (Task 7), UI theme + components (Task 8), screens (Task 9), app root + entry points (Task 10). The type filter feature (practices) is in Task 5 and wired into the ScheduleScreen in Task 9.
2. **Placeholder scan:** No TBD/TODO. All code blocks contain actual implementation. The iOS Xcode project creation (Task 10, Step 7) requires manual Xcode steps because `project.pbxproj` is impractical to write by hand — this is a concrete instruction, not a placeholder.
3. **Type consistency:** `OAuthClient` interface has `redirectUri()` and `authenticate()` — both implemented consistently in all three platform actuals. `ScheduleViewModel` uses `ScheduleState` data class with computed `filteredEvents`, `agegroups`, `subgroups` — consistent with test expectations. `TypeFilter` enum values (ALL, GAMES, PRACTICES) used consistently across filter, viewmodel, and screen. `AuthTokenHolder.token` is set/cleared by `AuthSettings.setToken/clear` and read by `ApiService` via `authHeader()`.
4. **Known issue:** The `kotlinx.datetime` dependency (Task 4, Step 4) is added for `Clock.System.now().toEpochMilliseconds()`. An alternative would be to use `System.currentTimeMillis()` on JVM/Android and `kotlinx.datetime` on iOS — but `kotlinx-datetime` is the clean KMP solution and worth the small dependency.