120 KiB
MyIce Kotlin Multiplatform App Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Port the MyIce Flutter app (ice hockey schedule/convocation viewer) to Kotlin Multiplatform targeting Android, iOS, and Desktop using Compose Multiplatform.
Architecture: Single composeApp module with shared Compose UI across all three targets. Platform-specific code limited to OAuth glue (via interface + per-platform implementations). Ktor Client for networking, kotlinx.serialization for models, multiplatform-settings for caching, lifecycle-viewmodel + StateFlow for state.
Tech Stack: Kotlin 2.2.20, Compose Multiplatform 1.11.1, Ktor 3.5.1, kotlinx.serialization 1.11.0, multiplatform-settings 1.3.0, lifecycle-viewmodel-compose 2.10.0, Gradle 9.5.1, AGP 8.13.2
Global Constraints
- Package:
ch.parano.myice - Base URL:
https://myice.parano.ch(hardcoded, no dev toggle) - OAuth callback scheme:
myice://callback(Android/iOS),http://localhost:<port>/callback(Desktop) - UI language: French (hardcoded strings, matching Flutter app)
- Min Android SDK: 24
- Android compile/target SDK: 35
- App ID:
ch.parano.myice - Only GET requests — the app is read-only
- Token: Bearer token from OAuth, stored in settings, no refresh/revocation
- Practice filter: App shows both games (
event == "Jeu") and practices (event != "Jeu") via a type filter dropdown. Default is GAMES. - Sub-group extraction: Split event
nameon" - ", drop last segment, rejoin. Ported faithfully from Flutter. - Tests: TDD for models, networking, caching, filtering logic, and viewmodels. No UI tests.
File Structure
myice_kotlin/
├── settings.gradle.kts
├── build.gradle.kts
├── gradle.properties
├── gradle/
│ ├── libs.versions.toml
│ └── wrapper/gradle-wrapper.properties
├── gradlew, gradlew.bat
├── composeApp/
│ ├── build.gradle.kts
│ └── src/
│ ├── commonMain/kotlin/ch/parano/myice/
│ │ ├── models/
│ │ │ ├── Account.kt
│ │ │ ├── Event.kt
│ │ │ ├── EventDetail.kt
│ │ │ ├── Convocation.kt
│ │ │ ├── Player.kt
│ │ │ ├── Staff.kt
│ │ │ ├── UserInfo.kt
│ │ │ └── FlexibleStringSerializer.kt
│ │ ├── network/
│ │ │ ├── ApiConfig.kt
│ │ │ ├── ApiEngine.kt # expect/actual HttpClient engine
│ │ │ ├── ApiService.kt
│ │ │ └── AuthTokenHolder.kt
│ │ ├── cache/
│ │ │ └── ScheduleCache.kt
│ │ ├── auth/
│ │ │ ├── AuthSettings.kt
│ │ │ └── OAuthClient.kt # interface
│ │ ├── filter/
│ │ │ ├── TypeFilter.kt
│ │ │ └── EventFilter.kt
│ │ ├── viewmodel/
│ │ │ ├── AuthViewModel.kt
│ │ │ ├── ScheduleViewModel.kt
│ │ │ └── EventDetailViewModel.kt
│ │ ├── ui/
│ │ │ ├── theme/Theme.kt
│ │ │ ├── components/EventCard.kt
│ │ │ ├── components/FilterDropdown.kt
│ │ │ ├── components/LoadingIndicator.kt
│ │ │ └── screens/
│ │ │ ├── LoginScreen.kt
│ │ │ ├── ScheduleScreen.kt
│ │ │ └── EventDetailScreen.kt
│ │ └── App.kt
│ ├── commonTest/kotlin/ch/parano/myice/
│ │ ├── models/ModelSerializationTest.kt
│ │ ├── network/ApiServiceTest.kt
│ │ ├── cache/ScheduleCacheTest.kt
│ │ ├── filter/EventFilterTest.kt
│ │ └── viewmodel/ScheduleViewModelTest.kt
│ ├── androidMain/kotlin/ch/parano/myice/
│ │ ├── network/ApiEngine.android.kt
│ │ ├── auth/AndroidOAuthClient.kt
│ │ ├── MainActivity.kt
│ │ └── OAuthCallbackActivity.kt
│ ├── androidMain/AndroidManifest.xml
│ ├── iosMain/kotlin/ch/parano/myice/
│ │ ├── network/ApiEngine.ios.kt
│ │ ├── auth/IosOAuthClient.kt
│ │ └── MainViewController.kt
│ ├── desktopMain/kotlin/ch/parano/myice/
│ │ ├── network/ApiEngine.desktop.kt
│ │ ├── auth/DesktopOAuthClient.kt
│ │ └── Main.kt
│ └── iosApp/ # Xcode project (created in Task 10)
└── iosApp/
├── iosApp.xcodeproj/
└── iosApp/
├── MyIceApp.swift
├── ContentView.swift
└── Info.plist
Task 1: Scaffold KMP Project
Files:
- Create:
settings.gradle.kts - Create:
build.gradle.kts - Create:
gradle.properties - Create:
gradle/libs.versions.toml - Create:
composeApp/build.gradle.kts - Create:
composeApp/src/androidMain/AndroidManifest.xml - Create:
composeApp/src/commonMain/kotlin/ch/parano/myice/Placeholder.kt - Create:
composeApp/src/androidMain/kotlin/ch/parano/myice/MainActivity.kt - Create:
composeApp/src/desktopMain/kotlin/ch/parano/myice/Main.kt - Create:
composeApp/src/iosMain/kotlin/ch/parano/myice/MainViewController.kt
Interfaces:
-
Consumes: nothing (first task)
-
Produces: a buildable KMP project with Compose Multiplatform targeting Android, iOS, Desktop
-
Step 1: Create
settings.gradle.kts
pluginManagement {
repositories {
google {
mavenContent {
releasesOnly()
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositories {
google {
mavenContent {
releasesOnly()
}
}
mavenCentral()
}
}
rootProject.name = "myice_kotlin"
include(":composeApp")
- Step 2: Create
gradle.properties
kotlin.code.style=official
android.useAndroidX=true
android.nonTransitiveRClass=true
org.gradle.jvmargs=-Xmx4096m
- Step 3: Create
gradle/libs.versions.toml
[versions]
kotlin = "2.2.20"
compose-multiplatform = "1.11.1"
agp = "8.13.2"
ktor = "3.5.1"
kotlinx-serialization-json = "1.11.0"
kotlinx-coroutines = "1.11.0"
multiplatform-settings = "1.3.0"
lifecycle-viewmodel-compose = "2.10.0"
androidx-activity-compose = "1.13.0"
[libraries]
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" }
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
multiplatform-settings = { module = "com.russhwolf:multiplatform-settings", version.ref = "multiplatform-settings" }
multiplatform-settings-no-arg = { module = "com.russhwolf:multiplatform-settings-no-arg", version.ref = "multiplatform-settings" }
multiplatform-settings-test = { module = "com.russhwolf:multiplatform-settings-test", version.ref = "multiplatform-settings" }
lifecycle-viewmodel-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle-viewmodel-compose" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity-compose" }
[plugins]
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
android-application = { id = "com.android.application", version.ref = "agp" }
- Step 4: Create root
build.gradle.kts
plugins {
alias(libs.plugins.kotlin.multiplatform) apply false
alias(libs.plugins.compose.multiplatform) apply false
alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.android.application) apply false
}
- Step 5: Create
composeApp/build.gradle.kts
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.compose.multiplatform)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.android.application)
}
kotlin {
androidTarget {
@OptIn(ExperimentalKotlinGradlePluginApi::class)
instrumentedTestVariant.sourceSetTree.set(KotlinSourceSetTree.test)
compilations.all {
compileTaskProvider.configure {
compilerOptions.jvmTarget.set(JvmTarget.JVM_11)
}
}
}
listOf(iosX64(), iosArm64(), iosSimulatorArm64())
jvm("desktop")
sourceSets {
commonMain.dependencies {
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.components.uiToolingPreview)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.multiplatform.settings)
implementation(libs.multiplatform.settings.no.arg)
implementation(libs.lifecycle.viewmodel.compose)
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.ktor.client.mock)
implementation(libs.kotlinx.coroutines.test)
implementation(libs.multiplatform.settings.test)
}
androidMain.dependencies {
implementation(libs.ktor.client.cio)
implementation(libs.androidx.activity.compose)
}
iosMain.dependencies {
implementation(libs.ktor.client.darwin)
}
desktopMain.dependencies {
implementation(libs.ktor.client.cio)
implementation(compose.desktop.currentOs)
}
}
}
android {
namespace = "ch.parano.myice"
compileSdk = 35
defaultConfig {
applicationId = "ch.parano.myice"
minSdk = 24
targetSdk = 35
versionCode = 1
versionName = "1.0"
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
}
compose.desktop {
application {
mainClass = "ch.parano.myice.MainKt"
nativeDistributions {
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
packageName = "myice"
packageVersion = "1.0.0"
}
}
}
- Step 6: Create
composeApp/src/androidMain/AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:label="MyIce"
android:supportsRtl="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
- Step 7: Create placeholder source files
Create composeApp/src/commonMain/kotlin/ch/parano/myice/Placeholder.kt:
package ch.parano.myice
Create composeApp/src/androidMain/kotlin/ch/parano/myice/MainActivity.kt:
package ch.parano.myice
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContent {
Text("MyIce")
}
}
}
Create composeApp/src/desktopMain/kotlin/ch/parano/myice/Main.kt:
package ch.parano.myice
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
fun main() = application {
Window(onCloseRequest = ::exitApplication, title = "MyIce") {
Text("MyIce")
}
}
Create composeApp/src/iosMain/kotlin/ch/parano/myice/MainViewController.kt:
package ch.parano.myice
import androidx.compose.ui.window.ComposeUIViewController
fun MainViewController() = ComposeUIViewController {
Text("MyIce")
}
}
- Step 8: Generate Gradle wrapper
Run:
gradle wrapper --gradle-version 9.6.1
If gradle is not installed, download it from https://gradle.org/install/ and run the wrapper command, or manually create gradle/wrapper/gradle-wrapper.properties:
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Then download the gradle-wrapper.jar and gradlew/gradlew.bat scripts from a Gradle installation or another project.
- Step 9: Verify the build
Run:
./gradlew help
Expected: BUILD SUCCESSFUL
- Step 10: Add
.gitignore
Create .gitignore:
.gradle/
build/
.idea/
local.properties
*.iml
.DS_Store
*.apk
*.aab
*.xcworkspace/xcuserdata/
iosApp/iosApp.xcodeproj/xcuserdata/
- Step 11: Commit
git add -A
git commit -m "feat: scaffold KMP project with Compose Multiplatform (Android, iOS, Desktop)"
Task 2: Data Models
Files:
- Create:
composeApp/src/commonMain/kotlin/ch/parano/myice/models/FlexibleStringSerializer.kt - Create:
composeApp/src/commonMain/kotlin/ch/parano/myice/models/Account.kt - Create:
composeApp/src/commonMain/kotlin/ch/parano/myice/models/Event.kt - Create:
composeApp/src/commonMain/kotlin/ch/parano/myice/models/Convocation.kt - Create:
composeApp/src/commonMain/kotlin/ch/parano/myice/models/Player.kt - Create:
composeApp/src/commonMain/kotlin/ch/parano/myice/models/Staff.kt - Create:
composeApp/src/commonMain/kotlin/ch/parano/myice/models/EventDetail.kt - Create:
composeApp/src/commonMain/kotlin/ch/parano/myice/models/UserInfo.kt - Test:
composeApp/src/commonTest/kotlin/ch/parano/myice/models/ModelSerializationTest.kt
Interfaces:
-
Consumes: nothing
-
Produces:
Account,Event,EventDetail,Convocation,Player,Staff,UserInfodata classes;FlexibleStringSerializer -
Step 1: Write the failing test
Create composeApp/src/commonTest/kotlin/ch/parano/myice/models/ModelSerializationTest.kt:
package ch.parano.myice.models
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class ModelSerializationTest {
private val json = Json { ignoreUnknownKeys = true }
@Test
fun testAccountDeserialization() {
val input = """{"name":"default","label":"Default"}"""
val account = json.decodeFromString<Account>(input)
assertEquals("default", account.name)
assertEquals("Default", account.label)
}
@Test
fun testEventDeserialization() {
val input = """
{
"id_event": 761040,
"agegroup": "U13 (Elite)",
"name": "U13 Elite - HC Ajoie",
"title": "U13 (Elite)\nGame\nRaiffeisen Arena",
"opponent": "HC Ajoie",
"place": "Raiffeisen Arena, Porrentruy",
"start": "2024-11-10T14:00:00",
"end": "2024-11-10T16:15:00",
"color": "#e4222e",
"event": "Jeu"
}
""".trimIndent()
val event = json.decodeFromString<Event>(input)
assertEquals("761040", event.idEvent)
assertEquals("U13 (Elite)", event.agegroup)
assertEquals("U13 Elite - HC Ajoie", event.name)
assertEquals("HC Ajoie", event.opponent)
assertEquals("#e4222e", event.color)
assertEquals("Jeu", event.eventType)
}
@Test
fun testEventDeserializationWithNullFields() {
val input = """
{
"id_event": "12345",
"agegroup": "U15",
"name": "",
"title": "Practice",
"opponent": "",
"place": "Les Vernets",
"start": "2024-11-10T14:00:00",
"end": "2024-11-10T15:30:00"
}
""".trimIndent()
val event = json.decodeFromString<Event>(input)
assertEquals("12345", event.idEvent)
assertEquals("", event.name)
assertEquals("", event.opponent)
assertNull(event.color)
assertEquals("", event.eventType)
}
@Test
fun testPlayerWithIntNumber() {
val input = """
{"position":"Gardien","number":1,"fname":"Jean","lname":"Dupont","dob":"2012-03-15"}
""".trimIndent()
val player = json.decodeFromString<Player>(input)
assertEquals("Gardien", player.position)
assertEquals("1", player.number)
assertEquals("Jean", player.fname)
}
@Test
fun testPlayerWithStringNumber() {
val input = """
{"position":"Attaquant","number":"27","fname":"Pierre","lname":"Martin","dob":"2012-05-20"}
""".trimIndent()
val player = json.decodeFromString<Player>(input)
assertEquals("27", player.number)
}
@Test
fun testPlayerWithNullNumber() {
val input = """
{"position":null,"number":null,"fname":"Paul","lname":"Durand","dob":"2012-01-10"}
""".trimIndent()
val player = json.decodeFromString<Player>(input)
assertNull(player.position)
assertNull(player.number)
}
@Test
fun testStaffDeserialization() {
val input = """
{"role":"Coach","fname":"Jean","lname":"Dupont"}
""".trimIndent()
val staff = json.decodeFromString<Staff>(input)
assertEquals("Coach", staff.role)
assertEquals("Jean", staff.fname)
assertEquals("Dupont", staff.lname)
}
@Test
fun testConvocationDeserialization() {
val input = """
{
"available": [
{"position":"Gardien","number":1,"fname":"Jean","lname":"Dupont","dob":"2012-03-15"}
],
"staff": [
{"role":"Coach","fname":"Marc","lname":"Blanc"}
]
}
""".trimIndent()
val convocation = json.decodeFromString<Convocation>(input)
assertEquals(1, convocation.available.size)
assertEquals("1", convocation.available[0].number)
assertEquals(1, convocation.staff.size)
assertEquals("Coach", convocation.staff[0].role)
}
@Test
fun testConvocationWithEmptyArrays() {
val input = """{"available":[],"staff":[]}"""
val convocation = json.decodeFromString<Convocation>(input)
assertEquals(0, convocation.available.size)
assertEquals(0, convocation.staff.size)
}
@Test
fun testEventDetailDeserialization() {
val input = """
{
"title": "U13 (Elite) - Saison HC Ajoie",
"type": "games",
"place": "Raiffeisen Arena, Porrentruy",
"time_start": "2024-11-10T14:00:00",
"time_end": "2024-11-10T16:15:00",
"is_away": "1",
"convocation": {
"available": [],
"staff": []
}
}
""".trimIndent()
val detail = json.decodeFromString<EventDetail>(input)
assertEquals("U13 (Elite) - Saison HC Ajoie", detail.title)
assertEquals("games", detail.type)
assertEquals("2024-11-10T14:00:00", detail.timeStart)
assertEquals(0, detail.convocation.available.size)
}
@Test
fun testUserInfoDeserialization() {
val input = """{"email":"rene@luria.ch","sub":"abc123"}"""
val userInfo = json.decodeFromString<UserInfo>(input)
assertEquals("rene@luria.ch", userInfo.email)
}
@Test
fun testEventRoundTripSerialization() {
val event = Event(
idEvent = "761040",
agegroup = "U13 (Elite)",
name = "U13 Elite - HC Ajoie",
title = "U13 (Elite)\nGame\nArena",
opponent = "HC Ajoie",
place = "Arena",
start = "2024-11-10T14:00:00",
end = "2024-11-10T16:15:00",
color = "#e4222e",
eventType = "Jeu",
)
val jsonString = json.encodeToString(Event.serializer(), event)
val decoded = json.decodeFromString<Event>(jsonString)
assertEquals(event, decoded)
}
}
- Step 2: Run test to verify it fails
Run:
./gradlew composeApp:commonTest --tests "ch.parano.myice.models.ModelSerializationTest"
Expected: FAIL with unresolved reference errors (classes not defined)
- Step 3: Write
FlexibleStringSerializer
Create composeApp/src/commonMain/kotlin/ch/parano/myice/models/FlexibleStringSerializer.kt:
package ch.parano.myice.models
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerializationException
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.jsonPrimitive
object FlexibleStringSerializer : KSerializer<String> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("FlexibleString", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: String) {
encoder.encodeString(value)
}
override fun deserialize(decoder: Decoder): String {
val jsonDecoder = decoder as? JsonDecoder
?: throw SerializationException("FlexibleStringSerializer expects JsonDecoder")
return jsonDecoder.decodeJsonElement().jsonPrimitive.content
}
}
- Step 4: Write model classes
Create composeApp/src/commonMain/kotlin/ch/parano/myice/models/Account.kt:
package ch.parano.myice.models
import kotlinx.serialization.Serializable
@Serializable
data class Account(
val name: String,
val label: String,
)
Create composeApp/src/commonMain/kotlin/ch/parano/myice/models/Event.kt:
package ch.parano.myice.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class Event(
@SerialName("id_event") val idEvent: String = "",
val agegroup: String = "",
val name: String = "",
val title: String = "",
val opponent: String = "",
val place: String = "",
val start: String = "",
val end: String = "",
val color: String? = null,
@SerialName("event") val eventType: String = "",
)
Create composeApp/src/commonMain/kotlin/ch/parano/myice/models/Staff.kt:
package ch.parano.myice.models
import kotlinx.serialization.Serializable
@Serializable
data class Staff(
val role: String,
val fname: String,
val lname: String,
)
Create composeApp/src/commonMain/kotlin/ch/parano/myice/models/Player.kt:
package ch.parano.myice.models
import kotlinx.serialization.Serializable
@Serializable
data class Player(
val position: String? = null,
@Serializable(with = FlexibleStringSerializer::class) val number: String? = null,
val fname: String,
val lname: String,
val dob: String,
)
Create composeApp/src/commonMain/kotlin/ch/parano/myice/models/Convocation.kt:
package ch.parano.myice.models
import kotlinx.serialization.Serializable
@Serializable
data class Convocation(
val available: List<Player> = emptyList(),
val staff: List<Staff> = emptyList(),
)
Create composeApp/src/commonMain/kotlin/ch/parano/myice/models/EventDetail.kt:
package ch.parano.myice.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class EventDetail(
val title: String = "",
val type: String = "",
val place: String = "",
@SerialName("time_start") val timeStart: String = "",
@SerialName("time_end") val timeEnd: String = "",
val convocation: Convocation = Convocation(),
)
Create composeApp/src/commonMain/kotlin/ch/parano/myice/models/UserInfo.kt:
package ch.parano.myice.models
import kotlinx.serialization.Serializable
@Serializable
data class UserInfo(
val email: String = "",
)
- Step 5: Run tests to verify they pass
Run:
./gradlew composeApp:commonTest --tests "ch.parano.myice.models.ModelSerializationTest"
Expected: PASS (all 12 tests)
- Step 6: Commit
git add -A
git commit -m "feat: add serializable data models with FlexibleStringSerializer for number coercion"
Task 3: Networking Layer
Files:
- Create:
composeApp/src/commonMain/kotlin/ch/parano/myice/network/ApiConfig.kt - Create:
composeApp/src/commonMain/kotlin/ch/parano/myice/network/AuthTokenHolder.kt - Create:
composeApp/src/commonMain/kotlin/ch/parano/myice/network/ApiEngine.kt - Create:
composeApp/src/commonMain/kotlin/ch/parano/myice/network/ApiService.kt - Create:
composeApp/src/androidMain/kotlin/ch/parano/myice/network/ApiEngine.android.kt - Create:
composeApp/src/iosMain/kotlin/ch/parano/myice/network/ApiEngine.ios.kt - Create:
composeApp/src/desktopMain/kotlin/ch/parano/myice/network/ApiEngine.desktop.kt - Test:
composeApp/src/commonTest/kotlin/ch/parano/myice/network/ApiServiceTest.kt
Interfaces:
-
Consumes:
Account,Event,EventDetail,UserInfofrom Task 2 -
Produces:
ApiServiceclass withgetUserInfo(),getAccounts(),getSchedule(account),getGameDetail(gameId, account);AuthTokenHoldersingleton;createHttpClient()factory -
Step 1: Write the failing test
Create composeApp/src/commonTest/kotlin/ch/parano/myice/network/ApiServiceTest.kt:
package ch.parano.myice.network
import ch.parano.myice.models.EventDetail
import io.ktor.client.HttpClient
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.respond
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.headersOf
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class ApiServiceTest {
private val json = Json { ignoreUnknownKeys = true }
private fun mockClient(respondJson: String, requestUrl: String, expectedAuth: String? = "Bearer test-token"): HttpClient {
val engine = MockEngine { request ->
assertEquals(requestUrl, request.url.encodedPath + "?" + request.url.encodedQuery)
if (expectedAuth != null) {
assertEquals(expectedAuth, request.headers["Authorization"])
}
respond(
content = respondJson,
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, "application/json"),
)
}
return HttpClient(engine) {
install(ContentNegotiation) { json(json) }
}
}
@Test
fun testGetUserInfo() {
AuthTokenHolder.token = "test-token"
val client = mockClient(
"""{"email":"rene@luria.ch","sub":"abc"}""",
"/userinfo",
)
val api = ApiService(client)
val result = api.getUserInfo()
assertEquals("rene@luria.ch", result.email)
}
@Test
fun testGetAccounts() {
AuthTokenHolder.token = "test-token"
val client = mockClient(
"""[{"name":"default","label":"Default"},{"name":"leonard","label":"Leonard"}]""",
"/accounts?",
)
val api = ApiService(client)
val result = api.getAccounts()
assertEquals(2, result.size)
assertEquals("default", result[0].name)
assertEquals("Leonard", result[1].label)
}
@Test
fun testGetSchedule() {
AuthTokenHolder.token = "test-token"
val responseJson = """
[{"id_event":"761040","agegroup":"U13 (Elite)","name":"U13 Elite - HC Ajoie","title":"U13 (Elite)\nGame\nArena","opponent":"HC Ajoie","place":"Arena","start":"2024-11-10T14:00:00","end":"2024-11-10T16:15:00","color":"#e4222e","event":"Jeu"}]
""".trimIndent()
val client = mockClient(responseJson, "/schedule?account=default")
val api = ApiService(client)
val result = api.getSchedule("default")
assertEquals(1, result.size)
assertEquals("761040", result[0].idEvent)
assertEquals("Jeu", result[0].eventType)
}
@Test
fun testGetGameDetail() {
AuthTokenHolder.token = "test-token"
val responseJson = """
{"title":"U13 - Game","type":"games","place":"Arena","time_start":"2024-11-10T14:00:00","time_end":"2024-11-10T16:15:00","convocation":{"available":[{"position":"Gardien","number":1,"fname":"Jean","lname":"Dupont","dob":"2012-03-15"}],"staff":[{"role":"Coach","fname":"Marc","lname":"Blanc"}]}}
""".trimIndent()
val client = mockClient(responseJson, "/game/761040?account=default")
val api = ApiService(client)
val result: EventDetail = api.getGameDetail("761040", "default")
assertEquals("U13 - Game", result.title)
assertEquals("games", result.type)
assertEquals(1, result.convocation.available.size)
assertEquals("1", result.convocation.available[0].number)
assertEquals(1, result.convocation.staff.size)
}
@Test
fun testNoAuthTokenSendsNoAuthHeader() {
AuthTokenHolder.token = null
val engine = MockEngine { request ->
assertNull(request.headers["Authorization"])
respond(
content = """{"email":""}""",
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, "application/json"),
)
}
val client = HttpClient(engine) {
install(ContentNegotiation) { json(json) }
}
val api = ApiService(client)
val result = api.getUserInfo()
assertEquals("", result.email)
}
}
- Step 2: Run test to verify it fails
Run:
./gradlew composeApp:commonTest --tests "ch.parano.myice.network.ApiServiceTest"
Expected: FAIL with unresolved reference errors
- Step 3: Write
ApiConfig
Create composeApp/src/commonMain/kotlin/ch/parano/myice/network/ApiConfig.kt:
package ch.parano.myice.network
object ApiConfig {
const val baseUrl = "https://myice.parano.ch"
}
- Step 4: Write
AuthTokenHolder
Create composeApp/src/commonMain/kotlin/ch/parano/myice/network/AuthTokenHolder.kt:
package ch.parano.myice.network
object AuthTokenHolder {
var token: String? = null
}
- Step 5: Write
ApiEngine(expect/actual)
Create composeApp/src/commonMain/kotlin/ch/parano/myice/network/ApiEngine.kt:
package ch.parano.myice.network
import io.ktor.client.HttpClient
import io.ktor.client.HttpClientEngineFactory
import io.ktor.client.engine.HttpClientEngineConfig
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.httptimeout.HttpTimeoutPlugin
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
expect fun createEngine(): HttpClientEngineFactory<HttpClientEngineConfig>
fun createHttpClient(): HttpClient {
return HttpClient(createEngine()) {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
install(HttpTimeoutPlugin) {
connectTimeoutMillis = 10_000
requestTimeoutMillis = 10_000
}
}
}
Create composeApp/src/androidMain/kotlin/ch/parano/myice/network/ApiEngine.android.kt:
package ch.parano.myice.network
import io.ktor.client.engine.HttpClientEngineConfig
import io.ktor.client.engine.HttpClientEngineFactory
import io.ktor.client.engine.cio.CIO
actual fun createEngine(): HttpClientEngineFactory<HttpClientEngineConfig> = CIO
Create composeApp/src/iosMain/kotlin/ch/parano/myice/network/ApiEngine.ios.kt:
package ch.parano.myice.network
import io.ktor.client.engine.HttpClientEngineConfig
import io.ktor.client.engine.HttpClientEngineFactory
import io.ktor.client.engine.darwin.Darwin
actual fun createEngine(): HttpClientEngineFactory<HttpClientEngineConfig> = Darwin
Create composeApp/src/desktopMain/kotlin/ch/parano/myice/network/ApiEngine.desktop.kt:
package ch.parano.myice.network
import io.ktor.client.engine.HttpClientEngineConfig
import io.ktor.client.engine.HttpClientEngineFactory
import io.ktor.client.engine.cio.CIO
actual fun createEngine(): HttpClientEngineFactory<HttpClientEngineConfig> = CIO
- Step 6: Write
ApiService
Create composeApp/src/commonMain/kotlin/ch/parano/myice/network/ApiService.kt:
package ch.parano.myice.network
import ch.parano.myice.models.Account
import ch.parano.myice.models.Event
import ch.parano.myice.models.EventDetail
import ch.parano.myice.models.UserInfo
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.http.HttpHeaders
class ApiService(private val client: HttpClient) {
private fun authHeader(): String? = AuthTokenHolder.token?.let { "Bearer $it" }
suspend fun getUserInfo(): UserInfo =
client.get("${ApiConfig.baseUrl}/userinfo") {
authHeader()?.let { header(HttpHeaders.Authorization, it) }
}.body()
suspend fun getAccounts(): List<Account> =
client.get("${ApiConfig.baseUrl}/accounts") {
authHeader()?.let { header(HttpHeaders.Authorization, it) }
}.body()
suspend fun getSchedule(account: String): List<Event> =
client.get("${ApiConfig.baseUrl}/schedule") {
url.parameters.append("account", account)
authHeader()?.let { header(HttpHeaders.Authorization, it) }
}.body()
suspend fun getGameDetail(gameId: String, account: String): EventDetail =
client.get("${ApiConfig.baseUrl}/game/$gameId") {
url.parameters.append("account", account)
authHeader()?.let { header(HttpHeaders.Authorization, it) }
}.body()
}
- Step 7: Run tests to verify they pass
Run:
./gradlew composeApp:commonTest --tests "ch.parano.myice.network.ApiServiceTest"
Expected: PASS (all 5 tests)
- Step 8: Commit
git add -A
git commit -m "feat: add Ktor networking layer with ApiService and auth token holder"
Task 4: Caching
Files:
- Create:
composeApp/src/commonMain/kotlin/ch/parano/myice/cache/ScheduleCache.kt - Test:
composeApp/src/commonTest/kotlin/ch/parano/myice/cache/ScheduleCacheTest.kt
Interfaces:
-
Consumes:
Eventfrom Task 2,multiplatform-settings -
Produces:
ScheduleCacheclass withsaveEvents,loadEvents,getLastUpdated,saveFilters,loadFilters,clearCache,clearAllCache -
Step 1: Write the failing test
Create composeApp/src/commonTest/kotlin/ch/parano/myice/cache/ScheduleCacheTest.kt:
package ch.parano.myice.cache
import ch.parano.myice.models.Event
import com.russhwolf.settings.MapSettings
import com.russhwolf.settings.Settings
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class ScheduleCacheTest {
private fun createCache(): Pair<ScheduleCache, Settings> {
val settings = MapSettings()
return ScheduleCache(settings) to settings
}
private fun sampleEvent(id: String, eventType: String = "Jeu") = Event(
idEvent = id,
agegroup = "U13 (Elite)",
name = "U13 Elite - HC Ajoie",
title = "U13 (Elite)\nGame\nArena",
opponent = "HC Ajoie",
place = "Arena",
start = "2024-11-10T14:00:00",
end = "2024-11-10T16:15:00",
color = "#e4222e",
eventType = eventType,
)
@Test
fun testSaveAndLoadEvents() {
val (cache, _) = createCache()
val events = listOf(sampleEvent("1"), sampleEvent("2"))
cache.saveEvents("default", events)
val loaded = cache.loadEvents("default")
assertNotNull(loaded)
assertEquals(2, loaded.size)
assertEquals("1", loaded[0].idEvent)
assertEquals("2", loaded[1].idEvent)
}
@Test
fun testLoadEventsReturnsNullWhenEmpty() {
val (cache, _) = createCache()
assertNull(cache.loadEvents("default"))
}
@Test
fun testGetLastUpdated() {
val (cache, _) = createCache()
assertNull(cache.getLastUpdated("default"))
cache.saveEvents("default", listOf(sampleEvent("1")))
val timestamp = cache.getLastUpdated("default")
assertNotNull(timestamp)
assertTrue(timestamp > 0)
}
@Test
fun testSaveAndLoadFilters() {
val (cache, _) = createCache()
cache.saveFilters("default", agegroup = "U13 (Elite)", subgroup = "U13 Elite")
val (agegroup, subgroup) = cache.loadFilters("default")
assertEquals("U13 (Elite)", agegroup)
assertEquals("U13 Elite", subgroup)
}
@Test
fun testSaveFiltersWithNulls() {
val (cache, _) = createCache()
cache.saveFilters("default", agegroup = "U13", subgroup = "Elite")
cache.saveFilters("default", agegroup = null, subgroup = null)
val (agegroup, subgroup) = cache.loadFilters("default")
assertNull(agegroup)
assertNull(subgroup)
}
@Test
fun testClearCache() {
val (cache, _) = createCache()
cache.saveEvents("default", listOf(sampleEvent("1")))
cache.saveFilters("default", "U13", "Elite")
cache.clearCache("default")
assertNull(cache.loadEvents("default"))
val (agegroup, subgroup) = cache.loadFilters("default")
assertNull(agegroup)
assertNull(subgroup)
}
@Test
fun testClearAllCache() {
val (cache, _) = createCache()
cache.saveEvents("default", listOf(sampleEvent("1")))
cache.saveEvents("leonard", listOf(sampleEvent("2")))
cache.clearAllCache()
assertNull(cache.loadEvents("default"))
assertNull(cache.loadEvents("leonard"))
}
@Test
fun testTypeFilterPersistence() {
val (cache, _) = createCache()
cache.saveTypeFilter("default", "GAMES")
assertEquals("GAMES", cache.loadTypeFilter("default"))
}
@Test
fun testTypeFilterNullWhenNotSet() {
val (cache, _) = createCache()
assertNull(cache.loadTypeFilter("default"))
}
}
- Step 2: Run test to verify it fails
Run:
./gradlew composeApp:commonTest --tests "ch.parano.myice.cache.ScheduleCacheTest"
Expected: FAIL with unresolved reference
- Step 3: Write
ScheduleCache
Create composeApp/src/commonMain/kotlin/ch/parano/myice/cache/ScheduleCache.kt:
package ch.parano.myice.cache
import ch.parano.myice.models.Event
import com.russhwolf.settings.Settings
import kotlinx.serialization.json.Json
import kotlinx.serialization.builtins.ListSerializer
class ScheduleCache(private val settings: Settings) {
private val json = Json { ignoreUnknownKeys = true }
private val eventListSerializer = ListSerializer(Event.serializer())
private fun eventsKey(account: String) = "cached_events_$account"
private fun agegroupKey(account: String) = "filters_agegroup_$account"
private fun subgroupKey(account: String) = "filters_subgroup_$account"
private fun typeFilterKey(account: String) = "filters_typefilter_$account"
private fun timestampKey(account: String) = "cached_events_timestamp_$account"
fun saveEvents(account: String, events: List<Event>) {
settings.putString(eventsKey(account), json.encodeToString(eventListSerializer, events))
settings.putLong(timestampKey(account), kotlinx.datetime.Clock.System.now().toEpochMilliseconds())
}
fun loadEvents(account: String): List<Event>? {
val jsonString = settings.getStringOrNull(eventsKey(account)) ?: return null
return try {
json.decodeFromString(eventListSerializer, jsonString)
} catch (e: Exception) {
null
}
}
fun getLastUpdated(account: String): Long? {
val ts = settings.getLongOrNull(timestampKey(account))
return ts
}
fun saveFilters(account: String, agegroup: String?, subgroup: String?) {
if (agegroup != null) settings.putString(agegroupKey(account), agegroup)
else settings.remove(agegroupKey(account))
if (subgroup != null) settings.putString(subgroupKey(account), subgroup)
else settings.remove(subgroupKey(account))
}
fun loadFilters(account: String): Pair<String?, String?> {
return settings.getStringOrNull(agegroupKey(account)) to
settings.getStringOrNull(subgroupKey(account))
}
fun saveTypeFilter(account: String, typeFilter: String) {
settings.putString(typeFilterKey(account), typeFilter)
}
fun loadTypeFilter(account: String): String? {
return settings.getStringOrNull(typeFilterKey(account))
}
fun clearCache(account: String) {
settings.remove(eventsKey(account))
settings.remove(timestampKey(account))
settings.remove(agegroupKey(account))
settings.remove(subgroupKey(account))
settings.remove(typeFilterKey(account))
}
fun clearAllCache() {
val keys = settings.keys.filter { key ->
key.startsWith("cached_events_") ||
key.startsWith("filters_") ||
key.startsWith("cached_events_timestamp_")
}
keys.forEach { settings.remove(it) }
}
}
- Step 4: Add kotlinx-datetime dependency
The ScheduleCache uses kotlinx.datetime.Clock for timestamps. Add it to the version catalog and build file.
In gradle/libs.versions.toml, add to [versions]:
kotlinx-datetime = "0.7.1"
Add to [libraries]:
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" }
In composeApp/build.gradle.kts, in commonMain.dependencies, add:
implementation(libs.kotlinx.datetime)
- Step 5: Run tests to verify they pass
Run:
./gradlew composeApp:commonTest --tests "ch.parano.myice.cache.ScheduleCacheTest"
Expected: PASS (all 9 tests)
- Step 6: Commit
git add -A
git commit -m "feat: add ScheduleCache with multiplatform-settings for offline caching"
Task 5: Filtering Logic + Auth Interface
Files:
- Create:
composeApp/src/commonMain/kotlin/ch/parano/myice/filter/TypeFilter.kt - Create:
composeApp/src/commonMain/kotlin/ch/parano/myice/filter/EventFilter.kt - Create:
composeApp/src/commonMain/kotlin/ch/parano/myice/auth/AuthSettings.kt - Create:
composeApp/src/commonMain/kotlin/ch/parano/myice/auth/OAuthClient.kt - Test:
composeApp/src/commonTest/kotlin/ch/parano/myice/filter/EventFilterTest.kt
Interfaces:
-
Consumes:
Eventfrom Task 2 -
Produces:
TypeFilterenum,filterEvents()pure function,extractSubgroup()function,AuthSettingsclass,OAuthClientinterface -
Step 1: Write the failing test for filtering
Create composeApp/src/commonTest/kotlin/ch/parano/myice/filter/EventFilterTest.kt:
package ch.parano.myice.filter
import ch.parano.myice.models.Event
import kotlin.test.Test
import kotlin.test.assertEquals
class EventFilterTest {
private fun event(
id: String = "1",
agegroup: String = "U13 (Elite)",
name: String = "U13 Elite - HC Ajoie",
eventType: String = "Jeu",
) = Event(
idEvent = id,
agegroup = agegroup,
name = name,
title = "Title",
opponent = "Opp",
place = "Place",
start = "2024-11-10T14:00:00",
end = "2024-11-10T16:15:00",
color = "#e4222e",
eventType = eventType,
)
@Test
fun testExtractSubgroupWithSeparator() {
assertEquals("U13 Elite", extractSubgroup("U13 Elite - HC Ajoie"))
}
@Test
fun testExtractSubgroupMultipleSeparators() {
assertEquals("U13 Elite - Group A", extractSubgroup("U13 Elite - Group A - HC Ajoie"))
}
@Test
fun testExtractSubgroupNoSeparator() {
assertEquals("U13 Elite", extractSubgroup("U13 Elite"))
}
@Test
fun testExtractSubgroupEmptyString() {
assertEquals("", extractSubgroup(""))
}
@Test
fun testFilterGamesOnly() {
val events = listOf(
event(id = "1", eventType = "Jeu"),
event(id = "2", eventType = ""),
event(id = "3", eventType = "Jeu"),
)
val result = filterEvents(events, agegroup = null, subgroup = null, typeFilter = TypeFilter.GAMES)
assertEquals(2, result.size)
assertEquals("1", result[0].idEvent)
assertEquals("3", result[1].idEvent)
}
@Test
fun testFilterPracticesOnly() {
val events = listOf(
event(id = "1", eventType = "Jeu"),
event(id = "2", eventType = ""),
event(id = "3", eventType = ""),
)
val result = filterEvents(events, agegroup = null, subgroup = null, typeFilter = TypeFilter.PRACTICES)
assertEquals(2, result.size)
assertEquals("2", result[0].idEvent)
assertEquals("3", result[1].idEvent)
}
@Test
fun testFilterAll() {
val events = listOf(
event(id = "1", eventType = "Jeu"),
event(id = "2", eventType = ""),
)
val result = filterEvents(events, agegroup = null, subgroup = null, typeFilter = TypeFilter.ALL)
assertEquals(2, result.size)
}
@Test
fun testFilterByAgegroup() {
val events = listOf(
event(id = "1", agegroup = "U13 (Elite)"),
event(id = "2", agegroup = "U15"),
event(id = "3", agegroup = "U13 (Elite)"),
)
val result = filterEvents(events, agegroup = "U13 (Elite)", subgroup = null, typeFilter = TypeFilter.ALL)
assertEquals(2, result.size)
assertEquals("1", result[0].idEvent)
}
@Test
fun testFilterBySubgroup() {
val events = listOf(
event(id = "1", name = "U13 Elite - HC Ajoie"),
event(id = "2", name = "U13 Top - HC Geneva"),
event(id = "3", name = "U13 Elite - HC Lausanne"),
)
val result = filterEvents(events, agegroup = null, subgroup = "U13 Elite", typeFilter = TypeFilter.ALL)
assertEquals(2, result.size)
assertEquals("1", result[0].idEvent)
assertEquals("3", result[1].idEvent)
}
@Test
fun testFilterCombinedAgegroupAndSubgroup() {
val events = listOf(
event(id = "1", agegroup = "U13 (Elite)", name = "U13 Elite - HC Ajoie"),
event(id = "2", agegroup = "U13 (Elite)", name = "U13 Top - HC Geneva"),
event(id = "3", agegroup = "U15", name = "U13 Elite - HC Lausanne"),
)
val result = filterEvents(events, agegroup = "U13 (Elite)", subgroup = "U13 Elite", typeFilter = TypeFilter.ALL)
assertEquals(1, result.size)
assertEquals("1", result[0].idEvent)
}
@Test
fun testFilterGamesAndAgegroup() {
val events = listOf(
event(id = "1", agegroup = "U13 (Elite)", eventType = "Jeu"),
event(id = "2", agegroup = "U13 (Elite)", eventType = ""),
event(id = "3", agegroup = "U15", eventType = "Jeu"),
)
val result = filterEvents(events, agegroup = "U13 (Elite)", subgroup = null, typeFilter = TypeFilter.GAMES)
assertEquals(1, result.size)
assertEquals("1", result[0].idEvent)
}
@Test
fun testDistinctAgegroups() {
val events = listOf(
event(agegroup = "U15"),
event(agegroup = "U13 (Elite)"),
event(agegroup = "U15"),
)
val result = events.distinctAgegroups()
assertEquals(2, result.size)
assertEquals("U13 (Elite)", result[0])
assertEquals("U15", result[1])
}
@Test
fun testDistinctSubgroups() {
val events = listOf(
event(name = "U13 Elite - HC Ajoie"),
event(name = "U13 Top - HC Geneva"),
event(name = "U13 Elite - HC Lausanne"),
)
val result = events.distinctSubgroups()
assertEquals(2, result.size)
assertEquals("U13 Elite", result[0])
assertEquals("U13 Top", result[1])
}
}
- Step 2: Run test to verify it fails
Run:
./gradlew composeApp:commonTest --tests "ch.parano.myice.filter.EventFilterTest"
Expected: FAIL with unresolved reference
- Step 3: Write
TypeFilter
Create composeApp/src/commonMain/kotlin/ch/parano/myice/filter/TypeFilter.kt:
package ch.parano.myice.filter
enum class TypeFilter {
ALL, GAMES, PRACTICES;
companion object {
val DEFAULT = GAMES
}
}
- Step 4: Write
EventFilter
Create composeApp/src/commonMain/kotlin/ch/parano/myice/filter/EventFilter.kt:
package ch.parano.myice.filter
import ch.parano.myice.models.Event
fun extractSubgroup(name: String): String {
val parts = name.split(" - ")
return if (parts.size > 1) parts.dropLast(1).joinToString(" - ") else name
}
fun filterEvents(
events: List<Event>,
agegroup: String?,
subgroup: String?,
typeFilter: TypeFilter,
): List<Event> = events.filter { event ->
val typeMatches = when (typeFilter) {
TypeFilter.ALL -> true
TypeFilter.GAMES -> event.eventType == "Jeu"
TypeFilter.PRACTICES -> event.eventType != "Jeu"
}
if (!typeMatches) return@filter false
if (agegroup != null && event.agegroup != agegroup) return@filter false
if (subgroup != null) {
val eventSubgroup = extractSubgroup(event.name)
if (eventSubgroup != subgroup) return@filter false
}
true
}
fun List<Event>.distinctAgegroups(): List<String> =
map { it.agegroup }.toSet().sorted()
fun List<Event>.distinctSubgroups(): List<String> =
map { extractSubgroup(it.name) }.toSet().sorted()
- Step 5: Write
AuthSettings
Create composeApp/src/commonMain/kotlin/ch/parano/myice/auth/AuthSettings.kt:
package ch.parano.myice.auth
import ch.parano.myice.network.AuthTokenHolder
import com.russhwolf.settings.Settings
class AuthSettings(private val settings: Settings) {
companion object {
private const val TOKEN_KEY = "access_token"
private const val ACCOUNT_KEY = "selected_account"
}
fun getStoredToken(): String? = settings.getStringOrNull(TOKEN_KEY)
fun setToken(token: String?) {
if (token != null) {
settings.putString(TOKEN_KEY, token)
} else {
settings.remove(TOKEN_KEY)
}
AuthTokenHolder.token = token
}
fun getStoredAccount(): String? = settings.getStringOrNull(ACCOUNT_KEY)
fun setAccount(account: String) {
settings.putString(ACCOUNT_KEY, account)
}
fun clear() {
settings.remove(TOKEN_KEY)
settings.remove(ACCOUNT_KEY)
AuthTokenHolder.token = null
}
}
- Step 6: Write
OAuthClientinterface
Create composeApp/src/commonMain/kotlin/ch/parano/myice/auth/OAuthClient.kt:
package ch.parano.myice.auth
interface OAuthClient {
suspend fun authenticate(loginUrl: String): String?
}
- Step 7: Run tests to verify they pass
Run:
./gradlew composeApp:commonTest --tests "ch.parano.myice.filter.EventFilterTest"
Expected: PASS (all 12 tests)
- Step 8: Commit
git add -A
git commit -m "feat: add event filtering logic, TypeFilter enum, AuthSettings, and OAuthClient interface"
Task 6: ViewModels
Files:
- Create:
composeApp/src/commonMain/kotlin/ch/parano/myice/viewmodel/AuthViewModel.kt - Create:
composeApp/src/commonMain/kotlin/ch/parano/myice/viewmodel/ScheduleViewModel.kt - Create:
composeApp/src/commonMain/kotlin/ch/parano/myice/viewmodel/EventDetailViewModel.kt - Test:
composeApp/src/commonTest/kotlin/ch/parano/myice/viewmodel/ScheduleViewModelTest.kt
Interfaces:
-
Consumes:
ApiService(Task 3),ScheduleCache(Task 4),AuthSettings+OAuthClient(Task 5),filterEvents(Task 5) -
Produces:
AuthViewModel,ScheduleViewModel,EventDetailViewModelwithStateFlowstate -
Step 1: Write the failing test
Create composeApp/src/commonTest/kotlin/ch/parano/myice/viewmodel/ScheduleViewModelTest.kt:
package ch.parano.myice.viewmodel
import ch.parano.myice.cache.ScheduleCache
import ch.parano.myice.filter.TypeFilter
import ch.parano.myice.models.Account
import ch.parano.myice.models.Event
import ch.parano.myice.network.ApiService
import com.russhwolf.settings.MapSettings
import io.ktor.client.HttpClient
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.respond
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.headersOf
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
class ScheduleViewModelTest {
private val json = Json { ignoreUnknownKeys = true }
private fun mockApi(accountsJson: String, scheduleJson: String): ApiService {
val engine = MockEngine { request ->
val path = request.url.encodedPath
val response = when {
path == "/accounts" -> accountsJson
path == "/schedule" -> scheduleJson
else -> "[]"
}
respond(
content = response,
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, "application/json"),
)
}
val client = HttpClient(engine) {
install(ContentNegotiation) { json(json) }
}
return ApiService(client)
}
private fun sampleEvent(
id: String = "1",
agegroup: String = "U13 (Elite)",
name: String = "U13 Elite - HC Ajoie",
eventType: String = "Jeu",
) = Event(
idEvent = id, agegroup = agegroup, name = name, title = "Title",
opponent = "Opp", place = "Place", start = "2024-11-10T14:00:00",
end = "2024-11-10T16:15:00", color = "#e4222e", eventType = eventType,
)
@Test
fun testLoadAccounts() = runTest {
val api = mockApi(
"""[{"name":"default","label":"Default"}]""",
"[]",
)
val cache = ScheduleCache(MapSettings())
val vm = ScheduleViewModel(api, cache)
vm.loadAccounts()
assertEquals(1, vm.state.value.accounts.size)
assertEquals("default", vm.state.value.selectedAccount)
}
@Test
fun testRefreshScheduleStoresEvents() = runTest {
val scheduleJson = """
[{"id_event":"1","agegroup":"U13 (Elite)","name":"U13 Elite - HC Ajoie","title":"T","opponent":"O","place":"P","start":"2024-11-10T14:00:00","end":"2024-11-10T16:15:00","color":"#e4222e","event":"Jeu"}]
""".trimIndent()
val api = mockApi("""[{"name":"default","label":"Default"}]""", scheduleJson)
val cache = ScheduleCache(MapSettings())
val vm = ScheduleViewModel(api, cache)
vm.loadAccounts()
vm.refreshSchedule()
assertEquals(1, vm.state.value.events.size)
assertEquals("1", vm.state.value.events[0].idEvent)
}
@Test
fun testFilteredEventsDefaultGamesOnly() = runTest {
val scheduleJson = """
[{"id_event":"1","agegroup":"U13","name":"U13 - A","title":"T","opponent":"O","place":"P","start":"s","end":"e","event":"Jeu"},
{"id_event":"2","agegroup":"U13","name":"U13 - B","title":"T","opponent":"","place":"P","start":"s","end":"e"}]
""".trimIndent()
val api = mockApi("""[{"name":"default","label":"Default"}]""", scheduleJson)
val cache = ScheduleCache(MapSettings())
val vm = ScheduleViewModel(api, cache)
vm.loadAccounts()
vm.refreshSchedule()
assertEquals(2, vm.state.value.events.size)
assertEquals(1, vm.state.value.filteredEvents.size)
assertEquals("1", vm.state.value.filteredEvents[0].idEvent)
}
@Test
fun testSetTypeFilterAll() = runTest {
val scheduleJson = """
[{"id_event":"1","agegroup":"U13","name":"U13 - A","title":"T","opponent":"O","place":"P","start":"s","end":"e","event":"Jeu"},
{"id_event":"2","agegroup":"U13","name":"U13 - B","title":"T","opponent":"","place":"P","start":"s","end":"e"}]
""".trimIndent()
val api = mockApi("""[{"name":"default","label":"Default"}]""", scheduleJson)
val cache = ScheduleCache(MapSettings())
val vm = ScheduleViewModel(api, cache)
vm.loadAccounts()
vm.refreshSchedule()
vm.setTypeFilter(TypeFilter.ALL)
assertEquals(2, vm.state.value.filteredEvents.size)
}
@Test
fun testSetAgegroup() = runTest {
val scheduleJson = """
[{"id_event":"1","agegroup":"U13","name":"U13 - A","title":"T","opponent":"O","place":"P","start":"s","end":"e","event":"Jeu"},
{"id_event":"2","agegroup":"U15","name":"U15 - B","title":"T","opponent":"O","place":"P","start":"s","end":"e","event":"Jeu"}]
""".trimIndent()
val api = mockApi("""[{"name":"default","label":"Default"}]""", scheduleJson)
val cache = ScheduleCache(MapSettings())
val vm = ScheduleViewModel(api, cache)
vm.loadAccounts()
vm.refreshSchedule()
vm.setAgegroup("U15")
assertEquals(1, vm.state.value.filteredEvents.size)
assertEquals("2", vm.state.value.filteredEvents[0].idEvent)
}
@Test
fun testSetSubgroup() = runTest {
val scheduleJson = """
[{"id_event":"1","agegroup":"U13","name":"U13 Elite - HC Ajoie","title":"T","opponent":"O","place":"P","start":"s","end":"e","event":"Jeu"},
{"id_event":"2","agegroup":"U13","name":"U13 Top - HC Geneva","title":"T","opponent":"O","place":"P","start":"s","end":"e","event":"Jeu"}]
""".trimIndent()
val api = mockApi("""[{"name":"default","label":"Default"}]""", scheduleJson)
val cache = ScheduleCache(MapSettings())
val vm = ScheduleViewModel(api, cache)
vm.loadAccounts()
vm.refreshSchedule()
vm.setSubgroup("U13 Elite")
assertEquals(1, vm.state.value.filteredEvents.size)
assertEquals("1", vm.state.value.filteredEvents[0].idEvent)
}
@Test
fun testCacheLoadOnLoadCachedSchedule() = runTest {
val api = mockApi("""[{"name":"default","label":"Default"}]""", "[]")
val cache = ScheduleCache(MapSettings())
cache.saveEvents("default", listOf(sampleEvent("1")))
val vm = ScheduleViewModel(api, cache)
vm.loadAccounts()
vm.loadCachedSchedule()
assertEquals(1, vm.state.value.events.size)
assertFalse(vm.state.value.isLoading)
}
@Test
fun testAgegroupsComputed() = runTest {
val scheduleJson = """
[{"id_event":"1","agegroup":"U15","name":"N","title":"T","opponent":"","place":"P","start":"s","end":"e","event":"Jeu"},
{"id_event":"2","agegroup":"U13","name":"N","title":"T","opponent":"","place":"P","start":"s","end":"e","event":"Jeu"}]
""".trimIndent()
val api = mockApi("""[{"name":"default","label":"Default"}]""", scheduleJson)
val cache = ScheduleCache(MapSettings())
val vm = ScheduleViewModel(api, cache)
vm.loadAccounts()
vm.refreshSchedule()
assertEquals(listOf("U13", "U15"), vm.state.value.agegroups)
}
}
- Step 2: Run test to verify it fails
Run:
./gradlew composeApp:commonTest --tests "ch.parano.myice.viewmodel.ScheduleViewModelTest"
Expected: FAIL with unresolved reference
- Step 3: Write
AuthViewModel
Create composeApp/src/commonMain/kotlin/ch/parano/myice/viewmodel/AuthViewModel.kt:
package ch.parano.myice.viewmodel
import ch.parano.myice.auth.AuthSettings
import ch.parano.myice.auth.OAuthClient
import ch.parano.myice.network.ApiConfig
import ch.parano.myice.network.ApiService
import ch.parano.myice.network.AuthTokenHolder
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
sealed class AuthState {
data object Loading : AuthState()
data class Authenticated(val email: String) : AuthState()
data object Unauthenticated : AuthState()
}
class AuthViewModel(
private val apiService: ApiService,
private val authSettings: AuthSettings,
private val oauthClient: OAuthClient,
) : ViewModel() {
private val _state = MutableStateFlow<AuthState>(AuthState.Loading)
val state: StateFlow<AuthState> = _state.asStateFlow()
fun init() {
viewModelScope.launch {
val token = authSettings.getStoredToken()
if (token != null) {
AuthTokenHolder.token = token
try {
val userInfo = apiService.getUserInfo()
_state.value = AuthState.Authenticated(userInfo.email)
} catch (e: Exception) {
authSettings.clear()
_state.value = AuthState.Unauthenticated
}
} else {
_state.value = AuthState.Unauthenticated
}
}
}
fun login() {
viewModelScope.launch {
_state.value = AuthState.Loading
try {
val loginUrl = "${ApiConfig.baseUrl}/login?redirect_uri=" +
oauthClient.redirectUri()
val token = oauthClient.authenticate(loginUrl)
if (token != null) {
authSettings.setToken(token)
val userInfo = apiService.getUserInfo()
_state.value = AuthState.Authenticated(userInfo.email)
} else {
_state.value = AuthState.Unauthenticated
}
} catch (e: Exception) {
_state.value = AuthState.Unauthenticated
}
}
}
fun logout() {
authSettings.clear()
_state.value = AuthState.Unauthenticated
}
}
- Step 4: Write
ScheduleViewModel
Create composeApp/src/commonMain/kotlin/ch/parano/myice/viewmodel/ScheduleViewModel.kt:
package ch.parano.myice.viewmodel
import ch.parano.myice.auth.AuthSettings
import ch.parano.myice.cache.ScheduleCache
import ch.parano.myice.filter.TypeFilter
import ch.parano.myice.filter.distinctAgegroups
import ch.parano.myice.filter.distinctSubgroups
import ch.parano.myice.filter.filterEvents
import ch.parano.myice.models.Account
import ch.parano.myice.models.Event
import ch.parano.myice.network.ApiService
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class ScheduleState(
val accounts: List<Account> = emptyList(),
val events: List<Event> = emptyList(),
val selectedAccount: String? = null,
val selectedAgegroup: String? = null,
val selectedSubgroup: String? = null,
val typeFilter: TypeFilter = TypeFilter.DEFAULT,
val isLoading: Boolean = false,
val isRefreshing: Boolean = false,
val error: String? = null,
val lastUpdated: Long? = null,
) {
val filteredEvents: List<Event>
get() = filterEvents(events, selectedAgegroup, selectedSubgroup, typeFilter)
val agegroups: List<String>
get() = events.distinctAgegroups()
val subgroups: List<String>
get() = if (selectedAgegroup == null) emptyList()
else events.filter { it.agegroup == selectedAgegroup }.distinctSubgroups()
}
class ScheduleViewModel(
private val apiService: ApiService,
private val cache: ScheduleCache,
private val authSettings: AuthSettings? = null,
) : ViewModel() {
private val _state = MutableStateFlow(ScheduleState())
val state: StateFlow<ScheduleState> = _state.asStateFlow()
fun loadAccounts() {
viewModelScope.launch {
try {
val accounts = apiService.getAccounts()
val storedAccount = authSettings?.getStoredAccount()
var selected = storedAccount
if (selected == null || accounts.none { it.name == selected }) {
selected = accounts.firstOrNull()?.name
}
val (cachedAgegroup, cachedSubgroup) = if (selected != null) {
cache.loadFilters(selected)
} else null to null
val cachedTypeFilter = if (selected != null) {
cache.loadTypeFilter(selected)?.let { name ->
runCatching { TypeFilter.valueOf(name) }.getOrNull()
} ?: TypeFilter.DEFAULT
} else TypeFilter.DEFAULT
_state.value = _state.value.copy(
accounts = accounts,
selectedAccount = selected,
selectedAgegroup = cachedAgegroup,
selectedSubgroup = cachedSubgroup,
typeFilter = cachedTypeFilter,
)
} catch (e: Exception) {
_state.value = _state.value.copy(error = "Failed to load accounts: ${e.message}")
}
}
}
fun loadCachedSchedule() {
val account = _state.value.selectedAccount ?: return
_state.value = _state.value.copy(isLoading = true, error = null)
val cachedEvents = cache.loadEvents(account)
if (cachedEvents != null) {
_state.value = _state.value.copy(
events = cachedEvents,
lastUpdated = cache.getLastUpdated(account),
isLoading = false,
)
} else {
_state.value = _state.value.copy(events = emptyList(), isLoading = false)
}
if (_state.value.events.isEmpty()) {
refreshSchedule()
}
}
fun refreshSchedule() {
val account = _state.value.selectedAccount ?: return
viewModelScope.launch {
_state.value = _state.value.copy(isRefreshing = true, error = null)
try {
val allEvents = apiService.getSchedule(account)
cache.saveEvents(account, allEvents)
_state.value = _state.value.copy(
events = allEvents,
isRefreshing = false,
lastUpdated = cache.getLastUpdated(account),
)
} catch (e: Exception) {
_state.value = _state.value.copy(
isRefreshing = false,
error = "Failed to refresh schedule: ${e.message}",
)
}
}
}
fun setAccount(account: String) {
authSettings?.setAccount(account)
val (cachedAgegroup, cachedSubgroup) = cache.loadFilters(account)
val cachedTypeFilter = cache.loadTypeFilter(account)?.let { name ->
runCatching { TypeFilter.valueOf(name) }.getOrNull()
} ?: TypeFilter.DEFAULT
_state.value = _state.value.copy(
selectedAccount = account,
selectedAgegroup = cachedAgegroup,
selectedSubgroup = cachedSubgroup,
typeFilter = cachedTypeFilter,
)
loadCachedSchedule()
}
fun setAgegroup(agegroup: String?) {
val account = _state.value.selectedAccount
if (account != null) {
cache.saveFilters(account, agegroup, null)
}
_state.value = _state.value.copy(selectedAgegroup = agegroup, selectedSubgroup = null)
}
fun setSubgroup(subgroup: String?) {
val account = _state.value.selectedAccount
if (account != null) {
cache.saveFilters(account, _state.value.selectedAgegroup, subgroup)
}
_state.value = _state.value.copy(selectedSubgroup = subgroup)
}
fun setTypeFilter(filter: TypeFilter) {
val account = _state.value.selectedAccount
if (account != null) {
cache.saveTypeFilter(account, filter.name)
}
_state.value = _state.value.copy(typeFilter = filter)
}
fun clearCache() {
cache.clearAllCache()
}
}
- Step 5: Write
EventDetailViewModel
Create composeApp/src/commonMain/kotlin/ch/parano/myice/viewmodel/EventDetailViewModel.kt:
package ch.parano.myice.viewmodel
import ch.parano.myice.models.EventDetail
import ch.parano.myice.network.ApiService
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
data class EventDetailState(
val eventDetail: EventDetail? = null,
val isLoading: Boolean = false,
val error: String? = null,
)
class EventDetailViewModel(
private val apiService: ApiService,
) : ViewModel() {
private val _state = MutableStateFlow(EventDetailState(isLoading = false))
val state: StateFlow<EventDetailState> = _state.asStateFlow()
fun loadEventDetail(gameId: String, account: String) {
viewModelScope.launch {
_state.value = EventDetailState(isLoading = true)
try {
val detail = apiService.getGameDetail(gameId, account)
_state.value = EventDetailState(eventDetail = detail, isLoading = false)
} catch (e: Exception) {
_state.value = EventDetailState(
error = "Failed to load event detail: ${e.message}",
isLoading = false,
)
}
}
}
}
- Step 6: Add
redirectUri()toOAuthClientinterface
Update composeApp/src/commonMain/kotlin/ch/parano/myice/auth/OAuthClient.kt:
package ch.parano.myice.auth
interface OAuthClient {
fun redirectUri(): String
suspend fun authenticate(loginUrl: String): String?
}
- Step 7: Run tests to verify they pass
Run:
./gradlew composeApp:commonTest --tests "ch.parano.myice.viewmodel.ScheduleViewModelTest"
Expected: PASS (all 8 tests)
- Step 8: Commit
git add -A
git commit -m "feat: add AuthViewModel, ScheduleViewModel, and EventDetailViewModel with StateFlow"
Task 7: Platform OAuth Implementations
Files:
- Create:
composeApp/src/androidMain/kotlin/ch/parano/myice/auth/AndroidOAuthClient.kt - Create:
composeApp/src/androidMain/kotlin/ch/parano/myice/OAuthCallbackActivity.kt - Modify:
composeApp/src/androidMain/AndroidManifest.xml - Create:
composeApp/src/iosMain/kotlin/ch/parano/myice/auth/IosOAuthClient.kt - Create:
composeApp/src/desktopMain/kotlin/ch/parano/myice/auth/DesktopOAuthClient.kt
Interfaces:
-
Consumes:
OAuthClientinterface from Task 5 -
Produces:
AndroidOAuthClient,IosOAuthClient,DesktopOAuthClientimplementingOAuthClient -
Step 1: Write Android OAuth implementation
Create composeApp/src/androidMain/kotlin/ch/parano/myice/auth/AndroidOAuthClient.kt:
package ch.parano.myice.auth
import android.content.Context
import android.content.Intent
import android.net.Uri
import kotlinx.coroutines.CompletableDeferred
import java.net.URLEncoder
class AndroidOAuthClient(private val context: Context) : OAuthClient {
override fun redirectUri(): String = "myice://callback"
override suspend fun authenticate(loginUrl: String): String? {
OAuthCallbackHolder.deferred = CompletableDeferred()
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(loginUrl)).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
val callbackUri = OAuthCallbackHolder.deferred?.await() ?: return null
val fragment = callbackUri.fragment ?: return null
val params = fragment.split("&").associate { pair ->
val idx = pair.indexOf("=")
if (idx >= 0) pair.substring(0, idx) to pair.substring(idx + 1)
else pair to ""
}
return params["access_token"]
}
}
object OAuthCallbackHolder {
var deferred: CompletableDeferred<Uri>? = null
fun handleCallback(uri: Uri) {
deferred?.complete(uri)
deferred = null
}
}
- Step 2: Write Android callback activity
Create composeApp/src/androidMain/kotlin/ch/parano/myice/OAuthCallbackActivity.kt:
package ch.parano.myice
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import ch.parano.myice.auth.OAuthCallbackHolder
class OAuthCallbackActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val uri: Uri? = intent?.data
if (uri != null) {
OAuthCallbackHolder.handleCallback(uri)
}
finish()
}
}
- Step 3: Update AndroidManifest.xml
Modify composeApp/src/androidMain/AndroidManifest.xml to add the callback activity and intent filter:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:label="MyIce"
android:supportsRtl="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".OAuthCallbackActivity"
android:exported="true"
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="myice" android:host="callback" />
</intent-filter>
</activity>
</application>
</manifest>
- Step 4: Write iOS OAuth implementation
Create composeApp/src/iosMain/kotlin/ch/parano/myice/auth/IosOAuthClient.kt:
package ch.parano.myice.auth
import kotlinx.cinterop.ExperimentalForeignApi
import platform.Foundation.NSURL
import platform.AuthenticationServices.ASWebAuthenticationSession
import platform.AuthenticationServices.ASWebAuthenticationPresentationContextProvider
import platform.AuthenticationServices.ASCallback
import platform.UIKit.UIApplication
import platform.UIKit.UIWindow
import platform.UIKit.UIWindowScene
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
@OptIn(ExperimentalForeignApi::class)
class IosOAuthClient : OAuthClient {
override fun redirectUri(): String = "myice://callback"
override suspend fun authenticate(loginUrl: String): String? = suspendCoroutine { continuation ->
val nsUrl = NSURL.URLWithString(loginUrl) ?: run {
continuation.resume(null)
return@suspendCoroutine
}
val session = ASWebAuthenticationSession(
URL = nsUrl,
callbackURLScheme = "myice",
completionHandler = ASCallback { callbackURL, error ->
if (error != null || callbackURL == null) {
continuation.resume(null)
} else {
val urlStr = callbackURL.absoluteString ?: ""
val fragmentIndex = urlStr.indexOf("#")
if (fragmentIndex < 0) {
continuation.resume(null)
} else {
val fragment = urlStr.substring(fragmentIndex + 1)
val params = fragment.split("&").associate { pair ->
val idx = pair.indexOf("=")
if (idx >= 0) pair.substring(0, idx) to pair.substring(idx + 1)
else pair to ""
}
continuation.resume(params["access_token"])
}
}
}
)
session.setPresentationContextProvider(object : ASWebAuthenticationPresentationContextProviderProtocol {
override fun presentationAnchorForWebAuthenticationSession(
session: ASWebAuthenticationSession
): UIWindow {
return keyWindow()
}
})
session.start()
}
private fun keyWindow(): UIWindow {
val scenes = UIApplication.sharedApplication.connectedScenes
val windowScene = scenes.firstOrNull { it is UIWindowScene } as? UIWindowScene
return windowScene?.windows?.firstOrNull { it.keyWindow } ?: UIWindow()
}
}
- Step 5: Write Desktop OAuth implementation
Create composeApp/src/desktopMain/kotlin/ch/parano/myice/auth/DesktopOAuthClient.kt:
package ch.parano.myice.auth
import kotlinx.coroutines.CompletableDeferred
import java.awt.Desktop
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.PrintWriter
import java.net.InetSocketAddress
import java.net.ServerSocket
import java.net.URI
import java.net.URLDecoder
import java.net.URLEncoder
import kotlin.concurrent.thread
class DesktopOAuthClient : OAuthClient {
private var serverPort: Int = 0
override fun redirectUri(): String = "http://localhost:$serverPort/callback"
override suspend fun authenticate(loginUrl: String): String? {
val server = ServerSocket()
server.bind(InetSocketAddress(0))
serverPort = server.localPort
val redirectUri = "http://localhost:$serverPort/callback"
val fullLoginUrl = loginUrl + "&redirect_uri=" + URLEncoder.encode(redirectUri, "UTF-8")
val deferred = CompletableDeferred<String?>()
thread {
try {
while (true) {
val socket = server.accept()
val reader = BufferedReader(InputStreamReader(socket.getInputStream()))
val writer = PrintWriter(socket.getOutputStream())
val requestLine = reader.readLine() ?: continue
val path = requestLine.split(" ").getOrNull(1) ?: continue
if (path.startsWith("/callback")) {
val html = """
<html><body>
<script>
var hash = window.location.hash.substring(1);
window.location.href = "/token?" + hash;
</script>
</body></html>
""".trimIndent()
writer.println("HTTP/1.1 200 OK")
writer.println("Content-Type: text/html")
writer.println("Connection: close")
writer.println()
writer.println(html)
writer.flush()
} else if (path.startsWith("/token")) {
val query = path.substringAfter("?")
val params = query.split("&").associate { pair ->
val idx = pair.indexOf("=")
if (idx >= 0) {
pair.substring(0, idx) to URLDecoder.decode(pair.substring(idx + 1), "UTF-8")
} else pair to ""
}
val token = params["access_token"]
deferred.complete(token)
writer.println("HTTP/1.1 200 OK")
writer.println("Content-Type: text/html")
writer.println("Connection: close")
writer.println()
writer.println("<html><body><h1>Authentication complete. You can close this window.</h1></body></html>")
writer.flush()
socket.close()
break
}
socket.close()
}
} catch (e: Exception) {
deferred.complete(null)
} finally {
server.close()
}
}
Desktop.getDesktop().browse(URI(fullLoginUrl))
return deferred.await()
}
}
- Step 6: Verify build compiles
Run:
./gradlew composeApp:compileKotlinDesktop composeApp:compileDebugKotlinAndroid
Expected: BUILD SUCCESSFUL
Note: iOS compilation requires Xcode. If not on macOS, verify Android + Desktop only:
./gradlew composeApp:compileKotlinDesktop composeApp:compileDebugKotlinAndroid
- Step 7: Commit
git add -A
git commit -m "feat: add platform OAuth implementations (Android, iOS, Desktop)"
Task 8: UI Theme + Components
Files:
- Create:
composeApp/src/commonMain/kotlin/ch/parano/myice/ui/theme/Theme.kt - Create:
composeApp/src/commonMain/kotlin/ch/parano/myice/ui/components/EventCard.kt - Create:
composeApp/src/commonMain/kotlin/ch/parano/myice/ui/components/FilterDropdown.kt - Create:
composeApp/src/commonMain/kotlin/ch/parano/myice/ui/components/LoadingIndicator.kt
Interfaces:
-
Consumes:
Eventmodel from Task 2 -
Produces:
MyIceThemecomposable,EventCard,FilterDropdown,LoadingIndicatorcomposables -
Step 1: Write theme
Create composeApp/src/commonMain/kotlin/ch/parano/myice/ui/theme/Theme.kt:
package ch.parano.myice.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
private val Indigo = Color(0xFF3F51B5)
private val IndigoLight = Color(0xFF7986CB)
private val IndigoDark = Color(0xFF303F9F)
private val LightColors = lightColorScheme(
primary = Indigo,
onPrimary = Color.White,
primaryContainer = IndigoDark,
secondary = IndigoLight,
)
private val DarkColors = darkColorScheme(
primary = IndigoLight,
onPrimary = Color.Black,
primaryContainer = Indigo,
secondary = Indigo,
)
@Composable
fun MyIceTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
MaterialTheme(
colorScheme = if (darkTheme) DarkColors else LightColors,
content = content,
)
}
- Step 2: Write
LoadingIndicator
Create composeApp/src/commonMain/kotlin/ch/parano/myice/ui/components/LoadingIndicator.kt:
package ch.parano.myice.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@Composable
fun LoadingIndicator(modifier: Modifier = Modifier) {
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}
- Step 3: Write
FilterDropdown
Create composeApp/src/commonMain/kotlin/ch/parano/myice/ui/components/FilterDropdown.kt:
package ch.parano.myice.ui.components
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FilterDropdown(
label: String,
selectedValue: String?,
options: List<String>,
onSelect: (String?) -> Unit,
modifier: Modifier = Modifier,
) {
var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it },
modifier = modifier.fillMaxWidth(),
) {
OutlinedTextField(
value = selectedValue ?: "",
onValueChange = {},
readOnly = true,
label = { Text(label) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) },
modifier = Modifier.menuAnchor().fillMaxWidth(),
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
) {
options.forEach { option ->
DropdownMenuItem(
text = { Text(option) },
onClick = {
onSelect(option)
expanded = false
},
)
}
}
}
}
- Step 4: Write
EventCard
Create composeApp/src/commonMain/kotlin/ch/parano/myice/ui/components/EventCard.kt:
package ch.parano.myice.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import ch.parano.myice.models.Event
@Composable
fun EventCard(
event: Event,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val stripeColor = event.color?.let { parseHexColor(it) }
?: MaterialTheme.colorScheme.primary
Card(
onClick = onClick,
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Row(
modifier = Modifier.height(120.dp),
) {
Box(
modifier = Modifier
.width(4.dp)
.fillMaxHeight()
.background(stripeColor),
)
Column(
modifier = Modifier
.padding(16.dp)
.fillMaxHeight(),
verticalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(4.dp),
) {
Text(
text = "${event.agegroup} - ${event.name}",
style = MaterialTheme.typography.titleMedium,
)
Text(
text = event.title,
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = "Adversaire: ${event.opponent}",
style = MaterialTheme.typography.bodySmall,
)
Text(
text = "Lieu: ${event.place}",
style = MaterialTheme.typography.bodySmall,
)
Text(
text = "${event.start} - ${event.end}",
style = MaterialTheme.typography.bodySmall,
)
}
}
}
}
private fun parseHexColor(hex: String): Color {
val cleanHex = hex.removePrefix("#")
val value = cleanHex.toLong(16)
return Color(0xFF000000 or value)
}
- Step 5: Verify build compiles
Run:
./gradlew composeApp:compileKotlinDesktop
Expected: BUILD SUCCESSFUL
- Step 6: Commit
git add -A
git commit -m "feat: add Material 3 theme and UI components (EventCard, FilterDropdown, LoadingIndicator)"
Task 9: Screens
Files:
- Create:
composeApp/src/commonMain/kotlin/ch/parano/myice/ui/screens/LoginScreen.kt - Create:
composeApp/src/commonMain/kotlin/ch/parano/myice/ui/screens/ScheduleScreen.kt - Create:
composeApp/src/commonMain/kotlin/ch/parano/myice/ui/screens/EventDetailScreen.kt
Interfaces:
-
Consumes: ViewModels from Task 6, components from Task 8, models from Task 2
-
Produces:
LoginScreen,ScheduleScreen,EventDetailScreencomposables -
Step 1: Write
LoginScreen
Create composeApp/src/commonMain/kotlin/ch/parano/myice/ui/screens/LoginScreen.kt:
package ch.parano.myice.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import ch.parano.myice.viewmodel.AuthViewModel
import ch.parano.myice.viewmodel.AuthState
@Composable
fun LoginScreen(
viewModel: AuthViewModel,
modifier: Modifier = Modifier,
) {
val state by viewModel.state.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(state) {
if (state is AuthState.Unauthenticated) {
snackbarHostState.showSnackbar("Erreur de connexion")
}
}
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
modifier = modifier,
) { padding ->
Column(
modifier = Modifier.fillMaxSize().padding(padding),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = "MyIce",
style = MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.Bold,
)
Text(
text = "Games",
style = MaterialTheme.typography.titleMedium,
)
Spacer(modifier = Modifier.height(32.dp))
if (state is AuthState.Loading) {
CircularProgressIndicator()
} else {
Button(
onClick = { viewModel.login() },
) {
Text("Se connecter avec Infomaniak")
}
}
}
}
}
- Step 2: Write
ScheduleScreen
Create composeApp/src/commonMain/kotlin/ch/parano/myice/ui/screens/ScheduleScreen.kt:
package ch.parano.myice.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Logout
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import ch.parano.myice.filter.TypeFilter
import ch.parano.myice.ui.components.EventCard
import ch.parano.myice.ui.components.FilterDropdown
import ch.parano.myice.ui.components.LoadingIndicator
import ch.parano.myice.viewmodel.AuthViewModel
import ch.parano.myice.viewmodel.ScheduleViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ScheduleScreen(
scheduleViewModel: ScheduleViewModel,
authViewModel: AuthViewModel,
onEventClick: (gameId: String, account: String, eventTitle: String) -> Unit,
modifier: Modifier = Modifier,
) {
val scheduleState by scheduleViewModel.state.collectAsState()
val authState by authViewModel.state.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = { Text("MyIce") },
actions = {
val email = (authState as? ch.parano.myice.viewmodel.AuthState.Authenticated)?.email
if (email != null) {
Text(
text = email,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = 8.dp),
)
}
IconButton(onClick = { scheduleViewModel.refreshSchedule() }) {
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
}
IconButton(onClick = {
scheduleViewModel.clearCache()
authViewModel.logout()
}) {
Icon(Icons.Default.Logout, contentDescription = "Logout")
}
},
)
},
modifier = modifier,
) { padding ->
Column(
modifier = Modifier.fillMaxSize().padding(padding),
) {
Card(
modifier = Modifier.fillMaxWidth().padding(16.dp),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
FilterDropdown(
label = "Compte",
selectedValue = scheduleState.selectedAccount,
options = scheduleState.accounts.map { it.name },
onSelect = { scheduleViewModel.setAccount(it) },
)
FilterDropdown(
label = "Age",
selectedValue = scheduleState.selectedAgegroup,
options = scheduleState.agegroups,
onSelect = { scheduleViewModel.setAgegroup(it) },
)
FilterDropdown(
label = "Sous-groupe",
selectedValue = scheduleState.selectedSubgroup,
options = scheduleState.subgroups,
onSelect = { scheduleViewModel.setSubgroup(it) },
)
FilterDropdown(
label = "Type",
selectedValue = typeFilterLabel(scheduleState.typeFilter),
options = listOf("Tous", "Matchs", "Entrainements"),
onSelect = { label ->
val filter = when (label) {
"Tous" -> TypeFilter.ALL
"Matchs" -> TypeFilter.GAMES
"Entrainements" -> TypeFilter.PRACTICES
else -> TypeFilter.DEFAULT
}
scheduleViewModel.setTypeFilter(filter)
},
)
}
}
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
when {
scheduleState.isLoading && scheduleState.events.isEmpty() -> {
LoadingIndicator()
}
scheduleState.error != null && scheduleState.events.isEmpty() -> {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = scheduleState.error!!,
color = MaterialTheme.colorScheme.error,
)
Spacer(modifier = Modifier.height(8.dp))
TextButton(onClick = { scheduleViewModel.refreshSchedule() }) {
Text("Reessayer")
}
}
}
scheduleState.filteredEvents.isEmpty() -> {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text("Aucun evenement disponible")
if (scheduleState.events.isEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Appuyez sur le bouton de rafraichissement pour charger les evenements",
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
)
}
}
}
else -> {
LazyColumn(
modifier = Modifier.fillMaxSize(),
) {
items(scheduleState.filteredEvents) { event ->
EventCard(
event = event,
onClick = {
onEventClick(
event.idEvent,
scheduleState.selectedAccount!!,
event.title,
)
},
)
}
}
}
}
if (scheduleState.isRefreshing) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Card {
Column(
modifier = Modifier.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(16.dp))
Text("Chargement...", fontWeight = FontWeight.Medium)
}
}
}
}
}
}
}
}
private fun typeFilterLabel(filter: TypeFilter): String = when (filter) {
TypeFilter.ALL -> "Tous"
TypeFilter.GAMES -> "Matchs"
TypeFilter.PRACTICES -> "Entrainements"
}
- Step 3: Write
EventDetailScreen
Create composeApp/src/commonMain/kotlin/ch/parano/myice/ui/screens/EventDetailScreen.kt:
package ch.parano.myice.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import ch.parano.myice.ui.components.LoadingIndicator
import ch.parano.myice.viewmodel.EventDetailViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EventDetailScreen(
gameId: String,
account: String,
eventTitle: String,
viewModel: EventDetailViewModel,
modifier: Modifier = Modifier,
) {
LaunchedEffect(gameId, account) {
viewModel.loadEventDetail(gameId, account)
}
val state by viewModel.state.collectAsState()
Scaffold(
topBar = {
TopAppBar(title = { Text(eventTitle) })
},
modifier = modifier,
) { padding ->
when {
state.isLoading -> {
LoadingIndicator(modifier = Modifier.padding(padding))
}
state.error != null -> {
Column(
modifier = Modifier.fillMaxSize().padding(padding),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = state.error!!,
color = MaterialTheme.colorScheme.error,
)
Spacer(modifier = Modifier.height(8.dp))
TextButton(onClick = { viewModel.loadEventDetail(gameId, account) }) {
Text("Reessayer")
}
}
}
state.eventDetail != null -> {
val detail = state.eventDetail!!
val hasPlayers = detail.convocation.available.isNotEmpty()
val hasStaff = detail.convocation.staff.isNotEmpty()
LazyColumn(
modifier = Modifier.fillMaxSize().padding(padding),
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
item {
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text("Type: ${detail.type}")
Text("Lieu: ${detail.place}")
Text("Heure: ${detail.timeStart} - ${detail.timeEnd}")
}
}
}
if (!hasPlayers && !hasStaff) {
item {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = Color(0xFFFFC107),
),
) {
Text(
text = "Aucun joueur ni personnel convoque",
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(16.dp),
)
}
}
}
if (hasPlayers) {
item {
Text(
text = "Joueurs (${detail.convocation.available.size})",
style = MaterialTheme.typography.titleLarge,
)
}
items(detail.convocation.available) { player ->
val position = player.position ?: "N/A"
val number = player.number ?: "N/A"
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
) {
Text("[$position] #$number - ${player.fname} ${player.lname}")
Text(
text = "DOB: ${player.dob}",
style = MaterialTheme.typography.bodySmall,
)
}
}
}
}
if (hasStaff) {
item {
Text(
text = "Personnel",
style = MaterialTheme.typography.titleLarge,
)
}
items(detail.convocation.staff) { staff ->
Card(modifier = Modifier.fillMaxWidth()) {
Text(
text = "${staff.role}: ${staff.fname} ${staff.lname}",
modifier = Modifier.padding(16.dp),
)
}
}
}
}
}
else -> {
Column(
modifier = Modifier.fillMaxSize().padding(padding),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text("Aucune donnee disponible")
}
}
}
}
}
- Step 4: Verify build compiles
Run:
./gradlew composeApp:compileKotlinDesktop
Expected: BUILD SUCCESSFUL
- Step 5: Commit
git add -A
git commit -m "feat: add LoginScreen, ScheduleScreen, and EventDetailScreen"
Task 10: App Root + Entry Points
Files:
- Create:
composeApp/src/commonMain/kotlin/ch/parano/myice/App.kt - Modify:
composeApp/src/androidMain/kotlin/ch/parano/myice/MainActivity.kt - Create:
composeApp/src/iosMain/kotlin/ch/parano/myice/MainViewController.kt(modify existing) - Modify:
composeApp/src/desktopMain/kotlin/ch/parano/myice/Main.kt - Create:
iosApp/iosApp/MyIceApp.swift - Create:
iosApp/iosApp/ContentView.swift - Create:
iosApp/iosApp/Info.plist
Interfaces:
-
Consumes: all previous tasks
-
Produces: a runnable app on all three platforms
-
Step 1: Write
App.kt(root composable + routing)
Create composeApp/src/commonMain/kotlin/ch/parano/myice/App.kt:
package ch.parano.myice
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.lifecycle.viewmodel.compose.viewModel
import ch.parano.myice.auth.AuthSettings
import ch.parano.myice.auth.OAuthClient
import ch.parano.myice.cache.ScheduleCache
import ch.parano.myice.network.ApiService
import ch.parano.myice.network.createHttpClient
import ch.parano.myice.ui.screens.EventDetailScreen
import ch.parano.myice.ui.screens.LoginScreen
import ch.parano.myice.ui.screens.ScheduleScreen
import ch.parano.myice.ui.theme.MyIceTheme
import ch.parano.myice.viewmodel.AuthState
import ch.parano.myice.viewmodel.AuthViewModel
import ch.parano.myice.viewmodel.EventDetailViewModel
import ch.parano.myice.viewmodel.ScheduleViewModel
import com.russhwolf.settings.Settings
sealed class Screen {
data object Login : Screen()
data object Schedule : Screen()
data class EventDetail(val gameId: String, val account: String, val eventTitle: String) : Screen()
}
@Composable
fun App(
oauthClient: OAuthClient,
) {
val apiService = remember { ApiService(createHttpClient()) }
val authSettings = remember { AuthSettings(Settings()) }
val cache = remember { ScheduleCache(Settings()) }
val authViewModel = remember { AuthViewModel(apiService, authSettings, oauthClient) }
val scheduleViewModel = remember { ScheduleViewModel(apiService, cache, authSettings) }
val eventDetailViewModel = remember { EventDetailViewModel(apiService) }
var currentScreen by remember { mutableStateOf<Screen>(Screen.Login) }
LaunchedEffect(Unit) {
authViewModel.init()
}
val authState by authViewModel.state.collectAsState()
LaunchedEffect(authState) {
currentScreen = when (authState) {
is AuthState.Authenticated -> Screen.Schedule
is AuthState.Unauthenticated -> Screen.Login
AuthState.Loading -> currentScreen
}
}
MyIceTheme {
when (val screen = currentScreen) {
Screen.Login -> LoginScreen(viewModel = authViewModel)
Screen.Schedule -> ScheduleScreen(
scheduleViewModel = scheduleViewModel,
authViewModel = authViewModel,
onEventClick = { gameId, account, eventTitle ->
currentScreen = Screen.EventDetail(gameId, account, eventTitle)
},
)
is Screen.EventDetail -> EventDetailScreen(
gameId = screen.gameId,
account = screen.account,
eventTitle = screen.eventTitle,
viewModel = eventDetailViewModel,
)
}
}
}
- Step 2: Update
MainActivity.kt(Android entry point)
Replace composeApp/src/androidMain/kotlin/ch/parano/myice/MainActivity.kt:
package ch.parano.myice
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import ch.parano.myice.auth.AndroidOAuthClient
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContent {
App(oauthClient = AndroidOAuthClient(applicationContext))
}
}
}
- Step 3: Update
MainViewController.kt(iOS entry point)
Replace composeApp/src/iosMain/kotlin/ch/parano/myice/MainViewController.kt:
package ch.parano.myice
import androidx.compose.ui.window.ComposeUIViewController
import ch.parano.myice.auth.IosOAuthClient
fun MainViewController() = ComposeUIViewController {
App(oauthClient = IosOAuthClient())
}
- Step 4: Update
Main.kt(Desktop entry point)
Replace composeApp/src/desktopMain/kotlin/ch/parano/myice/Main.kt:
package ch.parano.myice
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import ch.parano.myice.auth.DesktopOAuthClient
fun main() = application {
Window(onCloseRequest = ::exitApplication, title = "MyIce") {
App(oauthClient = DesktopOAuthClient())
}
}
- Step 5: Delete the placeholder file
Delete composeApp/src/commonMain/kotlin/ch/parano/myice/Placeholder.kt (if it still exists).
- Step 6: Create iOS Xcode project files
Create iosApp/iosApp/MyIceApp.swift:
import SwiftUI
import ComposeApp
@main
struct MyIceApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
Create iosApp/iosApp/ContentView.swift:
import SwiftUI
import ComposeApp
struct ContentView: UIViewRepresentable {
func makeUIView(context: Context) -> some UIView {
MainViewControllerKt.MainViewController()
}
func updateUIView(_ uiView: UIViewType, context: Context) {}
}
Create iosApp/iosApp/Info.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
</dict>
<key>UILaunchScreen</key>
<dict/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>ch.parano.myice</string>
<key>CFBundleURLSchemes</key>
<array>
<string>myice</string>
</array>
</dict>
</array>
</dict>
</plist>
- Step 7: Create iOS Xcode project
The iOS Xcode project (iosApp/iosApp.xcodeproj) needs to be created. The standard approach:
- Open Xcode
- File → New → Project
- Choose iOS → App
- Product Name:
iosApp - Organization Identifier:
ch.parano.myice - Interface: SwiftUI
- Language: Swift
- Save into
iosApp/directory
Then configure the project:
- In the project settings, under "General" → "Frameworks, Libraries, and Embedded Content", add the
ComposeAppframework (it will be available after building the Kotlin project) - Under "Build Phases", add a "Run Script" phase with:
"$SRCROOT/../gradlew" -p "$SRCROOT/.." :composeApp:linkDebugFrameworkIosArm64 - Replace the generated
ContentView.swiftand app entry file with the files from Step 6 - Set the
Info.plistto the one from Step 6
Alternatively, if you have the KMP wizard template, you can copy the iosApp.xcodeproj from there and adjust the bundle identifier.
- Step 8: Verify Android build
Run:
./gradlew composeApp:assembleDebug
Expected: BUILD SUCCESSFUL
- Step 9: Verify Desktop build
Run:
./gradlew composeApp:run
Expected: A window opens showing the login screen
- Step 10: Verify tests pass
Run:
./gradlew composeApp:allTests
Expected: All tests pass (models, networking, caching, filtering, viewmodels)
- Step 11: Commit
git add -A
git commit -m "feat: wire up app root, navigation, and platform entry points"
Self-Review Notes
After writing this plan, I verified:
-
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.
-
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.pbxprojis impractical to write by hand — this is a concrete instruction, not a placeholder. -
Type consistency:
OAuthClientinterface hasredirectUri()andauthenticate()— both implemented consistently in all three platform actuals.ScheduleViewModelusesScheduleStatedata class with computedfilteredEvents,agegroups,subgroups— consistent with test expectations.TypeFilterenum values (ALL, GAMES, PRACTICES) used consistently across filter, viewmodel, and screen.AuthTokenHolder.tokenis set/cleared byAuthSettings.setToken/clearand read byApiServiceviaauthHeader(). -
Known issue: The
kotlinx.datetimedependency (Task 4, Step 4) is added forClock.System.now().toEpochMilliseconds(). An alternative would be to useSystem.currentTimeMillis()on JVM/Android andkotlinx.datetimeon iOS — butkotlinx-datetimeis the clean KMP solution and worth the small dependency.