Plan covers: theme foundation, date grouping helper (TDD), shared components, EventCard/FilterChips/LoginScreen/ScheduleScreen/ EventDetailScreen redesigns, App.kt transitions, desktop branding.
1906 lines
74 KiB
Markdown
1906 lines
74 KiB
Markdown
# 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 <https://www.gnu.org/licenses/>.
|
|
|
|
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 <https://www.gnu.org/licenses/>.
|
|
|
|
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<Event>): List<Pair<String, List<Event>>>` — 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<Event>): List<Pair<String, List<Event>>> =
|
|
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 <https://www.gnu.org/licenses/>.
|
|
|
|
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 <https://www.gnu.org/licenses/>.
|
|
|
|
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 <https://www.gnu.org/licenses/>.
|
|
|
|
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 <https://www.gnu.org/licenses/>.
|
|
|
|
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<String>, 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 <https://www.gnu.org/licenses/>.
|
|
|
|
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<String>,
|
|
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 <https://www.gnu.org/licenses/>.
|
|
|
|
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 <https://www.gnu.org/licenses/>.
|
|
|
|
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 <https://www.gnu.org/licenses/>.
|
|
|
|
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<String>()
|
|
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"
|
|
```
|