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.
This commit is contained in:
@@ -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 <token>` 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<Account>` | List configured accounts (kids) |
|
||||
| GET | `/schedule` | Yes | `account=<name>` | `[{...event...}, ...]` → `List<Event>` | Fetch schedule (games + practices) |
|
||||
| GET | `/game/{game_id}` | Yes | `account=<name>` | `{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<Account>
|
||||
suspend fun getSchedule(account: String): List<Event>
|
||||
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<Player>`, `staff: List<Staff>`
|
||||
- **`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=<platform-specific>`), 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<Uri>` (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:<port>/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:<port>/callback?token=<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_<account>` — JSON string of `List<Event>`
|
||||
- `cached_events_timestamp_<account>` — epoch millis (Long)
|
||||
- `filters_agegroup_<account>` — selected agegroup (String?)
|
||||
- `filters_subgroup_<account>` — selected subgroup (String?)
|
||||
- `filters_typefilter_<account>` — 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<Account>`
|
||||
- `events: List<Event>` (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: <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)
|
||||
Reference in New Issue
Block a user