feat: add AuthViewModel, ScheduleViewModel, and EventDetailViewModel with StateFlow

This commit is contained in:
2026-07-08 17:25:41 +02:00
parent bb13a12d00
commit 294aebd0b4
5 changed files with 495 additions and 0 deletions
@@ -1,5 +1,6 @@
package ch.parano.myice.auth package ch.parano.myice.auth
interface OAuthClient { interface OAuthClient {
fun redirectUri(): String
suspend fun authenticate(loginUrl: String): String? suspend fun authenticate(loginUrl: String): String?
} }
@@ -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>(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,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<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,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<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()
}
}
@@ -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)
}
}