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
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()
}
}