From 294aebd0b44f5aaef3990b7c79a0078a28c3f07b Mon Sep 17 00:00:00 2001 From: Rene Luria Date: Wed, 8 Jul 2026 17:25:41 +0200 Subject: [PATCH] feat: add AuthViewModel, ScheduleViewModel, and EventDetailViewModel with StateFlow --- .../ch/parano/myice/auth/OAuthClient.kt | 1 + .../parano/myice/viewmodel/AuthViewModel.kt | 72 ++++++ .../myice/viewmodel/EventDetailViewModel.kt | 39 ++++ .../myice/viewmodel/ScheduleViewModel.kt | 171 ++++++++++++++ .../myice/viewmodel/ScheduleViewModelTest.kt | 212 ++++++++++++++++++ 5 files changed, 495 insertions(+) create mode 100644 composeApp/src/commonMain/kotlin/ch/parano/myice/viewmodel/AuthViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/ch/parano/myice/viewmodel/EventDetailViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/ch/parano/myice/viewmodel/ScheduleViewModel.kt create mode 100644 composeApp/src/commonTest/kotlin/ch/parano/myice/viewmodel/ScheduleViewModelTest.kt diff --git a/composeApp/src/commonMain/kotlin/ch/parano/myice/auth/OAuthClient.kt b/composeApp/src/commonMain/kotlin/ch/parano/myice/auth/OAuthClient.kt index 07c311f..47fb75b 100644 --- a/composeApp/src/commonMain/kotlin/ch/parano/myice/auth/OAuthClient.kt +++ b/composeApp/src/commonMain/kotlin/ch/parano/myice/auth/OAuthClient.kt @@ -1,5 +1,6 @@ package ch.parano.myice.auth interface OAuthClient { + fun redirectUri(): String suspend fun authenticate(loginUrl: String): String? } diff --git a/composeApp/src/commonMain/kotlin/ch/parano/myice/viewmodel/AuthViewModel.kt b/composeApp/src/commonMain/kotlin/ch/parano/myice/viewmodel/AuthViewModel.kt new file mode 100644 index 0000000..7c205d1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ch/parano/myice/viewmodel/AuthViewModel.kt @@ -0,0 +1,72 @@ +package ch.parano.myice.viewmodel + +import ch.parano.myice.auth.AuthSettings +import ch.parano.myice.auth.OAuthClient +import ch.parano.myice.network.ApiConfig +import ch.parano.myice.network.ApiService +import ch.parano.myice.network.AuthTokenHolder +import 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.Loading) + val state: StateFlow = _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 + } +} diff --git a/composeApp/src/commonMain/kotlin/ch/parano/myice/viewmodel/EventDetailViewModel.kt b/composeApp/src/commonMain/kotlin/ch/parano/myice/viewmodel/EventDetailViewModel.kt new file mode 100644 index 0000000..7f8fde1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ch/parano/myice/viewmodel/EventDetailViewModel.kt @@ -0,0 +1,39 @@ +package ch.parano.myice.viewmodel + +import ch.parano.myice.models.EventDetail +import ch.parano.myice.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 = _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, + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/ch/parano/myice/viewmodel/ScheduleViewModel.kt b/composeApp/src/commonMain/kotlin/ch/parano/myice/viewmodel/ScheduleViewModel.kt new file mode 100644 index 0000000..f6efa44 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ch/parano/myice/viewmodel/ScheduleViewModel.kt @@ -0,0 +1,171 @@ +package ch.parano.myice.viewmodel + +import ch.parano.myice.auth.AuthSettings +import ch.parano.myice.cache.ScheduleCache +import ch.parano.myice.filter.TypeFilter +import ch.parano.myice.filter.distinctAgegroups +import ch.parano.myice.filter.distinctSubgroups +import ch.parano.myice.filter.filterEvents +import ch.parano.myice.models.Account +import ch.parano.myice.models.Event +import ch.parano.myice.network.ApiService +import 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 = emptyList(), + val events: List = 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 + get() = filterEvents(events, selectedAgegroup, selectedSubgroup, typeFilter) + + val agegroups: List + get() = events.distinctAgegroups() + + val subgroups: List + 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 = _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() + } +} diff --git a/composeApp/src/commonTest/kotlin/ch/parano/myice/viewmodel/ScheduleViewModelTest.kt b/composeApp/src/commonTest/kotlin/ch/parano/myice/viewmodel/ScheduleViewModelTest.kt new file mode 100644 index 0000000..e71b357 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/ch/parano/myice/viewmodel/ScheduleViewModelTest.kt @@ -0,0 +1,212 @@ +package ch.parano.myice.viewmodel + +import ch.parano.myice.cache.ScheduleCache +import ch.parano.myice.filter.TypeFilter +import ch.parano.myice.models.Account +import ch.parano.myice.models.Event +import ch.parano.myice.network.ApiService +import com.russhwolf.settings.MapSettings +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.MockEngineConfig +import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlinx.serialization.json.Json +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +class ScheduleViewModelTest { + + @BeforeTest + fun setUpDispatcher() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @AfterTest + fun tearDownDispatcher() { + Dispatchers.resetMain() + } + + private val json = Json { ignoreUnknownKeys = true } + + private fun mockApi(accountsJson: String, scheduleJson: String): ApiService { + val config = MockEngineConfig().apply { + addHandler { request -> + val path = request.url.encodedPath + val response = when { + path == "/accounts" -> accountsJson + path == "/schedule" -> scheduleJson + else -> "[]" + } + respond( + content = response, + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + } + dispatcher = Dispatchers.Unconfined + } + val client = HttpClient(MockEngine(config)) { + install(ContentNegotiation) { json(json) } + } + return ApiService(client) + } + + private fun sampleEvent( + id: String = "1", + agegroup: String = "U13 (Elite)", + name: String = "U13 Elite - HC Ajoie", + eventType: String = "Jeu", + ) = Event( + idEvent = id, agegroup = agegroup, name = name, title = "Title", + opponent = "Opp", place = "Place", start = "2024-11-10T14:00:00", + end = "2024-11-10T16:15:00", color = "#e4222e", eventType = eventType, + ) + + @Test + fun testLoadAccounts() = runTest { + val api = mockApi( + """[{"name":"default","label":"Default"}]""", + "[]", + ) + val cache = ScheduleCache(MapSettings()) + val vm = ScheduleViewModel(api, cache) + + vm.loadAccounts() + + assertEquals(1, vm.state.value.accounts.size) + assertEquals("default", vm.state.value.selectedAccount) + } + + @Test + fun testRefreshScheduleStoresEvents() = runTest { + val scheduleJson = """ + [{"id_event":"1","agegroup":"U13 (Elite)","name":"U13 Elite - HC Ajoie","title":"T","opponent":"O","place":"P","start":"2024-11-10T14:00:00","end":"2024-11-10T16:15:00","color":"#e4222e","event":"Jeu"}] + """.trimIndent() + val api = mockApi("""[{"name":"default","label":"Default"}]""", scheduleJson) + val cache = ScheduleCache(MapSettings()) + val vm = ScheduleViewModel(api, cache) + + vm.loadAccounts() + vm.refreshSchedule() + + assertEquals(1, vm.state.value.events.size) + assertEquals("1", vm.state.value.events[0].idEvent) + } + + @Test + fun testFilteredEventsDefaultGamesOnly() = runTest { + val scheduleJson = """ + [{"id_event":"1","agegroup":"U13","name":"U13 - A","title":"T","opponent":"O","place":"P","start":"s","end":"e","event":"Jeu"}, + {"id_event":"2","agegroup":"U13","name":"U13 - B","title":"T","opponent":"","place":"P","start":"s","end":"e"}] + """.trimIndent() + val api = mockApi("""[{"name":"default","label":"Default"}]""", scheduleJson) + val cache = ScheduleCache(MapSettings()) + val vm = ScheduleViewModel(api, cache) + + vm.loadAccounts() + vm.refreshSchedule() + + assertEquals(2, vm.state.value.events.size) + assertEquals(1, vm.state.value.filteredEvents.size) + assertEquals("1", vm.state.value.filteredEvents[0].idEvent) + } + + @Test + fun testSetTypeFilterAll() = runTest { + val scheduleJson = """ + [{"id_event":"1","agegroup":"U13","name":"U13 - A","title":"T","opponent":"O","place":"P","start":"s","end":"e","event":"Jeu"}, + {"id_event":"2","agegroup":"U13","name":"U13 - B","title":"T","opponent":"","place":"P","start":"s","end":"e"}] + """.trimIndent() + val api = mockApi("""[{"name":"default","label":"Default"}]""", scheduleJson) + val cache = ScheduleCache(MapSettings()) + val vm = ScheduleViewModel(api, cache) + + vm.loadAccounts() + vm.refreshSchedule() + vm.setTypeFilter(TypeFilter.ALL) + + assertEquals(2, vm.state.value.filteredEvents.size) + } + + @Test + fun testSetAgegroup() = runTest { + val scheduleJson = """ + [{"id_event":"1","agegroup":"U13","name":"U13 - A","title":"T","opponent":"O","place":"P","start":"s","end":"e","event":"Jeu"}, + {"id_event":"2","agegroup":"U15","name":"U15 - B","title":"T","opponent":"O","place":"P","start":"s","end":"e","event":"Jeu"}] + """.trimIndent() + val api = mockApi("""[{"name":"default","label":"Default"}]""", scheduleJson) + val cache = ScheduleCache(MapSettings()) + val vm = ScheduleViewModel(api, cache) + + vm.loadAccounts() + vm.refreshSchedule() + vm.setAgegroup("U15") + + assertEquals(1, vm.state.value.filteredEvents.size) + assertEquals("2", vm.state.value.filteredEvents[0].idEvent) + } + + @Test + fun testSetSubgroup() = runTest { + val scheduleJson = """ + [{"id_event":"1","agegroup":"U13","name":"U13 Elite - HC Ajoie","title":"T","opponent":"O","place":"P","start":"s","end":"e","event":"Jeu"}, + {"id_event":"2","agegroup":"U13","name":"U13 Top - HC Geneva","title":"T","opponent":"O","place":"P","start":"s","end":"e","event":"Jeu"}] + """.trimIndent() + val api = mockApi("""[{"name":"default","label":"Default"}]""", scheduleJson) + val cache = ScheduleCache(MapSettings()) + val vm = ScheduleViewModel(api, cache) + + vm.loadAccounts() + vm.refreshSchedule() + vm.setSubgroup("U13 Elite") + + assertEquals(1, vm.state.value.filteredEvents.size) + assertEquals("1", vm.state.value.filteredEvents[0].idEvent) + } + + @Test + fun testCacheLoadOnLoadCachedSchedule() = runTest { + val api = mockApi("""[{"name":"default","label":"Default"}]""", "[]") + val cache = ScheduleCache(MapSettings()) + cache.saveEvents("default", listOf(sampleEvent("1"))) + val vm = ScheduleViewModel(api, cache) + + vm.loadAccounts() + vm.loadCachedSchedule() + + assertEquals(1, vm.state.value.events.size) + assertFalse(vm.state.value.isLoading) + } + + @Test + fun testAgegroupsComputed() = runTest { + val scheduleJson = """ + [{"id_event":"1","agegroup":"U15","name":"N","title":"T","opponent":"","place":"P","start":"s","end":"e","event":"Jeu"}, + {"id_event":"2","agegroup":"U13","name":"N","title":"T","opponent":"","place":"P","start":"s","end":"e","event":"Jeu"}] + """.trimIndent() + val api = mockApi("""[{"name":"default","label":"Default"}]""", scheduleJson) + val cache = ScheduleCache(MapSettings()) + val vm = ScheduleViewModel(api, cache) + + vm.loadAccounts() + vm.refreshSchedule() + + assertEquals(listOf("U13", "U15"), vm.state.value.agegroups) + } +}