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.
17 KiB
MyIce Kotlin Multiplatform App — Design Spec
Date: 2026-07-08 Status: Approved Author: Brainstorming session (Luria + opencode)
Context
The existing MyIce webapp 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:
CIOfor Android/JVM;Darwinfor iOS (configured per-platform inactualengine setup). - Plugins:
ContentNegotiationwith kotlinx.serialization JSON (ignore unknown keys, lenient)HttpTimeout— 10s connect, 10s receive- Custom auth interceptor: reads token from
AuthTokenHolder, injectsAuthorization: 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
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: StringEvent—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: ConvocationConvocation—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: StringStaff—role: String,fname: String,lname: StringUserInfo—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
// 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):ASWebAuthenticationSessionwith callback schememyice. The session opens Safari, captures themyice://callback#access_token=...redirect, and returns the full URL. Clean suspendable wrapper. - Android (
androidMain): Launch a Custom Tab with the login URL. ACallbackActivity(registered inAndroidManifest.xmlwith an intent filter for themyicescheme) receives the redirect, extracts the full URI, posts it to a process-wideCompletableDeferred<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 orjava.net.ServerSocket) on a random free port. Setredirect_uri=http://localhost:<port>/callback. Open the system browser viajava.awt.Desktop.browse(URI). The server handles theGET /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 tohttp://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-settingsunder keyaccess_token. - Selected account stored under key
selected_account. AuthViewModel.init(): read stored token → set onAuthTokenHolder→ call/userinfo. On success → authenticated. On 401 or failure → clear token, show login.AuthViewModel.login(): callauthenticate()→ store token → set onAuthTokenHolder→ 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 ofList<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
ScheduleScreenopen: 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 statelogin()— run OAuth flow, store token, fetch userinfologout()— 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— enumALL | GAMES | PRACTICES(defaultGAMESto 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 matchagegroups— distinct agegroups from eventssubgroups— distinct subgroups from (agegroup-filtered) events
- Sub-group extraction logic (ported faithfully from Flutter):
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 accountloadCachedSchedule()— load fromScheduleCacherefreshSchedule()— fetch/schedule, update cache + statesetAccount(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:
LazyColumnofEventCarditems. - 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, subtitleDOB: <dob>. - Staff section: "Personnel" header + card per staff:
role: fname lname.
Reusable components
EventCard— schedule list item with colored left border parsed fromevent.colorhex (#RRGGBB→Color). Shows agegroup, name, title, opponent ("Adversaire"), place ("Lieu"), time range.FilterDropdown— reusable dropdown wrapper.LoadingIndicator— centeredCircularProgressIndicator.
Error Handling
- Network/HTTP errors on data endpoints → error state with message + "Reessayer" (retry) button.
- 401 on
/userinfoduringinit()→ 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—fromJsonround-trip with sample JSON fromschedule.jsonand the API docs. numbercoercion: verify int and string inputs both deserialize toString.- 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/,MainActivityhosts Compose,AndroidManifest.xmlwith INTERNET permission +myicescheme intent filter. Min SDK per Compose Multiplatform defaults. - iOS —
iosApp/Xcode project hosting Compose,Info.plistwithmyiceURL scheme (CFBundleURLTypes). Bundle IDch.parano.myice. - Desktop —
composeApp/src/desktopMain/,main()function withapplication { Window(...) }. OAuth via local HTTP server + system browser.
Deviations from the Flutter App
- Type filter (new): Flutter hardcodes
event == "Jeu"(games only). This port adds aTypeFilterdropdown (All/Games/Practices) so practices are visible. Default is GAMES to preserve original behavior on first load. - Auth interceptor (fixed): Flutter re-adds a Dio interceptor on each login (potential stacking). This port uses a single
AuthTokenHolderthat the interceptor reads from, avoiding the bug. - No
intlpackage: Flutter lists it but doesn't use it. Not ported. - Kotlin idioms:
StateFlowinstead ofChangeNotifier,sealed classfor auth state,enum classfor 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)