# 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" ```