Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e5c2fd864
|
||
|
|
9524bc93ca
|
||
|
|
1795337d01 |
@@ -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,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()}"
|
||||||
|
}
|
||||||
|
|||||||
+11
-2
@@ -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).
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user