Author SHA1 Message Date
herel a2d5a57aec feat(ui): Rename app from MyIce to MyIceK
Update the app display name in all user-visible locations:
Android manifest label, desktop window title, login screen,
and schedule screen top bar.

Refs #2
2026-08-06 13:49:35 +02:00
herel ea15c0423c feat(ui): Display staff in single card with aligned columns
Replace per-staff cards with a single card listing all staff. Each
line shows the name on the left and role in muted color on the right,
matching the players section style.
2026-08-06 11:39:50 +02:00
herel 3dc5890a7a feat(ui): Display players in single card with aligned columns
Replace per-player cards with a single card listing all players. Each
player shows on one line: number, name, position, and birth year in
muted color. Add Makefile with android and desktop targets.
2026-08-04 12:24:51 +02:00
herel fd8fcb9b4c fix(ui): Wire system back gesture to in-app navigation
Add PlatformBackHandler (expect/actual) to intercept the back gesture
on Android via BackHandler, and enable OnBackInvokedCallback in the
manifest so the swipe-back gesture triggers onBack instead of exiting
the app.

Fixes the back gesture exiting to home from the event detail screen.
2026-08-04 12:02:30 +02:00
herel 1e5c2fd864 chore: remove mention of myice_mobile 2026-08-04 11:15:05 +02:00
herel 9524bc93ca feat: show nice dates in events 2026-08-04 11:14:51 +02:00
herel 1795337d01 Merge pull request 'fix(ui): Show event dates in card view' (#1) from fix/event-card-date-display into main
Reviewed-on: #1
2026-08-04 07:59:29 +00:00
17 changed files with 212 additions and 36 deletions
-1
View File
@@ -3,7 +3,6 @@
## Project ## Project
MyIce — Kotlin Multiplatform ice hockey schedule/convocation viewer (Android, iOS, Desktop). MyIce — Kotlin Multiplatform ice hockey schedule/convocation viewer (Android, iOS, Desktop).
Port of the Flutter app at `~/work/gitea.parano.ch/herel/myice/myice_mobile/`.
## Build Commands ## Build Commands
+7
View File
@@ -0,0 +1,7 @@
.PHONY: android desktop
android:
./gradlew composeApp:assembleDebug
desktop:
./gradlew composeApp:run
+1 -1
View File
@@ -1,7 +1,7 @@
# MyIce Kotlin Multiplatform # MyIce Kotlin Multiplatform
Ice hockey schedule and convocation viewer for Android, iOS, and Desktop (macOS/Windows/Linux). Ice hockey schedule and convocation viewer for Android, iOS, and Desktop (macOS/Windows/Linux).
Port of the [Flutter app](https://gitea.parano.ch/herel/myice/myice_mobile) to Kotlin Multiplatform with Compose Multiplatform. Kotlin Multiplatform with Compose Multiplatform.
## Features ## Features
@@ -4,9 +4,10 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<application <application
android:label="MyIce" android:label="MyIceK"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:supportsRtl="true" android:supportsRtl="true"
android:enableOnBackInvokedCallback="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar"> android:theme="@android:style/Theme.Material.Light.NoActionBar">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
@@ -0,0 +1,26 @@
// 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.myice.ui
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
@Composable
actual fun PlatformBackHandler(enabled: Boolean, onBack: () -> Unit) {
BackHandler(enabled = enabled, onBack = onBack)
}
@@ -42,7 +42,13 @@ import com.russhwolf.settings.Settings
sealed class Screen { sealed class Screen {
data object Login : Screen() data object Login : Screen()
data object Schedule : Screen() data object Schedule : Screen()
data class EventDetail(val gameId: String, val account: String, val eventTitle: String) : Screen() data class EventDetail(
val gameId: String,
val account: String,
val eventTitle: String,
val eventStart: String,
val eventEnd: String,
) : Screen()
} }
@Composable @Composable
@@ -82,14 +88,16 @@ fun App(
Screen.Schedule -> ScheduleScreen( Screen.Schedule -> ScheduleScreen(
scheduleViewModel = scheduleViewModel, scheduleViewModel = scheduleViewModel,
authViewModel = authViewModel, authViewModel = authViewModel,
onEventClick = { gameId, account, eventTitle -> onEventClick = { gameId, account, eventTitle, eventStart, eventEnd ->
currentScreen = Screen.EventDetail(gameId, account, eventTitle) currentScreen = Screen.EventDetail(gameId, account, eventTitle, eventStart, eventEnd)
}, },
) )
is Screen.EventDetail -> EventDetailScreen( is Screen.EventDetail -> EventDetailScreen(
gameId = screen.gameId, gameId = screen.gameId,
account = screen.account, account = screen.account,
eventTitle = screen.eventTitle, eventTitle = screen.eventTitle,
eventStart = screen.eventStart,
eventEnd = screen.eventEnd,
viewModel = eventDetailViewModel, viewModel = eventDetailViewModel,
onBack = { currentScreen = Screen.Schedule }, onBack = { currentScreen = Screen.Schedule },
) )
@@ -0,0 +1,23 @@
// 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.myice.ui
import androidx.compose.runtime.Composable
@Composable
expect fun PlatformBackHandler(enabled: Boolean = true, onBack: () -> Unit)
@@ -80,7 +80,7 @@ fun EventCard(
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
Text( Text(
text = formatEventDateTime(event.start, event.end), text = "Date: ${formatEventDateTime(event.start, event.end)}",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
} }
@@ -19,11 +19,13 @@ package ch.parano.myice.ui.screens
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Card import androidx.compose.material3.Card
@@ -43,7 +45,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import ch.parano.myice.ui.PlatformBackHandler
import ch.parano.myice.ui.components.LoadingIndicator import ch.parano.myice.ui.components.LoadingIndicator
import ch.parano.myice.util.formatEventDate
import ch.parano.myice.util.formatEventTimeRange
import ch.parano.myice.viewmodel.EventDetailViewModel import ch.parano.myice.viewmodel.EventDetailViewModel
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -52,10 +57,14 @@ fun EventDetailScreen(
gameId: String, gameId: String,
account: String, account: String,
eventTitle: String, eventTitle: String,
eventStart: String,
eventEnd: String,
viewModel: EventDetailViewModel, viewModel: EventDetailViewModel,
onBack: () -> Unit, onBack: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
PlatformBackHandler { onBack() }
LaunchedEffect(gameId, account) { LaunchedEffect(gameId, account) {
viewModel.loadEventDetail(gameId, account) viewModel.loadEventDetail(gameId, account)
} }
@@ -113,7 +122,8 @@ fun EventDetailScreen(
) { ) {
Text("Type: ${detail.type}") Text("Type: ${detail.type}")
Text("Lieu: ${detail.place}") Text("Lieu: ${detail.place}")
Text("Heure: ${detail.timeStart} - ${detail.timeEnd}") Text("Date: ${formatEventDate(eventStart)}")
Text("Heure: ${formatEventTimeRange(eventStart, eventEnd)}")
} }
} }
} }
@@ -142,18 +152,31 @@ fun EventDetailScreen(
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
) )
} }
items(detail.convocation.available) { player -> item {
val position = player.position ?: "N/A"
val number = player.number ?: "N/A"
Card(modifier = Modifier.fillMaxWidth()) { Card(modifier = Modifier.fillMaxWidth()) {
Column( Column(modifier = Modifier.padding(16.dp)) {
modifier = Modifier.padding(16.dp), detail.convocation.available.forEach { player ->
) { val position = player.position ?: ""
Text("[$position] #$number - ${player.fname} ${player.lname}") val number = player.number ?: ""
Text( val muted = MaterialTheme.colorScheme.onSurfaceVariant
text = "DOB: ${player.dob}", Row(
style = MaterialTheme.typography.bodySmall, verticalAlignment = Alignment.CenterVertically,
) horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth(),
) {
Text(
if (number.isNotEmpty()) "#$number" else "",
color = muted,
modifier = Modifier.width(40.dp),
)
Text("${player.fname} ${player.lname}")
Spacer(Modifier.weight(1f))
if (position.isNotEmpty()) {
Text(position, color = muted)
}
Text("(${player.dob.take(4)})", color = muted)
}
}
} }
} }
} }
@@ -166,12 +189,22 @@ fun EventDetailScreen(
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
) )
} }
items(detail.convocation.staff) { staff -> item {
Card(modifier = Modifier.fillMaxWidth()) { Card(modifier = Modifier.fillMaxWidth()) {
Text( Column(modifier = Modifier.padding(16.dp)) {
text = "${staff.role}: ${staff.fname} ${staff.lname}", detail.convocation.staff.forEach { staff ->
modifier = Modifier.padding(16.dp), val muted = MaterialTheme.colorScheme.onSurfaceVariant
) Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth(),
) {
Text("${staff.fname} ${staff.lname}")
Spacer(Modifier.weight(1f))
Text(staff.role, color = muted)
}
}
}
} }
} }
} }
@@ -66,7 +66,7 @@ fun LoginScreen(
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
Text( Text(
text = "MyIce", text = "MyIceK",
style = MaterialTheme.typography.headlineLarge, style = MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
@@ -65,7 +65,7 @@ import ch.parano.myice.viewmodel.ScheduleViewModel
fun ScheduleScreen( fun ScheduleScreen(
scheduleViewModel: ScheduleViewModel, scheduleViewModel: ScheduleViewModel,
authViewModel: AuthViewModel, authViewModel: AuthViewModel,
onEventClick: (gameId: String, account: String, eventTitle: String) -> Unit, onEventClick: (gameId: String, account: String, eventTitle: String, eventStart: String, eventEnd: String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val scheduleState by scheduleViewModel.state.collectAsState() val scheduleState by scheduleViewModel.state.collectAsState()
@@ -80,7 +80,7 @@ fun ScheduleScreen(
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("MyIce") }, title = { Text("MyIceK") },
actions = { actions = {
val email = (authState as? ch.parano.myice.viewmodel.AuthState.Authenticated)?.email val email = (authState as? ch.parano.myice.viewmodel.AuthState.Authenticated)?.email
if (email != null) { if (email != null) {
@@ -250,6 +250,8 @@ fun ScheduleScreen(
event.idEvent, event.idEvent,
scheduleState.selectedAccount!!, scheduleState.selectedAccount!!,
event.title, event.title,
event.start,
event.end,
) )
}, },
) )
@@ -19,23 +19,43 @@ package ch.parano.myice.util
import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalDateTime
private val FRENCH_MONTHS = listOf(
"janvier", "février", "mars", "avril", "mai", "juin",
"juillet", "août", "septembre", "octobre", "novembre", "décembre",
)
private fun parseOrNull(iso: String): LocalDateTime? = runCatching { private fun parseOrNull(iso: String): LocalDateTime? = runCatching {
LocalDateTime.parse(iso) LocalDateTime.parse(iso.replace(' ', 'T'))
}.getOrNull() }.getOrNull()
private fun LocalDateTime.dateStr(): String = private fun LocalDateTime.dateStr(): String =
"${day.toString().padStart(2, '0')}.${monthNumber.toString().padStart(2, '0')}.${year.toString().padStart(4, '0')}" "${year.toString().padStart(4, '0')}-${monthNumber.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}"
private fun LocalDateTime.dateStrLong(): String =
"$day ${FRENCH_MONTHS[monthNumber - 1]} $year"
private fun LocalDateTime.timeStr(): String = private fun LocalDateTime.timeStr(): String =
"${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}" if (minute == 0) "${hour}h" else "${hour}h${minute.toString().padStart(2, '0')}"
fun formatEventDateTime(start: String, end: String): String { fun formatEventDateTime(start: String, end: String): String {
val startDt = parseOrNull(start) val startDt = parseOrNull(start)
val endDt = parseOrNull(end) val endDt = parseOrNull(end)
if (startDt == null || endDt == null) return "$start - $end" if (startDt == null || endDt == null) return "$start - $end"
return if (startDt.date == endDt.date) { return if (startDt.date == endDt.date) {
"${startDt.dateStr()} ${startDt.timeStr()} - ${endDt.timeStr()}" "${startDt.dateStrLong()} de ${startDt.timeStr()} à ${endDt.timeStr()}"
} else { } else {
"${startDt.dateStr()} ${startDt.timeStr()} - ${endDt.timeStr()} (${endDt.dateStr()})" "${startDt.dateStrLong()} de ${startDt.timeStr()} à ${endDt.timeStr()} (${endDt.dateStrLong()})"
} }
} }
fun formatEventDate(start: String): String {
val dt = parseOrNull(start)
return dt?.dateStrLong() ?: start
}
fun formatEventTimeRange(start: String, end: String): String {
val startDt = parseOrNull(start)
val endDt = parseOrNull(end)
if (startDt == null || endDt == null) return "$start - $end"
return "${startDt.timeStr()} - ${endDt.timeStr()}"
}
@@ -28,7 +28,16 @@ class EventDateTimeFormatterTest {
start = "2024-11-10T14:00:00", start = "2024-11-10T14:00:00",
end = "2024-11-10T16:15:00", end = "2024-11-10T16:15:00",
) )
assertEquals("10.11.2024 14:00 - 16:15", formatted) assertEquals("10 novembre 2024 de 14h à 16h15", formatted)
}
@Test
fun formatsSameDayEventWithSpaceSeparator() {
val formatted = formatEventDateTime(
start = "2026-08-09 10:00:00",
end = "2026-08-09 12:00:00",
)
assertEquals("9 août 2026 de 10h à 12h", formatted)
} }
@Test @Test
@@ -37,7 +46,7 @@ class EventDateTimeFormatterTest {
start = "2024-11-10T20:00:00", start = "2024-11-10T20:00:00",
end = "2024-11-11T01:30:00", end = "2024-11-11T01:30:00",
) )
assertEquals("10.11.2024 20:00 - 01:30 (11.11.2024)", formatted) assertEquals("10 novembre 2024 de 20h à 1h30 (11 novembre 2024)", formatted)
} }
@Test @Test
@@ -22,7 +22,7 @@ import androidx.compose.ui.window.application
import ch.parano.myice.auth.DesktopOAuthClient import ch.parano.myice.auth.DesktopOAuthClient
fun main() = application { fun main() = application {
Window(onCloseRequest = ::exitApplication, title = "MyIce") { Window(onCloseRequest = ::exitApplication, title = "MyIceK") {
App(oauthClient = DesktopOAuthClient()) App(oauthClient = DesktopOAuthClient())
} }
} }
@@ -0,0 +1,24 @@
// 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.myice.ui
import androidx.compose.runtime.Composable
@Composable
actual fun PlatformBackHandler(enabled: Boolean, onBack: () -> Unit) {
}
@@ -0,0 +1,24 @@
// 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.myice.ui
import androidx.compose.runtime.Composable
@Composable
actual fun PlatformBackHandler(enabled: Boolean, onBack: () -> Unit) {
}
@@ -6,7 +6,7 @@
## Context ## Context
The existing [MyIce webapp](https://gitea.parano.ch/herel/myice) is a FastAPI backend at `https://myice.parano.ch` that proxies the upstream MyIce Hockey API (`app.myice.hockey`). It serves a single-page web frontend (`index.html`) and a JSON API consumed by a Flutter mobile app (`myice_mobile/`). The existing [MyIce webapp](https://gitea.parano.ch/herel/myice) is a FastAPI backend at `https://myice.parano.ch` that proxies the upstream MyIce Hockey API (`app.myice.hockey`). It serves a single-page web frontend (`index.html`) and a JSON API.
This spec describes a **Kotlin Multiplatform (KMP) port** of the Flutter app, targeting **Android, iOS, and Desktop** (macOS/Linux/Windows JVM). It mirrors the Flutter app's clean, simple architecture while using idiomatic KMP tooling, and adds one small feature improvement: showing **practices** alongside games (the Flutter app fetches but discards them). This spec describes a **Kotlin Multiplatform (KMP) port** of the Flutter app, targeting **Android, iOS, and Desktop** (macOS/Linux/Windows JVM). It mirrors the Flutter app's clean, simple architecture while using idiomatic KMP tooling, and adds one small feature improvement: showing **practices** alongside games (the Flutter app fetches but discards them).