3b6fae1b95
10-task TDD plan covering: project scaffolding, data models, networking (Ktor), caching, filtering logic, viewmodels, platform OAuth, UI theme and components, screens, and app root with entry points for Android, iOS, and Desktop.
3650 lines
120 KiB
Markdown
3650 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.6.1, AGP 2.1.3
|
|
|
|
## Global Constraints
|
|
|
|
- **Package:** `ch.parano.myice`
|
|
- **Base URL:** `https://myice.parano.ch` (hardcoded, no dev toggle)
|
|
- **OAuth callback scheme:** `myice://callback` (Android/iOS), `http://localhost:<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 = "2.1.3"
|
|
ktor = "3.5.1"
|
|
kotlinx-serialization-json = "1.11.0"
|
|
kotlinx-coroutines = "1.11.0"
|
|
multiplatform-settings = "1.3.0"
|
|
lifecycle-viewmodel-compose = "2.10.0"
|
|
androidx-activity-compose = "1.13.0"
|
|
|
|
[libraries]
|
|
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
|
|
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
|
|
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
|
|
ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" }
|
|
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
|
|
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
|
|
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" }
|
|
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
|
|
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
|
|
multiplatform-settings = { module = "com.russhwolf:multiplatform-settings", version.ref = "multiplatform-settings" }
|
|
multiplatform-settings-no-arg = { module = "com.russhwolf:multiplatform-settings-no-arg", version.ref = "multiplatform-settings" }
|
|
multiplatform-settings-test = { module = "com.russhwolf:multiplatform-settings-test", version.ref = "multiplatform-settings" }
|
|
lifecycle-viewmodel-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle-viewmodel-compose" }
|
|
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity-compose" }
|
|
|
|
[plugins]
|
|
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
|
|
compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }
|
|
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
|
```
|
|
|
|
- [ ] **Step 4: Create root `build.gradle.kts`**
|
|
|
|
```kotlin
|
|
plugins {
|
|
alias(libs.plugins.kotlin.multiplatform) apply false
|
|
alias(libs.plugins.compose.multiplatform) apply false
|
|
alias(libs.plugins.kotlin.serialization) apply false
|
|
alias(libs.plugins.android.application) apply false
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Create `composeApp/build.gradle.kts`**
|
|
|
|
```kotlin
|
|
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
|
|
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
|
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
|
|
|
plugins {
|
|
alias(libs.plugins.kotlin.multiplatform)
|
|
alias(libs.plugins.compose.multiplatform)
|
|
alias(libs.plugins.kotlin.serialization)
|
|
alias(libs.plugins.android.application)
|
|
}
|
|
|
|
kotlin {
|
|
androidTarget {
|
|
@OptIn(ExperimentalKotlinGradlePluginApi::class)
|
|
instrumentedTestVariant.sourceSetTree.set(KotlinSourceSetTree.test)
|
|
compilations.all {
|
|
compileTaskProvider.configure {
|
|
compilerOptions.jvmTarget.set(JvmTarget.JVM_11)
|
|
}
|
|
}
|
|
}
|
|
|
|
listOf(iosX64(), iosArm64(), iosSimulatorArm64())
|
|
|
|
jvm("desktop")
|
|
|
|
sourceSets {
|
|
commonMain.dependencies {
|
|
implementation(compose.runtime)
|
|
implementation(compose.foundation)
|
|
implementation(compose.material3)
|
|
implementation(compose.components.uiToolingPreview)
|
|
implementation(libs.ktor.client.core)
|
|
implementation(libs.ktor.client.content.negotiation)
|
|
implementation(libs.ktor.serialization.kotlinx.json)
|
|
implementation(libs.kotlinx.serialization.json)
|
|
implementation(libs.kotlinx.coroutines.core)
|
|
implementation(libs.multiplatform.settings)
|
|
implementation(libs.multiplatform.settings.no.arg)
|
|
implementation(libs.lifecycle.viewmodel.compose)
|
|
}
|
|
commonTest.dependencies {
|
|
implementation(kotlin("test"))
|
|
implementation(libs.ktor.client.mock)
|
|
implementation(libs.kotlinx.coroutines.test)
|
|
implementation(libs.multiplatform.settings.test)
|
|
}
|
|
androidMain.dependencies {
|
|
implementation(libs.ktor.client.cio)
|
|
implementation(libs.androidx.activity.compose)
|
|
}
|
|
iosMain.dependencies {
|
|
implementation(libs.ktor.client.darwin)
|
|
}
|
|
desktopMain.dependencies {
|
|
implementation(libs.ktor.client.cio)
|
|
implementation(compose.desktop.currentOs)
|
|
}
|
|
}
|
|
}
|
|
|
|
android {
|
|
namespace = "ch.parano.myice"
|
|
compileSdk = 35
|
|
|
|
defaultConfig {
|
|
applicationId = "ch.parano.myice"
|
|
minSdk = 24
|
|
targetSdk = 35
|
|
versionCode = 1
|
|
versionName = "1.0"
|
|
}
|
|
|
|
compileOptions {
|
|
sourceCompatibility = JavaVersion.VERSION_11
|
|
targetCompatibility = JavaVersion.VERSION_11
|
|
}
|
|
}
|
|
|
|
compose.desktop {
|
|
application {
|
|
mainClass = "ch.parano.myice.MainKt"
|
|
nativeDistributions {
|
|
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
|
|
packageName = "myice"
|
|
packageVersion = "1.0.0"
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 6: Create `composeApp/src/androidMain/AndroidManifest.xml`**
|
|
|
|
```xml
|
|
<?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.
|