diff --git a/docs/superpowers/plans/2026-08-06-ui-redesign.md b/docs/superpowers/plans/2026-08-06-ui-redesign.md new file mode 100644 index 0000000..14fa58e --- /dev/null +++ b/docs/superpowers/plans/2026-08-06-ui-redesign.md @@ -0,0 +1,1905 @@ +# MyIce UI Redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Complete visual overhaul of MyIce KMP app — teal arctique theme, Material icons, redesigned screens and components, sticky date headers, French accent corrections. + +**Architecture:** Theme foundation first (colors, typography, shapes, spacing tokens), then shared components, then screen redesigns building on those components. One new testable utility (date grouping) follows TDD; UI composables are compile-verified (no Compose UI testing infrastructure in project). + +**Tech Stack:** Kotlin 2.3.20, Compose Multiplatform 1.11.1, Material 3, kotlinx-datetime 0.7.1 + +## Global Constraints + +- Package: `ch.parano.myicek` +- All source files include SPDX GPL-3.0-or-later headers (copy from existing files) +- No comments in code unless explicitly requested +- French UI strings with correct accents (é, è, ê, à, î, û) +- Compile check: `./gradlew composeApp:compileKotlinDesktop` +- Test run: `./gradlew composeApp:desktopTest` +- Compose Multiplatform 1.11.1 — `stickyHeader` requires `@OptIn(ExperimentalFoundationApi::class)` +- Material icons: add `compose.materialIconsExtended` dependency for `SportsHockey`, `CloudOff`, `CalendarClear`, `CalendarMonth` + +--- + +### Task 1: Theme Foundation & Design Tokens + +**Files:** +- Modify: `composeApp/build.gradle.kts:37-50` (add materialIconsExtended) +- Modify: `composeApp/src/commonMain/kotlin/ch/parano/myicek/ui/theme/Theme.kt` (full rewrite) +- Create: `composeApp/src/commonMain/kotlin/ch/parano/myicek/ui/theme/Dimens.kt` + +**Interfaces:** +- Produces: `MyIceTheme(darkTheme, content)` composable with full Material 3 color scheme, typography, shapes +- Produces: `Dimens` object with spacing tokens (`xs`, `sm`, `md`, `lg`, `xl`, `xxl`) + +- [ ] **Step 1: Add material-icons-extended dependency** + +In `composeApp/build.gradle.kts`, add to the `commonMain` dependencies block (after line 39 `implementation(compose.material3)`): + +```kotlin + implementation(compose.materialIconsExtended) +``` + +- [ ] **Step 2: Create Dimens.kt** + +Create `composeApp/src/commonMain/kotlin/ch/parano/myicek/ui/theme/Dimens.kt`: + +```kotlin +// SPDX-License-Identifier: GPL-3.0-or-later +// MyIce Kotlin Multiplatform — schedule/convocation viewer +// Copyright (C) 2026 parano.ch +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package ch.parano.myicek.ui.theme + +import androidx.compose.ui.unit.dp + +object Dimens { + val xs = 4.dp + val sm = 8.dp + val md = 12.dp + val lg = 16.dp + val xl = 24.dp + val xxl = 32.dp +} +``` + +- [ ] **Step 3: Rewrite Theme.kt** + +Replace the entire content of `composeApp/src/commonMain/kotlin/ch/parano/myicek/ui/theme/Theme.kt` with: + +```kotlin +// SPDX-License-Identifier: GPL-3.0-or-later +// MyIce Kotlin Multiplatform — schedule/convocation viewer +// Copyright (C) 2026 parano.ch +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package ch.parano.myicek.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Shapes +import androidx.compose.material3.Typography +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +private val LightColors = lightColorScheme( + primary = Color(0xFF0D9488), + onPrimary = Color(0xFFFFFFFF), + primaryContainer = Color(0xFF99F6E4), + onPrimaryContainer = Color(0xFF134E4A), + secondary = Color(0xFF475569), + onSecondary = Color(0xFFFFFFFF), + secondaryContainer = Color(0xFFE2E8F0), + onSecondaryContainer = Color(0xFF1E293B), + tertiary = Color(0xFF0EA5E9), + onTertiary = Color(0xFFFFFFFF), + tertiaryContainer = Color(0xFFE0F2FE), + onTertiaryContainer = Color(0xFF075985), + error = Color(0xFFDC2626), + onError = Color(0xFFFFFFFF), + errorContainer = Color(0xFFFEE2E2), + onErrorContainer = Color(0xFF7F1D1D), + background = Color(0xFFFAFAFA), + onBackground = Color(0xFF1C1917), + surface = Color(0xFFFFFFFF), + onSurface = Color(0xFF1C1917), + surfaceVariant = Color(0xFFF1F5F9), + onSurfaceVariant = Color(0xFF475569), + outline = Color(0xFFCBD5E1), +) + +private val DarkColors = darkColorScheme( + primary = Color(0xFF2DD4BF), + onPrimary = Color(0xFF003833), + primaryContainer = Color(0xFF0D9488), + onPrimaryContainer = Color(0xFF99F6E4), + secondary = Color(0xFF94A3B8), + onSecondary = Color(0xFF1E293B), + secondaryContainer = Color(0xFF334155), + onSecondaryContainer = Color(0xFFE2E8F0), + tertiary = Color(0xFF0EA5E9), + onTertiary = Color(0xFF003547), + background = Color(0xFF0F172A), + onBackground = Color(0xFFF1F5F9), + surface = Color(0xFF1E293B), + onSurface = Color(0xFFF1F5F9), + surfaceVariant = Color(0xFF334155), + onSurfaceVariant = Color(0xFF94A3B8), + outline = Color(0xFF475569), + error = Color(0xFFF87171), + onError = Color(0xFF7F1D1D), + errorContainer = Color(0xFF7F1D1D), + onErrorContainer = Color(0xFFFECACA), +) + +private val AppTypography = Typography( + headlineLarge = TextStyle(fontSize = 28.sp, fontWeight = FontWeight.Bold), + titleLarge = TextStyle(fontSize = 22.sp, fontWeight = FontWeight.SemiBold), + titleMedium = TextStyle(fontSize = 16.sp, fontWeight = FontWeight.SemiBold), + bodyLarge = TextStyle(fontSize = 15.sp, fontWeight = FontWeight.Normal), + bodyMedium = TextStyle(fontSize = 14.sp, fontWeight = FontWeight.Normal), + bodySmall = TextStyle(fontSize = 13.sp, fontWeight = FontWeight.Normal), + labelMedium = TextStyle(fontSize = 12.sp, fontWeight = FontWeight.Medium), +) + +private val AppShapes = Shapes( + small = RoundedCornerShape(8.dp), + medium = RoundedCornerShape(12.dp), + large = RoundedCornerShape(16.dp), +) + +@Composable +fun MyIceTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit, +) { + MaterialTheme( + colorScheme = if (darkTheme) DarkColors else LightColors, + typography = AppTypography, + shapes = AppShapes, + content = content, + ) +} +``` + +- [ ] **Step 4: Compile to verify** + +Run: `./gradlew composeApp:compileKotlinDesktop` +Expected: BUILD SUCCESSFUL + +- [ ] **Step 5: Commit** + +```bash +git add composeApp/build.gradle.kts composeApp/src/commonMain/kotlin/ch/parano/myicek/ui/theme/Theme.kt composeApp/src/commonMain/kotlin/ch/parano/myicek/ui/theme/Dimens.kt +git commit -m "feat: add teal arctique theme with full M3 color scheme, typography, shapes, spacing tokens + +- Complete light/dark color schemes (all M3 slots) +- Typography scale (headlineLarge through labelMedium) +- Shape tokens (small 8dp, medium 12dp, large 16dp) +- Dimens spacing object (xs through xxl) +- Add material-icons-extended dependency" +``` + +--- + +### Task 2: Date Grouping Helper (TDD) + +**Files:** +- Modify: `composeApp/src/commonMain/kotlin/ch/parano/myicek/filter/EventFilter.kt` (add `groupEventsByDate`) +- Test: `composeApp/src/commonTest/kotlin/ch/parano/myicek/filter/EventFilterTest.kt` + +**Interfaces:** +- Produces: `fun groupEventsByDate(events: List): List>>` — groups events by date key (ISO `YYYY-MM-DD`), sorted chronologically. Each pair is `(dateKey, eventsForThatDay)`. + +- [ ] **Step 1: Write the failing tests** + +Add these tests to the end of `EventFilterTest.kt` (before the closing `}`): + +```kotlin + @Test + fun testGroupEventsByDateSingleDay() { + val events = listOf( + event(id = "1", start = "2024-11-10T14:00:00"), + event(id = "2", start = "2024-11-10T16:00:00"), + ) + val grouped = groupEventsByDate(events) + assertEquals(1, grouped.size) + assertEquals("2024-11-10", grouped[0].first) + assertEquals(2, grouped[0].second.size) + } + + @Test + fun testGroupEventsByDateMultipleDays() { + val events = listOf( + event(id = "1", start = "2024-11-11T10:00:00"), + event(id = "2", start = "2024-11-10T14:00:00"), + event(id = "3", start = "2024-11-10T16:00:00"), + ) + val grouped = groupEventsByDate(events) + assertEquals(2, grouped.size) + assertEquals("2024-11-10", grouped[0].first) + assertEquals(2, grouped[0].second.size) + assertEquals("2024-11-11", grouped[1].first) + assertEquals(1, grouped[1].second.size) + } + + @Test + fun testGroupEventsByDateWithSpaceSeparator() { + val events = listOf( + event(id = "1", start = "2024-11-10 14:00:00"), + ) + val grouped = groupEventsByDate(events) + assertEquals(1, grouped.size) + assertEquals("2024-11-10", grouped[0].first) + } + + @Test + fun testGroupEventsByDateEmpty() { + val grouped = groupEventsByDate(emptyList()) + assertTrue(grouped.isEmpty()) + } +``` + +Also add `import kotlin.test.assertTrue` to the imports at the top of the test file if not already present. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `./gradlew composeApp:desktopTest --tests "ch.parano.myicek.filter.EventFilterTest.testGroupEventsByDate*"` +Expected: FAIL with "Unresolved reference: groupEventsByDate" + +- [ ] **Step 3: Implement groupEventsByDate** + +Add to the end of `composeApp/src/commonMain/kotlin/ch/parano/myicek/filter/EventFilter.kt` (before the final closing `}`... actually after `distinctSubgroups`): + +```kotlin +fun groupEventsByDate(events: List): List>> = + events + .sortedBy { it.start } + .groupBy { it.start.take(10) } + .toList() +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `./gradlew composeApp:desktopTest --tests "ch.parano.myicek.filter.EventFilterTest.testGroupEventsByDate*"` +Expected: PASS (4 tests) + +- [ ] **Step 5: Run full test suite to check for regressions** + +Run: `./gradlew composeApp:desktopTest` +Expected: All tests PASS + +- [ ] **Step 6: Commit** + +```bash +git add composeApp/src/commonMain/kotlin/ch/parano/myicek/filter/EventFilter.kt composeApp/src/commonTest/kotlin/ch/parano/myicek/filter/EventFilterTest.kt +git commit -m "feat: add groupEventsByDate helper for schedule date headers" +``` + +--- + +### Task 3: Shared State Components (EmptyState, ErrorState, SkeletonCard) + +**Files:** +- Create: `composeApp/src/commonMain/kotlin/ch/parano/myicek/ui/components/EmptyState.kt` +- Create: `composeApp/src/commonMain/kotlin/ch/parano/myicek/ui/components/ErrorState.kt` +- Create: `composeApp/src/commonMain/kotlin/ch/parano/myicek/ui/components/SkeletonCard.kt` + +**Interfaces:** +- Produces: `EmptyState(icon: ImageVector, title: String, message: String, modifier: Modifier)` composable +- Produces: `ErrorState(icon: ImageVector, title: String, message: String, onRetry: () -> Unit, modifier: Modifier)` composable +- Produces: `SkeletonCard(modifier: Modifier)` composable + +- [ ] **Step 1: Create EmptyState.kt** + +```kotlin +// SPDX-License-Identifier: GPL-3.0-or-later +// MyIce Kotlin Multiplatform — schedule/convocation viewer +// Copyright (C) 2026 parano.ch +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package ch.parano.myicek.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp + +@Composable +fun EmptyState( + icon: ImageVector, + title: String, + message: String, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(Dimens.md)) + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(Modifier.height(Dimens.sm)) + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} +``` + +- [ ] **Step 2: Create ErrorState.kt** + +```kotlin +// SPDX-License-Identifier: GPL-3.0-or-later +// MyIce Kotlin Multiplatform — schedule/convocation viewer +// Copyright (C) 2026 parano.ch +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package ch.parano.myicek.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp + +@Composable +fun ErrorState( + icon: ImageVector, + title: String, + message: String, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(Dimens.md)) + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(Modifier.height(Dimens.sm)) + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(Dimens.lg)) + Button(onClick = onRetry) { + Text("Réessayer") + } + } +} +``` + +- [ ] **Step 3: Create SkeletonCard.kt** + +```kotlin +// SPDX-License-Identifier: GPL-3.0-or-later +// MyIce Kotlin Multiplatform — schedule/convocation viewer +// Copyright (C) 2026 parano.ch +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package ch.parano.myicek.ui.components + +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import ch.parano.myicek.ui.theme.Dimens + +@Composable +fun SkeletonCard(modifier: Modifier = Modifier) { + val transition = rememberInfiniteTransition(label = "shimmer") + val alpha by transition.animateFloat( + initialValue = 0.3f, + targetValue = 0.6f, + animationSpec = infiniteRepeatable( + animation = tween(800), + repeatMode = RepeatMode.Reverse, + ), + label = "shimmerAlpha", + ) + + val skeletonColor = MaterialTheme.colorScheme.surfaceVariant + val skeletonModifier = Modifier + .alpha(alpha) + .background(skeletonColor, RoundedCornerShape(4.dp)) + + Box( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = Dimens.lg, vertical = Dimens.sm) + .clip(MaterialTheme.shapes.medium) + .background(MaterialTheme.colorScheme.surface) + .padding(Dimens.lg), + ) { + Column(verticalArrangement = Arrangement.spacedBy(Dimens.sm)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Box(modifier = Modifier + .height(20.dp) + .width(160.dp) + .then(skeletonModifier)) + Box(modifier = Modifier + .size(28.dp) + .clip(CircleShape) + .then(skeletonModifier)) + } + Box(modifier = Modifier + .height(16.dp) + .width(200.dp) + .then(skeletonModifier)) + Box(modifier = Modifier + .height(16.dp) + .width(180.dp) + .then(skeletonModifier)) + } + } +} +``` + +- [ ] **Step 4: Compile to verify** + +Run: `./gradlew composeApp:compileKotlinDesktop` +Expected: BUILD SUCCESSFUL + +- [ ] **Step 5: Commit** + +```bash +git add composeApp/src/commonMain/kotlin/ch/parano/myicek/ui/components/EmptyState.kt composeApp/src/commonMain/kotlin/ch/parano/myicek/ui/components/ErrorState.kt composeApp/src/commonMain/kotlin/ch/parano/myicek/ui/components/SkeletonCard.kt +git commit -m "feat: add EmptyState, ErrorState, and SkeletonCard shared components" +``` + +--- + +### Task 4: EventCard Redesign + +**Files:** +- Modify: `composeApp/src/commonMain/kotlin/ch/parano/myicek/ui/components/EventCard.kt` (full rewrite) + +**Interfaces:** +- Consumes: `Event` model, `formatEventTimeRange(start, end)` from `ch.parano.myicek.util` +- Produces: `EventCard(event: Event, onClick: () -> Unit, modifier: Modifier)` composable — same signature, new layout + +- [ ] **Step 1: Rewrite EventCard.kt** + +Replace the entire content of `composeApp/src/commonMain/kotlin/ch/parano/myicek/ui/components/EventCard.kt` with: + +```kotlin +// SPDX-License-Identifier: GPL-3.0-or-later +// MyIce Kotlin Multiplatform — schedule/convocation viewer +// Copyright (C) 2026 parano.ch +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package ch.parano.myicek.ui.components + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Place +import androidx.compose.material.icons.filled.Schedule +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import ch.parano.myicek.models.Event +import ch.parano.myicek.ui.theme.Dimens +import ch.parano.myicek.util.formatEventTimeRange + +@Composable +fun EventCard( + event: Event, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val isGame = event.eventType == "Jeu" + val stripeColor = event.color?.let { parseHexColor(it) } + ?: MaterialTheme.colorScheme.primary + + Card( + onClick = onClick, + modifier = modifier + .fillMaxWidth() + .padding(horizontal = Dimens.lg, vertical = Dimens.sm), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .border(width = 4.dp, color = stripeColor), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(Dimens.lg), + verticalArrangement = Arrangement.spacedBy(Dimens.xs), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "${event.agegroup} - ${event.name}", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f), + ) + AssistChip( + onClick = {}, + label = { + Text( + if (isGame) "Match" else "Entraînement", + style = MaterialTheme.typography.labelMedium, + ) + }, + colors = if (isGame) { + AssistChipDefaults.assistChipColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + labelColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } else { + AssistChipDefaults.assistChipColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + labelColor = MaterialTheme.colorScheme.onSecondaryContainer, + ) + }, + ) + } + Text( + text = if (isGame) "Match vs ${event.opponent}" else "Entraînement", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(Dimens.xs)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Dimens.xs), + ) { + Icon( + imageVector = Icons.Filled.Place, + contentDescription = null, + modifier = Modifier.width(18.dp).height(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = event.place, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Dimens.xs), + ) { + Icon( + imageVector = Icons.Filled.Schedule, + contentDescription = null, + modifier = Modifier.width(18.dp).height(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = formatEventTimeRange(event.start, event.end), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} + +private fun parseHexColor(hex: String): Color? { + val value = hex.removePrefix("#").toLongOrNull(16) ?: return null + return Color(0xFF000000 or value) +} +``` + +- [ ] **Step 2: Compile to verify** + +Run: `./gradlew composeApp:compileKotlinDesktop` +Expected: BUILD SUCCESSFUL + +- [ ] **Step 3: Commit** + +```bash +git add composeApp/src/commonMain/kotlin/ch/parano/myicek/ui/components/EventCard.kt +git commit -m "feat: redesign EventCard with color border, type badge, leading icons + +- 4dp colored border from event.color (fallback primary) +- AssistChip badge: Match (teal) vs Entraînement (slate) +- Opponent line only for games, 'Entraînement' for practices +- Place and Schedule icons leading info lines +- Condensed time range via formatEventTimeRange" +``` + +--- + +### Task 5: FilterChips Component + +**Files:** +- Create: `composeApp/src/commonMain/kotlin/ch/parano/myicek/ui/components/FilterChips.kt` +- Delete: `composeApp/src/commonMain/kotlin/ch/parano/myicek/ui/components/FilterDropdown.kt` + +**Interfaces:** +- Produces: `TypeFilterSegmented(selected: TypeFilter, onSelect: (TypeFilter) -> Unit, modifier: Modifier)` — 3-segment `SegmentedButton` for Tous/Matchs/Entraînements +- Produces: `FilterAssistChip(label: String, selectedValue: String?, options: List, onSelect: (String?) -> Unit, modifier: Modifier)` — `AssistChip` with dropdown menu + +- [ ] **Step 1: Create FilterChips.kt** + +```kotlin +// SPDX-License-Identifier: GPL-3.0-or-later +// MyIce Kotlin Multiplatform — schedule/convocation viewer +// Copyright (C) 2026 parano.ch +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package ch.parano.myicek.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import ch.parano.myicek.filter.TypeFilter + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TypeFilterSegmented( + selected: TypeFilter, + onSelect: (TypeFilter) -> Unit, + modifier: Modifier = Modifier, +) { + SingleChoiceSegmentedButtonRow(modifier = modifier.fillMaxWidth()) { + val options = listOf( + TypeFilter.ALL to "Tous", + TypeFilter.GAMES to "Matchs", + TypeFilter.PRACTICES to "Entraînements", + ) + options.forEachIndexed { index, (filter, label) -> + SegmentedButton( + selected = selected == filter, + onClick = { onSelect(filter) }, + shape = SegmentedButtonDefaults.itemShape(index, options.size), + ) { + Text(label) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FilterAssistChip( + label: String, + selectedValue: String?, + options: List, + onSelect: (String?) -> Unit, + modifier: Modifier = Modifier, +) { + var expanded by remember { mutableStateOf(false) } + val displayValue = selectedValue ?: "Tous" + + Box(modifier = modifier) { + AssistChip( + onClick = { expanded = true }, + label = { Text("$label: $displayValue") }, + trailingIcon = { + Icon( + imageVector = Icons.Filled.ArrowDropDown, + contentDescription = null, + ) + }, + colors = if (selectedValue != null) { + AssistChipDefaults.assistChipColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + labelColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } else { + AssistChipDefaults.assistChipColors() + }, + ) + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + DropdownMenuItem( + text = { Text("Tous") }, + onClick = { + onSelect(null) + expanded = false + }, + ) + options.forEach { option -> + DropdownMenuItem( + text = { Text(option) }, + onClick = { + onSelect(option) + expanded = false + }, + ) + } + } + } +} +``` + +- [ ] **Step 2: Delete FilterDropdown.kt** + +Delete `composeApp/src/commonMain/kotlin/ch/parano/myicek/ui/components/FilterDropdown.kt`: + +```bash +rm composeApp/src/commonMain/kotlin/ch/parano/myicek/ui/components/FilterDropdown.kt +``` + +- [ ] **Step 3: Compile to verify (expect failure — ScheduleScreen still references FilterDropdown)** + +Run: `./gradlew composeApp:compileKotlinDesktop` +Expected: FAIL — `Unresolved reference: FilterDropdown` in ScheduleScreen. This is expected; ScheduleScreen will be updated in Task 7. + +Note: We accept this intermediate broken state because ScheduleScreen is fully rewritten in Task 7 which resolves the reference. If you prefer to keep the build green between tasks, leave FilterDropdown.kt in place and delete it in Task 7 instead. + +- [ ] **Step 4: Commit** + +```bash +git add composeApp/src/commonMain/kotlin/ch/parano/myicek/ui/components/FilterChips.kt composeApp/src/commonMain/kotlin/ch/parano/myicek/ui/components/FilterDropdown.kt +git commit -m "feat: add FilterChips (SegmentedButton + AssistChip), remove FilterDropdown + +- TypeFilterSegmented: 3-segment single-choice for Tous/Matchs/Entraînements +- FilterAssistChip: chip with dropdown for account/age/subgroup +- Selected chips highlighted with primaryContainer +- Note: ScheduleScreen update in next task resolves FilterDropdown reference" +``` + +--- + +### Task 6: LoginScreen Redesign + +**Files:** +- Modify: `composeApp/src/commonMain/kotlin/ch/parano/myicek/ui/screens/LoginScreen.kt` (full rewrite) + +- [ ] **Step 1: Rewrite LoginScreen.kt** + +Replace the entire content of `composeApp/src/commonMain/kotlin/ch/parano/myicek/ui/screens/LoginScreen.kt` with: + +```kotlin +// SPDX-License-Identifier: GPL-3.0-or-later +// MyIce Kotlin Multiplatform — schedule/convocation viewer +// Copyright (C) 2026 parano.ch +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package ch.parano.myicek.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Login +import androidx.compose.material.icons.filled.SportsHockey +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import ch.parano.myicek.ui.theme.Dimens +import ch.parano.myicek.viewmodel.AuthState +import ch.parano.myicek.viewmodel.AuthViewModel + +@Composable +fun LoginScreen( + viewModel: AuthViewModel, + modifier: Modifier = Modifier, +) { + val state by viewModel.state.collectAsState() + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(state) { + if (state is AuthState.Unauthenticated) { + snackbarHostState.showSnackbar("Erreur de connexion") + } + } + + Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, + modifier = modifier, + ) { padding -> + Column( + modifier = Modifier.fillMaxSize().padding(padding), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Box( + modifier = Modifier + .size(80.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Filled.SportsHockey, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + Spacer(Modifier.height(Dimens.lg)) + Text( + text = "MyIce", + style = MaterialTheme.typography.headlineLarge, + fontWeight = FontWeight.Bold, + ) + Text( + text = "Votre calendrier hockey", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(Dimens.xxl)) + if (state is AuthState.Loading) { + CircularProgressIndicator() + } else { + Button( + onClick = { viewModel.login() }, + modifier = Modifier.height(48.dp), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Login, + contentDescription = null, + ) + Spacer(Modifier.size(Dimens.sm)) + Text("Se connecter avec Infomaniak") + } + } + } + } +} +``` + +Note: `Icons.Filled.SportsHockey` requires the `material-icons-extended` dependency added in Task 1. + +- [ ] **Step 2: Compile to verify** + +Run: `./gradlew composeApp:compileKotlinDesktop` +Expected: BUILD SUCCESSFUL + +- [ ] **Step 3: Commit** + +```bash +git add composeApp/src/commonMain/kotlin/ch/parano/myicek/ui/screens/LoginScreen.kt +git commit -m "feat: redesign LoginScreen with icon, branding, polished layout + +- Sports icon in primaryContainer circle (80dp) +- 'MyIce' branding (dropped 'K' tech codename) +- 'Votre calendrier hockey' subtitle +- Login icon on connect button +- Centered vertical layout with proper spacing" +``` + +--- + +### Task 7: ScheduleScreen Redesign + +**Files:** +- Modify: `composeApp/src/commonMain/kotlin/ch/parano/myicek/ui/screens/ScheduleScreen.kt` (full rewrite) + +**Interfaces:** +- Consumes: `TypeFilterSegmented`, `FilterAssistChip` from Task 5, `EmptyState`, `ErrorState`, `SkeletonCard` from Task 3, `EventCard` from Task 4, `groupEventsByDate` from Task 2, `formatEventDate` from util + +- [ ] **Step 1: Rewrite ScheduleScreen.kt** + +Replace the entire content of `composeApp/src/commonMain/kotlin/ch/parano/myicek/ui/screens/ScheduleScreen.kt` with: + +```kotlin +// SPDX-License-Identifier: GPL-3.0-or-later +// MyIce Kotlin Multiplatform — schedule/convocation viewer +// Copyright (C) 2026 parano.ch +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package ch.parano.myicek.ui.screens + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Logout +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.foundation.background +import androidx.compose.ui.Modifier +import androidx.compose.material.icons.filled.CalendarClear +import androidx.compose.material.icons.filled.CloudOff +import androidx.compose.material3.AssistChip +import ch.parano.myicek.filter.TypeFilter +import ch.parano.myicek.filter.groupEventsByDate +import ch.parano.myicek.ui.components.EmptyState +import ch.parano.myicek.ui.components.ErrorState +import ch.parano.myicek.ui.components.EventCard +import ch.parano.myicek.ui.components.FilterAssistChip +import ch.parano.myicek.ui.components.SkeletonCard +import ch.parano.myicek.ui.components.TypeFilterSegmented +import ch.parano.myicek.ui.theme.Dimens +import ch.parano.myicek.util.formatEventDate +import ch.parano.myicek.viewmodel.AuthViewModel +import ch.parano.myicek.viewmodel.ScheduleViewModel + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +fun ScheduleScreen( + scheduleViewModel: ScheduleViewModel, + authViewModel: AuthViewModel, + onEventClick: (gameId: String, account: String, eventTitle: String, eventStart: String, eventEnd: String) -> Unit, + modifier: Modifier = Modifier, +) { + val scheduleState by scheduleViewModel.state.collectAsState() + + LaunchedEffect(scheduleState.selectedAccount) { + if (scheduleState.selectedAccount != null && scheduleState.events.isEmpty()) { + scheduleViewModel.loadCachedSchedule() + } + } + + val hasActiveFilters = scheduleState.selectedAgegroup != null || + scheduleState.selectedSubgroup != null || + scheduleState.typeFilter != TypeFilter.DEFAULT + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Calendrier") }, + actions = { + IconButton(onClick = { scheduleViewModel.refreshSchedule() }) { + Icon( + imageVector = Icons.Filled.Refresh, + contentDescription = "Rafraîchir", + ) + } + IconButton(onClick = { + scheduleViewModel.clearCache() + authViewModel.logout() + }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Logout, + contentDescription = "Déconnexion", + ) + } + }, + ) + }, + modifier = modifier, + ) { padding -> + Column( + modifier = Modifier.fillMaxSize().padding(padding), + ) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Dimens.lg, vertical = Dimens.sm), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Column( + modifier = Modifier.padding(Dimens.lg), + verticalArrangement = Arrangement.spacedBy(Dimens.sm), + ) { + TypeFilterSegmented( + selected = scheduleState.typeFilter, + onSelect = { scheduleViewModel.setTypeFilter(it) }, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(Dimens.sm), + modifier = Modifier.fillMaxWidth(), + ) { + FilterAssistChip( + label = "Compte", + selectedValue = scheduleState.accounts.find { it.name == scheduleState.selectedAccount }?.label, + options = scheduleState.accounts.map { it.label }, + onSelect = { label -> + val account = scheduleState.accounts.find { it.label == label } + if (account != null) scheduleViewModel.setAccount(account.name) + }, + modifier = Modifier.weight(1f), + ) + FilterAssistChip( + label = "Âge", + selectedValue = scheduleState.selectedAgegroup, + options = scheduleState.agegroups, + onSelect = { scheduleViewModel.setAgegroup(it) }, + modifier = Modifier.weight(1f), + ) + } + Row( + horizontalArrangement = Arrangement.spacedBy(Dimens.sm), + modifier = Modifier.fillMaxWidth(), + ) { + FilterAssistChip( + label = "Sous-groupe", + selectedValue = scheduleState.selectedSubgroup, + options = scheduleState.subgroups, + onSelect = { scheduleViewModel.setSubgroup(it) }, + modifier = Modifier.weight(1f), + ) + if (hasActiveFilters) { + AssistChipClear( + onClear = { + scheduleViewModel.setAgegroup(null) + scheduleViewModel.setSubgroup(null) + scheduleViewModel.setTypeFilter(TypeFilter.DEFAULT) + }, + modifier = Modifier.weight(1f), + ) + } + } + } + } + + PullToRefreshBox( + isRefreshing = scheduleState.isRefreshing, + onRefresh = { scheduleViewModel.refreshSchedule() }, + modifier = Modifier.fillMaxSize(), + ) { + when { + scheduleState.isLoading && scheduleState.events.isEmpty() -> { + LazyColumn { + items(4) { SkeletonCard() } + } + } + scheduleState.error != null && scheduleState.events.isEmpty() -> { + ErrorState( + icon = Icons.Filled.CloudOff, + title = "Erreur de chargement", + message = scheduleState.error!!, + onRetry = { scheduleViewModel.refreshSchedule() }, + ) + } + scheduleState.filteredEvents.isEmpty() -> { + EmptyState( + icon = Icons.Filled.CalendarClear, + title = "Aucun événement disponible", + message = "Tirez vers le bas pour rafraîchir le calendrier", + ) + } + else -> { + val grouped = groupEventsByDate(scheduleState.filteredEvents) + LazyColumn( + modifier = Modifier.fillMaxSize(), + ) { + grouped.forEach { (dateKey, dayEvents) -> + stickyHeader { + Column( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .padding(horizontal = Dimens.lg, vertical = Dimens.sm), + ) { + Text( + text = formatEventDate(dateKey + "T00:00:00") + .replaceFirstChar { it.uppercase() }, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + HorizontalDivider() + } + } + items(dayEvents) { event -> + EventCard( + event = event, + onClick = { + onEventClick( + event.idEvent, + scheduleState.selectedAccount!!, + event.title, + event.start, + event.end, + ) + }, + ) + } + } + if (scheduleState.lastUpdated != null) { + item { + Text( + text = "Dernière mise à jour: ${formatLastUpdated(scheduleState.lastUpdated!!)}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .fillMaxWidth() + .padding(Dimens.lg), + ) + } + } + } + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AssistChipClear( + onClear: () -> Unit, + modifier: Modifier = Modifier, +) { + AssistChip( + onClick = onClear, + label = { Text("Effacer") }, + trailingIcon = { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = "Effacer les filtres", + ) + }, + modifier = modifier, + ) +} + +private fun formatLastUpdated(timestamp: Long): String { + val instant = kotlinx.datetime.Instant.fromEpochMilliseconds(timestamp) + val local = instant.toLocalDateTime(kotlinx.datetime.TimeZone.currentSystemDefault()) + return "${local.date} ${local.time}" +} +``` + +Note: Some imports may need adjustment. The key changes are: TopAppBar with icon buttons, FilterChips replacing FilterDropdown, sticky date headers, shared EmptyState/ErrorState, SkeletonCard loading, `lastUpdated` display, and `account.label` in the account chip. + +- [ ] **Step 2: Fix any compile errors** + +Run: `./gradlew composeApp:compileKotlinDesktop` + +If there are import errors, fix them. Common issues: +- `stickyHeader` needs `import androidx.compose.foundation.ExperimentalFoundationApi` and `import androidx.compose.foundation.lazy.stickyHeader` +- `background` needs `import androidx.compose.foundation.background` +- `toUpperCase` may need adjustment — use `replaceFirstChar { it.uppercase() }` instead + +Fix any errors until compilation succeeds. + +- [ ] **Step 3: Verify compilation succeeds** + +Run: `./gradlew composeApp:compileKotlinDesktop` +Expected: BUILD SUCCESSFUL + +- [ ] **Step 4: Run tests to check for regressions** + +Run: `./gradlew composeApp:desktopTest` +Expected: All tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add composeApp/src/commonMain/kotlin/ch/parano/myicek/ui/screens/ScheduleScreen.kt +git commit -m "feat: redesign ScheduleScreen with icon actions, filter chips, sticky date headers + +- TopAppBar: Refresh/Logout icon buttons, 'Calendrier' title +- TypeFilterSegmented + FilterAssistChips replace collapsible filter card +- Account dropdown uses label instead of name +- 'Effacer' chip appears when filters are active +- Sticky date headers group events by day +- SkeletonCard loading state (4 shimmer cards) +- ErrorState/EmptyState shared components +- lastUpdated timestamp displayed at bottom +- French accents corrected throughout" +``` + +--- + +### Task 8: EventDetailScreen Redesign + +**Files:** +- Modify: `composeApp/src/commonMain/kotlin/ch/parano/myicek/ui/screens/EventDetailScreen.kt` (full rewrite) + +- [ ] **Step 1: Rewrite EventDetailScreen.kt** + +Replace the entire content of `composeApp/src/commonMain/kotlin/ch/parano/myicek/ui/screens/EventDetailScreen.kt` with: + +```kotlin +// SPDX-License-Identifier: GPL-3.0-or-later +// MyIce Kotlin Multiplatform — schedule/convocation viewer +// Copyright (C) 2026 parano.ch +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package ch.parano.myicek.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.CalendarMonth +import androidx.compose.material.icons.filled.ErrorOutline +import androidx.compose.material.icons.filled.Event +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Place +import androidx.compose.material.icons.filled.Schedule +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import ch.parano.myicek.ui.PlatformBackHandler +import ch.parano.myicek.ui.components.ErrorState +import ch.parano.myicek.ui.components.LoadingIndicator +import ch.parano.myicek.ui.theme.Dimens +import ch.parano.myicek.util.formatEventDate +import ch.parano.myicek.util.formatEventTimeRange +import ch.parano.myicek.viewmodel.EventDetailViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EventDetailScreen( + gameId: String, + account: String, + eventTitle: String, + eventStart: String, + eventEnd: String, + viewModel: EventDetailViewModel, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + PlatformBackHandler { onBack() } + + LaunchedEffect(gameId, account) { + viewModel.loadEventDetail(gameId, account) + } + + val state by viewModel.state.collectAsState() + + Scaffold( + topBar = { + TopAppBar( + title = { Text(eventTitle) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Retour", + ) + } + }, + ) + }, + modifier = modifier, + ) { padding -> + when { + state.isLoading -> { + LoadingIndicator(modifier = Modifier.padding(padding)) + } + state.error != null -> { + ErrorState( + icon = Icons.Filled.ErrorOutline, + title = "Erreur", + message = state.error!!, + onRetry = { viewModel.loadEventDetail(gameId, account) }, + modifier = Modifier.padding(padding), + ) + } + state.eventDetail != null -> { + val detail = state.eventDetail!! + val players = detail.convocation.available.sortedBy { it.number?.toIntOrNull() ?: Int.MAX_VALUE } + val staff = detail.convocation.staff + val hasPlayers = players.isNotEmpty() + val hasStaff = staff.isNotEmpty() + + LazyColumn( + modifier = Modifier.fillMaxSize().padding(padding), + contentPadding = androidx.compose.foundation.layout.PaddingValues(Dimens.lg), + verticalArrangement = Arrangement.spacedBy(Dimens.lg), + ) { + item { + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + ) { + Column( + modifier = Modifier.padding(Dimens.lg), + verticalArrangement = Arrangement.spacedBy(Dimens.md), + ) { + InfoRow(Icons.Filled.Event, "Type", detail.type) + InfoRow(Icons.Filled.Place, "Lieu", detail.place) + InfoRow(Icons.Filled.CalendarMonth, "Date", formatEventDate(eventStart)) + InfoRow(Icons.Filled.Schedule, "Heure", formatEventTimeRange(eventStart, eventEnd)) + } + } + } + + if (!hasPlayers && !hasStaff) { + item { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + ), + ) { + Row( + modifier = Modifier.padding(Dimens.lg), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Dimens.sm), + ) { + Icon( + imageVector = Icons.Filled.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.onErrorContainer, + ) + Text( + text = "Aucun joueur ni personnel convoqué", + color = MaterialTheme.colorScheme.onErrorContainer, + ) + } + } + } + } + + if (hasPlayers) { + item { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Dimens.sm), + ) { + Text( + text = "Joueurs", + style = MaterialTheme.typography.titleMedium, + ) + BadgedBox(badge = { Badge { Text("${players.size}") } }) {} + } + } + item { + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + ) { + Column { + players.forEach { player -> + ListItem( + leadingContent = { + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + Text( + text = player.number?.let { "#$it" } ?: "", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + }, + headlineContent = { + Text("${player.fname} ${player.lname}") + }, + supportingContent = { + val parts = mutableListOf() + if (!player.position.isNullOrBlank()) parts.add(player.position) + if (player.dob.isNotBlank()) parts.add("(née ${player.dob.take(4)})") + if (parts.isNotEmpty()) Text(parts.joinToString(" ")) + }, + ) + } + } + } + } + } + + if (hasStaff) { + item { + Text( + text = "Personnel", + style = MaterialTheme.typography.titleMedium, + ) + } + item { + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + ) { + Column { + staff.forEach { member -> + ListItem( + leadingContent = { + Icon( + imageVector = Icons.Filled.Person, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + headlineContent = { + Text("${member.fname} ${member.lname}") + }, + trailingContent = { + AssistChip( + onClick = {}, + label = { Text(member.role) }, + colors = AssistChipDefaults.assistChipColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + labelColor = MaterialTheme.colorScheme.onSecondaryContainer, + ), + ) + }, + ) + } + } + } + } + } + } + } + else -> { + Column( + modifier = Modifier.fillMaxSize().padding(padding), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text("Aucune donnée disponible") + } + } + } + } +} + +@Composable +private fun InfoRow( + icon: androidx.compose.ui.graphics.vector.ImageVector, + label: String, + value: String, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Dimens.sm), + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Column { + Text( + text = label.uppercase(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = value, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } +} +``` + +- [ ] **Step 2: Compile to verify** + +Run: `./gradlew composeApp:compileKotlinDesktop` +Expected: BUILD SUCCESSFUL + +- [ ] **Step 3: Commit** + +```bash +git add composeApp/src/commonMain/kotlin/ch/parano/myicek/ui/screens/EventDetailScreen.kt +git commit -m "feat: redesign EventDetailScreen with icon back, styled info, ListItem players + +- ArrowBack icon replaces TextButton('Retour') +- Info card with label/value rows and leading icons +- Players as ListItem with number avatar in primaryContainer circle +- Staff as ListItem with role AssistChip +- ErrorState shared component +- errorContainer for empty convocation warning +- French accents corrected" +``` + +--- + +### Task 9: App.kt Transitions & Desktop Branding + +**Files:** +- Modify: `composeApp/src/commonMain/kotlin/ch/parano/myicek/App.kt` (add AnimatedContent) +- Modify: `composeApp/src/desktopMain/kotlin/ch/parano/myicek/Main.kt` (title "MyIce") + +- [ ] **Step 1: Update App.kt with AnimatedContent** + +In `composeApp/src/commonMain/kotlin/ch/parano/myicek/App.kt`, replace the `MyIceTheme` block (lines 85-105) with: + +```kotlin + MyIceTheme { + AnimatedContent( + targetState = currentScreen, + transitionSpec = { + androidx.compose.animation.fadeIn( + androidx.compose.animation.core.tween(300), + ) togetherWith androidx.compose.animation.fadeOut( + androidx.compose.animation.core.tween(300), + ) + }, + label = "screenTransition", + ) { screen -> + when (screen) { + Screen.Login -> LoginScreen(viewModel = authViewModel) + Screen.Schedule -> ScheduleScreen( + scheduleViewModel = scheduleViewModel, + authViewModel = authViewModel, + onEventClick = { gameId, account, eventTitle, eventStart, eventEnd -> + currentScreen = Screen.EventDetail(gameId, account, eventTitle, eventStart, eventEnd) + }, + ) + is Screen.EventDetail -> EventDetailScreen( + gameId = screen.gameId, + account = screen.account, + eventTitle = screen.eventTitle, + eventStart = screen.eventStart, + eventEnd = screen.eventEnd, + viewModel = eventDetailViewModel, + onBack = { currentScreen = Screen.Schedule }, + ) + } + } + } +``` + +Add these imports at the top of `App.kt`: + +```kotlin +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.core.tween +import androidx.compose.animation.togetherWith +``` + +- [ ] **Step 2: Update desktop Main.kt title** + +In `composeApp/src/desktopMain/kotlin/ch/parano/myicek/Main.kt`, change the Window title: + +```kotlin + Window(onCloseRequest = ::exitApplication, title = "MyIce") { +``` + +- [ ] **Step 3: Compile to verify** + +Run: `./gradlew composeApp:compileKotlinDesktop` +Expected: BUILD SUCCESSFUL + +- [ ] **Step 4: Run full test suite** + +Run: `./gradlew composeApp:desktopTest` +Expected: All tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add composeApp/src/commonMain/kotlin/ch/parano/myicek/App.kt composeApp/src/desktopMain/kotlin/ch/parano/myicek/Main.kt +git commit -m "feat: add screen transitions and rebrand to MyIce + +- AnimatedContent with fade transitions between screens (300ms) +- Desktop window title 'MyIce' (dropped 'K' codename)" +``` + +--- + +### Task 10: Final Verification + +- [ ] **Step 1: Full compile** + +Run: `./gradlew composeApp:compileKotlinDesktop` +Expected: BUILD SUCCESSFUL + +- [ ] **Step 2: Full test suite** + +Run: `./gradlew composeApp:desktopTest` +Expected: All tests PASS + +- [ ] **Step 3: Android compile check** + +Run: `./gradlew composeApp:assembleDebug` +Expected: BUILD SUCCESSFUL + +- [ ] **Step 4: Visual verification** + +Run: `./gradlew composeApp:run` +Expected: App launches with teal theme, redesigned login screen. Log in and verify: +- Login: icon + "MyIce" branding +- Schedule: icon actions, filter chips, sticky date headers, event cards with badges +- Event detail: arrow back, info card with icons, player list items with number avatars + +- [ ] **Step 5: Commit any remaining fixes** + +```bash +git add -A +git commit -m "fix: final adjustments from visual verification" +```