Merge pull request #21 from garfiec/refactor/nav-host-viewmodel_stage3
refactor: NavHostViewModel stage 3 — extract conversation list state holder
This commit is contained in:
commit
d942fbf3a0
2 changed files with 184 additions and 116 deletions
|
|
@ -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<List<Conversation>>(emptyList())
|
||||||
|
val recentConversations: StateFlow<List<Conversation>> = _recentConversations.asStateFlow()
|
||||||
|
|
||||||
|
private val _groupedConversations = MutableStateFlow<List<Pair<String, List<Conversation>>>>(emptyList())
|
||||||
|
val groupedConversations: StateFlow<List<Pair<String, List<Conversation>>>> = _groupedConversations.asStateFlow()
|
||||||
|
|
||||||
|
private val _activeConversationId = MutableStateFlow<String?>(null)
|
||||||
|
val activeConversationId: StateFlow<String?> = _activeConversationId.asStateFlow()
|
||||||
|
|
||||||
|
private val _searchQuery = MutableStateFlow("")
|
||||||
|
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
|
||||||
|
|
||||||
|
private var nextCursor: String? = null
|
||||||
|
|
||||||
|
private val _hasMore = MutableStateFlow(true)
|
||||||
|
val hasMore: StateFlow<Boolean> = _hasMore.asStateFlow()
|
||||||
|
|
||||||
|
private val _isLoadingMore = MutableStateFlow(false)
|
||||||
|
val isLoadingMore: StateFlow<Boolean> = _isLoadingMore.asStateFlow()
|
||||||
|
|
||||||
|
private val _isRefreshing = MutableStateFlow(false)
|
||||||
|
val isRefreshing: StateFlow<Boolean> = _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<Conversation>,
|
||||||
|
): List<Pair<String, List<Conversation>>> {
|
||||||
|
if (conversations.isEmpty()) return emptyList()
|
||||||
|
return conversations
|
||||||
|
.groupBy { conversation ->
|
||||||
|
conversation.updatedAt
|
||||||
|
?.toInstantOrNull()
|
||||||
|
?.toRelativeDateGroup()
|
||||||
|
?: "Unknown"
|
||||||
|
}
|
||||||
|
.toList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,8 +4,7 @@ import androidx.compose.runtime.Immutable
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.garfiec.librechat.core.common.extensions.toInstantOrNull
|
import com.garfiec.librechat.core.common.extensions.toInstantOrNull
|
||||||
import com.garfiec.librechat.core.common.extensions.toRelativeDateGroup
|
import com.garfiec.librechat.core.common.extensions.formatMonthAbbrev
|
||||||
import com.garfiec.librechat.core.common.result.Result
|
|
||||||
import com.garfiec.librechat.core.data.repository.AuthRepository
|
import com.garfiec.librechat.core.data.repository.AuthRepository
|
||||||
import com.garfiec.librechat.core.data.repository.BannerRepository
|
import com.garfiec.librechat.core.data.repository.BannerRepository
|
||||||
import com.garfiec.librechat.core.data.repository.ConfigRepository
|
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.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.debounce
|
|
||||||
import kotlinx.coroutines.flow.filter
|
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.FlowPreview
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import co.touchlab.kermit.Logger
|
import co.touchlab.kermit.Logger
|
||||||
import com.garfiec.librechat.core.common.extensions.firstBlocking
|
import com.garfiec.librechat.core.common.extensions.firstBlocking
|
||||||
import com.garfiec.librechat.core.common.extensions.formatMonthAbbrev
|
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
|
|
@ -66,33 +60,22 @@ class NavHostViewModel(
|
||||||
private val authRepository: AuthRepository,
|
private val authRepository: AuthRepository,
|
||||||
bannerRepository: BannerRepository,
|
bannerRepository: BannerRepository,
|
||||||
configRepository: ConfigRepository,
|
configRepository: ConfigRepository,
|
||||||
private val conversationRepository: ConversationRepository,
|
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,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val SEARCH_DEBOUNCE_MS = 300L
|
|
||||||
}
|
|
||||||
|
|
||||||
private val bannerStateHolder = BannerStateHolder(bannerRepository, viewModelScope)
|
private val bannerStateHolder = BannerStateHolder(bannerRepository, viewModelScope)
|
||||||
private val versionCheckStateHolder = VersionCheckStateHolder(configRepository, settingsDataStore, viewModelScope)
|
private val versionCheckStateHolder = VersionCheckStateHolder(configRepository, settingsDataStore, viewModelScope)
|
||||||
|
private val conversationListStateHolder = ConversationListStateHolder(conversationRepository, 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()
|
||||||
|
|
||||||
val versionMismatch: StateFlow<VersionMismatchState?> = versionCheckStateHolder.versionMismatch
|
val versionMismatch: StateFlow<VersionMismatchState?> = versionCheckStateHolder.versionMismatch
|
||||||
|
|
||||||
private val _recentConversations = MutableStateFlow<List<Conversation>>(emptyList())
|
|
||||||
private val _groupedConversations = MutableStateFlow<List<Pair<String, List<Conversation>>>>(emptyList())
|
|
||||||
private val _activeConversationId = MutableStateFlow<String?>(null)
|
|
||||||
private val _searchQuery = MutableStateFlow("")
|
|
||||||
private val _favorites = MutableStateFlow<Set<String>>(emptySet())
|
private val _favorites = MutableStateFlow<Set<String>>(emptySet())
|
||||||
private val _favoriteConversations = MutableStateFlow<List<Conversation>>(emptyList())
|
private val _favoriteConversations = MutableStateFlow<List<Conversation>>(emptyList())
|
||||||
private var nextCursor: String? = null
|
|
||||||
private val _hasMore = MutableStateFlow(true)
|
|
||||||
private val _isLoadingMore = MutableStateFlow(false)
|
|
||||||
private val _isRefreshing = MutableStateFlow(false)
|
|
||||||
|
|
||||||
val banners: StateFlow<List<Banner>> = bannerStateHolder.banners
|
val banners: StateFlow<List<Banner>> = bannerStateHolder.banners
|
||||||
val dismissedBannerIds: StateFlow<Set<String>> = bannerStateHolder.dismissedBannerIds
|
val dismissedBannerIds: StateFlow<Set<String>> = bannerStateHolder.dismissedBannerIds
|
||||||
|
|
@ -125,15 +108,19 @@ class NavHostViewModel(
|
||||||
|
|
||||||
val drawerUiState: StateFlow<DrawerUiState> = combine(
|
val drawerUiState: StateFlow<DrawerUiState> = combine(
|
||||||
combine(
|
combine(
|
||||||
_groupedConversations,
|
conversationListStateHolder.groupedConversations,
|
||||||
_activeConversationId,
|
conversationListStateHolder.activeConversationId,
|
||||||
_favorites,
|
_favorites,
|
||||||
_favoriteConversations,
|
_favoriteConversations,
|
||||||
_searchQuery,
|
conversationListStateHolder.searchQuery,
|
||||||
) { grouped, activeId, favIds, favConvos, query ->
|
) { grouped, activeId, favIds, favConvos, query ->
|
||||||
DrawerDataSnapshot(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)
|
Triple(refreshing, loadingMore, hasMore)
|
||||||
},
|
},
|
||||||
) { data, (refreshing, loadingMore, hasMore) ->
|
) { data, (refreshing, loadingMore, hasMore) ->
|
||||||
|
|
@ -161,104 +148,40 @@ class NavHostViewModel(
|
||||||
Logger.w(e) { "Failed to check auth state on init" }
|
Logger.w(e) { "Failed to check auth state on init" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
observeConversations()
|
|
||||||
observeSearchQuery()
|
|
||||||
observeBookmarks()
|
observeBookmarks()
|
||||||
|
observeRecentConversationsForFavorites()
|
||||||
bannerStateHolder.fetchBanners()
|
bannerStateHolder.fetchBanners()
|
||||||
loadInitialConversations()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun observeConversations() {
|
private fun observeRecentConversationsForFavorites() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
conversationListStateHolder.recentConversations.collect {
|
||||||
conversationRepository.observeConversations().collect { result ->
|
updateFavoriteConversations()
|
||||||
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" }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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() {
|
fun loadMoreConversations() {
|
||||||
if (_isLoadingMore.value || !_hasMore.value) return
|
conversationListStateHolder.loadMoreConversations()
|
||||||
_isLoadingMore.value = true
|
|
||||||
viewModelScope.launch {
|
|
||||||
loadPage(cursor = nextCursor)
|
|
||||||
_isLoadingMore.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onAuthComplete() {
|
fun onAuthComplete() {
|
||||||
_isLoggedIn.value = true
|
_isLoggedIn.value = true
|
||||||
loadInitialConversations()
|
conversationListStateHolder.refreshConversations()
|
||||||
bannerStateHolder.fetchBanners()
|
bannerStateHolder.fetchBanners()
|
||||||
versionCheckStateHolder.checkBackendVersion()
|
versionCheckStateHolder.checkBackendVersion()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refreshConversations() {
|
fun refreshConversations() {
|
||||||
_isRefreshing.value = true
|
conversationListStateHolder.refreshConversations()
|
||||||
viewModelScope.launch {
|
|
||||||
nextCursor = null
|
|
||||||
_hasMore.value = true
|
|
||||||
loadPage(cursor = null)
|
|
||||||
_isRefreshing.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setActiveConversation(conversationId: String?) {
|
fun setActiveConversation(conversationId: String?) {
|
||||||
_activeConversationId.value = conversationId
|
conversationListStateHolder.setActiveConversation(conversationId)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onSearchQueryChanged(query: String) {
|
fun onSearchQueryChanged(query: String) {
|
||||||
_searchQuery.value = query
|
conversationListStateHolder.onSearchQueryChanged(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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun observeBookmarks() {
|
private fun observeBookmarks() {
|
||||||
|
|
@ -282,7 +205,7 @@ class NavHostViewModel(
|
||||||
|
|
||||||
private fun updateFavoriteConversations() {
|
private fun updateFavoriteConversations() {
|
||||||
val bookmarkedIds = _favorites.value
|
val bookmarkedIds = _favorites.value
|
||||||
val allConversations = _recentConversations.value
|
val allConversations = conversationListStateHolder.recentConversations.value
|
||||||
_favoriteConversations.value = allConversations.filter { conversation ->
|
_favoriteConversations.value = allConversations.filter { conversation ->
|
||||||
conversation.conversationId in bookmarkedIds ||
|
conversation.conversationId in bookmarkedIds ||
|
||||||
conversation.tags.any {
|
conversation.tags.any {
|
||||||
|
|
@ -314,30 +237,13 @@ class NavHostViewModel(
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
authRepository.logout()
|
authRepository.logout()
|
||||||
_isLoggedIn.value = false
|
_isLoggedIn.value = false
|
||||||
_recentConversations.value = emptyList()
|
conversationListStateHolder.reset()
|
||||||
_groupedConversations.value = emptyList()
|
|
||||||
_activeConversationId.value = null
|
|
||||||
_searchQuery.value = ""
|
|
||||||
_favorites.value = emptySet()
|
_favorites.value = emptySet()
|
||||||
_favoriteConversations.value = emptyList()
|
_favoriteConversations.value = emptyList()
|
||||||
_sidebarMode.value = SidebarMode.Conversations
|
_sidebarMode.value = SidebarMode.Conversations
|
||||||
_selectedSettingsCategory.value = null
|
_selectedSettingsCategory.value = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun groupConversationsByDate(
|
|
||||||
conversations: List<Conversation>,
|
|
||||||
): List<Pair<String, List<Conversation>>> {
|
|
||||||
if (conversations.isEmpty()) return emptyList()
|
|
||||||
return conversations
|
|
||||||
.groupBy { conversation ->
|
|
||||||
conversation.updatedAt
|
|
||||||
?.toInstantOrNull()
|
|
||||||
?.toRelativeDateGroup()
|
|
||||||
?: "Unknown"
|
|
||||||
}
|
|
||||||
.toList()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class DrawerDataSnapshot(
|
private data class DrawerDataSnapshot(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue