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:
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user