From acd83661ccb9c79ac32f4bf83abec551cc9e35e6 Mon Sep 17 00:00:00 2001 From: Garfie Date: Sun, 5 Apr 2026 15:54:28 -0500 Subject: [PATCH] refactor: consolidate duplicate navigation code between app and shared modules --- app/CLAUDE.md | 49 +- app/build.gradle.kts | 1 + .../garfiec/librechat/LibreChatApplication.kt | 4 +- .../garfiec/librechat/navigation/AppModule.kt | 11 - .../librechat/navigation/DrawerContent.kt | 519 ------------------ .../librechat/navigation/LibreChatNavHost.kt | 381 ++----------- .../librechat/navigation/NavHostViewModel.kt | 457 --------------- .../navigation/NavigationSerializers.kt | 32 -- .../garfiec/librechat/navigation/Navigator.kt | 60 -- .../navigation/SettingsSidebarContent.kt | 151 ----- .../librechat/navigation/SidebarMode.kt | 17 - .../librechat/navigation/SidebarScaffold.kt | 75 --- .../librechat/navigation/TabletLayout.kt | 42 +- .../librechat/KoinGraphVerificationTest.kt | 4 +- .../librechat/navigation/NavigatorTest.kt | 1 + .../common/extensions/FlowBlocking.android.kt | 12 + .../core/common/extensions/FlowBlocking.kt | 12 + .../common/extensions/FlowBlocking.ios.kt | 5 + .../shared/navigation/LibreChatNavHost.kt | 195 ++++--- .../shared/navigation/NavHostViewModel.kt | 3 +- 20 files changed, 213 insertions(+), 1818 deletions(-) delete mode 100644 app/src/main/kotlin/com/garfiec/librechat/navigation/AppModule.kt delete mode 100644 app/src/main/kotlin/com/garfiec/librechat/navigation/DrawerContent.kt delete mode 100644 app/src/main/kotlin/com/garfiec/librechat/navigation/NavHostViewModel.kt delete mode 100644 app/src/main/kotlin/com/garfiec/librechat/navigation/NavigationSerializers.kt delete mode 100644 app/src/main/kotlin/com/garfiec/librechat/navigation/Navigator.kt delete mode 100644 app/src/main/kotlin/com/garfiec/librechat/navigation/SettingsSidebarContent.kt delete mode 100644 app/src/main/kotlin/com/garfiec/librechat/navigation/SidebarMode.kt delete mode 100644 app/src/main/kotlin/com/garfiec/librechat/navigation/SidebarScaffold.kt create mode 100644 core/common/src/androidMain/kotlin/com/garfiec/librechat/core/common/extensions/FlowBlocking.android.kt create mode 100644 core/common/src/commonMain/kotlin/com/garfiec/librechat/core/common/extensions/FlowBlocking.kt create mode 100644 core/common/src/iosMain/kotlin/com/garfiec/librechat/core/common/extensions/FlowBlocking.ios.kt diff --git a/app/CLAUDE.md b/app/CLAUDE.md index 26cee9e..3ab0492 100644 --- a/app/CLAUDE.md +++ b/app/CLAUDE.md @@ -4,36 +4,34 @@ Single Activity architecture. `MainActivity` is the sole entry point. ## Navigation -`LibreChatNavHost` is the root composable using Nav 3's `NavDisplay` with `NavBackStack` and `entryProvider`. It uses adaptive layout based on `WindowSizeClass`: +`LibreChatNavHost` (in this module) is the Android-specific entry point. It wraps the shared module's `LibreChatNavHost` via a `content` lambda, adding: -- **Phone**: `ModalNavigationDrawer` as primary navigation (sidebar-first pattern matching the web frontend). No bottom navigation bar. Drawer opens via hamburger button in chat header or swipe gesture. -- **Tablet** (600dp+ width): Persistent side panel with full conversation list. No `NavigationRail` or bottom bar. +- **Deep link handling** (`librechat://conversation/{conversationId}`) +- **Share intent routing** (navigates to NewChat if not already on a chat screen) +- **Tablet layout branching** based on `WindowSizeClass` + +The shared module owns the core navigation: `Navigator`, `NavHostViewModel`, `MainNavDisplay`, `PhoneLayout`, sidebar/drawer composables, and all feature entry providers. See `shared/CLAUDE.md` for details. + +### Layout Modes + +- **Phone**: Delegates to shared `PhoneLayout` — `ModalNavigationDrawer` sidebar-first pattern. +- **Tablet** (600dp+ width): Uses `TabletLayout` (Android-only, in this module) — persistent side panel with swipe gesture and `BackHandler`. Feature modules provide entries via `EntryProviderScope` extensions (e.g., `authEntries()`, `chatEntries()`). Navigation is driven by `onNavigate: (NavKey) -> Unit` and `onBack: () -> Unit` lambdas — feature modules never receive `NavBackStack` directly. -## Sidebar / Drawer Content +## Android-Only Files in This Module -`DrawerContent` is the rich sidebar matching the web's left panel: -- "New Chat" button at top -- Search bar with debounce filtering -- Conversation items grouped by date (Today, Yesterday, Previous 7 Days, etc.) -- Each item shows: endpoint icon, title, model name, relative time -- Active conversation highlighted with left border indicator and tinted background -- Footer: links to Agents, Files, Settings -- Sign out button at bottom +- `LibreChatNavHost.kt` — Thin wrapper adding deep links, share intents, and tablet/phone branching +- `TabletLayout.kt` — Persistent sidebar with `BackHandler`, `android.net.Uri`, and custom swipe gesture ## Top-Level Destinations Top-level routes: `NewChat` (Chat), `Conversations`, `AgentMarketplace` (Agents), `Files`, `SettingsTabbed` (Settings). -Conversations are integrated into the drawer body. Agents, Files, and Settings are accessible via drawer footer links. All routes are registered in the `NavDisplay` entry provider. - -## Active Conversation Tracking - -`NavHostViewModel.activeConversationId` tracks the currently viewed conversation. Updated automatically from the navigation back stack when the chat route changes. Used by `DrawerContent` to highlight the active conversation. +Conversations are integrated into the drawer body. Agents, Files, and Settings are accessible via drawer footer links. All routes are registered in the shared `MainNavDisplay` entry provider. ## Auth & Session -- `NavHostViewModel` observes auth state via `isLoggedIn` flow +- `NavHostViewModel` (in shared) observes auth state via `isLoggedIn` flow - Start destination is `NewChat` if logged in, `ServerUrl` otherwise - `sessionExpired` flow triggers nav to auth flow with full back stack clear - Drawer gestures are hidden during auth flow @@ -41,7 +39,7 @@ Conversations are integrated into the drawer body. Agents, Files, and Settings a ## Deep Linking - Scheme: `librechat://conversation/{conversationId}` -- Handled in `MainActivity.handleDeepLink()` and forwarded to `LibreChatNavHost` +- Handled in `MainActivity.handleDeepLink()` and forwarded to this module's `LibreChatNavHost` - `onNewIntent` handles deep links when app is already running ## Connectivity @@ -51,23 +49,22 @@ Conversations are integrated into the drawer body. Agents, Files, and Settings a ## Dependencies -This module depends on all `:core:*` and all `:feature:*` modules. +This module depends on `:shared`, all `:core:*`, and all `:feature:*` modules. It applies convention plugins: `librechat.mobile.application`, `librechat.mobile.compose`, `librechat.mobile.koin`. ### Server-Synced Favorites -- `NavHostViewModel` exposes `favorites: StateFlow>` and `toggleFavorite()` +- `NavHostViewModel` (shared) exposes `favorites: StateFlow>` and `toggleFavorite()` - Currently backed by `SettingsDataStore` (local) — server sync via `UserApi.getFavorites()/updateFavorites()` available but needs wiring -- `DrawerContent` shows star icon per conversation (filled = bookmarked) -- Favorites state passed through `LibreChatNavHost` and `TabletLayout` +- `DrawerContent` (shared) shows star icon per conversation (filled = bookmarked) ### Server Banners -- `NavHostViewModel` fetches banners from `BannerRepository` on init, filters expired via `displayFrom`/`displayTo` with `Instant.parse()` +- `NavHostViewModel` (shared) fetches banners from `BannerRepository` on init, filters expired via `displayFrom`/`displayTo` with `Instant.parse()` - Dismissed banner IDs tracked in-memory via `dismissedBannerIds: StateFlow>` (session-scoped, not persisted) -- `BannerDisplay` composable shown at top of content in both `LibreChatNavHost` (phone) and `TabletLayout` +- `BannerDisplay` composable shown at top of content in both `PhoneLayout` (shared) and `TabletLayout` (app) - Banner types: "info" (blue), "warning" (amber), "error" (red) — defaults to "info" if type is null - **Gotcha**: `displayFrom`/`displayTo` are ISO 8601 strings parsed with `Instant.parse()` — wrap in `runCatching` since the format from the server is not guaranteed ### Preset Navigation - `PresetManager` route registered in `SettingsNavigation.kt` - `PresetManagerScreen` accessible from Settings → Chat section -- Navigation wired through `MainNavDisplay` entry provider, accessible from both phone and tablet layouts +- Navigation wired through shared `MainNavDisplay` entry provider, accessible from both phone and tablet layouts diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c9d45c4..ec8ce3c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -18,6 +18,7 @@ android { } dependencies { + implementation(project(":shared")) implementation(project(":core:ui")) implementation(project(":core:data")) implementation(project(":core:network")) diff --git a/app/src/main/kotlin/com/garfiec/librechat/LibreChatApplication.kt b/app/src/main/kotlin/com/garfiec/librechat/LibreChatApplication.kt index 79d7fae..989dd99 100644 --- a/app/src/main/kotlin/com/garfiec/librechat/LibreChatApplication.kt +++ b/app/src/main/kotlin/com/garfiec/librechat/LibreChatApplication.kt @@ -18,7 +18,7 @@ import com.garfiec.librechat.feature.chat.di.chatModule import com.garfiec.librechat.feature.conversations.di.conversationsModule import com.garfiec.librechat.feature.files.di.filesModule import com.garfiec.librechat.feature.settings.di.settingsModule -import com.garfiec.librechat.navigation.appModule +import com.garfiec.librechat.shared.navigation.sharedAppModule import io.ktor.client.HttpClient import org.koin.android.ext.android.get import org.koin.android.ext.koin.androidContext @@ -41,7 +41,7 @@ class LibreChatApplication : Application(), SingletonImageLoader.Factory { commonModule, networkModule, dataModule, - appModule, + sharedAppModule, authModule, authPlatformModule, chatModule, diff --git a/app/src/main/kotlin/com/garfiec/librechat/navigation/AppModule.kt b/app/src/main/kotlin/com/garfiec/librechat/navigation/AppModule.kt deleted file mode 100644 index 1569555..0000000 --- a/app/src/main/kotlin/com/garfiec/librechat/navigation/AppModule.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.garfiec.librechat.navigation - -import kotlinx.serialization.modules.SerializersModule -import org.koin.core.module.dsl.viewModelOf -import org.koin.core.qualifier.named -import org.koin.dsl.module - -val appModule = module { - viewModelOf(::NavHostViewModel) - single(named("navigation")) { navigationSerializersModule } -} diff --git a/app/src/main/kotlin/com/garfiec/librechat/navigation/DrawerContent.kt b/app/src/main/kotlin/com/garfiec/librechat/navigation/DrawerContent.kt deleted file mode 100644 index bd21e90..0000000 --- a/app/src/main/kotlin/com/garfiec/librechat/navigation/DrawerContent.kt +++ /dev/null @@ -1,519 +0,0 @@ -package com.garfiec.librechat.navigation - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Folder -import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material.icons.filled.SmartToy -import androidx.compose.material.icons.filled.Star -import androidx.compose.material.icons.filled.StarBorder -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.pulltorefresh.PullToRefreshBox -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Immutable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.heading -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.garfiec.librechat.R -import com.garfiec.librechat.core.model.EModelEndpoint -import com.garfiec.librechat.core.ui.components.isMonochromeIcon -import com.garfiec.librechat.core.ui.components.toIconRes - -/** - * Lightweight snapshot of fields DrawerConversationItem actually renders. - * Avoids passing the full 28-field Conversation through composition. - */ -@Immutable -data class DrawerConversationDisplayData( - val conversationId: String, - val title: String, - val model: String?, - val endpoint: EModelEndpoint?, - val relativeTime: String, - val isActive: Boolean, - val isFavorite: Boolean, -) - -// Pre-computed shapes to avoid creating new ones per item per frame -private val ItemShape = RoundedCornerShape(8.dp) -private val ActiveIndicatorShape = RoundedCornerShape(2.dp) - -/** - * Stateful DrawerContent that collects its own state from the ViewModel. - * State changes only recompose inside this composable — not the parent - * PhoneLayout/TabletLayout, which avoids recomposing the NavDisplay/main content. - */ -@Composable -fun DrawerContent( - viewModel: NavHostViewModel, - onNewChat: () -> Unit, - onConversationClick: (String) -> Unit, - onSettingsClick: () -> Unit, - onAgentsClick: () -> Unit, - onFilesClick: () -> Unit, - modifier: Modifier = Modifier, -) { - val uiState by viewModel.drawerUiState.collectAsStateWithLifecycle() - DrawerContent( - uiState = uiState, - onSearchQueryChanged = viewModel::onSearchQueryChanged, - onNewChat = onNewChat, - onConversationClick = onConversationClick, - onSettingsClick = onSettingsClick, - onAgentsClick = onAgentsClick, - onFilesClick = onFilesClick, - onToggleFavorite = viewModel::toggleFavorite, - onRefresh = viewModel::refreshConversations, - onLoadMore = viewModel::loadMoreConversations, - modifier = modifier, - ) -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun DrawerContent( - uiState: DrawerUiState, - onSearchQueryChanged: (String) -> Unit, - onNewChat: () -> Unit, - onConversationClick: (String) -> Unit, - onSettingsClick: () -> Unit, - onAgentsClick: () -> Unit, - onFilesClick: () -> Unit, - modifier: Modifier = Modifier, - onToggleFavorite: (String) -> Unit = {}, - onRefresh: () -> Unit = {}, - onLoadMore: () -> Unit = {}, -) { - Column( - modifier = modifier - .fillMaxHeight() - .width(300.dp) - .statusBarsPadding() - .padding(top = 16.dp), - ) { - // "New Chat" button at top - Surface( - onClick = onNewChat, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp), - shape = ItemShape, - color = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary, - ) { - Row( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.Default.Add, - contentDescription = null, - modifier = Modifier.size(20.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = stringResource(R.string.new_chat), - style = MaterialTheme.typography.titleSmall, - ) - } - } - - Spacer(modifier = Modifier.height(12.dp)) - - // Search bar - OutlinedTextField( - value = uiState.searchQuery, - onValueChange = onSearchQueryChanged, - leadingIcon = { - Icon( - imageVector = Icons.Default.Search, - contentDescription = stringResource(R.string.cd_search), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp), - ) - }, - trailingIcon = { - if (uiState.searchQuery.isNotEmpty()) { - IconButton(onClick = { onSearchQueryChanged("") }) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = stringResource(R.string.cd_clear_search), - modifier = Modifier.size(20.dp), - ) - } - } - }, - placeholder = { - Text( - text = stringResource(R.string.search_conversations_placeholder), - style = MaterialTheme.typography.bodySmall, - ) - }, - singleLine = true, - shape = ItemShape, - textStyle = MaterialTheme.typography.bodySmall, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp), - ) - - Spacer(modifier = Modifier.height(8.dp)) - - // Conversation list with favorites section and date groups - val listState = rememberLazyListState() - - // Detect when scrolled near the end to trigger load-more - val shouldLoadMore = remember { - derivedStateOf { - val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 - val totalItems = listState.layoutInfo.totalItemsCount - lastVisibleItem >= totalItems - 8 && totalItems > 0 - } - } - - LaunchedEffect(shouldLoadMore.value) { - if (shouldLoadMore.value && uiState.hasMore && !uiState.isLoadingMore) { - onLoadMore() - } - } - - PullToRefreshBox( - isRefreshing = uiState.isRefreshing, - onRefresh = onRefresh, - modifier = Modifier.weight(1f), - ) { - LazyColumn( - state = listState, - modifier = Modifier.fillMaxSize(), - ) { - // Favorites section - if (uiState.favoriteConversations.isNotEmpty() && uiState.searchQuery.isEmpty()) { - item(key = "favorites_header") { - Row( - modifier = Modifier - .fillMaxWidth() - .padding( - start = 16.dp, - end = 16.dp, - top = 8.dp, - bottom = 4.dp, - ), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.Default.Star, - contentDescription = null, - modifier = Modifier.size(14.dp), - tint = MaterialTheme.colorScheme.primary, - ) - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = stringResource(R.string.favorites), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.semantics { heading() }, - ) - } - } - - items( - items = uiState.favoriteConversations, - key = { "fav_${it.conversationId}" }, - contentType = { "conversation" }, - ) { data -> - DrawerConversationItem( - data = data, - onClick = { onConversationClick(data.conversationId) }, - onToggleFavorite = { onToggleFavorite(data.conversationId) }, - ) - } - - item(key = "favorites_divider") { - HorizontalDivider( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), - color = MaterialTheme.colorScheme.outlineVariant, - ) - } - } - - if (uiState.groupedConversations.isEmpty() && uiState.searchQuery.isNotEmpty()) { - item(key = "empty_search") { - Text( - text = stringResource(R.string.no_conversations_found), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 24.dp), - ) - } - } - - uiState.groupedConversations.forEach { (dateGroup, displayItems) -> - // Date group header - item(key = "header_$dateGroup") { - Text( - text = dateGroup, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding( - start = 16.dp, - end = 16.dp, - top = 12.dp, - bottom = 4.dp, - ), - ) - } - - // Conversation items in this group - items( - items = displayItems, - key = { it.conversationId }, - contentType = { "conversation" }, - ) { data -> - DrawerConversationItem( - data = data, - onClick = { onConversationClick(data.conversationId) }, - onToggleFavorite = { onToggleFavorite(data.conversationId) }, - ) - } - } - - // Loading indicator at the bottom when loading more - if (uiState.isLoadingMore) { - item(key = "loading_more") { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - strokeWidth = 2.dp, - ) - } - } - } - } - } - - // Bottom section: divider + footer links + sign out - HorizontalDivider( - modifier = Modifier.padding(horizontal = 12.dp), - color = MaterialTheme.colorScheme.outlineVariant, - ) - - // Footer navigation links - DrawerFooterItem( - icon = Icons.Default.SmartToy, - label = stringResource(R.string.agents), - onClick = onAgentsClick, - ) - DrawerFooterItem( - icon = Icons.Default.Folder, - label = stringResource(R.string.files), - onClick = onFilesClick, - ) - DrawerFooterItem( - icon = Icons.Default.Settings, - label = stringResource(R.string.settings), - onClick = onSettingsClick, - ) - - Spacer(modifier = Modifier.height(8.dp)) - } -} - -@Composable -private fun DrawerConversationItem( - data: DrawerConversationDisplayData, - onClick: () -> Unit, - modifier: Modifier = Modifier, - onToggleFavorite: () -> Unit = {}, -) { - val endpointIconRes = remember(data.endpoint) { - data.endpoint?.toIconRes() - } - - val backgroundColor = if (data.isActive) { - MaterialTheme.colorScheme.secondaryContainer - } else { - MaterialTheme.colorScheme.surface - } - - Row( - modifier = modifier - .padding(horizontal = 4.dp, vertical = 1.dp) - .fillMaxWidth() - .background(backgroundColor, ItemShape) - .clickable(onClick = onClick) - .padding(horizontal = 12.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - // Active indicator (left border simulation) - if (data.isActive) { - Box( - modifier = Modifier - .width(3.dp) - .height(24.dp) - .background(MaterialTheme.colorScheme.primary, ActiveIndicatorShape), - ) - Spacer(modifier = Modifier.width(8.dp)) - } - - // Endpoint icon or star for favorites - if (data.isFavorite) { - Icon( - imageVector = Icons.Default.Star, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.primary, - ) - } else if (endpointIconRes != null) { - val isMonochrome = data.endpoint?.isMonochromeIcon() == true - Icon( - painter = painterResource(id = endpointIconRes), - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = if (isMonochrome) { - MaterialTheme.colorScheme.onSurfaceVariant - } else { - Color.Unspecified - }, - ) - } else { - Icon( - imageVector = Icons.Default.SmartToy, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = if (data.isActive) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) - } - - Spacer(modifier = Modifier.width(10.dp)) - - // Title and metadata - Column(modifier = Modifier.weight(1f)) { - Text( - text = data.title, - style = MaterialTheme.typography.bodyMedium, - color = if (data.isActive) { - MaterialTheme.colorScheme.onSecondaryContainer - } else { - MaterialTheme.colorScheme.onSurface - }, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - - // Model and time on second line - val subtitle = remember(data.model, data.relativeTime) { - buildString { - data.model?.let { model -> - append(model.take(20)) - } - if (data.relativeTime.isNotEmpty()) { - if (isNotEmpty()) append(" \u00B7 ") - append(data.relativeTime) - } - } - } - if (subtitle.isNotEmpty()) { - Text( - text = subtitle, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - - // Bookmark toggle — lightweight clickable icon instead of IconButton - Icon( - imageVector = if (data.isFavorite) Icons.Default.Star else Icons.Default.StarBorder, - contentDescription = if (data.isFavorite) stringResource(R.string.remove_bookmark) else stringResource(R.string.bookmark), - modifier = Modifier - .size(32.dp) - .clickable(onClick = onToggleFavorite) - .padding(8.dp), - tint = if (data.isFavorite) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) - } -} - -@Composable -private fun DrawerFooterItem( - icon: ImageVector, - label: String, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - Row( - modifier = modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = icon, - contentDescription = label, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(modifier = Modifier.width(12.dp)) - Text( - text = label, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - } -} diff --git a/app/src/main/kotlin/com/garfiec/librechat/navigation/LibreChatNavHost.kt b/app/src/main/kotlin/com/garfiec/librechat/navigation/LibreChatNavHost.kt index 16705d1..b8d2420 100644 --- a/app/src/main/kotlin/com/garfiec/librechat/navigation/LibreChatNavHost.kt +++ b/app/src/main/kotlin/com/garfiec/librechat/navigation/LibreChatNavHost.kt @@ -1,79 +1,22 @@ package com.garfiec.librechat.navigation import android.net.Uri -import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.animation.togetherWith -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.DrawerValue -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalDrawerSheet -import androidx.compose.material3.ModalNavigationDrawer -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.rememberDrawerState import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation3.runtime.NavKey -import androidx.navigation3.runtime.entryProvider -import androidx.navigation3.runtime.rememberNavBackStack -import androidx.navigation3.ui.NavDisplay -import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator -import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import com.garfiec.librechat.MainActivity -import com.garfiec.librechat.R -import com.garfiec.librechat.core.ui.components.BannerDisplay -import com.garfiec.librechat.feature.agents.navigation.AgentMarketplace -import com.garfiec.librechat.feature.agents.navigation.agentsEntries -import com.garfiec.librechat.feature.auth.navigation.AuthRoute -import com.garfiec.librechat.feature.auth.navigation.ServerUrl -import com.garfiec.librechat.feature.auth.navigation.authEntries +import com.garfiec.librechat.shared.navigation.LibreChatNavHost as SharedLibreChatNavHost +import com.garfiec.librechat.shared.navigation.PhoneLayout import com.garfiec.librechat.feature.chat.navigation.Chat import com.garfiec.librechat.feature.chat.navigation.NewChat -import com.garfiec.librechat.feature.chat.navigation.chatEntries -import com.garfiec.librechat.feature.conversations.navigation.ArchivedConversations -import com.garfiec.librechat.feature.conversations.navigation.conversationsEntries -import com.garfiec.librechat.feature.files.navigation.Files -import com.garfiec.librechat.feature.files.navigation.filesEntries -import com.garfiec.librechat.feature.settings.navigation.SettingsAccount -import com.garfiec.librechat.feature.settings.navigation.SettingsChat -import com.garfiec.librechat.feature.settings.navigation.SettingsData -import com.garfiec.librechat.feature.settings.navigation.SettingsGeneral -import com.garfiec.librechat.feature.settings.navigation.SettingsRoute -import com.garfiec.librechat.feature.settings.navigation.SettingsTabbed -import com.garfiec.librechat.feature.settings.navigation.memoriesEntry -import com.garfiec.librechat.feature.settings.navigation.mcpServersEntry -import com.garfiec.librechat.feature.settings.navigation.settingsEntries -import kotlinx.coroutines.launch -import org.koin.compose.viewmodel.koinViewModel import co.touchlab.kermit.Logger -/** Maps a [SettingsCategory] to its corresponding typed navigation route. */ -fun SettingsCategory.toRoute(): SettingsRoute = when (this) { - SettingsCategory.GENERAL -> SettingsGeneral - SettingsCategory.CHAT -> SettingsChat - SettingsCategory.ACCOUNT -> SettingsAccount - SettingsCategory.DATA -> SettingsData -} - +/** + * Android-specific entry point that wraps the shared [SharedLibreChatNavHost] + * with deep link handling, share intent support, and tablet layout branching. + */ @Composable fun LibreChatNavHost( modifier: Modifier = Modifier, @@ -81,296 +24,50 @@ fun LibreChatNavHost( deepLinkUri: Uri? = null, onDeepLinkConsumed: () -> Unit = {}, shareNavigationTrigger: Int = 0, - navHostViewModel: NavHostViewModel = koinViewModel(), ) { - val isLoggedIn by navHostViewModel.isLoggedIn.collectAsStateWithLifecycle() - - // Stable start key — auth redirect handled via LaunchedEffect below. - val backStack = rememberNavBackStack(navigationSavedStateConfig, NewChat) - val navigator = Navigator(backStack) - - // Redirect to auth if not logged in (on first composition only). - LaunchedEffect(Unit) { - if (!navHostViewModel.isLoggedIn.value) { - navigator.navigateToAuth() - } - } - - // Track active conversation from nav back stack - LaunchedEffect(navigator.currentRoute) { - val conversationId = (navigator.currentRoute as? Chat)?.conversationId - navHostViewModel.setActiveConversation(conversationId) - } - - // Handle session expiry - LaunchedEffect(Unit) { - navHostViewModel.sessionExpired.collect { - navigator.navigateToAuth() - } - } - - val isTablet = windowSizeClass?.widthSizeClass?.let { - it >= WindowWidthSizeClass.Medium - } ?: false - - if (isTablet) { - TabletLayout( - navigator = navigator, - navHostViewModel = navHostViewModel, - deepLinkUri = deepLinkUri, - onDeepLinkConsumed = onDeepLinkConsumed, - shareNavigationTrigger = shareNavigationTrigger, - modifier = modifier, - ) - } else { - PhoneLayout( - navigator = navigator, - navHostViewModel = navHostViewModel, - deepLinkUri = deepLinkUri, - onDeepLinkConsumed = onDeepLinkConsumed, - shareNavigationTrigger = shareNavigationTrigger, - modifier = modifier, - ) - } - - // Version mismatch warning dialog (shown over any layout) - val versionMismatch by navHostViewModel.versionMismatch.collectAsStateWithLifecycle() - versionMismatch?.let { mismatch -> - VersionMismatchDialog( - supportedVersion = mismatch.supportedVersion, - backendVersion = mismatch.backendVersion, - onDismiss = navHostViewModel::dismissVersionWarning, - onDismissPermanently = navHostViewModel::dismissVersionWarningPermanently, - ) - } -} - -/** - * Dialog shown when the backend server version does not match the version - * this app was built for. Offers "Dismiss" (session-only) and - * "Don't warn again" (persisted per backend version) options. - */ -@Composable -private fun VersionMismatchDialog( - supportedVersion: String, - backendVersion: String, - onDismiss: () -> Unit, - onDismissPermanently: () -> Unit, -) { - AlertDialog( - onDismissRequest = onDismiss, - title = { - Text( - text = stringResource(R.string.version_mismatch_title), - style = MaterialTheme.typography.headlineSmall, - ) - }, - text = { - Text( - text = stringResource(R.string.version_mismatch_message, supportedVersion, backendVersion), - style = MaterialTheme.typography.bodyMedium, - ) - }, - confirmButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.dismiss)) - } - }, - dismissButton = { - TextButton(onClick = onDismissPermanently) { - Text(stringResource(R.string.dont_warn_again)) - } - }, - ) -} - -@Composable -private fun PhoneLayout( - navigator: Navigator, - navHostViewModel: NavHostViewModel, - deepLinkUri: Uri?, - onDeepLinkConsumed: () -> Unit, - shareNavigationTrigger: Int, - modifier: Modifier = Modifier, -) { - val drawerState = rememberDrawerState(DrawerValue.Closed) - val scope = rememberCoroutineScope() - val banners by navHostViewModel.banners.collectAsStateWithLifecycle() - val dismissedBannerIds by navHostViewModel.dismissedBannerIds.collectAsStateWithLifecycle() - - // Handle deep links - LaunchedEffect(deepLinkUri) { - deepLinkUri?.let { uri -> - if (uri.scheme == "librechat" && uri.host == "conversation") { - uri.lastPathSegment?.let { conversationId -> - if (MainActivity.CONVERSATION_ID_REGEX.matches(conversationId)) { - navigator.navigateToChat(conversationId) - } else { - Logger.w { "Ignoring deep link with invalid conversation ID: $conversationId" } + SharedLibreChatNavHost(modifier = modifier) { navigator, navHostViewModel, mod -> + // Handle deep links + LaunchedEffect(deepLinkUri) { + deepLinkUri?.let { uri -> + if (uri.scheme == "librechat" && uri.host == "conversation") { + uri.lastPathSegment?.let { conversationId -> + if (MainActivity.CONVERSATION_ID_REGEX.matches(conversationId)) { + navigator.navigateToChat(conversationId) + } else { + Logger.w { "Ignoring deep link with invalid conversation ID: $conversationId" } + } } } - } - onDeepLinkConsumed() - } - } - - // Handle share intent - LaunchedEffect(shareNavigationTrigger) { - if (shareNavigationTrigger > 0) { - val currentRoute = navigator.currentRoute - val isOnChatScreen = currentRoute is Chat || currentRoute is NewChat - if (!isOnChatScreen) { - navigator.navigateToTopLevel(NewChat) + onDeepLinkConsumed() } } - } - // Reset sidebar mode to Conversations when the drawer closes - LaunchedEffect(drawerState.isClosed) { - if (drawerState.isClosed) { - navHostViewModel.setSidebarMode(SidebarMode.Conversations) + // Handle share intent + LaunchedEffect(shareNavigationTrigger) { + if (shareNavigationTrigger > 0) { + val currentRoute = navigator.currentRoute + if (currentRoute !is Chat && currentRoute !is NewChat) { + navigator.navigateToTopLevel(NewChat) + } + } } - } - ModalNavigationDrawer( - drawerState = drawerState, - gesturesEnabled = !navigator.isInAuthFlow, - drawerContent = { - ModalDrawerSheet { - SidebarScaffold( - viewModel = navHostViewModel, - onNewChat = { - scope.launch { drawerState.close() } - if (navigator.currentRoute !is NewChat) { - navigator.navigateToTopLevel(NewChat) - } - }, - onConversationClick = { conversationId -> - scope.launch { drawerState.close() } - navigator.navigateToChat(conversationId) - }, - onSettingsClick = { - scope.launch { drawerState.close() } - navigator.navigate(SettingsTabbed) - }, - onSettingsCategorySelected = { category -> - navigator.navigate(category.toRoute()) - }, - onAgentsClick = { - scope.launch { drawerState.close() } - navigator.navigate(AgentMarketplace) - }, - onFilesClick = { - scope.launch { drawerState.close() } - navigator.navigate(Files) - }, - ) - } - }, - ) { - Column(modifier = modifier.fillMaxSize()) { - if (!navigator.isInAuthFlow && banners.isNotEmpty()) { - BannerDisplay( - banners = banners, - dismissedIds = dismissedBannerIds, - onDismiss = navHostViewModel::dismissBanner, - modifier = Modifier.padding(top = 4.dp), - ) - } - MainNavDisplay( + val isTablet = windowSizeClass?.widthSizeClass?.let { + it >= WindowWidthSizeClass.Medium + } ?: false + + if (isTablet) { + TabletLayout( navigator = navigator, navHostViewModel = navHostViewModel, - onOpenDrawer = { scope.launch { drawerState.open() } }, - modifier = Modifier.fillMaxSize(), + modifier = mod, + ) + } else { + PhoneLayout( + navigator = navigator, + navHostViewModel = navHostViewModel, + modifier = mod, ) } } } - -@Composable -internal fun MainNavDisplay( - navigator: Navigator, - navHostViewModel: NavHostViewModel, - onOpenDrawer: (() -> Unit)? = null, - modifier: Modifier = Modifier, -) { - NavDisplay( - backStack = navigator.backStack, - onBack = { navigator.goBack() }, - modifier = modifier, - transitionSpec = { - slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(300)) togetherWith - fadeOut(animationSpec = tween(150)) - }, - popTransitionSpec = { - (fadeIn(initialAlpha = 0.5f, animationSpec = tween(300, easing = LinearEasing)) + - scaleIn(initialScale = 0.92f, animationSpec = tween(300, easing = LinearEasing))) togetherWith - (slideOutHorizontally( - targetOffsetX = { (it * 0.15f).toInt() }, - animationSpec = tween(300, easing = FastOutSlowInEasing), - ) + scaleOut( - targetScale = 0.92f, - animationSpec = tween(300, easing = LinearEasing), - )) - }, - predictivePopTransitionSpec = { - (fadeIn(initialAlpha = 0.5f, animationSpec = tween(300, easing = LinearEasing)) + - scaleIn(initialScale = 0.92f, animationSpec = tween(300, easing = LinearEasing))) togetherWith - (slideOutHorizontally( - targetOffsetX = { (it * 0.15f).toInt() }, - animationSpec = tween(300, easing = FastOutSlowInEasing), - ) + scaleOut( - targetScale = 0.92f, - animationSpec = tween(300, easing = LinearEasing), - )) - }, - entryDecorators = listOf( - rememberSaveableStateHolderNavEntryDecorator(), - rememberViewModelStoreNavEntryDecorator(), - ), - entryProvider = entryProvider { - authEntries( - onNavigate = { navigator.navigate(it) }, - onBack = { navigator.goBack() }, - onAuthComplete = { - navHostViewModel.onAuthComplete() - navigator.navigateToChat() - }, - ) - chatEntries( - onNavigate = { navigator.navigate(it) }, - onBack = { navigator.goBack() }, - onNavigateToChat = { navigator.navigateToChat(it) }, - onOpenDrawer = onOpenDrawer, - ) - conversationsEntries( - onConversationClick = { navigator.navigateToChat(it) }, - onNavigateToArchived = { navigator.navigate(ArchivedConversations) }, - onBack = { navigator.goBack() }, - ) - agentsEntries( - onNavigate = { navigator.navigate(it) }, - onBack = { navigator.goBack() }, - onStartChat = { _ -> navigator.navigateToTopLevel(NewChat) }, - ) - filesEntries( - onBack = { navigator.goBack() }, - ) - settingsEntries( - onNavigate = { navigator.navigate(it) }, - onBack = { navigator.goBack() }, - onLogout = { - navHostViewModel.logout() - navigator.navigateToAuth() - }, - onNavigateToArchived = { navigator.navigate(ArchivedConversations) }, - ) - memoriesEntry( - onBack = { navigator.goBack() }, - ) - mcpServersEntry( - onBack = { navigator.goBack() }, - ) - }, - ) -} diff --git a/app/src/main/kotlin/com/garfiec/librechat/navigation/NavHostViewModel.kt b/app/src/main/kotlin/com/garfiec/librechat/navigation/NavHostViewModel.kt deleted file mode 100644 index 66513e3..0000000 --- a/app/src/main/kotlin/com/garfiec/librechat/navigation/NavHostViewModel.kt +++ /dev/null @@ -1,457 +0,0 @@ -package com.garfiec.librechat.navigation - -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.data.repository.AuthRepository -import com.garfiec.librechat.core.data.repository.BannerRepository -import com.garfiec.librechat.core.data.repository.ConfigRepository -import com.garfiec.librechat.core.data.repository.ConversationRepository -import com.garfiec.librechat.core.model.Banner -import com.garfiec.librechat.core.model.Conversation -import com.garfiec.librechat.core.network.client.TokenManager -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import co.touchlab.kermit.Logger -import kotlin.time.Clock -import kotlin.time.Instant -import kotlinx.datetime.TimeZone -import kotlinx.datetime.toLocalDateTime - -/** - * Combined UI state for the drawer sidebar. A single emission replaces - * 10+ individual StateFlow collections in the parent composable, preventing - * unnecessary recomposition of the entire PhoneLayout/TabletLayout tree. - */ -@Immutable -data class DrawerUiState( - val groupedConversations: List>> = emptyList(), - val favoriteConversations: List = emptyList(), - val searchQuery: String = "", - val isRefreshing: Boolean = false, - val isLoadingMore: Boolean = false, - 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, - private val conversationRepository: ConversationRepository, - private val tokenManager: TokenManager, - private val settingsDataStore: com.garfiec.librechat.core.data.datastore.SettingsDataStore, -) : ViewModel() { - - private val _isLoggedIn = MutableStateFlow(tokenManager.isAuthenticated) - val isLoggedIn: StateFlow = _isLoggedIn.asStateFlow() - - /** - * Non-null when a version mismatch has been detected and should be shown to the user. - * Set to null after the user dismisses the dialog (for this session or permanently). - */ - private val _versionMismatch = MutableStateFlow(null) - val versionMismatch: StateFlow = _versionMismatch.asStateFlow() - - private val _recentConversations = MutableStateFlow>(emptyList()) - - /** Conversations grouped by date (internal, feeds into drawerUiState). */ - private val _groupedConversations = MutableStateFlow>>>(emptyList()) - - /** The ID of the currently viewed conversation (for active highlight). */ - private val _activeConversationId = MutableStateFlow(null) - - /** Drawer search query. */ - private val _searchQuery = MutableStateFlow("") - - /** Server-synced bookmarked conversation IDs. */ - private val _favorites = MutableStateFlow>(emptySet()) - - /** Bookmarked/favorite conversations (internal, feeds into drawerUiState). */ - private val _favoriteConversations = MutableStateFlow>(emptyList()) - - /** Pagination cursor for loading more conversations from the server. */ - private var nextCursor: String? = null - - /** Whether there are more conversations to load from the server. */ - private val _hasMore = MutableStateFlow(true) - - /** Whether a load-more request is in progress. */ - private val _isLoadingMore = MutableStateFlow(false) - - /** Whether a pull-to-refresh is in progress. */ - private val _isRefreshing = MutableStateFlow(false) - - /** Active banners from the server, filtered to non-expired. */ - private val _banners = MutableStateFlow>(emptyList()) - val banners: StateFlow> = _banners.asStateFlow() - - /** Banner IDs dismissed by the user during this session. */ - private val _dismissedBannerIds = MutableStateFlow>(emptySet()) - val dismissedBannerIds: StateFlow> = _dismissedBannerIds.asStateFlow() - - val sessionExpired: SharedFlow = tokenManager.sessionExpiredFlow - - /** Persisted sidebar open/close state for tablet mode. - * The initial value is read synchronously so that first composition - * sees the real persisted state instead of a hardcoded default, - * avoiding a visible open-then-close flicker on cold start. */ - val tabletSidebarOpen: StateFlow = settingsDataStore.tabletSidebarOpen - .stateIn( - viewModelScope, - SharingStarted.Eagerly, - runBlocking { settingsDataStore.tabletSidebarOpen.first() }, - ) - - /** Whether the swipe gesture to open/close the tablet sidebar is enabled. */ - val tabletSidebarGestureEnabled: StateFlow = settingsDataStore.tabletSidebarGestureEnabled - .stateIn(viewModelScope, SharingStarted.Eagerly, true) - - /** Current sidebar mode: Conversations (default) or Settings. */ - private val _sidebarMode = MutableStateFlow(SidebarMode.Conversations) - val sidebarMode: StateFlow = _sidebarMode.asStateFlow() - - /** Currently selected settings category in the sidebar nav menu. */ - private val _selectedSettingsCategory = MutableStateFlow(null) - val selectedSettingsCategory: StateFlow = _selectedSettingsCategory.asStateFlow() - - fun setSidebarMode(mode: SidebarMode) { - _sidebarMode.value = mode - } - - fun selectSettingsCategory(category: SettingsCategory) { - _selectedSettingsCategory.value = category - } - - private var searchJob: Job? = null - - /** - * Single combined drawer UI state. All display-data mapping (Conversation → - * DrawerConversationDisplayData) happens here in the ViewModel, not in composition. - * Collected inside DrawerContent so state changes only recompose the drawer, - * not the entire PhoneLayout/TabletLayout. - */ - val drawerUiState: StateFlow = combine( - combine( - _groupedConversations, - _activeConversationId, - _favorites, - _favoriteConversations, - _searchQuery, - ) { grouped, activeId, favIds, favConvos, query -> - DrawerDataSnapshot(grouped, activeId, favIds, favConvos, query) - }, - combine(_isRefreshing, _isLoadingMore, _hasMore) { refreshing, loadingMore, hasMore -> - Triple(refreshing, loadingMore, hasMore) - }, - ) { data, (refreshing, loadingMore, hasMore) -> - DrawerUiState( - groupedConversations = data.grouped.map { (group, convos) -> - group to convos.map { it.toDrawerDisplayData(data.activeId, data.favIds) } - }, - favoriteConversations = data.favConvos.map { it.toDrawerDisplayData(data.activeId, data.favIds) }, - searchQuery = data.query, - isRefreshing = refreshing, - isLoadingMore = loadingMore, - hasMore = hasMore, - ) - }.stateIn(viewModelScope, SharingStarted.Eagerly, DrawerUiState()) - - init { - viewModelScope.launch { - val loggedIn = authRepository.isLoggedIn() - _isLoggedIn.value = loggedIn - // Check backend version on cold start for returning users - if (loggedIn) { - checkBackendVersion() - } - } - observeConversations() - observeBookmarks() - fetchBanners() - loadInitialConversations() - } - - private fun observeConversations() { - viewModelScope.launch { - conversationRepository.observeConversations().collect { result -> - if (result is Result.Success) { - _recentConversations.value = result.data - // Only regroup if not in search mode - if (_searchQuery.value.isBlank()) { - _groupedConversations.value = groupConversationsByDate(result.data) - } - updateFavoriteConversations() - } - } - } - } - - private fun loadInitialConversations() { - viewModelScope.launch { - val result = conversationRepository.loadNextPage(cursor = null) - if (result is Result.Success) { - nextCursor = result.data - _hasMore.value = result.data != null - } - } - } - - fun loadMoreConversations() { - if (_isLoadingMore.value || !_hasMore.value) return - _isLoadingMore.value = true - viewModelScope.launch { - val result = conversationRepository.loadNextPage(cursor = nextCursor) - if (result is Result.Success) { - nextCursor = result.data - _hasMore.value = result.data != null - } else { - Logger.w { "Failed to load more conversations" } - } - _isLoadingMore.value = false - } - } - - fun onAuthComplete() { - _isLoggedIn.value = true - loadInitialConversations() - fetchBanners() - checkBackendVersion() - } - - fun refreshConversations() { - _isRefreshing.value = true - viewModelScope.launch { - nextCursor = null - _hasMore.value = true - val result = conversationRepository.loadNextPage(cursor = null) - if (result is Result.Success) { - nextCursor = result.data - _hasMore.value = result.data != null - } - _isRefreshing.value = false - } - } - - fun setActiveConversation(conversationId: String?) { - _activeConversationId.value = conversationId - } - - fun onSearchQueryChanged(query: String) { - _searchQuery.value = query - searchJob?.cancel() - - if (query.isBlank()) { - // Reset to full grouped list from cached conversations - _groupedConversations.value = groupConversationsByDate(_recentConversations.value) - return - } - - searchJob = viewModelScope.launch { - delay(300) // debounce - val filtered = _recentConversations.value.filter { conversation -> - conversation.title?.contains(query, ignoreCase = true) == true - } - _groupedConversations.value = groupConversationsByDate(filtered) - } - } - - private fun observeBookmarks() { - viewModelScope.launch { - settingsDataStore.bookmarkedConversationIds.collect { bookmarkedIds -> - _favorites.value = bookmarkedIds - updateFavoriteConversations() - } - } - } - - fun toggleFavorite(conversationId: String) { - viewModelScope.launch { - settingsDataStore.toggleBookmark(conversationId) - } - } - - private fun updateFavoriteConversations() { - val bookmarkedIds = _favorites.value - val allConversations = _recentConversations.value - _favoriteConversations.value = allConversations.filter { conversation -> - conversation.conversationId in bookmarkedIds || - conversation.tags.any { - it.equals("favorite", ignoreCase = true) || - it.equals("bookmark", ignoreCase = true) - } - } - } - - private fun fetchBanners() { - viewModelScope.launch { - 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) }.getOrNull() } - val to = banner.displayTo?.let { runCatching { Instant.parse(it) }.getOrNull() } - val afterStart = from == null || now >= from - val beforeEnd = to == null || now < to - afterStart && beforeEnd - } - } - } - } - - fun setTabletSidebarOpen(open: Boolean) { - viewModelScope.launch { - settingsDataStore.setTabletSidebarOpen(open) - } - } - - fun dismissBanner(bannerId: String) { - _dismissedBannerIds.value = _dismissedBannerIds.value + bannerId - } - - /** - * Performs an async, non-blocking version check against the backend. - * If the versions are incompatible and the user hasn't dismissed the warning - * for this specific backend version, sets [versionMismatch] to trigger the dialog. - */ - private fun checkBackendVersion() { - viewModelScope.launch { - val result = configRepository.checkBackendVersion() - if (result is Result.Success) { - val checkResult = result.data - val detectedVersion = checkResult.backendVersion - if (!checkResult.isCompatible && detectedVersion != null) { - // Check if the user has already dismissed the warning for this version - val dismissedVersion = settingsDataStore.dismissedVersionWarning.first() - if (dismissedVersion != detectedVersion) { - _versionMismatch.value = VersionMismatchState( - supportedVersion = checkResult.supportedVersion, - backendVersion = detectedVersion, - ) - } else { - Logger.d { - "Version mismatch (backend=$detectedVersion, supported=${checkResult.supportedVersion}) but user dismissed for this version" - } - } - } - } - // On error, silently ignore -- the version check is non-critical - } - } - - /** Dismiss the version mismatch dialog for this session only. */ - fun dismissVersionWarning() { - _versionMismatch.value = null - } - - /** Dismiss the version mismatch dialog and suppress it for this backend version permanently. */ - fun dismissVersionWarningPermanently() { - val backendVersion = _versionMismatch.value?.backendVersion - _versionMismatch.value = null - if (backendVersion != null) { - viewModelScope.launch { - settingsDataStore.setDismissedVersionWarning(backendVersion) - } - } - } - - fun logout() { - viewModelScope.launch { - authRepository.logout() - _isLoggedIn.value = false - _recentConversations.value = emptyList() - _groupedConversations.value = emptyList() - _activeConversationId.value = null - _searchQuery.value = "" - _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() - } -} - -/** Intermediate holder for the 5-argument combine. */ -private data class DrawerDataSnapshot( - val grouped: List>>, - val activeId: String?, - val favIds: Set, - val favConvos: List, - val query: String, -) - -private fun Conversation.toDrawerDisplayData( - activeConversationId: String?, - favoriteIds: Set, -): DrawerConversationDisplayData { - val convId = conversationId ?: "" - return DrawerConversationDisplayData( - conversationId = convId, - title = title ?: "New Chat", - model = model, - endpoint = endpoint, - relativeTime = updatedAt?.toInstantOrNull()?.toRelativeTimeString() ?: "", - isActive = convId == activeConversationId, - isFavorite = convId in favoriteIds, - ) -} - -private fun Instant.toRelativeTimeString(): String { - val now = Clock.System.now() - val duration = now - this - val minutes = duration.inWholeMinutes - val hours = duration.inWholeHours - val days = duration.inWholeDays - - return when { - minutes < 1 -> "Just now" - minutes < 60 -> "${minutes}m ago" - hours < 24 -> "${hours}h ago" - days < 7 -> "${days}d ago" - else -> { - val date = toLocalDateTime(TimeZone.currentSystemDefault()).date - val monthAbbr = when (date.monthNumber) { - 1 -> "Jan"; 2 -> "Feb"; 3 -> "Mar"; 4 -> "Apr" - 5 -> "May"; 6 -> "Jun"; 7 -> "Jul"; 8 -> "Aug" - 9 -> "Sep"; 10 -> "Oct"; 11 -> "Nov"; 12 -> "Dec" - else -> "" - } - "$monthAbbr ${date.dayOfMonth}" - } - } -} diff --git a/app/src/main/kotlin/com/garfiec/librechat/navigation/NavigationSerializers.kt b/app/src/main/kotlin/com/garfiec/librechat/navigation/NavigationSerializers.kt deleted file mode 100644 index e82989e..0000000 --- a/app/src/main/kotlin/com/garfiec/librechat/navigation/NavigationSerializers.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.garfiec.librechat.navigation - -import androidx.savedstate.serialization.SavedStateConfiguration -import com.garfiec.librechat.feature.agents.navigation.agentsSerializersModule -import com.garfiec.librechat.feature.auth.navigation.authSerializersModule -import com.garfiec.librechat.feature.chat.navigation.chatSerializersModule -import com.garfiec.librechat.feature.conversations.navigation.conversationsSerializersModule -import com.garfiec.librechat.feature.files.navigation.filesSerializersModule -import com.garfiec.librechat.feature.settings.navigation.settingsSerializersModule -import kotlinx.serialization.modules.SerializersModule -import kotlinx.serialization.modules.plus - -/** - * Combined [SerializersModule] for all navigation route types. - * Registers polymorphic serializers for each feature module's sealed route hierarchy. - * Used by Nav 3's SavedStateConfiguration for type-safe state saving. - */ -val navigationSerializersModule: SerializersModule = - authSerializersModule + - chatSerializersModule + - conversationsSerializersModule + - agentsSerializersModule + - filesSerializersModule + - settingsSerializersModule - -/** - * Nav 3 [SavedStateConfiguration] with polymorphic serialization for all route types. - * Required for KMP (iOS) since Nav 3 cannot use reflection-based serialization on non-JVM platforms. - */ -val navigationSavedStateConfig = SavedStateConfiguration { - serializersModule = navigationSerializersModule -} diff --git a/app/src/main/kotlin/com/garfiec/librechat/navigation/Navigator.kt b/app/src/main/kotlin/com/garfiec/librechat/navigation/Navigator.kt deleted file mode 100644 index a01a2da..0000000 --- a/app/src/main/kotlin/com/garfiec/librechat/navigation/Navigator.kt +++ /dev/null @@ -1,60 +0,0 @@ -package com.garfiec.librechat.navigation - -import androidx.navigation3.runtime.NavBackStack -import androidx.navigation3.runtime.NavKey -import com.garfiec.librechat.feature.auth.navigation.AuthRoute -import com.garfiec.librechat.feature.auth.navigation.ServerUrl -import com.garfiec.librechat.feature.chat.navigation.Chat -import com.garfiec.librechat.feature.chat.navigation.NewChat - -/** - * Encapsulates all back stack mutations for Nav 3 navigation. - * Follows UDF: UI events → Navigator → back stack state → NavDisplay recomposition. - */ -class Navigator(val backStack: NavBackStack) { - - val currentRoute: NavKey? get() = backStack.lastOrNull() - - val isInAuthFlow: Boolean get() = currentRoute is AuthRoute - - /** Navigate forward to a route. */ - fun navigate(route: NavKey) { - backStack.add(route) - } - - /** Pop the top entry. Safe on empty stacks. */ - fun goBack() { - backStack.removeLastOrNull() - } - - /** Navigate to a chat, replacing any current chat on the stack. */ - fun navigateToChat(conversationId: String) { - if (backStack.lastOrNull() is Chat) { - backStack.removeLastOrNull() - } - backStack.add(Chat(conversationId)) - } - - /** Navigate to a top-level destination, popping to root first. */ - fun navigateToTopLevel(route: NavKey) { - while (backStack.size > 1) { - backStack.removeLastOrNull() - } - if (backStack.lastOrNull()?.let { it::class } != route::class) { - backStack.removeLastOrNull() - backStack.add(route) - } - } - - /** Clear back stack and navigate to auth (session expiry / logout). */ - fun navigateToAuth() { - backStack.clear() - backStack.add(ServerUrl) - } - - /** Clear back stack and navigate to chat (auth complete). */ - fun navigateToChat() { - backStack.clear() - backStack.add(NewChat) - } -} diff --git a/app/src/main/kotlin/com/garfiec/librechat/navigation/SettingsSidebarContent.kt b/app/src/main/kotlin/com/garfiec/librechat/navigation/SettingsSidebarContent.kt deleted file mode 100644 index a290fe9..0000000 --- a/app/src/main/kotlin/com/garfiec/librechat/navigation/SettingsSidebarContent.kt +++ /dev/null @@ -1,151 +0,0 @@ -package com.garfiec.librechat.navigation - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.automirrored.filled.Chat -import androidx.compose.material.icons.filled.AccountCircle -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material.icons.filled.Storage -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.heading -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.unit.dp -import com.garfiec.librechat.R - -/** - * Settings sidebar content shown when the sidebar mode is [SidebarMode.Settings]. - * Displays a simple vertical list of settings categories. Tapping a category - * navigates to its sub-page in the main content area. - */ -@Composable -fun SettingsSidebarContent( - selectedCategory: SettingsCategory?, - onBackToConversations: () -> Unit, - onCategorySelected: (SettingsCategory) -> Unit, - modifier: Modifier = Modifier, -) { - Column( - modifier = modifier - .fillMaxHeight() - .width(300.dp) - .statusBarsPadding() - .padding(top = 8.dp), - ) { - // Header with back arrow - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 4.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton(onClick = onBackToConversations) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.cd_back_to_conversations), - ) - } - Text( - text = stringResource(R.string.settings), - style = MaterialTheme.typography.titleMedium, - modifier = Modifier - .weight(1f) - .semantics { heading() }, - ) - } - - Spacer(modifier = Modifier.padding(top = 8.dp)) - - // Category list - SettingsCategoryItem( - icon = Icons.Default.Settings, - category = SettingsCategory.GENERAL, - isSelected = selectedCategory == SettingsCategory.GENERAL, - onClick = { onCategorySelected(SettingsCategory.GENERAL) }, - ) - SettingsCategoryItem( - icon = Icons.AutoMirrored.Filled.Chat, - category = SettingsCategory.CHAT, - isSelected = selectedCategory == SettingsCategory.CHAT, - onClick = { onCategorySelected(SettingsCategory.CHAT) }, - ) - SettingsCategoryItem( - icon = Icons.Default.AccountCircle, - category = SettingsCategory.ACCOUNT, - isSelected = selectedCategory == SettingsCategory.ACCOUNT, - onClick = { onCategorySelected(SettingsCategory.ACCOUNT) }, - ) - SettingsCategoryItem( - icon = Icons.Default.Storage, - category = SettingsCategory.DATA, - isSelected = selectedCategory == SettingsCategory.DATA, - onClick = { onCategorySelected(SettingsCategory.DATA) }, - ) - - Spacer(modifier = Modifier.weight(1f)) - } -} - -@Composable -private fun SettingsCategoryItem( - icon: ImageVector, - category: SettingsCategory, - isSelected: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - val backgroundColor = if (isSelected) { - MaterialTheme.colorScheme.secondaryContainer - } else { - MaterialTheme.colorScheme.surface - } - val contentColor = if (isSelected) { - MaterialTheme.colorScheme.onSecondaryContainer - } else { - MaterialTheme.colorScheme.onSurface - } - - Row( - modifier = modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 2.dp) - .clip(RoundedCornerShape(12.dp)) - .background(backgroundColor, RoundedCornerShape(12.dp)) - .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier.size(22.dp), - tint = contentColor, - ) - Spacer(modifier = Modifier.width(16.dp)) - Text( - text = category.label, - style = MaterialTheme.typography.bodyLarge, - color = contentColor, - ) - } -} diff --git a/app/src/main/kotlin/com/garfiec/librechat/navigation/SidebarMode.kt b/app/src/main/kotlin/com/garfiec/librechat/navigation/SidebarMode.kt deleted file mode 100644 index 7f77d32..0000000 --- a/app/src/main/kotlin/com/garfiec/librechat/navigation/SidebarMode.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.garfiec.librechat.navigation - -sealed interface SidebarMode { - data object Conversations : SidebarMode - data object Settings : SidebarMode -} - -/** - * Settings categories shown as a navigation menu in the sidebar. - * Each maps to a dedicated settings sub-page in the main content area. - */ -enum class SettingsCategory(val label: String) { - GENERAL("General"), - CHAT("Chat"), - ACCOUNT("Account"), - DATA("Data"), -} diff --git a/app/src/main/kotlin/com/garfiec/librechat/navigation/SidebarScaffold.kt b/app/src/main/kotlin/com/garfiec/librechat/navigation/SidebarScaffold.kt deleted file mode 100644 index 2e91a0f..0000000 --- a/app/src/main/kotlin/com/garfiec/librechat/navigation/SidebarScaffold.kt +++ /dev/null @@ -1,75 +0,0 @@ -package com.garfiec.librechat.navigation - -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.animation.togetherWith -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.lifecycle.compose.collectAsStateWithLifecycle - -/** - * Sidebar scaffold that switches between Conversations and Settings modes. - * Uses [AnimatedContent] for a smooth horizontal slide transition. - * - * The Settings footer button now navigates directly to the tabbed settings - * screen via [onSettingsClick], rather than switching sidebar mode. - * The mode-switching infrastructure is preserved for potential future use. - */ -@Composable -fun SidebarScaffold( - viewModel: NavHostViewModel, - onNewChat: () -> Unit, - onConversationClick: (String) -> Unit, - onSettingsClick: () -> Unit, - onSettingsCategorySelected: (SettingsCategory) -> Unit, - onAgentsClick: () -> Unit, - onFilesClick: () -> Unit, - modifier: Modifier = Modifier, -) { - val sidebarMode by viewModel.sidebarMode.collectAsStateWithLifecycle() - val selectedCategory by viewModel.selectedSettingsCategory.collectAsStateWithLifecycle() - - AnimatedContent( - targetState = sidebarMode, - modifier = modifier, - transitionSpec = { - if (targetState is SidebarMode.Settings) { - // Slide in from right when switching to Settings - slideInHorizontally { fullWidth -> fullWidth } togetherWith - slideOutHorizontally { fullWidth -> -fullWidth } - } else { - // Slide in from left when switching back to Conversations - slideInHorizontally { fullWidth -> -fullWidth } togetherWith - slideOutHorizontally { fullWidth -> fullWidth } - } - }, - label = "SidebarModeTransition", - ) { mode -> - when (mode) { - is SidebarMode.Conversations -> { - DrawerContent( - viewModel = viewModel, - onNewChat = onNewChat, - onConversationClick = onConversationClick, - onSettingsClick = onSettingsClick, - onAgentsClick = onAgentsClick, - onFilesClick = onFilesClick, - ) - } - is SidebarMode.Settings -> { - SettingsSidebarContent( - selectedCategory = selectedCategory, - onBackToConversations = { - viewModel.setSidebarMode(SidebarMode.Conversations) - }, - onCategorySelected = { category -> - viewModel.selectSettingsCategory(category) - onSettingsCategorySelected(category) - }, - ) - } - } - } -} diff --git a/app/src/main/kotlin/com/garfiec/librechat/navigation/TabletLayout.kt b/app/src/main/kotlin/com/garfiec/librechat/navigation/TabletLayout.kt index 56af52a..148de6b 100644 --- a/app/src/main/kotlin/com/garfiec/librechat/navigation/TabletLayout.kt +++ b/app/src/main/kotlin/com/garfiec/librechat/navigation/TabletLayout.kt @@ -1,6 +1,5 @@ package com.garfiec.librechat.navigation -import android.net.Uri import androidx.activity.compose.BackHandler import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Spring @@ -27,16 +26,17 @@ import androidx.compose.ui.layout.Layout import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation3.runtime.NavKey -import com.garfiec.librechat.MainActivity import com.garfiec.librechat.core.ui.components.BannerDisplay +import com.garfiec.librechat.shared.navigation.NavHostViewModel +import com.garfiec.librechat.shared.navigation.Navigator +import com.garfiec.librechat.shared.navigation.MainNavDisplay +import com.garfiec.librechat.shared.navigation.SidebarScaffold +import com.garfiec.librechat.shared.navigation.toRoute import com.garfiec.librechat.feature.agents.navigation.AgentMarketplace -import com.garfiec.librechat.feature.chat.navigation.Chat import com.garfiec.librechat.feature.chat.navigation.NewChat import com.garfiec.librechat.feature.files.navigation.Files import com.garfiec.librechat.feature.settings.navigation.SettingsTabbed import kotlinx.coroutines.launch -import co.touchlab.kermit.Logger private val SidebarWidth = 320.dp @@ -47,10 +47,7 @@ private const val FLING_VELOCITY_THRESHOLD = 800f fun TabletLayout( navigator: Navigator, navHostViewModel: NavHostViewModel, - deepLinkUri: Uri?, - onDeepLinkConsumed: () -> Unit, modifier: Modifier = Modifier, - shareNavigationTrigger: Int = 0, ) { // Banner state only -- drawer state is collected inside DrawerContent itself val banners by navHostViewModel.banners.collectAsStateWithLifecycle() @@ -74,33 +71,6 @@ fun TabletLayout( navHostViewModel.setTabletSidebarOpen(false) } - // Handle deep links - LaunchedEffect(deepLinkUri) { - deepLinkUri?.let { uri -> - if (uri.scheme == "librechat" && uri.host == "conversation") { - uri.lastPathSegment?.let { conversationId -> - if (MainActivity.CONVERSATION_ID_REGEX.matches(conversationId)) { - navigator.navigateToChat(conversationId) - } else { - Logger.w { "Ignoring deep link with invalid conversation ID: $conversationId" } - } - } - } - onDeepLinkConsumed() - } - } - - // Handle share intent - LaunchedEffect(shareNavigationTrigger) { - if (shareNavigationTrigger > 0) { - val currentRoute = navigator.currentRoute - val isOnChatScreen = currentRoute is Chat || currentRoute is NewChat - if (!isOnChatScreen) { - navigator.navigateToTopLevel(NewChat) - } - } - } - // Sync: when ViewModel state changes (e.g. hamburger button, back press), // animate the sidebar to match. LaunchedEffect(isSidebarOpen) { @@ -273,7 +243,7 @@ private fun MainContent( MainNavDisplay( navigator = navigator, navHostViewModel = navHostViewModel, - onOpenDrawer = onToggleDrawer, + onMenuClick = onToggleDrawer, modifier = Modifier.fillMaxSize(), ) } diff --git a/app/src/test/kotlin/com/garfiec/librechat/KoinGraphVerificationTest.kt b/app/src/test/kotlin/com/garfiec/librechat/KoinGraphVerificationTest.kt index f7d6dbd..bd86864 100644 --- a/app/src/test/kotlin/com/garfiec/librechat/KoinGraphVerificationTest.kt +++ b/app/src/test/kotlin/com/garfiec/librechat/KoinGraphVerificationTest.kt @@ -62,7 +62,7 @@ import com.garfiec.librechat.feature.conversations.di.conversationsModule import com.garfiec.librechat.feature.files.di.filesModule import com.garfiec.librechat.feature.files.platform.FileReader import com.garfiec.librechat.feature.settings.di.settingsModule -import com.garfiec.librechat.navigation.appModule +import com.garfiec.librechat.shared.navigation.sharedAppModule import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import org.junit.Test @@ -76,7 +76,7 @@ class KoinGraphVerificationTest { commonModule, networkModule, dataModule, - appModule, + sharedAppModule, authModule, chatModule, conversationsModule, diff --git a/app/src/test/kotlin/com/garfiec/librechat/navigation/NavigatorTest.kt b/app/src/test/kotlin/com/garfiec/librechat/navigation/NavigatorTest.kt index 1898a16..5aecaa3 100644 --- a/app/src/test/kotlin/com/garfiec/librechat/navigation/NavigatorTest.kt +++ b/app/src/test/kotlin/com/garfiec/librechat/navigation/NavigatorTest.kt @@ -2,6 +2,7 @@ package com.garfiec.librechat.navigation import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey +import com.garfiec.librechat.shared.navigation.Navigator import com.garfiec.librechat.feature.agents.navigation.AgentMarketplace import com.garfiec.librechat.feature.auth.navigation.Login import com.garfiec.librechat.feature.auth.navigation.ServerUrl diff --git a/core/common/src/androidMain/kotlin/com/garfiec/librechat/core/common/extensions/FlowBlocking.android.kt b/core/common/src/androidMain/kotlin/com/garfiec/librechat/core/common/extensions/FlowBlocking.android.kt new file mode 100644 index 0000000..5cdb360 --- /dev/null +++ b/core/common/src/androidMain/kotlin/com/garfiec/librechat/core/common/extensions/FlowBlocking.android.kt @@ -0,0 +1,12 @@ +package com.garfiec.librechat.core.common.extensions + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking + +actual fun firstBlocking(flow: Flow, default: T): T = + try { + runBlocking { flow.first() } + } catch (_: Exception) { + default + } diff --git a/core/common/src/commonMain/kotlin/com/garfiec/librechat/core/common/extensions/FlowBlocking.kt b/core/common/src/commonMain/kotlin/com/garfiec/librechat/core/common/extensions/FlowBlocking.kt new file mode 100644 index 0000000..7dc93f0 --- /dev/null +++ b/core/common/src/commonMain/kotlin/com/garfiec/librechat/core/common/extensions/FlowBlocking.kt @@ -0,0 +1,12 @@ +package com.garfiec.librechat.core.common.extensions + +import kotlinx.coroutines.flow.Flow + +/** + * Reads the first value from [flow] synchronously via `runBlocking`, returning + * [default] if the flow is empty or the read fails for any reason. + * + * Used so that initial UI state (e.g. persisted sidebar open/close) is available + * before first composition, avoiding visible flicker on cold start. + */ +expect fun firstBlocking(flow: Flow, default: T): T diff --git a/core/common/src/iosMain/kotlin/com/garfiec/librechat/core/common/extensions/FlowBlocking.ios.kt b/core/common/src/iosMain/kotlin/com/garfiec/librechat/core/common/extensions/FlowBlocking.ios.kt new file mode 100644 index 0000000..e10b84f --- /dev/null +++ b/core/common/src/iosMain/kotlin/com/garfiec/librechat/core/common/extensions/FlowBlocking.ios.kt @@ -0,0 +1,5 @@ +package com.garfiec.librechat.core.common.extensions + +import kotlinx.coroutines.flow.Flow + +actual fun firstBlocking(flow: Flow, default: T): T = default diff --git a/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/LibreChatNavHost.kt b/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/LibreChatNavHost.kt index 7415953..d9b945a 100644 --- a/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/LibreChatNavHost.kt +++ b/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/navigation/LibreChatNavHost.kt @@ -86,6 +86,7 @@ fun SettingsCategory.toRoute(): SettingsRoute = when (this) { fun LibreChatNavHost( modifier: Modifier = Modifier, navHostViewModel: NavHostViewModel = koinViewModel(), + content: (@Composable (Navigator, NavHostViewModel, Modifier) -> Unit)? = null, ) { val isLoggedIn by navHostViewModel.isLoggedIn.collectAsStateWithLifecycle() @@ -113,11 +114,15 @@ fun LibreChatNavHost( } } - PhoneLayout( - navigator = navigator, - navHostViewModel = navHostViewModel, - modifier = modifier, - ) + if (content != null) { + content(navigator, navHostViewModel, modifier) + } else { + PhoneLayout( + navigator = navigator, + navHostViewModel = navHostViewModel, + modifier = modifier, + ) + } // Version mismatch warning dialog val versionMismatch by navHostViewModel.versionMismatch.collectAsStateWithLifecycle() @@ -165,8 +170,9 @@ private fun VersionMismatchDialog( ) } +/** Default phone layout with modal drawer sidebar. Used by iOS directly and by Android as the non-tablet path. */ @Composable -private fun PhoneLayout( +fun PhoneLayout( navigator: Navigator, navHostViewModel: NavHostViewModel, modifier: Modifier = Modifier, @@ -228,88 +234,103 @@ private fun PhoneLayout( modifier = Modifier.padding(top = 4.dp), ) } - NavDisplay( - backStack = navigator.backStack, - onBack = { navigator.goBack() }, - transitionSpec = { - slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(300)) togetherWith - fadeOut(animationSpec = tween(150)) - }, - popTransitionSpec = { - (fadeIn(initialAlpha = 0.5f, animationSpec = tween(300, easing = LinearEasing)) + - scaleIn(initialScale = 0.92f, animationSpec = tween(300, easing = LinearEasing))) togetherWith - (slideOutHorizontally( - targetOffsetX = { (it * 0.15f).toInt() }, - animationSpec = tween(300, easing = FastOutSlowInEasing), - ) + scaleOut( - targetScale = 0.92f, - animationSpec = tween(300, easing = LinearEasing), - )) - }, - predictivePopTransitionSpec = { - (fadeIn(initialAlpha = 0.5f, animationSpec = tween(300, easing = LinearEasing)) + - scaleIn(initialScale = 0.92f, animationSpec = tween(300, easing = LinearEasing))) togetherWith - (slideOutHorizontally( - targetOffsetX = { (it * 0.15f).toInt() }, - animationSpec = tween(300, easing = FastOutSlowInEasing), - ) + scaleOut( - targetScale = 0.92f, - animationSpec = tween(300, easing = LinearEasing), - )) - }, - entryDecorators = listOf( - rememberSaveableStateHolderNavEntryDecorator(), - rememberViewModelStoreNavEntryDecorator(), - ), - entryProvider = entryProvider { - authEntries( - onNavigate = { navigator.navigate(it) }, - onBack = { navigator.goBack() }, - onAuthComplete = { - navHostViewModel.onAuthComplete() - navigator.navigateToChat() - }, - ) - chatEntries( - onNavigate = { navigator.navigate(it) }, - onBack = { navigator.goBack() }, - onNavigateToChat = { navigator.navigateToChat(it) }, - onOpenDrawer = { - scope.launch { drawerState.open() } - }, - ) - conversationsEntries( - onConversationClick = { navigator.navigateToChat(it) }, - onNavigateToArchived = { navigator.navigate(ArchivedConversations) }, - onBack = { navigator.goBack() }, - ) - agentsEntries( - onNavigate = { navigator.navigate(it) }, - onBack = { navigator.goBack() }, - onStartChat = { _ -> - navigator.navigateToTopLevel(NewChat) - }, - ) - filesEntries( - onBack = { navigator.goBack() }, - ) - settingsEntries( - onNavigate = { navigator.navigate(it) }, - onBack = { navigator.goBack() }, - onLogout = { - navHostViewModel.logout() - navigator.navigateToAuth() - }, - onNavigateToArchived = { navigator.navigate(ArchivedConversations) }, - ) - memoriesEntry( - onBack = { navigator.goBack() }, - ) - mcpServersEntry( - onBack = { navigator.goBack() }, - ) - }, + MainNavDisplay( + navigator = navigator, + navHostViewModel = navHostViewModel, + onMenuClick = { scope.launch { drawerState.open() } }, + modifier = Modifier.fillMaxSize(), ) } } } + +/** Core NavDisplay with entry providers for all feature modules. Used by both PhoneLayout and Android's TabletLayout. */ +@Composable +fun MainNavDisplay( + navigator: Navigator, + navHostViewModel: NavHostViewModel, + onMenuClick: (() -> Unit)? = null, + modifier: Modifier = Modifier, +) { + NavDisplay( + backStack = navigator.backStack, + onBack = { navigator.goBack() }, + modifier = modifier, + transitionSpec = { + slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(300)) togetherWith + fadeOut(animationSpec = tween(150)) + }, + popTransitionSpec = { + (fadeIn(initialAlpha = 0.5f, animationSpec = tween(300, easing = LinearEasing)) + + scaleIn(initialScale = 0.92f, animationSpec = tween(300, easing = LinearEasing))) togetherWith + (slideOutHorizontally( + targetOffsetX = { (it * 0.15f).toInt() }, + animationSpec = tween(300, easing = FastOutSlowInEasing), + ) + scaleOut( + targetScale = 0.92f, + animationSpec = tween(300, easing = LinearEasing), + )) + }, + predictivePopTransitionSpec = { + (fadeIn(initialAlpha = 0.5f, animationSpec = tween(300, easing = LinearEasing)) + + scaleIn(initialScale = 0.92f, animationSpec = tween(300, easing = LinearEasing))) togetherWith + (slideOutHorizontally( + targetOffsetX = { (it * 0.15f).toInt() }, + animationSpec = tween(300, easing = FastOutSlowInEasing), + ) + scaleOut( + targetScale = 0.92f, + animationSpec = tween(300, easing = LinearEasing), + )) + }, + entryDecorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator(), + ), + entryProvider = entryProvider { + authEntries( + onNavigate = { navigator.navigate(it) }, + onBack = { navigator.goBack() }, + onAuthComplete = { + navHostViewModel.onAuthComplete() + navigator.navigateToChat() + }, + ) + chatEntries( + onNavigate = { navigator.navigate(it) }, + onBack = { navigator.goBack() }, + onNavigateToChat = { navigator.navigateToChat(it) }, + onOpenDrawer = onMenuClick, + ) + conversationsEntries( + onConversationClick = { navigator.navigateToChat(it) }, + onNavigateToArchived = { navigator.navigate(ArchivedConversations) }, + onBack = { navigator.goBack() }, + ) + agentsEntries( + onNavigate = { navigator.navigate(it) }, + onBack = { navigator.goBack() }, + onStartChat = { _ -> + navigator.navigateToTopLevel(NewChat) + }, + ) + filesEntries( + onBack = { navigator.goBack() }, + ) + settingsEntries( + onNavigate = { navigator.navigate(it) }, + onBack = { navigator.goBack() }, + onLogout = { + navHostViewModel.logout() + navigator.navigateToAuth() + }, + onNavigateToArchived = { navigator.navigate(ArchivedConversations) }, + ) + memoriesEntry( + onBack = { navigator.goBack() }, + ) + mcpServersEntry( + onBack = { navigator.goBack() }, + ) + }, + ) +} 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 53766f8..67a2248 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 @@ -26,6 +26,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.stateIn 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 @@ -107,7 +108,7 @@ class NavHostViewModel( .stateIn( viewModelScope, SharingStarted.Eagerly, - initialValue = false, // Avoid runBlocking — not safe on iOS/Kotlin Native main thread + firstBlocking(settingsDataStore.tabletSidebarOpen, false), ) val tabletSidebarGestureEnabled: StateFlow = settingsDataStore.tabletSidebarGestureEnabled