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