Merge pull request #20 from garfiec/refactor/nav-host-viewmodel_stage2

refactor: NavHostViewModel stage 2 — extract banner and version check state holders
This commit is contained in:
Garfie Chiu 2026-04-07 19:50:24 -05:00 committed by GitHub
commit 30f15dcdc9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 140 additions and 85 deletions

View file

@ -0,0 +1,58 @@
package com.garfiec.librechat.shared.navigation
import co.touchlab.kermit.Logger
import com.garfiec.librechat.core.common.result.Result
import com.garfiec.librechat.core.data.repository.BannerRepository
import com.garfiec.librechat.core.model.Banner
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlin.time.Clock
import kotlin.time.Instant
class BannerStateHolder(
private val bannerRepository: BannerRepository,
private val scope: CoroutineScope,
) {
private val _banners = MutableStateFlow<List<Banner>>(emptyList())
val banners: StateFlow<List<Banner>> = _banners.asStateFlow()
private val _dismissedBannerIds = MutableStateFlow<Set<String>>(emptySet())
val dismissedBannerIds: StateFlow<Set<String>> = _dismissedBannerIds.asStateFlow()
fun fetchBanners() {
scope.launch {
try {
val result = bannerRepository.getBanners()
if (result is Result.Success) {
val now = Clock.System.now()
_banners.value = result.data.filter { banner ->
val from = banner.displayFrom?.let {
runCatching { Instant.parse(it) }
.onFailure { e -> Logger.w(e) { "Failed to parse banner displayFrom: $it" } }
.getOrNull()
}
val to = banner.displayTo?.let {
runCatching { Instant.parse(it) }
.onFailure { e -> Logger.w(e) { "Failed to parse banner displayTo: $it" } }
.getOrNull()
}
val afterStart = from == null || now >= from
val beforeEnd = to == null || now < to
afterStart && beforeEnd
}
}
} catch (e: Exception) {
Logger.w(e) { "Failed to fetch banners" }
}
}
}
fun dismissBanner(bannerId: String) {
_dismissedBannerIds.update { it + bannerId }
}
}

View file

