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.debounce
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.launch
import co.touchlab.kermit.Logger
@ -64,19 +62,10 @@ data class DrawerUiState(
val hasMore: Boolean = true,
)
/**
* UI state for a backend version mismatch warning.
*/
@Immutable
data class VersionMismatchState(
val supportedVersion: String,
val backendVersion: String,
)
class NavHostViewModel(
private val authRepository: AuthRepository,
private val bannerRepository: BannerRepository,
private val configRepository: ConfigRepository,
bannerRepository: BannerRepository,
configRepository: ConfigRepository,
private val conversationRepository: ConversationRepository,
private val tokenManager: TokenManager,
private val settingsDataStore: com.garfiec.librechat.core.data.datastore.SettingsDataStore,
@ -86,11 +75,13 @@ class NavHostViewModel(
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)
val isLoggedIn: StateFlow<Boolean> = _isLoggedIn.asStateFlow()
private val _versionMismatch = MutableStateFlow<VersionMismatchState?>(null)
val versionMismatch: StateFlow<VersionMismatchState?> = _versionMismatch.asStateFlow()
val versionMismatch: StateFlow<VersionMismatchState?> = versionCheckStateHolder.versionMismatch
private val _recentConversations = MutableStateFlow<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 _isRefreshing = MutableStateFlow(false)
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()
val banners: StateFlow<List<Banner>> = bannerStateHolder.banners
val dismissedBannerIds: StateFlow<Set<String>> = bannerStateHolder.dismissedBannerIds
val sessionExpired: SharedFlow<Unit> = tokenManager.sessionExpiredFlow
@ -167,7 +155,7 @@ class NavHostViewModel(
val loggedIn = authRepository.isLoggedIn()
_isLoggedIn.value = loggedIn
if (loggedIn) {
checkBackendVersion()
versionCheckStateHolder.checkBackendVersion()
}
} catch (e: Exception) {
Logger.w(e) { "Failed to check auth state on init" }
@ -176,7 +164,7 @@ class NavHostViewModel(
observeConversations()
observeSearchQuery()
observeBookmarks()
fetchBanners()
bannerStateHolder.fetchBanners()
loadInitialConversations()
}
@ -233,8 +221,8 @@ class NavHostViewModel(
fun onAuthComplete() {
_isLoggedIn.value = true
loadInitialConversations()
fetchBanners()
checkBackendVersion()
bannerStateHolder.fetchBanners()
versionCheckStateHolder.checkBackendVersion()
}
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) {
viewModelScope.launch {
settingsDataStore.setTabletSidebarOpen(open)
@ -339,45 +299,15 @@ class NavHostViewModel(
}
fun dismissBanner(bannerId: String) {
_dismissedBannerIds.update { it + 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 -> {}
}
}
bannerStateHolder.dismissBanner(bannerId)
}
fun dismissVersionWarning() {
_versionMismatch.value = null
versionCheckStateHolder.dismissVersionWarning()
}
fun dismissVersionWarningPermanently() {
val backendVersion = _versionMismatch.value?.backendVersion
_versionMismatch.value = null
if (backendVersion != null) {
viewModelScope.launch {
settingsDataStore.setDismissedVersionWarning(backendVersion)
}
}
versionCheckStateHolder.dismissVersionWarningPermanently()
}
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)
}
}
}
}