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.
This commit is contained in:
2026-08-06 15:20:47 +02:00
parent a2d5a57aec
commit 418a8978f2
52 changed files with 137 additions and 142 deletions
@@ -0,0 +1,106 @@
// 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
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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,
val eventStart: String,
val eventEnd: String,
) : Screen()
}
@Composable
fun App(
oauthClient: OAuthClient,
) {
val apiService = remember { ApiService(createHttpClient()) }
val authSettings = remember { AuthSettings(Settings()) }
val cache = remember { ScheduleCache(Settings()) }
val authViewModel = remember { AuthViewModel(apiService, authSettings, oauthClient) }
val scheduleViewModel = remember { ScheduleViewModel(apiService, cache, authSettings) }
val eventDetailViewModel = remember { EventDetailViewModel(apiService) }
var currentScreen by remember { mutableStateOf<Screen>(Screen.Login) }
LaunchedEffect(Unit) {
authViewModel.init()
}
val authState by authViewModel.state.collectAsState()
LaunchedEffect(authState) {
currentScreen = when (authState) {
is AuthState.Authenticated -> Screen.Schedule
is AuthState.Unauthenticated -> Screen.Login
AuthState.Loading -> currentScreen
}
if (authState is AuthState.Authenticated) {
scheduleViewModel.loadAccounts()
}
}
MyIceTheme {
when (val screen = currentScreen) {
Screen.Login -> LoginScreen(viewModel = authViewModel)
Screen.Schedule -> ScheduleScreen(
scheduleViewModel = scheduleViewModel,
authViewModel = authViewModel,
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,52 @@
// 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.auth
import ch.parano.myicek.network.AuthTokenHolder
import com.russhwolf.settings.Settings
class AuthSettings(private val settings: Settings) {
companion object {
private const val TOKEN_KEY = "access_token"
private const val ACCOUNT_KEY = "selected_account"
}
fun getStoredToken(): String? = settings.getStringOrNull(TOKEN_KEY)
fun setToken(token: String?) {
if (token != null) {
settings.putString(TOKEN_KEY, token)
} else {
settings.remove(TOKEN_KEY)
}
AuthTokenHolder.token = token
}
fun getStoredAccount(): String? = settings.getStringOrNull(ACCOUNT_KEY)
fun setAccount(account: String) {
settings.putString(ACCOUNT_KEY, account)
}
fun clear() {
settings.remove(TOKEN_KEY)
settings.remove(ACCOUNT_KEY)
AuthTokenHolder.token = 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.auth
interface OAuthClient {
fun redirectUri(): String
suspend fun authenticate(loginUrl: String): String?
}
@@ -0,0 +1,27 @@
// 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.auth
internal fun parseAccessTokenFromFragment(fragment: String): String? {
val params = fragment.split("&").associate { pair ->
val idx = pair.indexOf("=")
if (idx >= 0) pair.substring(0, idx) to pair.substring(idx + 1)
else pair to ""
}
return params["access_token"]
}
@@ -0,0 +1,124 @@
// 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.cache
import ch.parano.myicek.models.Event
import com.russhwolf.settings.Settings
import kotlin.time.Clock
import kotlin.time.ExperimentalTime
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
class ScheduleCache(private val settings: Settings) {
companion object {
private const val CHUNK_SIZE = 8000
}
private val json = Json { ignoreUnknownKeys = true }
private val eventListSerializer = ListSerializer(Event.serializer())
private fun eventsKey(account: String) = "cached_events_$account"
private fun eventsChunkCountKey(account: String) = "cached_events_count_$account"
private fun eventsChunkKey(account: String, index: Int) = "cached_events_chunk_${account}_$index"
private fun agegroupKey(account: String) = "filters_agegroup_$account"
private fun subgroupKey(account: String) = "filters_subgroup_$account"
private fun typeFilterKey(account: String) = "filters_typefilter_$account"
private fun timestampKey(account: String) = "cached_events_timestamp_$account"
@OptIn(ExperimentalTime::class)
fun saveEvents(account: String, events: List<Event>) {
val jsonStr = json.encodeToString(eventListSerializer, events)
val chunks = jsonStr.chunked(CHUNK_SIZE)
settings.remove(eventsKey(account))
settings.putInt(eventsChunkCountKey(account), chunks.size)
chunks.forEachIndexed { i, chunk ->
settings.putString(eventsChunkKey(account, i), chunk)
}
settings.putLong(timestampKey(account), Clock.System.now().toEpochMilliseconds())
}
fun loadEvents(account: String): List<Event>? {
val chunkCount = settings.getIntOrNull(eventsChunkCountKey(account))
if (chunkCount != null && chunkCount > 0) {
val jsonStr = (0 until chunkCount).mapNotNull { i ->
settings.getStringOrNull(eventsChunkKey(account, i))
}.joinToString("")
if (jsonStr.isEmpty()) return null
return try {
json.decodeFromString(eventListSerializer, jsonStr)
} catch (e: Exception) {
null
}
}
val jsonString = settings.getStringOrNull(eventsKey(account)) ?: return null
return try {
json.decodeFromString(eventListSerializer, jsonString)
} catch (e: Exception) {
null
}
}
fun getLastUpdated(account: String): Long? {
return settings.getLongOrNull(timestampKey(account))
}
fun saveFilters(account: String, agegroup: String?, subgroup: String?) {
if (agegroup != null) settings.putString(agegroupKey(account), agegroup)
else settings.remove(agegroupKey(account))
if (subgroup != null) settings.putString(subgroupKey(account), subgroup)
else settings.remove(subgroupKey(account))
}
fun loadFilters(account: String): Pair<String?, String?> {
return settings.getStringOrNull(agegroupKey(account)) to
settings.getStringOrNull(subgroupKey(account))
}
fun saveTypeFilter(account: String, typeFilter: String) {
settings.putString(typeFilterKey(account), typeFilter)
}
fun loadTypeFilter(account: String): String? {
return settings.getStringOrNull(typeFilterKey(account))
}
fun clearCache(account: String) {
val chunkCount = settings.getIntOrNull(eventsChunkCountKey(account)) ?: 0
for (i in 0 until chunkCount) {
settings.remove(eventsChunkKey(account, i))
}
settings.remove(eventsChunkCountKey(account))
settings.remove(eventsKey(account))
settings.remove(timestampKey(account))
settings.remove(agegroupKey(account))
settings.remove(subgroupKey(account))
settings.remove(typeFilterKey(account))
}
fun clearAllCache() {
val keys = settings.keys.filter { key ->
key.startsWith("cached_events_") ||
key.startsWith("filters_") ||
key.startsWith("cached_events_timestamp_")
}
keys.forEach { settings.remove(it) }
}
}
@@ -0,0 +1,54 @@
// 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.filter
import ch.parano.myicek.models.Event
fun extractSubgroup(name: String): String {
val parts = name.split(" - ")
return if (parts.size > 1) parts.dropLast(1).joinToString(" - ") else name
}
fun filterEvents(
events: List<Event>,
agegroup: String?,
subgroup: String?,
typeFilter: TypeFilter,
): List<Event> = events.filter { event ->
val typeMatches = when (typeFilter) {
TypeFilter.ALL -> true
TypeFilter.GAMES -> event.eventType == "Jeu"
TypeFilter.PRACTICES -> event.eventType != "Jeu"
}
if (!typeMatches) return@filter false
if (agegroup != null && event.agegroup != agegroup) return@filter false
if (subgroup != null) {
val eventSubgroup = extractSubgroup(event.name)
if (eventSubgroup != subgroup) return@filter false
}
true
}
fun List<Event>.distinctAgegroups(): List<String> =
map { it.agegroup }.toSet().sorted()
fun List<Event>.distinctSubgroups(): List<String> =
map { extractSubgroup(it.name) }.toSet().sorted()
@@ -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.filter
enum class TypeFilter {
ALL, GAMES, PRACTICES;
companion object {
val DEFAULT = GAMES
}
}
@@ -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.models
import kotlinx.serialization.Serializable
@Serializable
data class Account(
val name: String,
val label: String,
)
@@ -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.models
import kotlinx.serialization.Serializable
@Serializable
data class Convocation(
val available: List<Player> = emptyList(),
val staff: List<Staff> = emptyList(),
)
@@ -0,0 +1,37 @@
// 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.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class Event(
@SerialName("id_event")
@Serializable(with = FlexibleStringSerializer::class)
val idEvent: String = "",
val agegroup: String = "",
val name: String = "",
val title: String = "",
val opponent: String = "",
val place: String = "",
val start: String = "",
val end: String = "",
val color: String? = null,
@SerialName("event") val eventType: String = "",
)
@@ -0,0 +1,31 @@
// 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.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class EventDetail(
val title: String = "",
val type: String = "",
val place: String = "",
@SerialName("time_start") val timeStart: String = "",
@SerialName("time_end") val timeEnd: String = "",
val convocation: Convocation = Convocation(),
)
@@ -0,0 +1,43 @@
// 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.models
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerializationException
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.jsonPrimitive
object FlexibleStringSerializer : KSerializer<String> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("FlexibleString", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: String) {
encoder.encodeString(value)
}
override fun deserialize(decoder: Decoder): String {
val jsonDecoder = decoder as? JsonDecoder
?: throw SerializationException("FlexibleStringSerializer expects JsonDecoder")
return jsonDecoder.decodeJsonElement().jsonPrimitive.content
}
}
@@ -0,0 +1,29 @@
// 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.models
import kotlinx.serialization.Serializable
@Serializable
data class Player(
val position: String? = null,
@Serializable(with = FlexibleStringSerializer::class) val number: String? = null,
val fname: String,
val lname: String,
val dob: String,
)
@@ -0,0 +1,27 @@
// 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.models
import kotlinx.serialization.Serializable
@Serializable
data class Staff(
val role: String,
val fname: String,
val lname: String,
)
@@ -0,0 +1,25 @@
// 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.models
import kotlinx.serialization.Serializable
@Serializable
data class UserInfo(
val email: String = "",
)
@@ -0,0 +1,22 @@
// 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.network
object ApiConfig {
const val baseUrl = "https://myice.parano.ch"
}
@@ -0,0 +1,40 @@
// 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.network
import io.ktor.client.HttpClient
import io.ktor.client.engine.HttpClientEngineConfig
import io.ktor.client.engine.HttpClientEngineFactory
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.HttpTimeout
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
expect fun createEngine(): HttpClientEngineFactory<HttpClientEngineConfig>
fun createHttpClient(): HttpClient {
return HttpClient(createEngine()) {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
install(HttpTimeout) {
connectTimeoutMillis = 10_000
requestTimeoutMillis = 10_000
}
}
}
@@ -0,0 +1,55 @@
// 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.network
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
import io.ktor.client.request.header
import io.ktor.http.HttpHeaders
class ApiService(private val client: HttpClient) {
private fun authHeader(): String? = AuthTokenHolder.token?.let { "Bearer $it" }
suspend fun getUserInfo(): UserInfo =
client.get("${ApiConfig.baseUrl}/userinfo") {
authHeader()?.let { header(HttpHeaders.Authorization, it) }
}.body()
suspend fun getAccounts(): List<Account> =
client.get("${ApiConfig.baseUrl}/accounts") {
authHeader()?.let { header(HttpHeaders.Authorization, it) }
}.body()
suspend fun getSchedule(account: String): List<Event> =
client.get("${ApiConfig.baseUrl}/schedule") {
url.parameters.append("account", account)
authHeader()?.let { header(HttpHeaders.Authorization, it) }
}.body()
suspend fun getGameDetail(gameId: String, account: String): EventDetail =
client.get("${ApiConfig.baseUrl}/game/$gameId") {
url.parameters.append("account", account)
authHeader()?.let { header(HttpHeaders.Authorization, it) }
}.body()
}
@@ -0,0 +1,22 @@
// 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.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)
@@ -0,0 +1,94 @@
// 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.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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.myicek.models.Event
import ch.parano.myicek.util.formatEventDateTime
@Composable
fun EventCard(
event: Event,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val stripeColor = event.color?.let { parseHexColor(it) }
?: MaterialTheme.colorScheme.primary
Card(
onClick = onClick,
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Row {
Box(
modifier = Modifier
.width(4.dp)
.fillMaxHeight()
.background(stripeColor),
)
Column(
modifier = Modifier
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = "${event.agegroup} - ${event.name}",
style = MaterialTheme.typography.titleMedium,
)
Text(
text = event.title,
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = "Adversaire: ${event.opponent}",
style = MaterialTheme.typography.bodySmall,
)
Text(
text = "Lieu: ${event.place}",
style = MaterialTheme.typography.bodySmall,
)
Text(
text = "Date: ${formatEventDateTime(event.start, event.end)}",
style = MaterialTheme.typography.bodySmall,
)
}
}
}
}
private fun parseHexColor(hex: String): Color? {
val value = hex.removePrefix("#").toLongOrNull(16) ?: return null
return Color(0xFF000000 or value)
}
@@ -0,0 +1,73 @@
// 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.components
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FilterDropdown(
label: String,
selectedValue: String?,
options: List<String>,
onSelect: (String?) -> Unit,
modifier: Modifier = Modifier,
) {
var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it },
modifier = modifier,
) {
OutlinedTextField(
value = selectedValue ?: "",
onValueChange = {},
readOnly = true,
label = { Text(label) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) },
modifier = Modifier.menuAnchor().fillMaxWidth(),
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
) {
options.forEach { option ->
DropdownMenuItem(
text = { Text(option) },
onClick = {
onSelect(option)
expanded = false
},
)
}
}
}
}
@@ -0,0 +1,35 @@
// 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.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@Composable
fun LoadingIndicator(modifier: Modifier = Modifier) {
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}
@@ -0,0 +1,224 @@
// 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.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
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
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.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
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)
}
val state by viewModel.state.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = { Text(eventTitle) },
navigationIcon = {
androidx.compose.material3.TextButton(onClick = onBack) {
Text("Retour")
}
},
)
},
modifier = modifier,
) { padding ->
when {
state.isLoading -> {
LoadingIndicator(modifier = Modifier.padding(padding))
}
state.error != null -> {
Column(
modifier = Modifier.fillMaxSize().padding(padding),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = state.error!!,
color = MaterialTheme.colorScheme.error,
)
Spacer(modifier = Modifier.height(8.dp))
TextButton(onClick = { viewModel.loadEventDetail(gameId, account) }) {
Text("Reessayer")
}
}
}
state.eventDetail != null -> {
val detail = state.eventDetail!!
val hasPlayers = detail.convocation.available.isNotEmpty()
val hasStaff = detail.convocation.staff.isNotEmpty()
LazyColumn(
modifier = Modifier.fillMaxSize().padding(padding),
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
item {
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text("Type: ${detail.type}")
Text("Lieu: ${detail.place}")
Text("Date: ${formatEventDate(eventStart)}")
Text("Heure: ${formatEventTimeRange(eventStart, eventEnd)}")
}
}
}
if (!hasPlayers && !hasStaff) {
item {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = Color(0xFFFFC107),
),
) {
Text(
text = "Aucun joueur ni personnel convoque",
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(16.dp),
)
}
}
}
if (hasPlayers) {
item {
Text(
text = "Joueurs (${detail.convocation.available.size})",
style = MaterialTheme.typography.titleLarge,
)
}
item {
Card(modifier = Modifier.fillMaxWidth()) {
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)
}
}
}
}
}
}
if (hasStaff) {
item {
Text(
text = "Personnel",
style = MaterialTheme.typography.titleLarge,
)
}
item {
Card(modifier = Modifier.fillMaxWidth()) {
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)
}
}
}
}
}
}
}
}
else -> {
Column(
modifier = Modifier.fillMaxSize().padding(padding),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text("Aucune donnee disponible")
}
}
}
}
}
@@ -0,0 +1,89 @@
// 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.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
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.myicek.viewmodel.AuthViewModel
import ch.parano.myicek.viewmodel.AuthState
@Composable
fun LoginScreen(
viewModel: AuthViewModel,
modifier: Modifier = Modifier,
) {
val state by viewModel.state.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(state) {
if (state is AuthState.Unauthenticated) {
snackbarHostState.showSnackbar("Erreur de connexion")
}
}
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
modifier = modifier,
) { padding ->
Column(
modifier = Modifier.fillMaxSize().padding(padding),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = "MyIceK",
style = MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.Bold,
)
Text(
text = "Games",
style = MaterialTheme.typography.titleMedium,
)
Spacer(modifier = Modifier.height(32.dp))
if (state is AuthState.Loading) {
CircularProgressIndicator()
} else {
Button(
onClick = { viewModel.login() },
) {
Text("Se connecter avec Infomaniak")
}
}
}
}
}
@@ -0,0 +1,273 @@
// 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.screens
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
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.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
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 androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
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, eventStart: String, eventEnd: String) -> Unit,
modifier: Modifier = Modifier,
) {
val scheduleState by scheduleViewModel.state.collectAsState()
val authState by authViewModel.state.collectAsState()
LaunchedEffect(scheduleState.selectedAccount) {
if (scheduleState.selectedAccount != null && scheduleState.events.isEmpty()) {
scheduleViewModel.loadCachedSchedule()
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("MyIceK") },
actions = {
val email = (authState as? ch.parano.myicek.viewmodel.AuthState.Authenticated)?.email
if (email != null) {
Text(
text = email,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = 8.dp),
)
}
TextButton(onClick = { scheduleViewModel.refreshSchedule() }) {
Text("Rafraichir")
}
TextButton(onClick = {
scheduleViewModel.clearCache()
authViewModel.logout()
}) {
Text("Deconnexion")
}
},
)
},
modifier = modifier,
) { padding ->
Column(
modifier = Modifier.fillMaxSize().padding(padding),
) {
var filtersExpanded by remember { mutableStateOf(false) }
val activeFilterCount = listOf(
scheduleState.selectedAccount,
scheduleState.selectedAgegroup,
scheduleState.selectedSubgroup,
).count { it != null } + if (scheduleState.typeFilter != TypeFilter.GAMES) 1 else 0
Card(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { filtersExpanded = !filtersExpanded }
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "Filtres",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium,
)
if (activeFilterCount > 0) {
Spacer(Modifier.height(0.dp))
Text(
text = "($activeFilterCount actif${if (activeFilterCount > 1) "s" else ""})",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 8.dp),
)
}
Spacer(Modifier.weight(1f))
Text(
text = if (filtersExpanded) "" else "",
style = MaterialTheme.typography.bodySmall,
)
}
AnimatedVisibility(
visible = filtersExpanded,
enter = expandVertically(),
exit = shrinkVertically(),
) {
Column(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
FilterDropdown(
label = "Compte",
selectedValue = scheduleState.selectedAccount,
options = scheduleState.accounts.map { it.name },
onSelect = { it?.let { acc -> scheduleViewModel.setAccount(acc) } },
modifier = Modifier.weight(1f),
)
FilterDropdown(
label = "Type",
selectedValue = typeFilterLabel(scheduleState.typeFilter),
options = listOf("Tous", "Matchs", "Entrainements"),
onSelect = { label ->
val filter = when (label) {
"Tous" -> TypeFilter.ALL
"Matchs" -> TypeFilter.GAMES
"Entrainements" -> TypeFilter.PRACTICES
else -> TypeFilter.DEFAULT
}
scheduleViewModel.setTypeFilter(filter)
},
modifier = Modifier.weight(1f),
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
FilterDropdown(
label = "Age",
selectedValue = scheduleState.selectedAgegroup,
options = scheduleState.agegroups,
onSelect = { scheduleViewModel.setAgegroup(it) },
modifier = Modifier.weight(1f),
)
FilterDropdown(
label = "Sous-groupe",
selectedValue = scheduleState.selectedSubgroup,
options = scheduleState.subgroups,
onSelect = { scheduleViewModel.setSubgroup(it) },
modifier = Modifier.weight(1f),
)
}
}
}
}
PullToRefreshBox(
isRefreshing = scheduleState.isRefreshing,
onRefresh = { scheduleViewModel.refreshSchedule() },
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
when {
scheduleState.isLoading && scheduleState.events.isEmpty() -> {
LoadingIndicator()
}
scheduleState.error != null && scheduleState.events.isEmpty() -> {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = scheduleState.error!!,
color = MaterialTheme.colorScheme.error,
)
Spacer(modifier = Modifier.height(8.dp))
TextButton(onClick = { scheduleViewModel.refreshSchedule() }) {
Text("Reessayer")
}
}
}
scheduleState.filteredEvents.isEmpty() -> {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text("Aucun evenement disponible")
if (scheduleState.events.isEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Appuyez sur le bouton de rafraichissement pour charger les evenements",
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
)
}
}
}
else -> {
LazyColumn(
modifier = Modifier.fillMaxSize(),
) {
items(scheduleState.filteredEvents) { event ->
EventCard(
event = event,
onClick = {
onEventClick(
event.idEvent,
scheduleState.selectedAccount!!,
event.title,
event.start,
event.end,
)
},
)
}
}
}
}
}
}
}
}
private fun typeFilterLabel(filter: TypeFilter): String = when (filter) {
TypeFilter.ALL -> "Tous"
TypeFilter.GAMES -> "Matchs"
TypeFilter.PRACTICES -> "Entrainements"
}
@@ -0,0 +1,54 @@
// 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.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
private val Indigo = Color(0xFF3F51B5)
private val IndigoLight = Color(0xFF7986CB)
private val IndigoDark = Color(0xFF303F9F)
private val LightColors = lightColorScheme(
primary = Indigo,
onPrimary = Color.White,
primaryContainer = IndigoDark,
secondary = IndigoLight,
)
private val DarkColors = darkColorScheme(
primary = IndigoLight,
onPrimary = Color.Black,
primaryContainer = Indigo,
secondary = Indigo,
)
@Composable
fun MyIceTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
MaterialTheme(
colorScheme = if (darkTheme) DarkColors else LightColors,
content = content,
)
}
@@ -0,0 +1,65 @@
// 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.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.replace(' ', 'T'))
}.getOrNull()
private fun LocalDateTime.dateStr(): String =
"${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 =
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.dateStrLong()} de ${startDt.timeStr()} à ${endDt.timeStr()}"
} else {
"${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()}"
}
@@ -0,0 +1,89 @@
// 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.viewmodel
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
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
sealed class AuthState {
data object Loading : AuthState()
data class Authenticated(val email: String) : AuthState()
data object Unauthenticated : AuthState()
}
class AuthViewModel(
private val apiService: ApiService,
private val authSettings: AuthSettings,
private val oauthClient: OAuthClient,
) : ViewModel() {
private val _state = MutableStateFlow<AuthState>(AuthState.Loading)
val state: StateFlow<AuthState> = _state.asStateFlow()
fun init() {
viewModelScope.launch {
val token = authSettings.getStoredToken()
if (token != null) {
AuthTokenHolder.token = token
try {
val userInfo = apiService.getUserInfo()
_state.value = AuthState.Authenticated(userInfo.email)
} catch (e: Exception) {
authSettings.clear()
_state.value = AuthState.Unauthenticated
}
} else {
_state.value = AuthState.Unauthenticated
}
}
}
fun login() {
viewModelScope.launch {
_state.value = AuthState.Loading
try {
val loginUrl = "${ApiConfig.baseUrl}/login?redirect_uri=" +
oauthClient.redirectUri()
val token = oauthClient.authenticate(loginUrl)
if (token != null) {
authSettings.setToken(token)
val userInfo = apiService.getUserInfo()
_state.value = AuthState.Authenticated(userInfo.email)
} else {
_state.value = AuthState.Unauthenticated
}
} catch (e: Exception) {
_state.value = AuthState.Unauthenticated
}
}
}
fun logout() {
authSettings.clear()
_state.value = AuthState.Unauthenticated
}
}
@@ -0,0 +1,56 @@
// 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.viewmodel
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
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
data class EventDetailState(
val eventDetail: EventDetail? = null,
val isLoading: Boolean = false,
val error: String? = null,
)
class EventDetailViewModel(
private val apiService: ApiService,
) : ViewModel() {
private val _state = MutableStateFlow(EventDetailState(isLoading = false))
val state: StateFlow<EventDetailState> = _state.asStateFlow()
fun loadEventDetail(gameId: String, account: String) {
viewModelScope.launch {
_state.value = EventDetailState(isLoading = true)
try {
val detail = apiService.getGameDetail(gameId, account)
_state.value = EventDetailState(eventDetail = detail, isLoading = false)
} catch (e: Exception) {
_state.value = EventDetailState(
error = "Failed to load event detail: ${e.message}",
isLoading = false,
)
}
}
}
}
@@ -0,0 +1,188 @@
// 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.viewmodel
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
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
data class ScheduleState(
val accounts: List<Account> = emptyList(),
val events: List<Event> = emptyList(),
val selectedAccount: String? = null,
val selectedAgegroup: String? = null,
val selectedSubgroup: String? = null,
val typeFilter: TypeFilter = TypeFilter.DEFAULT,
val isLoading: Boolean = false,
val isRefreshing: Boolean = false,
val error: String? = null,
val lastUpdated: Long? = null,
) {
val filteredEvents: List<Event>
get() = filterEvents(events, selectedAgegroup, selectedSubgroup, typeFilter)
val agegroups: List<String>
get() = events.distinctAgegroups()
val subgroups: List<String>
get() = if (selectedAgegroup == null) emptyList()
else events.filter { it.agegroup == selectedAgegroup }.distinctSubgroups()
}
class ScheduleViewModel(
private val apiService: ApiService,
private val cache: ScheduleCache,
private val authSettings: AuthSettings? = null,
) : ViewModel() {
private val _state = MutableStateFlow(ScheduleState())
val state: StateFlow<ScheduleState> = _state.asStateFlow()
fun loadAccounts() {
viewModelScope.launch {
try {
val accounts = apiService.getAccounts()
val storedAccount = authSettings?.getStoredAccount()
var selected = storedAccount
if (selected == null || accounts.none { it.name == selected }) {
selected = accounts.firstOrNull()?.name
}
val (cachedAgegroup, cachedSubgroup) = if (selected != null) {
cache.loadFilters(selected)
} else null to null
val cachedTypeFilter = if (selected != null) {
cache.loadTypeFilter(selected)?.let { name ->
runCatching { TypeFilter.valueOf(name) }.getOrNull()
} ?: TypeFilter.DEFAULT
} else TypeFilter.DEFAULT
_state.value = _state.value.copy(
accounts = accounts,
selectedAccount = selected,
selectedAgegroup = cachedAgegroup,
selectedSubgroup = cachedSubgroup,
typeFilter = cachedTypeFilter,
)
} catch (e: Exception) {
_state.value = _state.value.copy(error = "Failed to load accounts: ${e.message}")
}
}
}
fun loadCachedSchedule() {
val account = _state.value.selectedAccount ?: return
_state.value = _state.value.copy(isLoading = true, error = null)
val cachedEvents = cache.loadEvents(account)
if (cachedEvents != null) {
_state.value = _state.value.copy(
events = cachedEvents,
lastUpdated = cache.getLastUpdated(account),
isLoading = false,
)
} else {
_state.value = _state.value.copy(events = emptyList(), isLoading = false)
}
if (_state.value.events.isEmpty()) {
refreshSchedule()
}
}
fun refreshSchedule() {
val account = _state.value.selectedAccount ?: return
viewModelScope.launch {
_state.value = _state.value.copy(isRefreshing = true, error = null)
try {
val allEvents = apiService.getSchedule(account)
cache.saveEvents(account, allEvents)
_state.value = _state.value.copy(
events = allEvents,
isRefreshing = false,
lastUpdated = cache.getLastUpdated(account),
)
} catch (e: Exception) {
_state.value = _state.value.copy(
isRefreshing = false,
error = "Failed to refresh schedule: ${e.message}",
)
}
}
}
fun setAccount(account: String) {
authSettings?.setAccount(account)
val (cachedAgegroup, cachedSubgroup) = cache.loadFilters(account)
val cachedTypeFilter = cache.loadTypeFilter(account)?.let { name ->
runCatching { TypeFilter.valueOf(name) }.getOrNull()
} ?: TypeFilter.DEFAULT
_state.value = _state.value.copy(
selectedAccount = account,
selectedAgegroup = cachedAgegroup,
selectedSubgroup = cachedSubgroup,
typeFilter = cachedTypeFilter,
)
loadCachedSchedule()
}
fun setAgegroup(agegroup: String?) {
val account = _state.value.selectedAccount
if (account != null) {
cache.saveFilters(account, agegroup, null)
}
_state.value = _state.value.copy(selectedAgegroup = agegroup, selectedSubgroup = null)
}
fun setSubgroup(subgroup: String?) {
val account = _state.value.selectedAccount
if (account != null) {
cache.saveFilters(account, _state.value.selectedAgegroup, subgroup)
}
_state.value = _state.value.copy(selectedSubgroup = subgroup)
}
fun setTypeFilter(filter: TypeFilter) {
val account = _state.value.selectedAccount
if (account != null) {
cache.saveTypeFilter(account, filter.name)
}
_state.value = _state.value.copy(typeFilter = filter)
}
fun clearCache() {
cache.clearAllCache()
}
}