Files
myice-kmp/docs/superpowers/specs/2026-07-08-myice-kotlin-app-design.md
herel 0098abd15b 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.
2026-07-08 16:42:03 +02:00

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: 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

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.

  • Accountname: String, label: String
  • EventidEvent: 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)
  • EventDetailtitle: String, type: String, place: String, timeStart: String (← time_start), timeEnd: String (← time_end), convocation: Convocation
  • Convocationavailable: List<Player>, staff: List<Staff>
  • Playerposition: String?, number: String? (handle int coercion via custom serializer or @SerialName + String-typed field), fname: String, lname: String, dob: String
  • Staffrole: String, fname: String, lname: String
  • UserInfoemail: 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=.../callbackredirect_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): 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):
    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.stateLoadingScreen / LoginScreen / ScheduleScreen. Navigation from ScheduleScreenEventDetailScreen 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 (#RRGGBBColor). 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, UserInfofromJson 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

  • AndroidcomposeApp/src/androidMain/, MainActivity hosts Compose, AndroidManifest.xml with INTERNET permission + myice scheme intent filter. Min SDK per Compose Multiplatform defaults.
  • iOSiosApp/ Xcode project hosting Compose, Info.plist with myice URL scheme (CFBundleURLTypes). Bundle ID ch.parano.myice.
  • DesktopcomposeApp/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)