@ -23,9 +23,7 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
@ -64,19 +62,10 @@ data class DrawerUiState(
val hasMore: Boolean = true, val hasMore: Boolean = true,
) )
/**
* UI state for a backend version mismatch warning.
*/
@Immutable
data class VersionMismatchState(
val supportedVersion: String,
val backendVersion: String,
)
class NavHostViewModel( class NavHostViewModel(
private val authRepository: AuthRepository, private val authRepository: AuthRepository,
private val bannerRepository: BannerRepository, bannerRepository: BannerRepository,
private val configRepository: ConfigRepository, configRepository: ConfigRepository,
private val conversationRepository: ConversationRepository, private val conversationRepository: ConversationRepository,
private val tokenManager: TokenManager, private val tokenManager: TokenManager,
private val settingsDataStore: com.garfiec.librechat.core.data.datastore.SettingsDataStore, private val settingsDataStore: com.garfiec.librechat.core.data.datastore.SettingsDataStore,
@ -86,11 +75,13 @@ class NavHostViewModel(
private const val SEARCH_DEBOUNCE_MS = 300L private const val SEARCH_DEBOUNCE_MS = 300L
} }
private val bannerStateHolder = BannerStateHolder(bannerRepository, viewModelScope)
private val versionCheckStateHolder = VersionCheckStateHolder(configRepository, settingsDataStore, viewModelScope)
private val _isLoggedIn = MutableStateFlow(tokenManager.isAuthenticated) private val _isLoggedIn = MutableStateFlow(tokenManager.isAuthenticated)
val isLoggedIn: StateFlow<Boolean> = _isLoggedIn.asStateFlow() val isLoggedIn: StateFlow<Boolean> = _isLoggedIn.asStateFlow()
private val _versionMismatch = MutableStateFlow<VersionMismatchState?>(null) val versionMismatch: StateFlow<VersionMismatchState?> = versionCheckStateHolder.versionMismatch
val versionMismatch: StateFlow<VersionMismatchState?> = _versionMismatch.asStateFlow()
private val _recentConversations = MutableStateFlow<List<Conversation>>(emptyList()) private val _recentConversations = MutableStateFlow<List<Conversation>>(emptyList())
private val _groupedConversations = MutableStateFlow<List<Pair<String, List<Conversation>>>>(emptyList()) private val _groupedConversations = MutableStateFlow<List<Pair<String, List<Conversation>>>>(emptyList())
@ -103,11 +94,8 @@ class NavHostViewModel(
private val _isLoadingMore = MutableStateFlow(false) private val _isLoadingMore = MutableStateFlow(false)
private val _isRefreshing = MutableStateFlow(false) private val _isRefreshing = MutableStateFlow(false)
private val _banners = MutableStateFlow<List<Banner>>(emptyList()) val banners: StateFlow<List<Banner>> = bannerStateHolder.banners
val banners: StateFlow<List<Banner>> = _banners.asStateFlow() val dismissedBannerIds: StateFlow<Set<String>> = bannerStateHolder.dismissedBannerIds
private val _dismissedBannerIds = MutableStateFlow<Set<String>>(emptySet())
val dismissedBannerIds: StateFlow<Set<String>> = _dismissedBannerIds.asStateFlow()
val sessionExpired: SharedFlow<Unit> = tokenManager.sessionExpiredFlow val sessionExpired: SharedFlow<Unit> = tokenManager.sessionExpiredFlow
@ -167,7 +155,7 @@ class NavHostViewModel(
val loggedIn = authRepository.isLoggedIn() val loggedIn = authRepository.isLoggedIn()
_isLoggedIn.value = loggedIn _isLoggedIn.value = loggedIn
if (loggedIn) { if (loggedIn) {
checkBackendVersion() versionCheckStateHolder.checkBackendVersion()
} }
} catch (e: Exception) { } catch (e: Exception) {
Logger.w(e) { "Failed to check auth state on init" } Logger.w(e) { "Failed to check auth state on init" }
@ -176,7 +164,7 @@ class NavHostViewModel(
observeConversations() observeConversations()
observeSearchQuery() observeSearchQuery()
observeBookmarks() observeBookmarks()
fetchBanners() bannerStateHolder.fetchBanners()
loadInitialConversations() loadInitialConversations()
} }
@ -233,8 +221,8 @@ class NavHostViewModel(
fun onAuthComplete() { fun onAuthComplete() {
_isLoggedIn.value = true _isLoggedIn.value = true
loadInitialConversations() loadInitialConversations()
fetchBanners() bannerStateHolder.fetchBanners()
checkBackendVersion() versionCheckStateHolder.checkBackendVersion()
} }
fun refreshConversations() { fun refreshConversations() {
@ -304,34 +292,6 @@ class NavHostViewModel(
} }
} }
private fun fetchBanners() {
viewModelScope.launch {
try {
val result = bannerRepository.getBanners()
if (result is Result.Success) {
val now = Clock.System.now()
_banners.value = result.data.filter { banner ->
val from = banner.displayFrom?.let {
runCatching { Instant.parse(it) }
.onFailure { e -> Logger.w(e) { "Failed to parse banner displayFrom: $it" } }
.getOrNull()
}
val to = banner.displayTo?.let {
runCatching { Instant.parse(it) }
.onFailure { e -> Logger.w(e) { "Failed to parse banner displayTo: $it" } }
.getOrNull()
}
val afterStart = from == null || now >= from
val beforeEnd = to == null || now < to
afterStart && beforeEnd
}
}
} catch (e: Exception) {
Logger.w(e) { "Failed to fetch banners" }
}
}
}
fun setTabletSidebarOpen(open: Boolean) { fun setTabletSidebarOpen(open: Boolean) {
viewModelScope.launch { viewModelScope.launch {
settingsDataStore.setTabletSidebarOpen(open) settingsDataStore.setTabletSidebarOpen(open)
@ -339,45 +299,15 @@ class NavHostViewModel(
} }
fun dismissBanner(bannerId: String) { fun dismissBanner(bannerId: String) {
_dismissedBannerIds.update { it + bannerId } bannerStateHolder.dismissBanner(bannerId)
}
private fun checkBackendVersion() {
viewModelScope.launch {
when (val result = configRepository.checkBackendVersion()) {
is Result.Success -> {
val checkResult = result.data
val detectedVersion = checkResult.backendVersion
if (!checkResult.isCompatible && detectedVersion != null) {
val dismissedVersion = settingsDataStore.dismissedVersionWarning.first()
if (dismissedVersion != detectedVersion) {
_versionMismatch.value = VersionMismatchState(
supportedVersion = checkResult.supportedVersion,
backendVersion = detectedVersion,
)
}
}
}
is Result.Error -> {
Logger.w(result.exception) { "Failed to check backend version: ${result.message}" }
}
else -> {}
}
}
} }
fun dismissVersionWarning() { fun dismissVersionWarning() {
_versionMismatch.value = null versionCheckStateHolder.dismissVersionWarning()
} }
fun dismissVersionWarningPermanently() { fun dismissVersionWarningPermanently() {
val backendVersion = _versionMismatch.value?.backendVersion versionCheckStateHolder.dismissVersionWarningPermanently()
_versionMismatch.value = null
if (backendVersion != null) {
viewModelScope.launch {
settingsDataStore.setDismissedVersionWarning(backendVersion)
}
}
} }
fun logout() { fun logout() {

View file

@ -0,0 +1,67 @@
package com.garfiec.librechat.shared.navigation
import androidx.compose.runtime.Immutable
import co.touchlab.kermit.Logger
import com.garfiec.librechat.core.common.result.Result
import com.garfiec.librechat.core.data.datastore.SettingsDataStore
import com.garfiec.librechat.core.data.repository.ConfigRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
@Immutable
data class VersionMismatchState(
val supportedVersion: String,
val backendVersion: String,
)
class VersionCheckStateHolder(
private val configRepository: ConfigRepository,
private val settingsDataStore: SettingsDataStore,
private val scope: CoroutineScope,
) {
private val _versionMismatch = MutableStateFlow<VersionMismatchState?>(null)
val versionMismatch: StateFlow<VersionMismatchState?> = _versionMismatch.asStateFlow()
fun checkBackendVersion() {
scope.launch {
when (val result = configRepository.checkBackendVersion()) {
is Result.Success -> {
val checkResult = result.data
val detectedVersion = checkResult.backendVersion
if (!checkResult.isCompatible && detectedVersion != null) {
val dismissedVersion = settingsDataStore.dismissedVersionWarning.first()
if (dismissedVersion != detectedVersion) {
_versionMismatch.value = VersionMismatchState(
supportedVersion = checkResult.supportedVersion,
backendVersion = detectedVersion,
)
}
}
}
is Result.Error -> {
Logger.w(result.exception) { "Failed to check backend version: ${result.message}" }
}
else -> {}
}
}
}
fun dismissVersionWarning() {
_versionMismatch.value = null
}
fun dismissVersionWarningPermanently() {
val backendVersion = _versionMismatch.value?.backendVersion
_versionMismatch.value = null
if (backendVersion != null) {
scope.launch {
settingsDataStore.setDismissedVersionWarning(backendVersion)
}
}
}
}