Compare commits

..
Author SHA1 Message Date
herel efaa93d907 chore: add targets to makefile 2026-08-06 15:28:20 +02:00
herel 418a8978f2 ref: Rename package ch.parano.myice to ch.parano.myicek
Complete the MyIceK rename by updating the Kotlin package from ch.parano.myice to ch.parano.myicek across all source sets (androidMain, commonMain, desktopMain, iosMain, commonTest), the build.gradle.kts (namespace, applicationId, mainClass, desktop packageName) and the iOS Info.plist bundle URL name. The myice:// OAuth scheme is kept unchanged as it depends on the backend.

Also add pull-to-refresh to ScheduleScreen using Material 3 PullToRefreshBox, replacing the manual loading overlay.
2026-08-06 15:20:47 +02:00
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
57 changed files with 347 additions and 167 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
+16
View File
@@ -0,0 +1,16 @@
.PHONY: android desktop android-usb android-emulator
ADB := $(shell command -v adb 2>/dev/null || echo "$(HOME)/Library/Android/sdk/platform-tools/adb")
APK := composeApp/build/outputs/apk/debug/composeApp-debug.apk
android:
./gradlew composeApp:assembleDebug
desktop:
./gradlew composeApp:run
android-usb: android
$(ADB) -d install -r $(APK)
android-emulator: android
$(ADB) -e install -r $(APK)
+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 -4
View File
@@ -83,11 +83,11 @@ kotlin {
}
android {
namespace = "ch.parano.myice"
namespace = "ch.parano.myicek"
compileSdk = 36
defaultConfig {
applicationId = "ch.parano.myice"
applicationId = "ch.parano.myicek"
minSdk = 24
targetSdk = 35
versionCode = 1
@@ -118,10 +118,10 @@ android {
compose.desktop {
application {
mainClass = "ch.parano.myice.MainKt"
mainClass = "ch.parano.myicek.MainKt"
nativeDistributions {
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
packageName = "myice"
packageName = "myicek"
packageVersion = "1.0.0"
}
}
@@ -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"
@@ -15,13 +15,13 @@
// 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
package ch.parano.myicek
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import ch.parano.myice.auth.AndroidOAuthClient
import ch.parano.myicek.auth.AndroidOAuthClient
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@@ -15,12 +15,12 @@
// 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
package ch.parano.myicek
import android.app.Activity
import android.net.Uri
import android.os.Bundle
import ch.parano.myice.auth.OAuthCallbackHolder
import ch.parano.myicek.auth.OAuthCallbackHolder
class OAuthCallbackActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
@@ -15,7 +15,7 @@
// 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.auth
package ch.parano.myicek.auth
import android.content.Context
import android.content.Intent
@@ -15,7 +15,7 @@
// 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.network
package ch.parano.myicek.network
import io.ktor.client.engine.HttpClientEngineConfig
import io.ktor.client.engine.HttpClientEngineFactory
@@ -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.myicek.ui
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
@Composable
actual fun PlatformBackHandler(enabled: Boolean, onBack: () -> Unit) {
BackHandler(enabled = enabled, onBack = onBack)
}
@@ -15,7 +15,7 @@
// 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
package ch.parano.myicek
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -24,25 +24,31 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import ch.parano.myice.auth.AuthSettings
import ch.parano.myice.auth.OAuthClient
import ch.parano.myice.cache.ScheduleCache
import ch.parano.myice.network.ApiService
import ch.parano.myice.network.createHttpClient
import ch.parano.myice.ui.screens.EventDetailScreen
import ch.parano.myice.ui.screens.LoginScreen
import ch.parano.myice.ui.screens.ScheduleScreen
import ch.parano.myice.ui.theme.MyIceTheme
import ch.parano.myice.viewmodel.AuthState
import ch.parano.myice.viewmodel.AuthViewModel
import ch.parano.myice.viewmodel.EventDetailViewModel
import ch.parano.myice.viewmodel.ScheduleViewModel
import ch.parano.myicek.auth.AuthSettings
import ch.parano.myicek.auth.OAuthClient
import ch.parano.myicek.cache.ScheduleCache
import ch.parano.myicek.network.ApiService
import ch.parano.myicek.network.createHttpClient
import ch.parano.myicek.ui.screens.EventDetailScreen
import ch.parano.myicek.ui.screens.LoginScreen
import ch.parano.myicek.ui.screens.ScheduleScreen
import ch.parano.myicek.ui.theme.MyIceTheme
import ch.parano.myicek.viewmodel.AuthState
import ch.parano.myicek.viewmodel.AuthViewModel
import ch.parano.myicek.viewmodel.EventDetailViewModel
import ch.parano.myicek.viewmodel.ScheduleViewModel
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 },
)
@@ -15,9 +15,9 @@
// 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.auth
package ch.parano.myicek.auth
import ch.parano.myice.network.AuthTokenHolder
import ch.parano.myicek.network.AuthTokenHolder
import com.russhwolf.settings.Settings
class AuthSettings(private val settings: Settings) {
@@ -15,7 +15,7 @@
// 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.auth
package ch.parano.myicek.auth
interface OAuthClient {
fun redirectUri(): String
@@ -15,7 +15,7 @@
// 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.auth
package ch.parano.myicek.auth
internal fun parseAccessTokenFromFragment(fragment: String): String? {
val params = fragment.split("&").associate { pair ->
@@ -15,9 +15,9 @@
// 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.cache
package ch.parano.myicek.cache
import ch.parano.myice.models.Event
import ch.parano.myicek.models.Event
import com.russhwolf.settings.Settings
import kotlin.time.Clock
import kotlin.time.ExperimentalTime
@@ -15,9 +15,9 @@
// 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.filter
package ch.parano.myicek.filter
import ch.parano.myice.models.Event
import ch.parano.myicek.models.Event
fun extractSubgroup(name: String): String {
val parts = name.split(" - ")
@@ -15,7 +15,7 @@
// 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.filter
package ch.parano.myicek.filter
enum class TypeFilter {
ALL, GAMES, PRACTICES;
@@ -15,7 +15,7 @@
// 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.models
package ch.parano.myicek.models
import kotlinx.serialization.Serializable
@@ -15,7 +15,7 @@
// 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.models
package ch.parano.myicek.models
import kotlinx.serialization.Serializable
@@ -15,7 +15,7 @@
// 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.models
package ch.parano.myicek.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@@ -15,7 +15,7 @@
// 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.models
package ch.parano.myicek.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@@ -15,7 +15,7 @@
// 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.models
package ch.parano.myicek.models
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerializationException
@@ -15,7 +15,7 @@
// 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.models
package ch.parano.myicek.models
import kotlinx.serialization.Serializable
@@ -15,7 +15,7 @@
// 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.models
package ch.parano.myicek.models
import kotlinx.serialization.Serializable
@@ -15,7 +15,7 @@
// 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.models
package ch.parano.myicek.models
import kotlinx.serialization.Serializable
@@ -15,7 +15,7 @@
// 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.network
package ch.parano.myicek.network
object ApiConfig {
const val baseUrl = "https://myice.parano.ch"
@@ -15,7 +15,7 @@
// 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.network
package ch.parano.myicek.network
import io.ktor.client.HttpClient
import io.ktor.client.engine.HttpClientEngineConfig
@@ -15,12 +15,12 @@
// 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.network
package ch.parano.myicek.network
import ch.parano.myice.models.Account
import ch.parano.myice.models.Event
import ch.parano.myice.models.EventDetail
import ch.parano.myice.models.UserInfo
import ch.parano.myicek.models.Account
import ch.parano.myicek.models.Event
import ch.parano.myicek.models.EventDetail
import ch.parano.myicek.models.UserInfo
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.get
@@ -15,7 +15,7 @@
// 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.network
package ch.parano.myicek.network
object AuthTokenHolder {
var token: String? = null
@@ -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.myicek.ui
import androidx.compose.runtime.Composable
@Composable
expect fun PlatformBackHandler(enabled: Boolean = true, onBack: () -> Unit)
@@ -15,7 +15,7 @@
// 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.components
package ch.parano.myicek.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@@ -33,8 +33,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import ch.parano.myice.models.Event
import ch.parano.myice.util.formatEventDateTime
import ch.parano.myicek.models.Event
import ch.parano.myicek.util.formatEventDateTime
@Composable
fun EventCard(
@@ -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,
)
}
@@ -15,7 +15,7 @@
// 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.components
package ch.parano.myicek.ui.components
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.DropdownMenuItem
@@ -15,7 +15,7 @@
// 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.components
package ch.parano.myicek.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
@@ -15,15 +15,17 @@
// 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.screens
package ch.parano.myicek.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,8 +45,11 @@ 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.components.LoadingIndicator
import ch.parano.myice.viewmodel.EventDetailViewModel
import ch.parano.myicek.ui.PlatformBackHandler
import ch.parano.myicek.ui.components.LoadingIndicator
import ch.parano.myicek.util.formatEventDate
import ch.parano.myicek.util.formatEventTimeRange
import ch.parano.myicek.viewmodel.EventDetailViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -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),
) {
Text("[$position] #$number - ${player.fname} ${player.lname}")
Text(
text = "DOB: ${player.dob}",
style = MaterialTheme.typography.bodySmall,
)
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(
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)
}
}
}
}
}
}
@@ -15,7 +15,7 @@
// 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.screens
package ch.parano.myicek.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -39,8 +39,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import ch.parano.myice.viewmodel.AuthViewModel
import ch.parano.myice.viewmodel.AuthState
import ch.parano.myicek.viewmodel.AuthViewModel
import ch.parano.myicek.viewmodel.AuthState
@Composable
fun LoginScreen(
@@ -66,7 +66,7 @@ fun LoginScreen(
verticalArrangement = Arrangement.Center,
) {
Text(
text = "MyIce",
text = "MyIceK",
style = MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.Bold,
)
@@ -15,7 +15,7 @@
// 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.screens
package ch.parano.myicek.ui.screens
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
@@ -31,7 +31,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -53,19 +53,19 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import ch.parano.myice.filter.TypeFilter
import ch.parano.myice.ui.components.EventCard
import ch.parano.myice.ui.components.FilterDropdown
import ch.parano.myice.ui.components.LoadingIndicator
import ch.parano.myice.viewmodel.AuthViewModel
import ch.parano.myice.viewmodel.ScheduleViewModel
import ch.parano.myicek.filter.TypeFilter
import ch.parano.myicek.ui.components.EventCard
import ch.parano.myicek.ui.components.FilterDropdown
import ch.parano.myicek.ui.components.LoadingIndicator
import ch.parano.myicek.viewmodel.AuthViewModel
import ch.parano.myicek.viewmodel.ScheduleViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
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,9 +80,9 @@ fun ScheduleScreen(
Scaffold(
topBar = {
TopAppBar(
title = { Text("MyIce") },
title = { Text("MyIceK") },
actions = {
val email = (authState as? ch.parano.myice.viewmodel.AuthState.Authenticated)?.email
val email = (authState as? ch.parano.myicek.viewmodel.AuthState.Authenticated)?.email
if (email != null) {
Text(
text = email,
@@ -201,7 +201,9 @@ fun ScheduleScreen(
}
}
}
Box(
PullToRefreshBox(
isRefreshing = scheduleState.isRefreshing,
onRefresh = { scheduleViewModel.refreshSchedule() },
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
@@ -250,6 +252,8 @@ fun ScheduleScreen(
event.idEvent,
scheduleState.selectedAccount!!,
event.title,
event.start,
event.end,
)
},
)
@@ -257,23 +261,6 @@ fun ScheduleScreen(
}
}
}
if (scheduleState.isRefreshing) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Card {
Column(
modifier = Modifier.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(16.dp))
Text("Chargement...", fontWeight = FontWeight.Medium)
}
}
}
}
}
}
}
@@ -15,7 +15,7 @@
// 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.theme
package ch.parano.myicek.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
@@ -15,27 +15,51 @@
// 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.util
package ch.parano.myicek.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 val FRENCH_DAYS = listOf(
"Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi", "Dimanche",
)
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 =
"${FRENCH_DAYS[date.dayOfWeek.ordinal]} $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()}"
}
@@ -15,13 +15,13 @@
// 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.viewmodel
package ch.parano.myicek.viewmodel
import ch.parano.myice.auth.AuthSettings
import ch.parano.myice.auth.OAuthClient
import ch.parano.myice.network.ApiConfig
import ch.parano.myice.network.ApiService
import ch.parano.myice.network.AuthTokenHolder
import ch.parano.myicek.auth.AuthSettings
import ch.parano.myicek.auth.OAuthClient
import ch.parano.myicek.network.ApiConfig
import ch.parano.myicek.network.ApiService
import ch.parano.myicek.network.AuthTokenHolder
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
@@ -15,10 +15,10 @@
// 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.viewmodel
package ch.parano.myicek.viewmodel
import ch.parano.myice.models.EventDetail
import ch.parano.myice.network.ApiService
import ch.parano.myicek.models.EventDetail
import ch.parano.myicek.network.ApiService
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
@@ -15,17 +15,17 @@
// 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.viewmodel
package ch.parano.myicek.viewmodel
import ch.parano.myice.auth.AuthSettings
import ch.parano.myice.cache.ScheduleCache
import ch.parano.myice.filter.TypeFilter
import ch.parano.myice.filter.distinctAgegroups
import ch.parano.myice.filter.distinctSubgroups
import ch.parano.myice.filter.filterEvents
import ch.parano.myice.models.Account
import ch.parano.myice.models.Event
import ch.parano.myice.network.ApiService
import ch.parano.myicek.auth.AuthSettings
import ch.parano.myicek.cache.ScheduleCache
import ch.parano.myicek.filter.TypeFilter
import ch.parano.myicek.filter.distinctAgegroups
import ch.parano.myicek.filter.distinctSubgroups
import ch.parano.myicek.filter.filterEvents
import ch.parano.myicek.models.Account
import ch.parano.myicek.models.Event
import ch.parano.myicek.network.ApiService
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
@@ -15,9 +15,9 @@
// 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.cache
package ch.parano.myicek.cache
import ch.parano.myice.models.Event
import ch.parano.myicek.models.Event
import com.russhwolf.settings.MapSettings
import com.russhwolf.settings.Settings
import kotlin.test.Test
@@ -15,9 +15,9 @@
// 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.filter
package ch.parano.myicek.filter
import ch.parano.myice.models.Event
import ch.parano.myicek.models.Event
import kotlin.test.Test
import kotlin.test.assertEquals
@@ -15,7 +15,7 @@
// 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.models
package ch.parano.myicek.models
import kotlinx.serialization.json.Json
import kotlin.test.Test
@@ -15,9 +15,9 @@
// 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.network
package ch.parano.myicek.network
import ch.parano.myice.models.EventDetail
import ch.parano.myicek.models.EventDetail
import io.ktor.client.HttpClient
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.respond
@@ -15,7 +15,7 @@
// 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.util
package ch.parano.myicek.util
import kotlin.test.Test
import kotlin.test.assertEquals
@@ -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("Dimanche 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("Dimanche 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("Dimanche 10 novembre 2024 de 20h à 1h30 (Lundi 11 novembre 2024)", formatted)
}
@Test
@@ -57,4 +66,10 @@ class EventDateTimeFormatterTest {
val formatted = formatEventDateTime(start = "s", end = "e")
assertEquals("s - e", formatted)
}
@Test
fun formatsEventDateWithDayOfWeek() {
val formatted = formatEventDate("2026-08-09T10:00:00")
assertEquals("Dimanche 9 août 2026", formatted)
}
}
@@ -15,13 +15,13 @@
// 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.viewmodel
package ch.parano.myicek.viewmodel
import ch.parano.myice.cache.ScheduleCache
import ch.parano.myice.filter.TypeFilter
import ch.parano.myice.models.Account
import ch.parano.myice.models.Event
import ch.parano.myice.network.ApiService
import ch.parano.myicek.cache.ScheduleCache
import ch.parano.myicek.filter.TypeFilter
import ch.parano.myicek.models.Account
import ch.parano.myicek.models.Event
import ch.parano.myicek.network.ApiService
import com.russhwolf.settings.MapSettings
import io.ktor.client.HttpClient
import io.ktor.client.engine.mock.MockEngine
@@ -15,14 +15,14 @@
// 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
package ch.parano.myicek
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import ch.parano.myice.auth.DesktopOAuthClient
import ch.parano.myicek.auth.DesktopOAuthClient
fun main() = application {
Window(onCloseRequest = ::exitApplication, title = "MyIce") {
Window(onCloseRequest = ::exitApplication, title = "MyIceK") {
App(oauthClient = DesktopOAuthClient())
}
}
@@ -15,7 +15,7 @@
// 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.auth
package ch.parano.myicek.auth
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.TimeoutCancellationException
@@ -15,7 +15,7 @@
// 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.network
package ch.parano.myicek.network
import io.ktor.client.engine.HttpClientEngineConfig
import io.ktor.client.engine.HttpClientEngineFactory
@@ -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.myicek.ui
import androidx.compose.runtime.Composable
@Composable
actual fun PlatformBackHandler(enabled: Boolean, onBack: () -> Unit) {
}
@@ -15,10 +15,10 @@
// 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
package ch.parano.myicek
import androidx.compose.ui.window.ComposeUIViewController
import ch.parano.myice.auth.IosOAuthClient
import ch.parano.myicek.auth.IosOAuthClient
fun MainViewController() = ComposeUIViewController {
App(oauthClient = IosOAuthClient())
@@ -15,7 +15,7 @@
// 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.auth
package ch.parano.myicek.auth
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.coroutines.TimeoutCancellationException
@@ -15,7 +15,7 @@
// 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.network
package ch.parano.myicek.network
import io.ktor.client.engine.HttpClientEngineConfig
import io.ktor.client.engine.HttpClientEngineFactory
@@ -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.myicek.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).
+1 -1
View File
@@ -61,7 +61,7 @@
<array>
<dict>
<key>CFBundleURLName</key>
<string>ch.parano.myice</string>
<string>ch.parano.myicek</string>
<key>CFBundleURLSchemes</key>
<array>
<string>myice</string>