From 4875072e546b0050390897d0935ce486c90092d5 Mon Sep 17 00:00:00 2001 From: Garfie Date: Tue, 7 Apr 2026 19:52:22 -0500 Subject: [PATCH] refactor: NavHostViewModel stage 3 - extract conversation list state holder --- .../navigation/ConversationListStateHolder.kt | 162 ++++++++++++++++++ .../shared/navigation/NavHostViewModel.kt | 138 +++------------ 2 files changed, 184 insertions(+), 116 deletions(-) create mode 100644 shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/ConversationListStateHolder.kt diff --git a/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/ConversationListStateHolder.kt b/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/ConversationListStateHolder.kt new file mode 100644 index 0000000..c2918a2 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/ConversationListStateHolder.kt @@ -0,0 +1,162 @@ +package com.garfiec.librechat.shared.navigation + +import co.touchlab.kermit.Logger +import com.garfiec.librechat.core.common.extensions.toInstantOrNull +import com.garfiec.librechat.core.common.extensions.toRelativeDateGroup +import com.garfiec.librechat.core.common.result.Result +import com.garfiec.librechat.core.data.repository.ConversationRepository +import com.garfiec.librechat.core.model.Conversation +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.launch + +class ConversationListStateHolder( + private val conversationRepository: ConversationRepository, + private val scope: CoroutineScope, +) { + + companion object { + private const val SEARCH_DEBOUNCE_MS = 300L + } + + private val _recentConversations = MutableStateFlow>(emptyList()) + val recentConversations: StateFlow> = _recentConversations.asStateFlow() + + private val _groupedConversations = MutableStateFlow>>>(emptyList()) + val groupedConversations: StateFlow>>> = _groupedConversations.asStateFlow() + + private val _activeConversationId = MutableStateFlow(null) + val activeConversationId: StateFlow = _activeConversationId.asStateFlow() + + private val _searchQuery = MutableStateFlow("") + val searchQuery: StateFlow = _searchQuery.asStateFlow() + + private var nextCursor: String? = null + + private val _hasMore = MutableStateFlow(true) + val hasMore: StateFlow = _hasMore.asStateFlow() + + private val _isLoadingMore = MutableStateFlow(false) + val isLoadingMore: StateFlow = _isLoadingMore.asStateFlow() + + private val _isRefreshing = MutableStateFlow(false) + val isRefreshing: StateFlow = _isRefreshing.asStateFlow() + + init { + observeConversations() + observeSearchQuery() + loadInitialConversations() + } + + private fun observeConversations() { + scope.launch { + try { + conversationRepository.observeConversations().collect { result -> + if (result is Result.Success) { + _recentConversations.value = result.data + if (_searchQuery.value.isBlank()) { + _groupedConversations.value = groupConversationsByDate(result.data) + } + } + } + } catch (e: Exception) { + Logger.w(e) { "Failed to observe conversations" } + } + } + } + + private fun loadInitialConversations() { + scope.launch { + loadPage(cursor = null) + } + } + + private suspend fun loadPage(cursor: String?): Boolean { + return try { + val result = conversationRepository.loadNextPage(cursor = cursor) + if (result is Result.Success) { + nextCursor = result.data + _hasMore.value = result.data != null + true + } else { + Logger.w { "Failed to load conversations page" } + false + } + } catch (e: Exception) { + Logger.w(e) { "Failed to load conversations page" } + false + } + } + + fun loadMoreConversations() { + if (_isLoadingMore.value || !_hasMore.value) return + _isLoadingMore.value = true + scope.launch { + loadPage(cursor = nextCursor) + _isLoadingMore.value = false + } + } + + fun refreshConversations() { + _isRefreshing.value = true + scope.launch { + nextCursor = null + _hasMore.value = true + loadPage(cursor = null) + _isRefreshing.value = false + } + } + + fun setActiveConversation(conversationId: String?) { + _activeConversationId.value = conversationId + } + + fun onSearchQueryChanged(query: String) { + _searchQuery.value = query + if (query.isBlank()) { + _groupedConversations.value = groupConversationsByDate(_recentConversations.value) + } + } + + @OptIn(FlowPreview::class) + private fun observeSearchQuery() { + scope.launch { + _searchQuery + .filter { it.isNotBlank() } + .debounce(SEARCH_DEBOUNCE_MS) + .collectLatest { query -> + val filtered = _recentConversations.value.filter { conversation -> + conversation.title?.contains(query, ignoreCase = true) == true + } + _groupedConversations.value = groupConversationsByDate(filtered) + } + } + } + + fun reset() { + _recentConversations.value = emptyList() + _groupedConversations.value = emptyList() + _activeConversationId.value = null + _searchQuery.value = "" + } + + private fun groupConversationsByDate( + conversations: List, + ): List>> { + if (conversations.isEmpty()) return emptyList() + return conversations + .groupBy { conversation -> + conversation.updatedAt + ?.toInstantOrNull() + ?.toRelativeDateGroup() + ?: "Unknown" + } + .toList() + } +} diff --git a/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/NavHostViewModel.kt b/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/NavHostViewModel.kt index c60359d..faca131 100644 --- a/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/NavHostViewModel.kt +++ b/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/NavHostViewModel.kt @@ -4,8 +4,7 @@ import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.garfiec.librechat.core.common.extensions.toInstantOrNull -import com.garfiec.librechat.core.common.extensions.toRelativeDateGroup -import com.garfiec.librechat.core.common.result.Result +import com.garfiec.librechat.core.common.extensions.formatMonthAbbrev import com.garfiec.librechat.core.data.repository.AuthRepository import com.garfiec.librechat.core.data.repository.BannerRepository import com.garfiec.librechat.core.data.repository.ConfigRepository @@ -19,16 +18,11 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.launch import co.touchlab.kermit.Logger import com.garfiec.librechat.core.common.extensions.firstBlocking -import com.garfiec.librechat.core.common.extensions.formatMonthAbbrev import kotlin.time.Clock import kotlin.time.Instant import kotlinx.datetime.TimeZone @@ -66,33 +60,22 @@ class NavHostViewModel( private val authRepository: AuthRepository, bannerRepository: BannerRepository, configRepository: ConfigRepository, - private val conversationRepository: ConversationRepository, + conversationRepository: ConversationRepository, private val tokenManager: TokenManager, private val settingsDataStore: com.garfiec.librechat.core.data.datastore.SettingsDataStore, ) : ViewModel() { - companion object { - private const val SEARCH_DEBOUNCE_MS = 300L - } - private val bannerStateHolder = BannerStateHolder(bannerRepository, viewModelScope) private val versionCheckStateHolder = VersionCheckStateHolder(configRepository, settingsDataStore, viewModelScope) + private val conversationListStateHolder = ConversationListStateHolder(conversationRepository, viewModelScope) private val _isLoggedIn = MutableStateFlow(tokenManager.isAuthenticated) val isLoggedIn: StateFlow = _isLoggedIn.asStateFlow() val versionMismatch: StateFlow = versionCheckStateHolder.versionMismatch - private val _recentConversations = MutableStateFlow>(emptyList()) - private val _groupedConversations = MutableStateFlow>>>(emptyList()) - private val _activeConversationId = MutableStateFlow(null) - private val _searchQuery = MutableStateFlow("") private val _favorites = MutableStateFlow>(emptySet()) private val _favoriteConversations = MutableStateFlow>(emptyList()) - private var nextCursor: String? = null - private val _hasMore = MutableStateFlow(true) - private val _isLoadingMore = MutableStateFlow(false) - private val _isRefreshing = MutableStateFlow(false) val banners: StateFlow> = bannerStateHolder.banners val dismissedBannerIds: StateFlow> = bannerStateHolder.dismissedBannerIds @@ -125,15 +108,19 @@ class NavHostViewModel( val drawerUiState: StateFlow = combine( combine( - _groupedConversations, - _activeConversationId, + conversationListStateHolder.groupedConversations, + conversationListStateHolder.activeConversationId, _favorites, _favoriteConversations, - _searchQuery, + conversationListStateHolder.searchQuery, ) { grouped, activeId, favIds, favConvos, query -> DrawerDataSnapshot(grouped, activeId, favIds, favConvos, query) }, - combine(_isRefreshing, _isLoadingMore, _hasMore) { refreshing, loadingMore, hasMore -> + combine( + conversationListStateHolder.isRefreshing, + conversationListStateHolder.isLoadingMore, + conversationListStateHolder.hasMore, + ) { refreshing, loadingMore, hasMore -> Triple(refreshing, loadingMore, hasMore) }, ) { data, (refreshing, loadingMore, hasMore) -> @@ -161,104 +148,40 @@ class NavHostViewModel( Logger.w(e) { "Failed to check auth state on init" } } } - observeConversations() - observeSearchQuery() observeBookmarks() + observeRecentConversationsForFavorites() bannerStateHolder.fetchBanners() - loadInitialConversations() } - private fun observeConversations() { + private fun observeRecentConversationsForFavorites() { viewModelScope.launch { - try { - conversationRepository.observeConversations().collect { result -> - if (result is Result.Success) { - _recentConversations.value = result.data - if (_searchQuery.value.isBlank()) { - _groupedConversations.value = groupConversationsByDate(result.data) - } - updateFavoriteConversations() - } - } - } catch (e: Exception) { - Logger.w(e) { "Failed to observe conversations" } + conversationListStateHolder.recentConversations.collect { + updateFavoriteConversations() } } } - private fun loadInitialConversations() { - viewModelScope.launch { - loadPage(cursor = null) - } - } - - private suspend fun loadPage(cursor: String?): Boolean { - return try { - val result = conversationRepository.loadNextPage(cursor = cursor) - if (result is Result.Success) { - nextCursor = result.data - _hasMore.value = result.data != null - true - } else { - Logger.w { "Failed to load conversations page" } - false - } - } catch (e: Exception) { - Logger.w(e) { "Failed to load conversations page" } - false - } - } - fun loadMoreConversations() { - if (_isLoadingMore.value || !_hasMore.value) return - _isLoadingMore.value = true - viewModelScope.launch { - loadPage(cursor = nextCursor) - _isLoadingMore.value = false - } + conversationListStateHolder.loadMoreConversations() } fun onAuthComplete() { _isLoggedIn.value = true - loadInitialConversations() + conversationListStateHolder.refreshConversations() bannerStateHolder.fetchBanners() versionCheckStateHolder.checkBackendVersion() } fun refreshConversations() { - _isRefreshing.value = true - viewModelScope.launch { - nextCursor = null - _hasMore.value = true - loadPage(cursor = null) - _isRefreshing.value = false - } + conversationListStateHolder.refreshConversations() } fun setActiveConversation(conversationId: String?) { - _activeConversationId.value = conversationId + conversationListStateHolder.setActiveConversation(conversationId) } fun onSearchQueryChanged(query: String) { - _searchQuery.value = query - if (query.isBlank()) { - _groupedConversations.value = groupConversationsByDate(_recentConversations.value) - } - } - - @OptIn(FlowPreview::class) - private fun observeSearchQuery() { - viewModelScope.launch { - _searchQuery - .filter { it.isNotBlank() } - .debounce(SEARCH_DEBOUNCE_MS) - .collectLatest { query -> - val filtered = _recentConversations.value.filter { conversation -> - conversation.title?.contains(query, ignoreCase = true) == true - } - _groupedConversations.value = groupConversationsByDate(filtered) - } - } + conversationListStateHolder.onSearchQueryChanged(query) } private fun observeBookmarks() { @@ -282,7 +205,7 @@ class NavHostViewModel( private fun updateFavoriteConversations() { val bookmarkedIds = _favorites.value - val allConversations = _recentConversations.value + val allConversations = conversationListStateHolder.recentConversations.value _favoriteConversations.value = allConversations.filter { conversation -> conversation.conversationId in bookmarkedIds || conversation.tags.any { @@ -314,30 +237,13 @@ class NavHostViewModel( viewModelScope.launch { authRepository.logout() _isLoggedIn.value = false - _recentConversations.value = emptyList() - _groupedConversations.value = emptyList() - _activeConversationId.value = null - _searchQuery.value = "" + conversationListStateHolder.reset() _favorites.value = emptySet() _favoriteConversations.value = emptyList() _sidebarMode.value = SidebarMode.Conversations _selectedSettingsCategory.value = null } } - - private fun groupConversationsByDate( - conversations: List, - ): List>> { - if (conversations.isEmpty()) return emptyList() - return conversations - .groupBy { conversation -> - conversation.updatedAt - ?.toInstantOrNull() - ?.toRelativeDateGroup() - ?: "Unknown" - } - .toList() - } } private data class DrawerDataSnapshot(