commit 0098abd15b42bb4c0b1e0794620b39be86dd6502 Author: Rene Luria Date: Wed Jul 8 16:42:03 2026 +0200 docs: add MyIce KMP app design spec Design for porting the MyIce Flutter app to Kotlin Multiplatform (Android + iOS + Desktop) using Compose Multiplatform. Mirrors the 3-screen structure (Login, Schedule, Event Detail) and 4 GET endpoints with Bearer auth via Infomaniak OIDC. Adds a type filter to surface practices alongside games. diff --git a/docs/superpowers/specs/2026-07-08-myice-kotlin-app-design.md b/docs/superpowers/specs/2026-07-08-myice-kotlin-app-design.md new file mode 100644 index 0000000..ba62b06 --- /dev/null +++ b/docs/superpowers/specs/2026-07-08-myice-kotlin-app-design.md @@ -0,0 +1,282 @@ +# 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)