# MyIce Kotlin Multiplatform App — Design Spec **Date:** 2026-07-08 **Status:** Approved **Author:** Brainstorming session (Luria + opencode) ## Context The existing [MyIce webapp](https://gitea.parano.ch/herel/myice) is a FastAPI backend at `https://myice.parano.ch` that proxies the upstream MyIce Hockey API (`app.myice.hockey`). It serves a single-page web frontend (`index.html`) and a JSON API consumed by a Flutter mobile app (`myice_mobile/`). This spec describes a **Kotlin Multiplatform (KMP) port** of the Flutter app, targeting **Android, iOS, and Desktop** (macOS/Linux/Windows JVM). It mirrors the Flutter app's clean, simple architecture while using idiomatic KMP tooling, and adds one small feature improvement: showing **practices** alongside games (the Flutter app fetches but discards them). The backend API is read-only (4 GET endpoints) and unchanged by this work. ## Goals - Port the 3 Flutter screens (Login, Schedule, Event Detail) to Compose Multiplatform. - Share UI and logic across Android, iOS, and Desktop. - Faithfully port the filtering logic (sub-group extraction, agegroup/subgroup filters). - **Improve on the Flutter app:** surface practices (not just games) via a type filter. - Keep the same OAuth flow (Infomaniak OIDC via `myice://callback` / browser). - Match the Flutter app's caching behavior (offline-first schedule with manual refresh). ## Non-Goals - No POST/PUT/DELETE — the app remains read-only (no editing of convocations, accounts, or events). - No server-side changes to the FastAPI backend. - No token refresh or server-side token revocation (matches Flutter behavior — token expiry forces re-login). - No PDF generation (CLI-only feature, not in the web API). - No AI Q&A feature (CLI-only). - No web (Wasm) target in this iteration. ## Architecture: Approach A (Compose Multiplatform, shared UI) Single `composeApp` module with shared Compose UI across all three targets. Platform-specific code is limited to OAuth glue (via `expect/actual`) and platform entry points. **Stack:** - **UI:** Compose Multiplatform (Material 3) - **Networking:** Ktor Client + kotlinx.serialization - **State:** lifecycle-viewmodel + StateFlow (KMP-compatible) - **Caching:** multiplatform-settings - **OAuth:** platform-native via `expect/actual` **Rationale:** The app is small (3 screens, 4 endpoints). A shared UI keeps it maintainable and mirrors the Flutter app's single-UI model. Compose Multiplatform is the canonical KMP UI solution for Android+iOS+Desktop. ## Project Structure Standard KMP wizard layout with a single `composeApp` module: ``` myice_kotlin/ ├── settings.gradle.kts ├── build.gradle.kts ├── gradle/libs.versions.toml # version catalog ├── composeApp/ │ ├── build.gradle.kts │ └── src/ │ ├── commonMain/kotlin/ch/parano/myice/ │ │ ├── App.kt # root composable + routing │ │ ├── network/ # Ktor client, ApiService, AuthTokenHolder │ │ ├── models/ # @Serializable data classes │ │ ├── auth/ # AuthService (expect) │ │ ├── cache/ # ScheduleCache │ │ ├── viewmodel/ # 3 ViewModels + StateFlow │ │ └── ui/ │ │ ├── theme/ # Material 3 theme (indigo) │ │ ├── screens/ # LoginScreen, ScheduleScreen, EventDetailScreen │ │ └── components/ # EventCard, FilterDropdown, LoadingIndicator │ ├── androidMain/ # OAuth actual (Custom Tabs), MainActivity │ ├── iosMain/ # OAuth actual (ASWebAuthenticationSession) │ └── desktopMain/ # OAuth actual (browser + local HTTP server) └── iosApp/ # Xcode project (hosts Compose) ``` ## Networking & API **Ktor Client** (single shared instance in commonMain): - Engine: `CIO` for Android/JVM; `Darwin` for iOS (configured per-platform in `actual` engine setup). - Plugins: - `ContentNegotiation` with kotlinx.serialization JSON (ignore unknown keys, lenient) - `HttpTimeout` — 10s connect, 10s receive - Custom auth interceptor: reads token from `AuthTokenHolder`, injects `Authorization: Bearer ` header when present - Base URL hardcoded: `https://myice.parano.ch` **AuthTokenHolder:** a simple singleton holding the current bearer token in memory (set on login/init, cleared on logout). The Ktor interceptor reads from it. This avoids the Flutter app's bug where interceptors stack on repeated logins (a single interceptor reads from a mutable holder instead). ### API endpoints (all GET, all read-only) | Method | Path | Auth | Query params | Returns | Description | |--------|------|------|--------------|---------|-------------| | GET | `/login` | No | `redirect_uri` | 307 redirect to Infomaniak OIDC, then to `redirect_uri#access_token=...` | Start OAuth flow (opened in browser) | | GET | `/userinfo` | Yes | — | `{email, ...}` → `UserInfo` | Validate token + get user email | | GET | `/accounts` | Yes | — | `[{name, label}, ...]` → `List` | List configured accounts (kids) | | GET | `/schedule` | Yes | `account=` | `[{...event...}, ...]` → `List` | Fetch schedule (games + practices) | | GET | `/game/{game_id}` | Yes | `account=` | `{title, type, place, ...}` → `EventDetail` | Fetch convocation for an event | ### ApiService interface ```kotlin class ApiService(private val client: HttpClient) { suspend fun getUserInfo(): UserInfo suspend fun getAccounts(): List suspend fun getSchedule(account: String): List suspend fun getGameDetail(gameId: String, account: String): EventDetail } ``` ## Data Models All `@Serializable` data classes with `@SerialName` for snake_case JSON mapping. Direct ports of the Flutter Dart models. - **`Account`** — `name: String`, `label: String` - **`Event`** — `idEvent: String` (← `id_event`), `agegroup: String`, `name: String?`, `title: String`, `opponent: String?`, `place: String`, `start: String`, `end: String`, `color: String?`, `eventType: String?` (← `event`; `"Jeu"` = game, absent/null = practice) - **`EventDetail`** — `title: String`, `type: String`, `place: String`, `timeStart: String` (← `time_start`), `timeEnd: String` (← `time_end`), `convocation: Convocation` - **`Convocation`** — `available: List`, `staff: List` - **`Player`** — `position: String?`, `number: String?` (handle int coercion via custom serializer or `@SerialName` + String-typed field), `fname: String`, `lname: String`, `dob: String` - **`Staff`** — `role: String`, `fname: String`, `lname: String` - **`UserInfo`** — `email: String` **Note on `number`:** The upstream API may return `number` as either a string or an integer. Use a custom KSerializer (or `JsonPrimitive` extraction) that coerces both to `String`, matching the Flutter app's `number?.toString()`. ## Auth (OAuth via Infomaniak) The backend implements OIDC Authorization Code + PKCE against Infomaniak. The mobile/desktop client uses the **implicit-style callback** (token in URL fragment) that the backend provides via `/login?redirect_uri=...` → `/callback` → `redirect_uri#access_token=...`. ### Common interface ```kotlin // commonMain expect suspend fun authenticate(loginUrl: String): String? ``` Opens the browser at `loginUrl` (`https://myice.parano.ch/login?redirect_uri=`), waits for the callback, parses the `access_token` from the URL fragment, and returns it (or null/failure on error). ### Platform actuals - **iOS** (`iosMain`): `ASWebAuthenticationSession` with callback scheme `myice`. The session opens Safari, captures the `myice://callback#access_token=...` redirect, and returns the full URL. Clean suspendable wrapper. - **Android** (`androidMain`): Launch a Custom Tab with the login URL. A `CallbackActivity` (registered in `AndroidManifest.xml` with an intent filter for the `myice` scheme) receives the redirect, extracts the full URI, posts it to a process-wide `CompletableDeferred` (or singleton channel), and finishes. `authenticate()` awaits the deferred and parses the token. - **Desktop** (`desktopMain`): Start a local HTTP server (e.g. Ktor server engine or `java.net.ServerSocket`) on a random free port. Set `redirect_uri=http://localhost:/callback`. Open the system browser via `java.awt.Desktop.browse(URI)`. The server handles the `GET /callback#access_token=...` request, extracts the token from the fragment (note: fragments are NOT sent to the server by browsers — so the backend must return the token in a query param or a small HTML page that reads its own fragment and POSTs back). **Implementation detail to resolve:** the backend currently redirects with `#access_token=...` (fragment). For desktop local-server flow, we may need to either (a) serve a tiny HTML page at the callback that extracts the fragment via JS and redirects to `http://localhost:/callback?token=`, or (b) request the backend add a query-param mode. Option (a) is self-contained and preferred. ### Token storage & lifecycle - Token stored in `multiplatform-settings` under key `access_token`. - Selected account stored under key `selected_account`. - `AuthViewModel.init()`: read stored token → set on `AuthTokenHolder` → call `/userinfo`. On success → authenticated. On 401 or failure → clear token, show login. - `AuthViewModel.login()`: call `authenticate()` → store token → set on `AuthTokenHolder` → call `/userinfo` → authenticated. - `AuthViewModel.logout()`: clear token from settings + `AuthTokenHolder` → show login. No server-side revocation (matches Flutter). ## Caching `ScheduleCache` backed by `multiplatform-settings` (mirrors the Flutter `ScheduleCacheService`). JSON serialization via kotlinx.serialization. **Keys (per account):** - `cached_events_` — JSON string of `List` - `cached_events_timestamp_` — epoch millis (Long) - `filters_agegroup_` — selected agegroup (String?) - `filters_subgroup_` — selected subgroup (String?) - `filters_typefilter_` — selected type filter (String enum: ALL / GAMES / PRACTICES) **Behavior:** - On `ScheduleScreen` open: load cached events + filters first (instant display), then auto-refresh from network only if cache is empty. - Manual refresh (pull-to-refresh or button): re-fetch from network, update cache. - `clearCache()`: remove all keys for the current account (called on logout). ## State Management (ViewModels) Three ViewModels using `androidx.lifecycle:viewmodel-compose` (KMP-compatible) with `StateFlow`. ### AuthViewModel - **State (sealed):** `Loading | Authenticated(email: String) | Unauthenticated` - **Methods:** - `init()` — read stored token, validate via `/userinfo`, set state - `login()` — run OAuth flow, store token, fetch userinfo - `logout()` — clear token + state ### ScheduleViewModel - **State:** - `accounts: List` - `events: List` (all events from schedule, including practices) - `selectedAccount: String?`, `selectedAgegroup: String?`, `selectedSubgroup: String?` - `typeFilter: TypeFilter` — enum `ALL | GAMES | PRACTICES` (default `GAMES` to match Flutter default behavior, but user can switch) - `isLoading: Boolean` (initial load) - `isRefreshing: Boolean` (manual refresh) - `error: String?` - `lastUpdated: DateTime?` - **Computed:** - `filteredEvents` — apply typeFilter (GAMES → `eventType == "Jeu"`, PRACTICES → `eventType != "Jeu"`, ALL → no filter) + agegroup match + subgroup match - `agegroups` — distinct agegroups from events - `subgroups` — distinct subgroups from (agegroup-filtered) events - **Sub-group extraction logic (ported faithfully from Flutter):** ```kotlin fun extractSubgroup(name: String): String { val parts = name.split(" - ") return if (parts.size > 1) parts.dropLast(1).joinToString(" - ") else name } ``` - **Methods:** - `loadAccounts()` — fetch `/accounts`, auto-select stored or first account - `loadCachedSchedule()` — load from `ScheduleCache` - `refreshSchedule()` — fetch `/schedule`, update cache + state - `setAccount(name)`, `setAgegroup(value)`, `setSubgroup(value)`, `setTypeFilter(value)` - `clearCache()` ### EventDetailViewModel - **State:** `eventDetail: EventDetail?`, `isLoading: Boolean`, `error: String?` - **Methods:** `loadEventDetail(gameId: String, account: String)` ## UI Screens (Compose Multiplatform, Material 3) Root routing in `App.kt`: observe `AuthViewModel.state` → `LoadingScreen` / `LoginScreen` / `ScheduleScreen`. Navigation from `ScheduleScreen` → `EventDetailScreen` on event card tap (passing `gameId`, `account`, `eventTitle`). Theme: Material 3, indigo primary color (matching Flutter's `Colors.indigo`). French UI strings hardcoded (matches Flutter). ### 1. LoginScreen - Centered column: "MyIce" title, "Se connecter avec Infomaniak" button (indigo), loading spinner during auth, error snackbar ("Erreur de connexion") on failure. ### 2. ScheduleScreen - **AppBar:** title "MyIce", user email (right), refresh icon, logout icon. - **Filter card:** 4 dropdowns — Compte (account), Age (agegroup), Sous-groupe (subgroup), Type (ALL/GAMES/PRACTICES, labeled "Tous"/"Matchs"/"Entrainements"). - **List:** `LazyColumn` of `EventCard` items. - **States:** loading spinner (initial), pull-to-refresh, empty ("Aucun evenement disponible"), error with retry ("Reessayer"), overlay spinner while `isRefreshing`. ### 3. EventDetailScreen - **AppBar:** event title. - **Recap card:** Type, Lieu, Heure (`timeStart` - `timeEnd`). - **Empty state:** "Aucun joueur ni personnel convoque" (amber card) if no players and no staff. - **Players section:** "Joueurs (N)" header + card per player: `[position] #number - fname lname`, subtitle `DOB: `. - **Staff section:** "Personnel" header + card per staff: `role: fname lname`. ### Reusable components - **`EventCard`** — schedule list item with colored left border parsed from `event.color` hex (`#RRGGBB` → `Color`). Shows agegroup, name, title, opponent ("Adversaire"), place ("Lieu"), time range. - **`FilterDropdown`** — reusable dropdown wrapper. - **`LoadingIndicator`** — centered `CircularProgressIndicator`. ## Error Handling - Network/HTTP errors on data endpoints → error state with message + "Reessayer" (retry) button. - 401 on `/userinfo` during `init()` → auto-logout (clear token, show login). - 401 on data endpoints → same auto-logout behavior (token expired). - Empty schedule → "Aucun evenement disponible". - Empty convocation → "Aucun joueur ni personnel convoque". - OAuth failure (error param in callback, or user cancels) → "Erreur de connexion" snackbar on LoginScreen. ## Testing Unit tests in `commonTest` (shared, run on all platforms): - **Model serialization:** `Account`, `Event`, `EventDetail`, `Convocation`, `Player`, `Staff`, `UserInfo` — `fromJson` round-trip with sample JSON from `schedule.json` and the API docs. - **`number` coercion:** verify int and string inputs both deserialize to `String`. - **Filtering logic:** sub-group extraction, agegroup/subgroup filtering, type filter (GAMES/PRACTICES/ALL). - **Cache serialization:** events list → JSON string → back to events list. No UI/integration tests in this iteration (keeping scope tight). ## Platform Targets - **Android** — `composeApp/src/androidMain/`, `MainActivity` hosts Compose, `AndroidManifest.xml` with INTERNET permission + `myice` scheme intent filter. Min SDK per Compose Multiplatform defaults. - **iOS** — `iosApp/` Xcode project hosting Compose, `Info.plist` with `myice` URL scheme (`CFBundleURLTypes`). Bundle ID `ch.parano.myice`. - **Desktop** — `composeApp/src/desktopMain/`, `main()` function with `application { Window(...) }`. OAuth via local HTTP server + system browser. ## Deviations from the Flutter App 1. **Type filter (new):** Flutter hardcodes `event == "Jeu"` (games only). This port adds a `TypeFilter` dropdown (All/Games/Practices) so practices are visible. Default is GAMES to preserve original behavior on first load. 2. **Auth interceptor (fixed):** Flutter re-adds a Dio interceptor on each login (potential stacking). This port uses a single `AuthTokenHolder` that the interceptor reads from, avoiding the bug. 3. **No `intl` package:** Flutter lists it but doesn't use it. Not ported. 4. **Kotlin idioms:** `StateFlow` instead of `ChangeNotifier`, `sealed class` for auth state, `enum class` for type filter. ## Open Questions (to resolve during implementation) - **Desktop OAuth callback:** The backend redirects with `#access_token=...` (fragment), which browsers don't send to the server. The desktop local-server flow needs a tiny HTML page served at the callback that extracts the fragment via JS and redirects to `?token=...`. This is self-contained (no backend change) and preferred over requesting a backend change. Implementation detail to nail down in the plan. ## Out of Scope (future work) - Web (Wasm) target - PDF generation / viewing - AI Q&A feature - Token refresh / silent re-auth - Server-side token revocation - Editable convocations (would require backend POST/PUT/DELETE endpoints) - Localization (currently hardcoded French; could use a string resource system later)