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