feat: add AuthViewModel, ScheduleViewModel, and EventDetailViewModel with StateFlow
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
package ch.parano.myice.auth
|
||||
|
||||
interface OAuthClient {
|
||||
fun redirectUri(): 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user