9 changed files with 59 additions and 16 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
+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
@@ -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 },
) )
@@ -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,
) )
} }
@@ -44,6 +44,8 @@ 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.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,6 +54,8 @@ 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,
@@ -113,7 +117,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)}")
} }
} }
} }
@@ -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()
@@ -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
@@ -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).