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
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
+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
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
@@ -4,9 +4,10 @@
<uses-permission android:name="android.permission.INTERNET" />
<application
android:label="MyIce"
android:label="MyIceK"
android:icon="@mipmap/ic_launcher"
android:supportsRtl="true"
android:enableOnBackInvokedCallback="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
<activity
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 {
data object Login : 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
@@ -82,14 +88,16 @@ fun App(
Screen.Schedule -> ScheduleScreen(
scheduleViewModel = scheduleViewModel,
authViewModel = authViewModel,
onEventClick = { gameId, account, eventTitle ->
currentScreen = Screen.EventDetail(gameId, account, eventTitle)
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 },
)
@@ -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,
)
Text(
text = formatEventDateTime(event.start, event.end),
text = "Date: ${formatEventDateTime(event.start, event.end)}",
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.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.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Card
@@ -43,7 +45,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import ch.parano.myice.ui.PlatformBackHandler
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
@OptIn(ExperimentalMaterial3Api::class)
@@ -52,10 +57,14 @@ 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)
}
@@ -113,7 +122,8 @@ fun EventDetailScreen(
) {
Text("Type: ${detail.type}")
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,
)
}
items(detail.convocation.available) { player ->
val position = player.position ?: "N/A"
val number = player.number ?: "N/A"
item {
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
Column(modifier = Modifier.padding(16.dp)) {
detail.convocation.available.forEach { player ->
val position = player.position ?: ""
val number = player.number ?: ""
val muted = MaterialTheme.colorScheme.onSurfaceVariant
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth(),
) {
Text("[$position] #$number - ${player.fname} ${player.lname}")
Text(
text = "DOB: ${player.dob}",
style = MaterialTheme.typography.bodySmall,
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,
)
}
items(detail.convocation.staff) { staff ->
item {
Card(modifier = Modifier.fillMaxWidth()) {
Text(
text = "${staff.role}: ${staff.fname} ${staff.lname}",
modifier = Modifier.padding(16.dp),
)
Column(modifier = Modifier.padding(16.dp)) {
detail.convocation.staff.forEach { staff ->
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,
) {
Text(
text = "MyIce",
text = "MyIceK",
style = MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.Bold,
)
@@ -65,7 +65,7 @@ import ch.parano.myice.viewmodel.ScheduleViewModel
fun ScheduleScreen(
scheduleViewModel: ScheduleViewModel,
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,
) {
val scheduleState by scheduleViewModel.state.collectAsState()
@@ -80,7 +80,7 @@ fun ScheduleScreen(
Scaffold(
topBar = {
TopAppBar(
title = { Text("MyIce") },
title = { Text("MyIceK") },
actions = {
val email = (authState as? ch.parano.myice.viewmodel.AuthState.Authenticated)?.email
if (email != null) {
@@ -250,6 +250,8 @@ fun ScheduleScreen(
event.idEvent,
scheduleState.selectedAccount!!,
event.title,
event.start,
event.end,
)
},
)
@@ -19,23 +19,43 @@ package ch.parano.myice.util
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 {
LocalDateTime.parse(iso)
LocalDateTime.parse(iso.replace(' ', 'T'))
}.getOrNull()
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 =
"${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 {
val startDt = parseOrNull(start)
val endDt = parseOrNull(end)
if (startDt == null || endDt == null) return "$start - $end"
return if (startDt.date == endDt.date) {
"${startDt.dateStr()} ${startDt.timeStr()} - ${endDt.timeStr()}"
"${startDt.dateStrLong()} de ${startDt.timeStr()} à ${endDt.timeStr()}"
} 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",
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
@@ -37,7 +46,7 @@ class EventDateTimeFormatterTest {
start = "2024-11-10T20:00: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
@@ -22,7 +22,7 @@ import androidx.compose.ui.window.application
import ch.parano.myice.auth.DesktopOAuthClient
fun main() = application {
Window(onCloseRequest = ::exitApplication, title = "MyIce") {
Window(onCloseRequest = ::exitApplication, title = "MyIceK") {
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
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).